Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ef45477
Add slot occupancy and rebind declarations to the AgentClass API
josephschorr Oct 2, 2026
85bfa81
Enforce single-occupancy slot pinning with atomic SpiceDB preconditions
josephschorr Oct 2, 2026
6734178
Carry slot occupancy onto every binding and move pins only by approve…
josephschorr Oct 2, 2026
8a65e95
Mirror slot pins onto session status and surface refusals to the model
josephschorr Oct 2, 2026
34b1e93
Sweep stale slot tuples before mint-time binds and notice pin refusals
josephschorr Oct 2, 2026
0127813
Manage the pin lifecycle: fork copy, teardown delete, admission sweep
josephschorr Oct 2, 2026
7c7e711
Prove slot pinning end to end against real SpiceDB
josephschorr Oct 2, 2026
1068e23
Document slot pinning and correct the fill-once claims
josephschorr Oct 2, 2026
d8fa0b4
Fix slot-pin review findings: never-rebind, card moves, sweep retry
samkim Oct 8, 2026
fc6eb0b
Report a committed slot move when the revoke-set read fails
josephschorr Oct 14, 2026
d771355
Dedupe per-phase pin moves so a multi-phase approval keeps every grant
josephschorr Oct 14, 2026
f61d253
Categorize a plan-gate pinned refusal as a refusal, not an internal e…
josephschorr Oct 14, 2026
4982a52
Address second-round slot-pin review findings
josephschorr Oct 16, 2026
5594096
Report slot-bind outcomes per type and once per approval
josephschorr Oct 22, 2026
e28cd74
Close the admission-sweep bypass via forkedFrom and a pre-stamped fin…
josephschorr Oct 22, 2026
7cafaf2
Show every pin move an approval executes and refuse stay-beside-move …
josephschorr Oct 22, 2026
43aff91
Make pin copy and admission sweeps re-runnable and complete
josephschorr Oct 22, 2026
a40740e
Treat a not-yet-composed schema as nothing to sweep
josephschorr Oct 22, 2026
e1c8d3a
Sweep a subagent child's name before creating it
josephschorr Oct 22, 2026
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 @@ -368,10 +369,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, while the model itself never
sees a credential. 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, while the model
itself never sees a credential. None of them is sufficient alone, and each is
built on the assumption that the others may fail.

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions cmd/oap/internal/kube/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ func resolveGVR(gvk schemaGVK) (schemaGVR, bool, error) {
return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"}, false, nil
case "MutatingWebhookConfiguration":
return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "mutatingwebhookconfigurations"}, false, nil
case "ValidatingAdmissionPolicy":
return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicies"}, false, nil
case "ValidatingAdmissionPolicyBinding":
return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicybindings"}, false, nil
case "StorageClass":
return schemaGVR{Group: "storage.k8s.io", Version: "v1", Resource: "storageclasses"}, false, nil
}
Expand Down
1 change: 1 addition & 0 deletions cmd/oap/internal/kube/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func TestResolveGVRScope(t *testing.T) {
clusterScoped := []string{
"Namespace", "ClusterRole", "ClusterRoleBinding", "CustomResourceDefinition",
"ValidatingWebhookConfiguration", "MutatingWebhookConfiguration", "StorageClass",
"ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding",
}
for _, k := range clusterScoped {
ns, err := kube.ResolveNamespacedForTest("", k)
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/admission/agentsession-finalizer-policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Only the operator and channelsd may ADD the AgentSession finalizer.
#
# Why this exists. The AgentSession reconciler runs an "admission sweep" on a
# session's first reconcile: it deletes every slot_grant_* and slot_pin tuple
# SpiceDB holds under the session's (namespace, name), wiping whatever a dead
# predecessor with the same name left behind. Slot tuples are keyed by
# (namespace, name), not UID, and a slot_pin never expires, so without the
# sweep a recreated session inherits the predecessor's pin and grants.
#
# The sweep is skipped when the finalizer is ALREADY present at first
# reconcile, because two trusted creators pre-stamp it and own the sweep
# themselves:
# - channelsd's mint stamps it at create and runs the same sweep
# synchronously before its own mint-time binds;
# - the operator's fork path stamps it on every restart child, because the
# parent's restart copies its slot grants and pin onto the child right
# after creating it, and a sweep would delete them.
#
# Anyone else who can create an AgentSession could write that same finalizer
# into their manifest and skip the sweep, inheriting a predecessor's
# authority. This policy makes the skip unforgeable: a CREATE carrying the
# finalizer, or an UPDATE that adds it, is refused unless the caller is one of
# those two service accounts. Removing the finalizer, and every write that
# leaves it as it was, is not evaluated at all (matchConditions).
#
# Why a ValidatingAdmissionPolicy and not another webhook in webhook.yaml: it
# runs inside the apiserver, so it fails closed (failurePolicy: Fail) without
# making every AgentSession write depend on the operator pod being up and its
# serving cert being injected. A webhook would force a choice between that
# dependency (Fail) and a silent bypass during an operator outage (Ignore).
#
# The service-account names are spelled out because CEL cannot read
# kustomize's namespace. They must match config/manager/serviceaccount.yaml
# and pkg/platform/manifests/channelsd/serviceaccount.yaml.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: agentsession-finalizer.agentprimitives.authzed.com
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["agentprimitives.authzed.com"]
apiVersions: ["*"]
operations: ["CREATE", "UPDATE"]
# The main resource only. A status write cannot change
# metadata.finalizers, so agentsessions/status is not matched.
resources: ["agentsessions"]
matchConditions:
# True only for a write that ADDS the finalizer: present on a CREATE, or
# present in the new object and absent from the old one on an UPDATE.
# oldObject is null on CREATE, so the operation is tested first.
- name: adds-agentsession-finalizer
expression: >-
has(object.metadata.finalizers) &&
'agentsession.agentprimitives.authzed.com/finalizer' in object.metadata.finalizers &&
(request.operation == 'CREATE' ||
!has(oldObject.metadata.finalizers) ||
!('agentsession.agentprimitives.authzed.com/finalizer' in oldObject.metadata.finalizers))
validations:
- expression: >-
request.userInfo.username in [
'system:serviceaccount:agentprimitives-system:spicebox-operator',
'system:serviceaccount:agentprimitives-system:spicebox-channelsd']
reason: Forbidden
messageExpression: >-
'only the agentprimitives operator and channelsd may add the
agentsession.agentprimitives.authzed.com/finalizer finalizer; '
+ request.userInfo.username + ' may not. Create the AgentSession
without it and the operator adds it after sweeping stale slot grants.'
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: agentsession-finalizer.agentprimitives.authzed.com
spec:
policyName: agentsession-finalizer.agentprimitives.authzed.com
validationActions: ["Deny"]
9 changes: 9 additions & 0 deletions config/admission/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# No `namespace:` here, deliberately. ValidatingAdmissionPolicy and its binding
# are cluster-scoped, but kustomize's namespace transformer does not know that
# and would stamp agentprimitives-system onto them (as it does for everything
# under config/manager), which `oap install` would then send to a
# cluster-scoped endpoint.
resources:
- agentsession-finalizer-policy.yaml
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
Loading
Loading