Skip to content
Draft
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
32 changes: 30 additions & 2 deletions PRIMITIVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,41 @@ MCP servers, validated against a toolspec.
(authenticated DevTools + policy-enforced egress) the sandbox pod runs;
`pkg/tools/browserpolicy/` is the shared site-matching decision both the gate
and the tool's own pre-check consult. No dedicated controller yet (Phase A) —
identity-scoped sites and acting tools (click/type) arrive in later phases.
identity-scoped sites arrived in Phase B, acting tools in Phase C (below).
Phase B adds identity-bound login catalogs: the `directCredentials` credential
type (`pkg/platform/identity/directcred/`, sources `onepassword` and `secret`)
lets a BrowserToolbox bind an AgentIdentity and list its logins as
`{id, label, hosts}` onto `AgentSession.status` — the username/password/OTP
itself never reaches the model, the broker (`Resolve` errors unconditionally
for this type), or status.
for this type), or status. Phase C adds credentialed, acting browsing. Acting
tools (`web_click`/`web_type`/`web_press`/`web_select`/`web_submit`) are
synthesized only for an `interaction: act` toolbox
(`pkg/agent/tool/browser/synthesize.go`), each gated External on a per-session
`web_host` SpiceDB slot's `act` permission (`pkg/tools/browserschema/`) — so
every acting call on a host that lacks a grant routes to a human plan-gate
approval card, and one approval covers the whole acting phase for that host.
The page host reaches the authz check as live page state via `NamedArgs`, not
as an LLM-supplied arg; `web_host` object ids are `spicedb_escape`-encoded
because a dotted hostname is not a legal SpiceDB object id. The no-secret
filler (`pkg/agent/tool/browser/filler.go`): the runner Fetches the bound
login's credential and types it directly into the page (a short-lived
in-memory hold keyed by host), so the username/password/OTP never reaches the
model — the transcript's `type` args carry a placeholder/login ref, not the
value — and observation (`pkg/agent/tool/browser/snapshot.go`) is blinded to
filled fields so a subsequent read cannot echo a typed secret back.
`request_human_step` (the `login_human_step` interaction category,
`pkg/channels/channelinteractions/categories/`) lets the agent ask a person to
complete a step (a confirm, or an emailed/SMS code typed into a named page
element) — the code rides a free-text carrier to the runner and is typed into
the page but is never placed in the model's context, transcript, logs, or tool
result (`pkg/agent/tool/browser/humanstep.go`). An operator can mark an
allowlist+identity site `unattended` (`BrowserSite.Unattended`) so its
`web_host#act` grant is written at session start with no human card. A
credentialed browser (default `persistent` when an identity is bound) captures
cookies + localStorage after each acting call and on graceful stop into a
per-session Secret — the one Secret the runner is RBAC-allowed to write, never
model-visible — and re-injects it when the pod wakes after a reap, so a login
survives pod churn (`pkg/agent/tool/browser/capture.go`).
- **Binaries:** `internal/cmd/streamclient/` (claims a streaming ToolCall via
the gateway); tool execution is otherwise driven from `internal/cmd/runner/`

Expand Down
10 changes: 7 additions & 3 deletions config/crds/agentprimitives.authzed.com_agentidentities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,19 @@ spec:
or the host-keyed login Secret (secret).
properties:
key:
description: Key is the data key within the Secret holding
the value.
description: |-
Key is the data key within the Secret holding the value. Optional:
"token" (or omitted) for source=onepassword; must be omitted for
source=secret, whose login Secret is a multi-key shape with no single
named value key. ValidateSpec enforces the exact rule per source; this
marker only ensures the apiserver never rejects an omitted key before
ValidateSpec gets to run.
type: string
name:
description: Name is the Secret's name, in the referring
object's own namespace.
type: string
required:
- key
- name
type: object
source:
Expand Down
35 changes: 32 additions & 3 deletions config/crds/agentprimitives.authzed.com_agentsessions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2879,6 +2879,26 @@ spec:
maxLength: 253
pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$
type: string
unattended:
default: false
description: |-
Unattended marks this site's web_host#act grant as operator-approved: the
AgentSession reconciler's runner writes the slot_grant_act relationship
for this host at session cold start, unconditionally, with no human
plan-gate card -- the operator's declaration of Unattended IS the
authorization, exactly as an approved card would be. A Phase C addition;
the third field-scoped XValidation rule on BrowserToolbox.Spec requires
access.mode=allowlist and access.identity whenever any site sets this, so
an unattended grant is always for a specific, bound-identity session and
never for a general-access (blocklist) browser.

