Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ places the decision outside the model.
text can change what the model wants to do. It cannot change what the model is
allowed to do.
- **Slots.** A session commits to the specific resource it is working on, as a
relationship that can be written only once. It cannot drift to another
resource even when its credentials would reach one.
pinned relationship. A different resource is refused unless a human approves a
plan amendment that moves the pin and revokes the old resource's access — and
a slot set `rebind: never` admits no move at all.
- **Approvals the model cannot reword.** The platform generates the description
of the action from the call itself. The model contributes only its reasoning,
so it cannot restate the action in different terms.
Expand Down Expand Up @@ -367,10 +368,11 @@ For architecture and implementation:
## Defense in depth, enforced by code

To misuse an OAP agent, an attacker has to get past a plan a human approved, a
slot that cannot be reopened, a tool lens that cannot be widened, an
authorization check on every call, and a sandbox that never held the credential
in the first place. None of them is sufficient alone, and each is built on the
assumption that the others may fail.
slot that cannot be silently reopened — a different instance needs a human's
approval, and `rebind: never` needs a new session — a tool lens that cannot be
widened, an authorization check on every call, and a sandbox that never held the
credential in the first place. None of them is sufficient alone, and each is
built on the assumption that the others may fail.

## Contributing

Expand Down
19 changes: 19 additions & 0 deletions cmd/oap/internal/sessioncmd/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"strings"

humanize "github.com/dustin/go-humanize"
"github.com/spf13/cobra"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -112,6 +113,24 @@ func newSessionShowCmd(g *apcmd.Globals) *cobra.Command {
}
}

// Slot pins: display-only mirror of SpiceDB's slot_pin relation (see
// spiceboxv1alpha1.SlotPin's doc comment) — one line per
// single-occupancy resource type the session is currently pinned to.
if len(s.Status.SlotPins) > 0 {
fmt.Fprintln(out, "Slot pins:")
for _, p := range s.Status.SlotPins {
if p.MovedBy != "" {
when := "-"
if p.MovedAt != nil && !p.MovedAt.IsZero() {
when = humanize.Time(p.MovedAt.Time)
}
fmt.Fprintf(out, " %s → %s (moved %s by %s)\n", p.ResourceType, p.ResourceID, when, p.MovedBy)
} else {
fmt.Fprintf(out, " %s → %s\n", p.ResourceType, p.ResourceID)
}
}
}

// Operations summary (built from ToolCall labels — no memory fetch).
renderOperationsSummary(cmd.Context(), out, b, s.Name)

Expand Down
78 changes: 78 additions & 0 deletions config/crds/agentprimitives.authzed.com_agentclasses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,30 @@ spec:
- dynamic
- frozen
type: string
occupancy:
default: single
description: |-
Occupancy decides how many instances may occupy this slot at once.

single (default): at most one instance occupies the slot for the
session's life, recorded as a non-expiring slot_pin relationship. A
bind to a DIFFERENT instance is refused rather than silently added.
A second permission on the SAME instance, and a re-grant of the same
instance after its grants expired, are not drift and still bind.

multi: the slot binds a set; each addition is gated as it is today.
No pin is written.

UPGRADE NOTE. Before this field existed every slot behaved as multi, and a
class written then has no occupancy set, so the apiserver now defaults it
to single. A slot listing two or more defaults is then refused
(SlotDeclarationInvalid), and a thread seed or trigger binding several
instances of one type is refused rather than binding them all. Set
occupancy: multi on such slots before upgrading.
enum:
- single
- multi
type: string
permission:
minLength: 1
pattern: ^[a-z][a-z0-9_]*$
Expand All @@ -720,6 +744,21 @@ spec:
type: string
type: array
x-kubernetes-list-type: atomic
rebind:
default: approval
description: |-
Rebind decides what a bind to a DIFFERENT instance does on a filled
single-occupancy slot. Meaningful only when occupancy is single
(as triggerInstance is meaningful only with fillFrom "trigger").

approval (default): refused, and the refusal names the route — an
approved plan amendment naming the new instance moves the pin and
revokes the old instance's grants.
never: refused unconditionally; retargeting requires a new session.
enum:
- approval
- never
type: string
requires:
description: |-
Requires are predicates over the facts a candidate arrived with, all of
Expand Down Expand Up @@ -1017,6 +1056,30 @@ spec:
- dynamic
- frozen
type: string
occupancy:
default: single
description: |-
Occupancy decides how many instances may occupy this slot at once.

single (default): at most one instance occupies the slot for the
session's life, recorded as a non-expiring slot_pin relationship. A
bind to a DIFFERENT instance is refused rather than silently added.
A second permission on the SAME instance, and a re-grant of the same
instance after its grants expired, are not drift and still bind.

multi: the slot binds a set; each addition is gated as it is today.
No pin is written.

UPGRADE NOTE. Before this field existed every slot behaved as multi, and a
class written then has no occupancy set, so the apiserver now defaults it
to single. A slot listing two or more defaults is then refused
(SlotDeclarationInvalid), and a thread seed or trigger binding several
instances of one type is refused rather than binding them all. Set
occupancy: multi on such slots before upgrading.
enum:
- single
- multi
type: string
permission:
minLength: 1
pattern: ^[a-z][a-z0-9_]*$
Expand All @@ -1043,6 +1106,21 @@ spec:
type: string
type: array
x-kubernetes-list-type: atomic
rebind:
default: approval
description: |-
Rebind decides what a bind to a DIFFERENT instance does on a filled
single-occupancy slot. Meaningful only when occupancy is single
(as triggerInstance is meaningful only with fillFrom "trigger").

approval (default): refused, and the refusal names the route — an
approved plan amendment naming the new instance moves the pin and
revokes the old instance's grants.
never: refused unconditionally; retargeting requires a new session.
enum:
- approval
- never
type: string
requires:
description: |-
Requires are predicates over the facts a candidate arrived with, all of
Expand Down
57 changes: 57 additions & 0 deletions config/crds/agentprimitives.authzed.com_agentsessions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4340,6 +4340,63 @@ spec:
gone. Diagnostic + drives the reap decision's "already slept" guard.
format: date-time
type: string
slotPins:
description: |-
SlotPins is a DISPLAY-ONLY observation, one entry per single-occupancy
resource type, mirroring the instance SpiceDB's slot_pin relation
currently pins this session to. SpiceDB is the enforcement source of
truth for single-occupancy pinning; nothing that authorizes a bind or a
tool call reads this field. See SlotPin's doc comment for what writes
it and what MovedBy/MovedAt mean.
items:
description: |-
SlotPin is a DISPLAY-ONLY mirror of one single-occupancy slot's current
commitment — the instance SpiceDB's slot_pin relation pins the session to
for ResourceType, written by authz.GrantSlots (via SlotPinner.EnsurePin) on
a first bind and repointed by authz.BindApproved's move path
(SlotPinner.MovePin) when an approved plan moves it.