The default marker below lands a structural default on every site, even
one that omits the field entirely -- without it, the third XValidation
rule's `s.unattended` on a plain site with no explicit value errors
evaluation ("no such key: unattended") instead of reading false, and
CEL's fail-closed-on-error semantics then refuse EVERY BrowserToolbox
create, not just an unattended one.
type: boolean
required:
- host
type: object
Expand All @@ -2901,13 +2921,22 @@ spec:
- readonly
type: string
state:
default: ephemeral
description: |-
State selects how the browser's session state (cookies, storage) is
retained across turns.
Phase A: ephemeral is the only state; persistent arrives with identities (Phase C).
retained across turns: "persistent" captures cookies + localStorage for the
effective sites into a per-session Secret and re-injects them across reap and
wake (spec §1.4); "ephemeral" keeps a clean profile every pod boot and
captures nothing. An explicit "persistent" requires access.identity (the
field-scoped XValidation rule on BrowserToolbox.Spec).

DELIBERATELY NOT DEFAULTED at admission. An unset value is resolved in code
at the AgentSession snapshot (controller.go) to "persistent" when an identity
is bound and "ephemeral" otherwise -- a static kubebuilder default would mask
that identity-driven default, so the field is left omitempty and
enum-but-not-defaulted so an unset field stays distinguishably empty.
enum:
- ephemeral
- persistent
type: string
required:
- access
Expand Down
55 changes: 52 additions & 3 deletions config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ spec:
apiserver's rule-cost budget across that unbounded array. Precedent:
workshop_types.go:44-66 attaches its XValidation rules the same way, on
the field, not the type.
A Phase C rule, same reasoning as the two above: an `unattended` site's
web_host#act grant is written unconditionally at session start (C8, no
human card) for a bound identity's browsing session, so it needs both an
allowlist (a blocklist's "every site but these" shape has no per-site
standing to grant against) and a bound identity (there is no session
approver to ask if the binding is missing, and the grant is meant to
stand in for that identity's own sign-in).
A Phase C (Task C9) rule, same field-scoped reasoning as the four above: an
explicit state=persistent captures cookies + localStorage into a per-session
Secret that survives reap, which is only legitimate for a credentialed
(identity-bound) browser -- a general browser has nothing to keep and a
long-lived profile of arbitrary-site cookies is only a tracking surface
(spec §1.2, §1.4). An UNSET state is left to the AgentSession snapshot to
default (persistent iff an identity is bound, else ephemeral), so the rule
short-circuits on absence; it fires only on an EXPLICIT persistent.
properties:
access:
description: |-
Expand Down Expand Up @@ -137,6 +152,26 @@ spec:
maxLength: 253
pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$
type: string
unattended:
default: false
description: |-
Unattended marks this site's web_host#act grant as operator-approved: the
AgentSession reconciler's runner writes the slot_grant_act relationship
for this host at session cold start, unconditionally, with no human
plan-gate card -- the operator's declaration of Unattended IS the
authorization, exactly as an approved card would be. A Phase C addition;
the third field-scoped XValidation rule on BrowserToolbox.Spec requires
access.mode=allowlist and access.identity whenever any site sets this, so
an unattended grant is always for a specific, bound-identity session and
never for a general-access (blocklist) browser.

The default marker below lands a structural default on every site, even
one that omits the field entirely -- without it, the third XValidation
rule's `s.unattended` on a plain site with no explicit value errors
evaluation ("no such key: unattended") instead of reading false, and
CEL's fail-closed-on-error semantics then refuse EVERY BrowserToolbox
create, not just an unattended one.
type: boolean
required:
- host
type: object
Expand All @@ -159,13 +194,22 @@ spec:
- readonly
type: string
state:
default: ephemeral
description: |-
State selects how the browser's session state (cookies, storage) is
retained across turns.
Phase A: ephemeral is the only state; persistent arrives with identities (Phase C).
retained across turns: "persistent" captures cookies + localStorage for the
effective sites into a per-session Secret and re-injects them across reap and
wake (spec §1.4); "ephemeral" keeps a clean profile every pod boot and
captures nothing. An explicit "persistent" requires access.identity (the
field-scoped XValidation rule on BrowserToolbox.Spec).

DELIBERATELY NOT DEFAULTED at admission. An unset value is resolved in code
at the AgentSession snapshot (controller.go) to "persistent" when an identity
is bound and "ephemeral" otherwise -- a static kubebuilder default would mask
that identity-driven default, so the field is left omitempty and
enum-but-not-defaulted so an unset field stays distinguishably empty.
enum:
- ephemeral
- persistent
type: string
required:
- access
Expand All @@ -177,6 +221,11 @@ spec:
&& !self.access.identitySites)
- message: identitySites requires identity
rule: '!self.access.identitySites || has(self.access.identity)'
- message: an unattended site requires access.mode=allowlist and access.identity
rule: '!self.access.sites.exists(s, s.unattended) || (self.access.mode
== ''allowlist'' && has(self.access.identity))'
- message: a persistent browser requires access.identity
rule: '!has(self.state) || self.state != ''persistent'' || has(self.access.identity)'
status:
description: |-
BrowserToolboxStatus is intentionally minimal in Phase A: a condition set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,19 @@ spec:
or the host-keyed login Secret (secret).
properties:
key:
description: Key is the data key within the Secret holding
the value.
description: |-
Key is the data key within the Secret holding the value. Optional:
"token" (or omitted) for source=onepassword; must be omitted for
source=secret, whose login Secret is a multi-key shape with no single
named value key. ValidateSpec enforces the exact rule per source; this
marker only ensures the apiserver never rejects an omitted key before
ValidateSpec gets to run.
type: string
name:
description: Name is the Secret's name, in the referring
object's own namespace.
type: string
required:
- key
- name
type: object
source:
Expand Down
10 changes: 7 additions & 3 deletions config/crds/agentprimitives.authzed.com_useridentities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,19 @@ spec:
or the host-keyed login Secret (secret).
properties:
key:
description: Key is the data key within the Secret holding
the value.
description: |-
Key is the data key within the Secret holding the value. Optional:
"token" (or omitted) for source=onepassword; must be omitted for
source=secret, whose login Secret is a multi-key shape with no single
named value key. ValidateSpec enforces the exact rule per source; this
marker only ensures the apiserver never rejects an omitted key before
ValidateSpec gets to run.
type: string
name:
description: Name is the Secret's name, in the referring
object's own namespace.
type: string
required:
- key
- name
type: object
source:
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/channelsd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,15 @@ func run(rootCtx context.Context, cfg *config) error {
// (the request's Resources).
channelinteractions.Bind(categories.InfoLeakage, pipeline.ApprovalDecisionHandler)

// Bind login_human_step: a browser toolbox's request_human_step confirm. A
// PURE approve/deny the runner resumes off the interaction_applied bridge —
// no channelsd-side grant, like content_inspection. Standing is enforced by
// the pipe's DecideApprovers policy (the clicker must be in the session
// approve-set). Without this binding an approve is published, a person clicks
// it, and the decision is dropped — request_human_step waits out its full
// approval timeout and the session reads as hung.
pipeline.BindLoginHumanStepHandler(pl)

// Bind the permission_request decision handler: the interaction_decision
// pipe already validates standing (DecideOwner — the session's approve-set)
// before invoking it; decidePermission's own job is just to apply the
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/operator/schema_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
guardianschema "github.com/authzed/openagentprimitives/pkg/authz/guardian/schema"
"github.com/authzed/openagentprimitives/pkg/channels/channelkinds"
channelregistry "github.com/authzed/openagentprimitives/pkg/channels/channelkinds/registry"
"github.com/authzed/openagentprimitives/pkg/tools/browserschema"
"github.com/authzed/openagentprimitives/toolkits"
)

Expand Down Expand Up @@ -87,11 +88,19 @@ func TestCompileTimeFragmentsCompose(t *testing.T) {
toolkitFrags := guardianschema.ToolkitFragments(toolkits.All())
compileTime = append(compileTime, toolkitFrags...)

// The browser toolbox's builtin web_host fragment is part of the same
// compile-time baseline the guardian controller composes (schemaBaseline),
// so it must compose here too — this is the only net before a bad builtin
// fragment reaches a cluster as a startup-time schema freeze.
compileTime = append(compileTime, browserschema.Fragment())

out, err := guardianschema.ComposeBase(compileTime)
require.NoError(t, err, "every compile-time fragment must compose into the base")
assert.Contains(t, out, "definition agentsession")
assert.Contains(t, out, "definition slack_channel",
"the Slack fragment is load-bearing: audience resolution reads slack_channel#view")
assert.Contains(t, out, "definition web_host",
"the browser toolbox's builtin web_host fragment must reach the composed base")

// Every definition a built-in toolkit fragment declares must actually
// reach the composed base. Until this loop existed, nothing in this test
Expand Down
Loading
Loading