SpiceDB is the enforcement source of truth: the slot_pin tuple is what a
tool-call Check and GrantSlots' own single-occupancy gate actually consult.
Nothing that authorizes a bind or a call reads this field — it exists so
`oap session show` and operator logs can see the commitment without a live
SpiceDB read, and it can lag or be absent (e.g. no status writer wired)
without changing what is actually enforced.
properties:
movedAt:
description: |-
MovedAt is when MovedBy's move landed on this pin. Empty for a pin that
has only ever been first-filled.
format: date-time
type: string
movedBy:
description: |-
MovedBy is the approval-record reference that authorized the most
recent MOVE off a prior instance — empty for a pin that has only ever
been first-filled. Populated only by the plan-gate approval path
(pkg/agent/runner's narrowToApproved), from the plan-gate audit record
that carried the approval: its PhaseKey, or PlanDigest+PhaseIndex when
PhaseKey is unset on an older record.
type: string
resourceID:
description: ResourceID is the instance ResourceType is currently
pinned to.
type: string
resourceType:
description: |-
ResourceType is the slot's declared resource type, and the key
UpsertSlotPin replaces by — a single-occupancy slot holds at most one
instance per type for the session's life, so one entry per type is
always the complete picture.
type: string
required:
- resourceID
- resourceType
type: object
type: array
x-kubernetes-list-map-keys:
- resourceType
x-kubernetes-list-type: map
startApprovalParkedAt:
description: |-
StartApprovalParkedAt is when the session entered AwaitingStartApproval
Expand Down
101 changes: 84 additions & 17 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,24 +181,90 @@ Implemented in: `pkg/authz/plangate`, `pkg/agent/runner/plangate_*.go`,

### Slots

**A slot binds a session to a specific resource, and once filled it cannot be
changed.**
**A slot binds a session to a specific resource. A single-occupancy slot cannot
be silently reopened to a different one — a different instance needs a human's
approval, and a slot configured `rebind: never` needs a new session.**

Credentials are usually broader than any single task. A GitHub token might reach
dozens of repositories when the request at hand concerns one. A slot is a named
placeholder for a resource, filled at plan time or on first encounter, and the
commitment is recorded as a SpiceDB relationship whose write checks that the
slot is still empty as a precondition. Every later action is checked against the
filled slot, so a session cannot drift to another resource even when its
credentials would allow it. The intended scope of a task becomes an enforced
boundary.

| Typical agent platform | OAP |
| ------------------------------------------------ | --------------------------------------------------- |
| A session can act anywhere its credentials reach | A session acts only on the resource it committed to |
placeholder for a resource, filled at plan time or on first encounter. By
default a slot is single-occupancy: the first bind writes a non-expiring
`slot_pin` relationship recording the one instance that occupies it, guarded by
an atomic precondition that the slot was still empty. A bind to a different
instance is refused rather than silently added alongside it, so the intended
scope of a task becomes an enforced boundary rather than a hint the agent can
wander past. (A slot can instead be declared `occupancy: multi`, which keeps the
unpinned behavior: each addition is gated on its own and no pin is written.)

A class whose tools bind a constant, class-fixed instance (a literal
`resourceIDTemplate`, not a per-call `resourceIDExpr`) alongside the chosen
instance under the same resource type needs `occupancy: multi` on that slot — or
a split into two resource types — because the pin treats the constant and the
chosen instance as two distinct instances of one type, and the default
single-occupancy slot refuses the second as drift away from the first.

**Upgrading changes the default for existing classes.** An AgentClass written
before slot pinning has no `occupancy` field, and the CRD now defaults it to
`single`. Any such slot that lists two or more `defaults` is then rejected as
`SlotDeclarationInvalid`, and a thread seed or trigger that binds several
instances of one type is refused instead of binding them all. Before upgrading,
set `occupancy: multi` on every slot that is meant to hold more than one
instance.

The refusal names a way out, and which way depends on the slot's `rebind`
setting. The default, `rebind: approval`, routes to a plan amendment: the agent
proposes a plan naming the new instance, and if a human approves it, the
approval moves the pin and revokes the old instance's grants in the same atomic
write — the approval card shows the move as current → proposed, with a line
stating plainly that approving it revokes the session's access to the current
instance. `rebind: never` admits no such move; the refusal is unconditional and
retargeting the slot means starting a new session. Neither mode is tripped by a
second permission check on the already-pinned instance, or by a re-grant of that
same instance after its prior grant expired — those are not drift, and they bind
without needing approval.

One honest limitation: the plan-amendment route exists only for a class that
runs behind the plan gate. A class without it has nowhere to propose the
amendment, so for such a class `rebind: approval` behaves like a refusal whose
only remedy is the same as `rebind: never`'s — a new session for the different
instance.

A grant and a pin carry different things and have different lifetimes. The grant
is the actual authority to call tools against the instance, and it expires and
can be revoked by anyone who could have approved it. The pin is only the record
of which instance was committed to, and it does not expire on its own: it is
deleted when the session ends, swept again at admission for any session that is
not a fork (so a reused session name can never inherit a dead predecessor's
commitment), and copied verbatim onto a forked or restarted session rather than
re-asked. A tool can never write a `slot_pin` or `slot_grant_*` relation
directly — those are platform mechanism relations, and the write is refused
before it reaches SpiceDB.

Two limits are worth stating plainly, because the enforcement is real but
narrower than it can sound. A pin is scoped to a resource **type**, not to a
real-world resource: a class that exposes one underlying resource through two
different SpiceDB types — say a URL-keyed git-repo type and an identity-keyed
GitHub-repo type for the same repository — pins each independently, and nothing
stops the two from drifting to different targets at once. And pin equality is
derived-ID string equality: a type keyed by a transform that does not fully
canonicalize spelling (a trailing slash, a `.git` suffix, inconsistent casing)
treats two spellings of the same resource as two different instances, each
eligible to be "first" into the slot. Where an identity-keyed transform is
available — an account or repository ID rather than a URL — prefer it for a
single-occupancy slot.

`AgentSession.status.slotPins` mirrors the current pin for display, and
`oap session show` renders it, but the status field is an observation, not the
enforcement point: SpiceDB's relationship is what every check reads, and the
mirror can lag behind it.

| Typical agent platform | OAP |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| A session can act anywhere its credentials reach | A session commits to one resource; changing it takes a human's approval or a new session |

Implemented in: `pkg/authz/slotspec`, `pkg/authz/slot_grant.go`,
`pkg/authz/data_slot.go`, `pkg/agent/runner/dataslot_*.go`
`pkg/authz/slot_pin.go`, `pkg/authz/data_slot.go`,
`pkg/agent/runner/dataslot_*.go`

### Deterministic approval descriptions

Expand Down Expand Up @@ -623,10 +689,11 @@ Implemented in: `pkg/platform/oap`, `cmd/oap/internal/agentcmd`
## What an attacker has to get through

To misuse an OAP agent, an attacker has to get past a plan a human approved, a
slot that cannot be reopened, a tool lens that cannot be widened, an
authorization check on every call, and a sandbox that never held the credential
in the first place. No single one of these is sufficient on its own, which is
the point: each assumes the others may fail.
slot that cannot be silently reopened — a different instance needs a human's
approval, and `rebind: never` needs a new session — a tool lens that cannot be
widened, an authorization check on every call, and a sandbox that never held the
credential in the first place. No single one of these is sufficient on its own,
which is the point: each assumes the others may fail.

## Related reading

Expand Down
Loading
Loading