diff --git a/PRIMITIVES.md b/PRIMITIVES.md index a98fcf7a..6a9edea8 100644 --- a/PRIMITIVES.md +++ b/PRIMITIVES.md @@ -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/` diff --git a/config/crds/agentprimitives.authzed.com_agentidentities.yaml b/config/crds/agentprimitives.authzed.com_agentidentities.yaml index 2fc495a8..6d7cce49 100644 --- a/config/crds/agentprimitives.authzed.com_agentidentities.yaml +++ b/config/crds/agentprimitives.authzed.com_agentidentities.yaml @@ -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: diff --git a/config/crds/agentprimitives.authzed.com_agentsessions.yaml b/config/crds/agentprimitives.authzed.com_agentsessions.yaml index 7b41d461..a2714020 100644 --- a/config/crds/agentprimitives.authzed.com_agentsessions.yaml +++ b/config/crds/agentprimitives.authzed.com_agentsessions.yaml @@ -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 @@ -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 diff --git a/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml index 0c0dfc20..23febf7b 100644 --- a/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml +++ b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml @@ -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: |- @@ -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 @@ -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 @@ -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 diff --git a/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml b/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml index 19598136..66893e17 100644 --- a/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml +++ b/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml @@ -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: diff --git a/config/crds/agentprimitives.authzed.com_useridentities.yaml b/config/crds/agentprimitives.authzed.com_useridentities.yaml index f22de9cb..e50aed9e 100644 --- a/config/crds/agentprimitives.authzed.com_useridentities.yaml +++ b/config/crds/agentprimitives.authzed.com_useridentities.yaml @@ -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: diff --git a/internal/cmd/channelsd/main.go b/internal/cmd/channelsd/main.go index 3f531eed..965690cc 100644 --- a/internal/cmd/channelsd/main.go +++ b/internal/cmd/channelsd/main.go @@ -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 diff --git a/internal/cmd/operator/schema_conformance_test.go b/internal/cmd/operator/schema_conformance_test.go index 8af40ebc..d26a8cac 100644 --- a/internal/cmd/operator/schema_conformance_test.go +++ b/internal/cmd/operator/schema_conformance_test.go @@ -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" ) @@ -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 diff --git a/internal/cmd/runner/main.go b/internal/cmd/runner/main.go index cb16e8ce..6ca6f76f 100644 --- a/internal/cmd/runner/main.go +++ b/internal/cmd/runner/main.go @@ -153,7 +153,9 @@ import ( "github.com/authzed/openagentprimitives/pkg/platform/identity/broker/inproc" "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/githubapp" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated/githubApp credkind.Kinds the broker dispatches to via registry.Get + credkindregistry "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" "github.com/authzed/openagentprimitives/pkg/platform/identity/credresolve" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" // register the "onepassword" directcred.Source Phase C's browser form filler dispatches to via directcred.Get (Fetch at use time) _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" // register the "secret" directcred.Source for the same Phase C Fetch consumer "github.com/authzed/openagentprimitives/pkg/platform/identity/externaltoken" @@ -394,6 +396,14 @@ func run(cfg *config) error { // race-free and establishes a happens-before with the Loop's field writes. var loopRef atomic.Pointer[runner.Loop] + // browserCapturersRef holds the per-session `persistent` browser toolboxes' + // state capturers, stored once the browser tools are synthesized (below) and + // read late-bound by the SIGTERM handler so the last login state is captured + // into each state Secret BEFORE the pod dies (spec §2.3 "and on graceful + // stop"). Empty (nil) until browser tools exist and for every ephemeral-only + // session, so the handler is a no-op in the common case. + var browserCapturersRef atomic.Pointer[[]browsertoolboxsynth.StateCapturer] + // All memory access — turn transcripts and the gap Kinds // (authz_decision, relwrites_audit, label, approval, lifecycle) — // goes over HTTP to the operator-hosted Memory. The runner keeps no @@ -483,6 +493,11 @@ func run(cfg *config) error { loop = l } runStopHandler(stopCtx, memSigned, scope, loop, sessionUID, time.Now().UTC()) + // Capture each persistent browser toolbox's session state BEFORE the loop + // is cancelled and the pod dies, so a login that happened this run survives + // the reap and is re-injected on wake. Best-effort on its own timeout; a + // failure is logged and never blocks the stop. + captureBrowserStateOnStop(browserCapturersRef.Load()) stopCancel() cancel() }() @@ -1489,6 +1504,17 @@ func run(cfg *config) error { const browserPodIPPollInterval = 2 * time.Second browserTools := []tool.Tool{} + // browserCapturers accumulates the persistent toolboxes' SIGTERM-time state + // capturers, published to browserCapturersRef after the loop. + var browserCapturers []browsertoolboxsynth.StateCapturer + // browserSections carries each resolved toolbox's one-line prompt fragment + // (spec §5.1 — query_logins' catalog size, and the allowed-host count in + // allowlist mode). The browser toolbox is hand-wired here rather than + // through capability.Capability/SectionOfferer (see + // browsertoolboxsynth.PromptSection's doc comment), so this is a second, + // independent source of PromptSections alongside assembled.Sections below + // — merged at the WithPromptSections call rather than decided there. + var browserSections []capability.PromptSection for _, rt := range sess.Status.ResolvedBrowserToolboxes { if rt.BrowserPodIP == "" { slog.Info("browser toolbox IP not yet reflected; polling", @@ -1560,7 +1586,22 @@ func run(cfg *config) error { // offered to a model that cannot consume it. acceptsImages := provider.NativeInputMIMEs(sess.Status.EffectiveSettings.Model.Name).Has("image/png") gateURL := fmt.Sprintf("http://%s:%d", rt.BrowserPodIP, rt.GatePort) - built, serr := browsertoolboxsynth.Synthesize(rt, gateURL, token, acceptsImages) + // The credential-filler Fetch seam: resolves {{login...}} placeholders at + // use time against the bound identity's directcred source. nil when the + // toolbox binds no identity (no catalog → no placeholder resolves). + fetch := newBrowserCredentialFetcher(c, ns, rt) + // request_human_step's blocking confirm + captcha notice. Late-bound over + // loopRef the same way PreferenceSaver is (the tool table is assembled + // before the session's Loop exists), so the requester resolves the live + // host/Loop only at call time. Shared across this session's toolboxes. + humanStep := runner.NewLateBoundHumanStepRequester(func() *runner.Loop { return loopRef.Load() }) + // Persistent-state store (Task C9): a StateStore over the per-session state + // Secret for a `persistent` toolbox (the effective state resolved by the + // operator and frozen into rt.Spec.State), nil for an ephemeral one — which + // disables capture/inject in Synthesize. The operator created the Secret + // empty and pinned the runner Role's get;update to exactly it. + store := newBrowserStateStore(c, ns, &sess, rt) + built, serr := browsertoolboxsynth.Synthesize(rt, gateURL, token, acceptsImages, fetch, humanStep, store) if serr != nil { if werr := failSession(spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, fmt.Sprintf("browser toolbox %q: %v", rt.Name, serr)); werr != nil { @@ -1569,6 +1610,20 @@ func run(cfg *config) error { return nil } browserTools = append(browserTools, built...) + // Collect the state capturer for the SIGTERM handler (one per toolbox; every + // tool of a toolbox shares one Session, so the first built tool is enough). + if store != nil && len(built) > 0 { + if sc, ok := built[0].(browsertoolboxsynth.StateCapturer); ok { + browserCapturers = append(browserCapturers, sc) + } + } + if sec := browsertoolboxsynth.PromptSection(rt); sec != nil { + browserSections = append(browserSections, *sec) + } + } + // Publish the persistent-browser capturers for the late-bound SIGTERM handler. + if len(browserCapturers) > 0 { + browserCapturersRef.Store(&browserCapturers) } // Resolve opted-in skills for this agent class. The ceiling enforcement @@ -2168,7 +2223,13 @@ func run(cfg *config) error { // Capability-owned prompt sections ride with the tools they describe // (capability.SectionOfferer): the views capability's "Your page" exists // only when update_view does. Nothing here decides whether to include one. - composeOpts = append(composeOpts, runner.WithPromptSections(assembled.Sections)) + // + // browserSections is the browser toolbox's analog of the same rule, from + // its own hand-wired path (built alongside browserTools above, not through + // capability.Capability): PromptSection returns nil exactly when + // query_logins is withheld, so appending it here is still "the tool's own + // decision rides along", not a decision made in this file. + composeOpts = append(composeOpts, runner.WithPromptSections(append(assembled.Sections, browserSections...))) composedSystem := runner.ComposeSystem(systemPrompt, mergedTools, skillMeta, repoInstrs, assetKinds, modalityInstructions, composeOpts...) // Record WHAT the agent was asked, alongside the turn log's record of WHICH @@ -2346,10 +2407,14 @@ func run(cfg *config) error { PlanGateSlotStanding: runner.SlotStandingOf(&class), PlanGatePermissionTitles: runner.PermissionTitlesOf(&class), PlanGateResourceDisplays: runner.ResourceDisplaysOf(&class), - ResourceStandings: runner.ResourceStandingsOf(&class), - PlanGateMaxCardHandles: runner.PlanGateMaxCardHandles(sess.Status.EffectiveSettings), - PlanGateMaxAutoApprove: runner.PlanGateMaxAutoApprove(sess.Status.EffectiveSettings), - PlanGateRequirePlan: runner.PlanGateRequirePlan(sess.Status.EffectiveSettings), + // Display only (C8): names the signing identity on the web_host#act + // card line, from the resolved toolboxes' own catalog -- not from the + // class, since the identity binding is a BrowserToolbox concern. + PlanGateWebHostIdentityLabels: runner.BrowserHostIdentityLabels(sess.Status.ResolvedBrowserToolboxes), + ResourceStandings: runner.ResourceStandingsOf(&class), + PlanGateMaxCardHandles: runner.PlanGateMaxCardHandles(sess.Status.EffectiveSettings), + PlanGateMaxAutoApprove: runner.PlanGateMaxAutoApprove(sess.Status.EffectiveSettings), + PlanGateRequirePlan: runner.PlanGateRequirePlan(sess.Status.EffectiveSettings), // Live CR pointers, so the loop can dispatch autofill and // per-user-message binding. Nil disables those paths, as a defence @@ -2557,6 +2622,25 @@ func run(cfg *config) error { } } + // Unattended browser sites (C8): write their web_host#act grant + // UNCONDITIONALLY at session cold start — no Check, no human card. An + // operator's BrowserSite.Unattended declaration IS the authorization, + // exactly like an approved plan-gate card writes its grant directly (see + // authz.BindApproved) — never through BindClassDefaults above, whose + // admissibleCandidates gate Checks each candidate against the subject's + // EXISTING standing. web_host's own standing arm is inert (its `owner` + // relation is never written — see browserschema.Resource), so that Check + // would always deny and drop every unattended candidate before any grant + // existed. See engine.Engine.GrantUnattendedSlots's doc for the same + // reasoning at the interface boundary. + if unattended := runner.UnattendedBrowserSlotBindings(sess.Status.ResolvedBrowserToolboxes, runner.SlotTransformsOf(&class)); len(unattended) > 0 { + sessRef := authz.SessionRef{Namespace: sess.Namespace, Name: sess.Name} + if err := loop.Engine.GrantUnattendedSlots(rootCtx, sessRef, unattended); err != nil { + slog.Info("authz GrantUnattendedSlots failed: unattended browser site(s) will not be acted on until a human approves", + "session", sess.Namespace+"/"+sess.Name, "bindings", len(unattended), "err", err.Error()) + } + } + // The per-session authz config snapshot (authz_session_config) is NOT // written here. authzd derives this session's cold-start authorization // policy from that record, so the runner authoring it would let a @@ -3964,3 +4048,159 @@ func sidecarNoticeSink(loop *runner.Loop, sessKey client.ObjectKey) func(context "session", sessKey.String()) } } + +// newBrowserCredentialFetcher builds the credential-filler Fetch seam for one +// resolved browser toolbox, mirroring the operator's resolveBrowserCatalog +// recipe (pkg/controllers/agentsession/browserpod.go): it resolves the bound +// identity's first directCredentials credential and its directcred source, then +// — lazily, on each Fetch — reads the backing Secret and dispatches to the +// source's Fetch. Reading the Secret per Fetch (rather than capturing its bytes +// for the session's lifetime) keeps the raw credential material in memory only +// for the moment of use plus the browser session's short §5.3 hold. +// +// Returns nil when the toolbox binds no identity: with no catalog, no +// {{login...}} placeholder can resolve (the filler's rule 2 denies first), so +// there is nothing to Fetch. Every error path returns the error to the filler, +// which renders it as a fail-closed tool result — never a credential value. +func newBrowserCredentialFetcher(c client.Client, ns string, rt spiceboxv1alpha1.ResolvedBrowserToolbox) browsertoolboxsynth.CredentialFetcher { + if rt.IdentityName == "" { + return nil + } + identityName := rt.IdentityName + return func(ctx context.Context, catalogID string) (directcred.Credential, error) { + var id spiceboxv1alpha1.AgentIdentity + if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: identityName}, &id); err != nil { + return directcred.Credential{}, fmt.Errorf("get AgentIdentity %q: %w", identityName, err) + } + var cred *spiceboxv1alpha1.AgentCredential + for i := range id.Spec.Credentials { + if id.Spec.Credentials[i].DirectCredentials != nil { + cred = &id.Spec.Credentials[i] + break + } + } + if cred == nil { + return directcred.Credential{}, fmt.Errorf("AgentIdentity %q has no directCredentials credential", identityName) + } + // Classify through the credkind registry (never a cred.Type switch) to + // locate the backing Secret and key, exactly as resolveBrowserCatalog does. + k, err := credkindregistry.Get(cred.Type) + if err != nil { + return directcred.Credential{}, fmt.Errorf("classify credential %q: %w", cred.Name, err) + } + secRef := k.SecretRef(*cred) + if secRef == nil || secRef.Name == "" { + return directcred.Credential{}, fmt.Errorf("credential %q has no backing Secret", cred.Name) + } + var sec corev1.Secret + if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: secRef.Name}, &sec); err != nil { + return directcred.Credential{}, fmt.Errorf("read credential Secret %q: %w", secRef.Name, err) + } + src, ok := directcred.Get(cred.DirectCredentials.Source) + if !ok { + return directcred.Credential{}, fmt.Errorf( + "no directcred source registered for %q (the binary may be missing its blank import)", cred.DirectCredentials.Source) + } + cfg := directcred.Config{ + SecretData: sec.Data, + Vault: cred.DirectCredentials.Vault, + VaultID: pinnedBrowserVaultID(&id, cred.Name), + } + return src.Fetch(ctx, cfg, catalogID) + } +} + +// pinnedBrowserVaultID returns the vault id the identity's status pinned for +// credName (onepassword), or "" when none is recorded (the secret source has no +// vault concept). Mirrors the operator's pinnedVaultID, which is unexported. +func pinnedBrowserVaultID(id *spiceboxv1alpha1.AgentIdentity, credName string) string { + for _, pv := range id.Status.PinnedVaults { + if pv.Credential == credName { + return pv.VaultID + } + } + return "" +} + +// browserStateSecretDataKey is the state Secret's one data key the runner writes +// the captured state blob under. The runner is the SOLE owner of this key: the +// operator creates the state Secret EMPTY (ensureBrowserStateSecret) and never +// reads or writes this key itself, so there is no operator-side constant to stay +// byte-identical with — the same pattern the gate-token "token" key already uses +// in this file. +const browserStateSecretDataKey = "state" + +// browserStateStore persists a persistent browser toolbox's captured session +// state (cookies + localStorage) into the per-session state Secret the operator +// created empty. The runner is the SOLE writer: its per-session Role grants +// get;update pinned to exactly this Secret (agentsession.BrowserStateRBACRules). +type browserStateStore struct { + c client.Client + key client.ObjectKey +} + +var _ browsertoolboxsynth.StateStore = (*browserStateStore)(nil) + +// newBrowserStateStore returns a StateStore for a PERSISTENT toolbox, or nil for +// an ephemeral one (which disables capture/inject in Synthesize). The effective +// state was resolved by the operator and frozen into rt.Spec.State, so the runner +// reads one consistent answer. +func newBrowserStateStore(c client.Client, ns string, sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox) browsertoolboxsynth.StateStore { + if rt.Spec.State != spiceboxv1alpha1.BrowserStatePersistent { + return nil + } + return &browserStateStore{ + c: c, + key: client.ObjectKey{Namespace: ns, Name: agentsession.BrowserStateSecretName(sess, rt.Ref)}, + } +} + +// Load returns the last-saved state blob, or (nil, nil) when the Secret is empty +// (a first boot — the operator created it with no data). A missing Secret is also +// treated as empty rather than an error: an ephemeral→persistent edit mid-session +// is degenerate, and a clean start is the safe behavior. +func (s *browserStateStore) Load(ctx context.Context) ([]byte, error) { + var sec corev1.Secret + if err := s.c.Get(ctx, s.key, &sec); err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get browser state Secret %q: %w", s.key.Name, err) + } + return sec.Data[browserStateSecretDataKey], nil +} + +// Save overwrites the state Secret's single data key. The operator created the +// Secret, so a get-then-update preserves its labels/ownerRefs; the runner only +// ever rewrites this one key. +func (s *browserStateStore) Save(ctx context.Context, data []byte) error { + var sec corev1.Secret + if err := s.c.Get(ctx, s.key, &sec); err != nil { + return fmt.Errorf("get browser state Secret %q for update: %w", s.key.Name, err) + } + if sec.Data == nil { + sec.Data = map[string][]byte{} + } + sec.Data[browserStateSecretDataKey] = data + if err := s.c.Update(ctx, &sec); err != nil { + return fmt.Errorf("update browser state Secret %q: %w", s.key.Name, err) + } + return nil +} + +// captureBrowserStateOnStop runs each persistent toolbox's SIGTERM-time state +// capture on its own bounded context. Best-effort: a capture failure is logged +// and never blocks the stop (the pod is terminating anyway). A nil/empty set +// (ephemeral-only or pre-boot SIGTERM) is a no-op. +func captureBrowserStateOnStop(capturers *[]browsertoolboxsynth.StateCapturer) { + if capturers == nil { + return + } + for _, sc := range *capturers { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := sc.CaptureState(ctx); err != nil { + slog.Info("browser: capture-on-stop failed (best-effort)", "err", err.Error()) + } + cancel() + } +} diff --git a/pkg/agent/runner/browser_unattended.go b/pkg/agent/runner/browser_unattended.go new file mode 100644 index 00000000..0b8ff1fe --- /dev/null +++ b/pkg/agent/runner/browser_unattended.go @@ -0,0 +1,140 @@ +package runner + +import ( + "log/slog" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" + "github.com/authzed/openagentprimitives/pkg/tools/browserschema" +) + +// UnattendedBrowserSlotBindings derives the web_host slot grants an operator's +// BrowserSite.Unattended declaration (C8) authorizes UNCONDITIONALLY, with no +// human card: for every resolved BrowserToolbox, every EXPLICIT site +// (Spec.Access.Sites -- identity-catalog hosts are never unattended, so +// EffectiveSites is deliberately not read here) that sets Unattended under +// allowlist mode contributes one binding on its canonical host. +// +// A blocklist toolbox -- or one with no bound identity -- is excluded even if a +// site somehow set Unattended under it (defense in depth -- the CRD's +// field-scoped XValidation rule on BrowserToolbox.Spec already refuses BOTH +// combinations at admission, and the CLI's ValidateFile mirrors it, so reaching +// this function should never happen; these are the fail-closed backstops if it +// ever does). The identity backstop is symmetric with the mode one on purpose: +// an unattended grant signs in as the bound identity's login, so without an +// identity there is nothing to sign in as. A host that does +// not canonicalize is skipped and logged rather than aborting every other +// binding in the batch -- the same "drop and say why" shape +// BindClassDefaults's own default-derivation failures use. +// +// The caller (internal/cmd/runner's session cold start) writes the result +// through engine.Engine.GrantUnattendedSlots -- see that method's doc for why +// this must NOT go through BindClassDefaults. +// +// transforms is the per-type value-transform chain the AgentClass published on +// status.resolvedSlots (runner.SlotTransformsOf) -- the SAME source +// BindClassDefaults and the plan-gate approval bind derive their object ids +// from, so this cold-start grant cannot mint a different id than the acting +// tool's Check or an approved card would. For web_host that chain is +// spicedb_escape: a dotted host is not a legal SpiceDB object id, so writing the +// grant on the raw host would fail WriteRelationships (or, if it somehow landed, +// never match the escaped id the Check resolves). This is the ONE grant-write +// site that bypasses the tool-check derivation, so it must apply the same +// transform explicitly. +func UnattendedBrowserSlotBindings(toolboxes []spiceboxv1alpha1.ResolvedBrowserToolbox, transforms map[string][]string) []authz.SlotBinding { + var out []authz.SlotBinding + for _, rt := range toolboxes { + if rt.Spec.Access.Mode != browserpolicy.ModeAllowlist { + for _, site := range rt.Spec.Access.Sites { + if site.Unattended { + slog.Info("browser toolbox: unattended site under a non-allowlist mode; refusing the implicit grant", + "browser", rt.Name, "host", site.Host, "mode", rt.Spec.Access.Mode) + } + } + continue + } + if rt.Spec.Access.Identity == "" { + for _, site := range rt.Spec.Access.Sites { + if site.Unattended { + slog.Info("browser toolbox: unattended site without a bound identity; refusing the implicit grant", + "browser", rt.Name, "host", site.Host) + } + } + continue + } + for _, site := range rt.Spec.Access.Sites { + if !site.Unattended { + continue + } + host, err := browserpolicy.CanonicalHost(site.Host) + if err != nil { + slog.Info("browser toolbox: unattended site's host could not be canonicalized; skipped", + "browser", rt.Name, "host", site.Host, "err", err.Error()) + continue + } + // Escape the host into a legal SpiceDB object id via the class's + // published web_host chain (spicedb_escape) -- dots are illegal in a + // SpiceDB object id, and this is the same chain the acting tool's + // Check and an approved plan-gate card derive their id from, so a + // grant written here matches the id a later acting Check resolves. + id, err := authz.NewObjectID(host, transforms[browserschema.WebHostType]) + if err != nil { + slog.Info("browser toolbox: unattended site's host could not become an object id; skipped", + "browser", rt.Name, "host", host, "err", err.Error()) + continue + } + out = append(out, authz.SlotBinding{ + ResourceType: browserschema.WebHostType, + ResourceID: id, + Permission: browserschema.ActPermission, + }) + } + } + return out +} + +// BrowserHostIdentityLabels maps every resolved browser toolbox's known host +// (EffectiveSites -- explicit sites plus any identity-derived ones) to the +// bound identity's login whose Hosts pattern matches it, using the SAME +// suffix-by-label/exact matching browserpolicy.Match applies to egress. A +// host matched by more than one login keeps the first match; a host matched +// by none is simply absent, and the card then names the host with no +// signing-in clause (see plangate.CardInput.WebHostIdentityLabels). +// +// Feeds Loop.PlanGateWebHostIdentityLabels, which is display-only and never +// an input to any authorization decision: naming the WRONG identity on a +// card would be a readability bug, not a security one, because the grant +// itself (GrantUnattendedSlots / an approved plan-gate card) never reads this +// map. +func BrowserHostIdentityLabels(toolboxes []spiceboxv1alpha1.ResolvedBrowserToolbox) map[string]string { + out := map[string]string{} + for _, rt := range toolboxes { + for _, site := range rt.EffectiveSites { + if _, ok := out[site.Host]; ok { + continue + } + if label, ok := matchingLoginLabel(rt.Catalog, site.Host); ok { + out[site.Host] = label + } + } + } + return out +} + +// matchingLoginLabel returns the Label of the first catalog entry whose Hosts +// matches host, using browserpolicy's own equality-or-suffix-by-label rule so +// this never disagrees with what the egress gate itself considers a match. +func matchingLoginLabel(catalog []spiceboxv1alpha1.BrowserLoginRef, host string) (string, bool) { + for _, login := range catalog { + if login.Label == "" { + continue + } + for _, h := range login.Hosts { + if browserpolicy.Match(browserpolicy.Site{Host: h.Host, Exact: h.Exact}, host) { + return login.Label, true + } + } + } + return "", false +} diff --git a/pkg/agent/runner/browser_unattended_integration_test.go b/pkg/agent/runner/browser_unattended_integration_test.go new file mode 100644 index 00000000..8474f9f4 --- /dev/null +++ b/pkg/agent/runner/browser_unattended_integration_test.go @@ -0,0 +1,201 @@ +//go:build integration + +// pkg/agent/runner/browser_unattended_integration_test.go +// +// Proves C8's unattended grant end to end against a REAL SpiceDB, using a REAL +// DOTTED hostname (app.example.com): given a ResolvedBrowserToolbox whose spec +// names one unattended site and one attended site, +// runner.UnattendedBrowserSlotBindings derives exactly the unattended one ON ITS +// ESCAPED OBJECT ID, and engine.Engine.GrantUnattendedSlots writes a real +// slot_grant_act relationship for it -- with no Check, no human card -- while the +// attended site gets none. +// +// This is the regression guard the dots bug (C-R4) lacked. A bare dotted +// hostname is NOT a legal SpiceDB object id (SpiceDB's charset is +// [a-zA-Z0-9_/\-=|+] -- "." is not in it), so the pre-fix code's +// authz.NewObjectID(host, nil) wrote the grant on the raw "app.example.com" and +// WriteRelationships rejected it with "object_id: does not match regex pattern". +// The fix threads the class's published web_host chain (spicedb_escape) through +// UnattendedBrowserSlotBindings so the grant is written on "app=2Eexample=2Ecom", +// the identical id the acting tool's Check resolves. Revert that thread and this +// test fails on the GrantUnattendedSlots write. +// +// This deliberately exercises the SAME mechanism the ruling (C-R3) requires: +// authz.GrantSlots called unconditionally, never authz.BindClassDefaults / +// BindClassDefaults's admissibleCandidates gate, which would Check web_host's +// inert owner leg and drop every candidate before any grant existed. +package runner_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/agent/runner" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/authz/engine" + "github.com/authzed/openagentprimitives/pkg/authz/spicedb" + "github.com/authzed/openagentprimitives/pkg/authz/spicedb/toolcheck" + "github.com/authzed/openagentprimitives/pkg/tools/browserschema" + "github.com/authzed/openagentprimitives/test/testspicedb" +) + +// webHostComposedFragment is the web_host definition as ComposeSlots emits it +// for a declared web_host#act slot -- the same fixture +// pkg/authz/spicedb/web_host_slot_integration_test.go uses, duplicated here +// rather than imported because it is a small schema literal, not a function, +// and the two tests belong to different packages. +const webHostComposedFragment = ` +definition web_host { + relation slot_grant_act: agentsession with expiration + relation owner: user + permission act = slot_grant_act->interact + owner +} +` + +// webHostTransforms is the per-type chain the AgentClass publishes on +// status.resolvedSlots for web_host (runner.SlotTransformsOf), passed into +// UnattendedBrowserSlotBindings exactly as the runner cold start passes +// runner.SlotTransformsOf(&class). +var webHostTransforms = map[string][]string{browserschema.WebHostType: browserschema.WebHostValueTransforms()} + +// actPerm is the acting tool's External web_host#act check, including the +// spicedb_escape chain the tool's Permission() now carries -- so the check path +// resolves the SAME escaped object id the grant was written on. +func actPerm() authz.Permission { + return authz.Permission{ + StateImpact: authz.External, + Check: &authz.PermissionCheck{ + ResourceType: browserschema.WebHostType, + Permission: browserschema.ActPermission, + ResourceIDTemplate: "{host}", + ResourceIDTransforms: browserschema.WebHostValueTransforms(), + }, + } +} + +func TestIntegration_UnattendedBrowserGrant_RealSpiceDB(t *testing.T) { + endpoint := testspicedb.Endpoint(t) + token := testspicedb.UniqueToken(t) + testspicedb.WriteSchemaWithExtra(t, endpoint, token, webHostComposedFragment) + c, err := spicedb.NewClient(endpoint, token, true) + require.NoError(t, err, "NewClient") + t.Cleanup(func() { _ = c.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + const ns, sessName = "ns", "s1" + const unattendedHost = "app.example.com" // dotted: real hostname shape + const attendedHost = "portal.example.com" // dotted: must NOT bind + const escapedUnattended = "app=2Eexample=2Ecom" // what spicedb_escape produces + sess := authz.SessionRef{Namespace: ns, Name: sessName} + t.Cleanup(func() { _ = c.DeleteSlotGrants(context.Background(), ns, sessName) }) + + toolboxes := []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{ + {Host: unattendedHost, Unattended: true}, // unattended: must bind + {Host: attendedHost}, // attended: must not bind + }, + Identity: "id1", + }, + }, + }} + + bindings := runner.UnattendedBrowserSlotBindings(toolboxes, webHostTransforms) + require.Len(t, bindings, 1, "only the unattended site derives a binding") + assert.Equal(t, "web_host", bindings[0].ResourceType) + assert.Equal(t, "act", bindings[0].Permission) + // By VALUE: the grant must be written on the escaped id, so a regression to a + // raw dotted id is caught here rather than only by the write failing. + assert.Equal(t, escapedUnattended, bindings[0].ResourceID.String(), + "the grant must be written on the spicedb_escaped host, not the raw dotted one") + + eng := engine.New(engine.Deps{RelWriter: c.Relations(), SessionExpiration: time.Hour}) + require.NoError(t, eng.GrantUnattendedSlots(ctx, sess, bindings), + "GrantUnattendedSlots must accept the escaped id (the raw dotted id is rejected by SpiceDB)") + + // *Client satisfies toolcheck.Client (CheckPermission) directly, so the + // real external-check path runs against real SpiceDB -- the same path + // browserTool.Permission()'s External check uses at dispatch. + checker := toolcheck.Checker{Cli: c} + + // The unattended host: a real slot_grant_act relationship now exists on the + // escaped id, scoped to this session -- written with no Check and no human + // card. The acting check resolves the SAME escaped id and is allowed. + allowed := checker.CheckToolCall(ctx, actPerm(), authz.Inputs{ + Subject: "u-1", + Args: map[string]any{"host": unattendedHost}, + AgentSessionRef: ns + "/" + sessName, + SlotResourceTypes: []string{browserschema.WebHostType}, + }) + assert.Equal(t, authz.OutcomeAllowed, allowed.Outcome, + "web_host:app=2Eexample=2Ecom#act@agentsession:ns/s1 must be permitted at session start with nobody asked") + + // The attended host: UnattendedBrowserSlotBindings derived no binding for it, + // so no grant was ever written and acting on it is still denied. + denied := checker.CheckToolCall(ctx, actPerm(), authz.Inputs{ + Subject: "u-1", + Args: map[string]any{"host": attendedHost}, + AgentSessionRef: ns + "/" + sessName, + SlotResourceTypes: []string{browserschema.WebHostType}, + }) + assert.Equal(t, authz.OutcomeDenied, denied.Outcome, + "an attended site must get no implicit grant") +} + +// TestIntegration_UnattendedBrowserGrant_BlocklistRefusesEvenAgainstRealSpiceDB +// is the defensive backstop's own proof: UnattendedBrowserSlotBindings +// refuses a blocklist toolbox's unattended site (the CRD's own XValidation +// rule should already make this combination unreachable; this proves the +// derivation does not ALSO need that rule to hold -- it writes nothing of its +// own accord), so no grant ever reaches SpiceDB and the host stays denied. +func TestIntegration_UnattendedBrowserGrant_BlocklistRefusesEvenAgainstRealSpiceDB(t *testing.T) { + endpoint := testspicedb.Endpoint(t) + token := testspicedb.UniqueToken(t) + testspicedb.WriteSchemaWithExtra(t, endpoint, token, webHostComposedFragment) + c, err := spicedb.NewClient(endpoint, token, true) + require.NoError(t, err, "NewClient") + t.Cleanup(func() { _ = c.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + const ns, sessName = "ns", "s2" + sess := authz.SessionRef{Namespace: ns, Name: sessName} + t.Cleanup(func() { _ = c.DeleteSlotGrants(context.Background(), ns, sessName) }) + + toolboxes := []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "blocklist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "app.example.com", Unattended: true}}, + }, + }, + }} + + bindings := runner.UnattendedBrowserSlotBindings(toolboxes, webHostTransforms) + require.Empty(t, bindings, "a blocklist toolbox must derive no bindings even when a site sets unattended") + + eng := engine.New(engine.Deps{RelWriter: c.Relations(), SessionExpiration: time.Hour}) + require.NoError(t, eng.GrantUnattendedSlots(ctx, sess, bindings), "GrantUnattendedSlots on an empty batch is a no-op") + + checker := toolcheck.Checker{Cli: c} + denied := checker.CheckToolCall(ctx, actPerm(), authz.Inputs{ + Subject: "u-1", + Args: map[string]any{"host": "app.example.com"}, + AgentSessionRef: ns + "/" + sessName, + SlotResourceTypes: []string{browserschema.WebHostType}, + }) + assert.Equal(t, authz.OutcomeDenied, denied.Outcome, + "a blocklist toolbox's unattended site must never be granted") +} diff --git a/pkg/agent/runner/browser_unattended_test.go b/pkg/agent/runner/browser_unattended_test.go new file mode 100644 index 00000000..e1b93931 --- /dev/null +++ b/pkg/agent/runner/browser_unattended_test.go @@ -0,0 +1,174 @@ +package runner + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/tools/browserschema" +) + +// webHostTransforms is the per-type chain the AgentClass publishes for web_host +// (SlotTransformsOf), passed into UnattendedBrowserSlotBindings exactly as the +// runner cold start passes runner.SlotTransformsOf(&class). It escapes a dotted +// host into a legal SpiceDB object id. +var webHostTransforms = map[string][]string{browserschema.WebHostType: browserschema.WebHostValueTransforms()} + +// TestUnattendedBrowserSlotBindings covers C8's required cases: an unattended +// allowlist site (with a bound identity) binds, an attended (Unattended=false) +// site does not, a blocklist toolbox refuses an unattended site even if one +// somehow sets the flag, and -- symmetric with the blocklist backstop -- an +// allowlist toolbox with NO bound identity refuses it too. The CRD's own +// XValidation rule should already prevent both bad combinations from existing; +// these are the defensive fail-closed backstops. +func TestUnattendedBrowserSlotBindings(t *testing.T) { + cases := []struct { + name string + toolboxes []spiceboxv1alpha1.ResolvedBrowserToolbox + wantHosts []string + wantEmpty bool + }{ + { + name: "unattended host under allowlist with identity binds (escaped id)", + toolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Identity: "web-creds", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "aa.com", Unattended: true}}, + }, + }, + }}, + // The grant is written on the ESCAPED host (dots are illegal in a + // SpiceDB object id), not the raw "aa.com". + wantHosts: []string{"aa=2Ecom"}, + }, + { + name: "attended (unattended=false) host does not bind", + toolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Identity: "web-creds", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "bb.com"}}, + }, + }, + }}, + wantEmpty: true, + }, + { + name: "unattended host under blocklist is refused (defensive)", + toolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "blocklist", + Identity: "web-creds", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "aa.com", Unattended: true}}, + }, + }, + }}, + wantEmpty: true, + }, + { + name: "unattended host under allowlist WITHOUT identity is refused (defensive)", + toolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "aa.com", Unattended: true}}, + }, + }, + }}, + wantEmpty: true, + }, + { + name: "mixed: one unattended, one attended, in one toolbox", + toolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Identity: "web-creds", + Sites: []spiceboxv1alpha1.BrowserSite{ + {Host: "aa.com", Unattended: true}, + {Host: "bb.com"}, + }, + }, + }, + }}, + wantHosts: []string{"aa=2Ecom"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := UnattendedBrowserSlotBindings(tc.toolboxes, webHostTransforms) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + require.Len(t, got, len(tc.wantHosts)) + for i, h := range tc.wantHosts { + assert.Equal(t, "web_host", got[i].ResourceType) + assert.Equal(t, "act", got[i].Permission) + assert.Equal(t, h, got[i].ResourceID.String()) + } + }) + } +} + +// TestUnattendedBrowserSlotBindings_IgnoresEffectiveSites proves Unattended is +// read only from the EXPLICIT spec sites, never from identity-derived +// EffectiveSites entries -- an identity-catalog host is never unattended, so +// even an EffectiveSites entry that happens to carry the same host as an +// unattended explicit site must not cause a double count, and an +// EffectiveSites-only host (Source "identity", no matching explicit site) +// must never bind. +func TestUnattendedBrowserSlotBindings_IgnoresEffectiveSites(t *testing.T) { + toolboxes := []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Identity: "web-creds", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "aa.com", Unattended: true}}, + }, + }, + EffectiveSites: []spiceboxv1alpha1.BrowserEffectiveSite{ + {Host: "aa.com", Source: "explicit"}, + {Host: "identity-only.com", Source: "identity"}, + }, + }} + got := UnattendedBrowserSlotBindings(toolboxes, webHostTransforms) + require.Len(t, got, 1) + assert.Equal(t, "aa=2Ecom", got[0].ResourceID.String(), "the grant is written on the escaped host") +} + +// TestBrowserHostIdentityLabels covers the card-display matching: an +// EffectiveSite host whose host matches a catalog login's Hosts pattern +// (suffix-by-label or exact) resolves to that login's Label; a host with no +// match is simply absent. +func TestBrowserHostIdentityLabels(t *testing.T) { + toolboxes := []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "browser", + EffectiveSites: []spiceboxv1alpha1.BrowserEffectiveSite{ + {Host: "aa.com"}, + {Host: "sub.bb.com"}, + {Host: "cc.com"}, + }, + Catalog: []spiceboxv1alpha1.BrowserLoginRef{ + {ID: "l1", Label: "Acme SSO", Hosts: []spiceboxv1alpha1.BrowserLoginHost{{Host: "aa.com"}}}, + {ID: "l2", Label: "Beta Portal", Hosts: []spiceboxv1alpha1.BrowserLoginHost{{Host: "bb.com"}}}, + }, + }} + got := BrowserHostIdentityLabels(toolboxes) + assert.Equal(t, "Acme SSO", got["aa.com"]) + assert.Equal(t, "Beta Portal", got["sub.bb.com"], "non-exact login host matches a subdomain, mirroring browserpolicy.Match") + _, ok := got["cc.com"] + assert.False(t, ok, "a host with no matching login must be absent, not a guessed label") +} diff --git a/pkg/agent/runner/hooks_dataplane.go b/pkg/agent/runner/hooks_dataplane.go index 702984e7..7e0b3d84 100644 --- a/pkg/agent/runner/hooks_dataplane.go +++ b/pkg/agent/runner/hooks_dataplane.go @@ -80,10 +80,11 @@ func init() { // can tell when two slots of different types are one instance — // git names a repository by URL and gh by OWNER/NAME, and a human // deciding should see one target, not two. - SlotValueTransforms: l.PlanGateSlotTransforms, - PermissionTitles: l.PlanGatePermissionTitles, - ResourceDisplays: l.PlanGateResourceDisplays, - Resolve: l.planGateHandleResolver(), + SlotValueTransforms: l.PlanGateSlotTransforms, + PermissionTitles: l.PlanGatePermissionTitles, + ResourceDisplays: l.PlanGateResourceDisplays, + WebHostIdentityLabels: l.PlanGateWebHostIdentityLabels, + Resolve: l.planGateHandleResolver(), // Records makes the active phase a FOLD of the log rather than a // value the gate holds. Without it the gate would sit on state a // restart loses and the agent's document could contradict. diff --git a/pkg/agent/runner/host.go b/pkg/agent/runner/host.go index e9ef1efe..4f9c4ed2 100644 --- a/pkg/agent/runner/host.go +++ b/pkg/agent/runner/host.go @@ -43,6 +43,16 @@ type runnerHost struct { approvalDenyMu sync.Mutex approvalDenyReason string + // humanStepCode carries the human's typed login code (login_human_step + // kind:code) from AwaitDecision's resolution to humanStepRequester.RequestCode, + // which reads it off this SAME host (one host per round-trip, see hostBinder). + // It is a SECRET: captured here only so the runner can type it into the page, + // never returned as a tool result, logged, or shown to the model. Guarded by + // humanStepCodeMu because AwaitDecision's goroutine writes it and RequestCode + // reads it. Mirrors approvalDenyReason's capture-on-the-host handoff exactly. + humanStepCodeMu sync.Mutex + humanStepCode string + // coldStart* capture the SessionStart ColdStartScope hook's turn-0 placement // (place cleaned / place raw / place nothing) so it reaches Run() after the // SessionStart executor returns Allow. The generic Decision deliberately @@ -142,6 +152,25 @@ func (h *runnerHost) setApprovalDenyReason(reason string) { h.approvalDenyReason = reason } +// setHumanStepCode captures the human's typed login code during AwaitDecision's +// login_human_step resolution. SECRET — see the field doc. +func (h *runnerHost) setHumanStepCode(code string) { + h.humanStepCodeMu.Lock() + defer h.humanStepCodeMu.Unlock() + h.humanStepCode = code +} + +// takeHumanStepCode returns and CLEARS the captured login code, so the secret +// does not linger on the host after RequestCode has typed it into the page. It is +// the humanStepCodeHost capability humanStepRequester.RequestCode reads back. +func (h *runnerHost) takeHumanStepCode() string { + h.humanStepCodeMu.Lock() + defer h.humanStepCodeMu.Unlock() + c := h.humanStepCode + h.humanStepCode = "" + return c +} + // newRunnerHost constructs a runnerHost for the given Loop and session. func newRunnerHost(l *Loop, sess hostSession) *runnerHost { return &runnerHost{l: l, sess: sess} diff --git a/pkg/agent/runner/host_approval.go b/pkg/agent/runner/host_approval.go index 596124c6..213e645e 100644 --- a/pkg/agent/runner/host_approval.go +++ b/pkg/agent/runner/host_approval.go @@ -271,6 +271,15 @@ func (h *runnerHost) PublishApproval(ctx context.Context, ask pipeline.ApprovalA h.recordDecisionAsked(ctx, reqID, ask.Kind) return reqID, nil + case "login_human_step": + pa, err := h.buildLoginHumanStepPending(ctx, reqID, ask) + if err != nil { + return "", fmt.Errorf("host: build login_human_step approval: %w", err) + } + h.pending().store(reqID, pa) + h.recordDecisionAsked(ctx, reqID, ask.Kind) + return reqID, nil + default: return "", fmt.Errorf("host: unknown approval kind %q", ask.Kind) } @@ -476,6 +485,21 @@ func (h *runnerHost) AwaitDecision(ctx context.Context, reqID string, timeout ti // verified decider — never anything the runner could supply // post-publish. approved/timedOut flow back to preferencesSaver.Save // unchanged, which is the whole of what this case needs to do. + + case "login_human_step": + // No grant-write effect: the approved bool alone tells request_human_step + // whether the person completed the sign-in step (pure confirm, like + // content_inspection). approved/timedOut flow back to + // humanStepRequester.Confirm/RequestCode unchanged. + // + // The kind:code half additionally captures the human's typed code off the + // resolved decision onto this host, where RequestCode reads it back (same + // host, one round-trip). It is a SECRET: captured only so the runner can + // type it into the page — never returned to the model. Empty for the confirm + // half and for every deny/timeout, so this is a no-op there. + if d.Approved && d.ResponseText != "" { + h.setHumanStepCode(d.ResponseText) + } } return d.Approved, d.ApproverID, timedOut, nil @@ -522,6 +546,13 @@ func (h *runnerHost) publishTimeoutApplied(ctx context.Context, reqID, kind stri // tells it the deadline passed. Deny-only: a lapsed deadline never // commits a preference. category = categories.UserPreferenceConfirm + case "login_human_step": + // Same reason again: a lapsed login_human_step confirm must clear + // channelsd's pending queue + the LoginHumanStepPending condition, else + // the browser session's card strands with live buttons. Deny-only: a + // lapsed deadline never completes a sign-in step — request_human_step + // returns fail-closed. + category = categories.LoginHumanStep default: return } @@ -1161,6 +1192,100 @@ func (h *runnerHost) buildContentInspectionPending(ctx context.Context, reqID st }, nil } +// buildLoginHumanStepPending constructs the pending state + interaction envelope +// for a login_human_step confirm — a browser toolbox's request_human_step asking +// a person to complete a sign-in step the agent cannot do itself (a push approval +// today). +// +// It mirrors buildContentInspectionPending: host-side approver resolution on the +// session approve-set (DecideApprovers — a sign-in step names no external +// resource counterparty), approve/deny actions, a future ExpiresAt, and +// fail-closed on an empty approve-set (surfacing "no one has standing" beats +// prompting into the void, and InteractionRequestPayload.Validate rejects an +// empty approvers scope anyway). There is deliberately NO AwaitDecision +// post-effect and NO grant write: the approved bool alone tells +// request_human_step whether the person completed the step. +// +// The model-authored prompt is UNTRUSTED and travels in Excerpt (rendered inert +// by every surface), never in the trusted Lead/Body — the same rule +// content_inspection's excerpt and identity_choice's recommender reason follow. +func (h *runnerHost) buildLoginHumanStepPending(ctx context.Context, reqID string, ask pipeline.ApprovalAsk) (*pendingApproval, error) { + if h.l.InteractionRequestPublish == nil { + return nil, fmt.Errorf("login_human_step approval unavailable (interaction publish hook not configured)") + } + sessNS, sessName := h.sess.Namespace, h.sess.Name + + prompt := extractString(ask.Payload, "prompt") + + sessionApproveSet := "agentsession:" + sessNS + "/" + sessName + "#approve" + approvers, aerr := h.resolveInteractionApprovers(ctx, sessionApproveSet, nil) + if aerr != nil { + return nil, fmt.Errorf("resolve login_human_step approvers: %w", aerr) + } + if len(approvers) == 0 { + return nil, fmt.Errorf("login_human_step approval has no resolvable approvers for %s", sessionApproveSet) + } + + pl := channelevents.InteractionRequestPayload{ + AgentSessionRef: channelevents.SessionRef{Namespace: sessNS, Name: sessName}, + Category: categories.LoginHumanStep, + RequestRef: reqID, + Lead: loginHumanStepLead(), + Excerpt: loginHumanStepExcerpt(prompt), + Actions: approveDenyActions(), + Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceApprovers, Approvers: approvers}, + ExpiresAt: expiresAtPtr(hostNow().Add(h.loginHumanStepTTL())), + Interruptible: true, + } + env, eerr := channelevents.BuildEnvelope(sessNS, sessName, channelevents.KindInteractionRequest, pl) + if eerr != nil { + return nil, fmt.Errorf("build login_human_step interaction envelope: %w", eerr) + } + + publish := h.l.InteractionRequestPublish + return &pendingApproval{ + kind: "login_human_step", + onPublish: func(c context.Context) error { + return publish(c, sessNS, sessName, env) + }, + }, nil +} + +// loginHumanStepTTL is the approval window for a login_human_step confirm — the +// SAME consolidated, tiered authz.approvalTimeout every other host-driven +// approval kind waits on (resolvedApprovalTimeout), guarded like +// contentInspectionTTL so a degenerate resolution never yields a zero-length +// await. +func (h *runnerHost) loginHumanStepTTL() time.Duration { + ttl := h.l.resolvedApprovalTimeout() + if ttl <= 0 { + return 10 * time.Minute + } + return ttl +} + +// loginHumanStepLead is the fixed, trusted headline the generic renderer bolds. +// The surface draws the tone chip from the category (ToneRoutine). The +// model-authored prompt goes in the Excerpt below, never here. +func loginHumanStepLead() string { return "A sign-in step needs you" } + +// loginHumanStepExcerpt carries the model-authored prompt as inert content. It +// is agent-authored and therefore untrusted: the trusted fields (Lead) are +// rendered as live markup by every surface, so the prompt MUST route through +// Excerpt, which every surface renders inert — the same discipline +// content_inspection's flagged excerpt and identity_choice's recommender reason +// follow. Rune-capped at the shared bound. An empty prompt yields no excerpt. +func loginHumanStepExcerpt(prompt string) *channelevents.InteractionExcerpt { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil + } + return &channelevents.InteractionExcerpt{ + Label: "What the agent is asking you to do", + Content: stringsx.CapRunes(prompt, maxExcerptRunes), + } +} + // buildPreferenceSavePending constructs the pending state + interaction // envelope for a preference_save confirm. // diff --git a/pkg/agent/runner/host_approval_login_human_step_test.go b/pkg/agent/runner/host_approval_login_human_step_test.go new file mode 100644 index 00000000..7956a91a --- /dev/null +++ b/pkg/agent/runner/host_approval_login_human_step_test.go @@ -0,0 +1,244 @@ +package runner + +// host_approval_login_human_step_test.go drives the login_human_step confirm +// (request_human_step's blocking half) through the runner host's +// PublishApproval → AwaitDecision round-trip, mirroring the content_inspection +// suite: build-shape, approve, deny, the fail-closed timeout, and the +// no-approvers fail-closed guard. + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz/guardian/approval" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" + "github.com/authzed/openagentprimitives/pkg/platform/pipeline" +) + +// loginHumanStepAsk is the representative confirm ask humanStepRequester.Confirm +// publishes: kind=login_human_step with the model-authored prompt. +func loginHumanStepAsk(prompt string) pipeline.ApprovalAsk { + return pipeline.ApprovalAsk{ + Kind: "login_human_step", + Summary: prompt, + Payload: map[string]any{"prompt": prompt}, + } +} + +func loginHumanStepLoop(orch *approval.Orchestrator, publish func(ns, name string, env channelevents.Envelope)) *Loop { + return &Loop{ + Status: LocalStatusPatcher(), + Approval: orch, + ChannelKind: "slack", + SpiceDBLookupSubjects: func(_ context.Context, _, _ string) ([]string, error) { + return []string{"user:owner@corp.example"}, nil + }, + InteractionRequestPublish: func(_ context.Context, ns, name string, env channelevents.Envelope) error { + if publish != nil { + publish(ns, name, env) + } + return nil + }, + } +} + +// TestBuildLoginHumanStepPending_BuildsInteractionRequest pins the published +// card: category login_human_step, AudienceApprovers resolved from the session +// approve-set, approve/deny actions, a future ExpiresAt, Interruptible, and — +// the security-relevant part — the model-authored prompt carried in the inert +// Excerpt, never in the trusted Lead. +func TestBuildLoginHumanStepPending_BuildsInteractionRequest(t *testing.T) { + const prompt = "united.com sent a code to your email — approve the push on your phone " + var published channelevents.Envelope + l := loginHumanStepLoop(approval.New(), func(_, _ string, env channelevents.Envelope) { published = env }) + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk(prompt)) + require.NoError(t, err) + require.NotEmpty(t, reqID) + + pa, ok := h.pending().m[reqID] + require.True(t, ok, "the pending approval must be registered under reqID") + require.NoError(t, pa.onPublish(context.Background())) + + require.Equal(t, channelevents.KindInteractionRequest, published.Kind) + var pl channelevents.InteractionRequestPayload + require.NoError(t, json.Unmarshal(published.Payload, &pl)) + + assert.Equal(t, categories.LoginHumanStep, pl.Category) + assert.Equal(t, reqID, pl.RequestRef) + assert.Equal(t, channelevents.SessionRef{Namespace: "ns", Name: "s"}, pl.AgentSessionRef) + + assert.Equal(t, channelevents.AudienceApprovers, pl.Audience.Scope) + require.Len(t, pl.Audience.Approvers, 1) + assert.Equal(t, "user:owner@corp.example", pl.Audience.Approvers[0].Subject.String()) + assert.Equal(t, "slack", pl.Audience.Approvers[0].Kind.String()) + + // The model-authored prompt must NOT be in the trusted Lead (rendered as live + // markup) — it rides the Excerpt, which every surface renders inert. + assert.NotContains(t, pl.Lead, "", "the untrusted prompt must never land in the trusted Lead") + require.NotNil(t, pl.Excerpt, "the model's prompt must be carried as an inert excerpt") + assert.Contains(t, pl.Excerpt.Content, "", "the prompt is carried verbatim in the inert excerpt") + + require.Len(t, pl.Actions, 2) + assert.Equal(t, "approve", pl.Actions[0].ID) + assert.Equal(t, "deny", pl.Actions[1].ID) + assert.True(t, pl.Interruptible) + require.NotNil(t, pl.ExpiresAt) + assert.True(t, pl.ExpiresAt.After(time.Now())) + + assert.NoError(t, pl.Validate()) +} + +// TestBuildLoginHumanStepPending_FailsClosedWithoutApprovers proves the publisher +// refuses to build an undeliverable card when the session approve-set resolves to +// no one — parity with content_inspection. +func TestBuildLoginHumanStepPending_FailsClosedWithoutApprovers(t *testing.T) { + l := &Loop{ + Status: LocalStatusPatcher(), + Approval: approval.New(), + ChannelKind: "slack", + SpiceDBLookupSubjects: func(_ context.Context, _, _ string) ([]string, error) { return nil, nil }, + InteractionRequestPublish: func(_ context.Context, _, _ string, _ channelevents.Envelope) error { return nil }, + } + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + _, err := h.PublishApproval(context.Background(), loginHumanStepAsk("approve the push")) + require.Error(t, err, "no resolvable approvers must fail closed") + assert.Contains(t, err.Error(), "approver") +} + +// TestAwaitDecision_LoginHumanStep_Approve drives the full round-trip: publish → +// approve delivered → the tool's block resumes with approved=true. +func TestAwaitDecision_LoginHumanStep_Approve(t *testing.T) { + orch := approval.New() + l := loginHumanStepLoop(orch, nil) + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk("approve the push")) + require.NoError(t, err) + + go func() { + time.Sleep(10 * time.Millisecond) + orch.DeliverDecision(reqID, approval.Decision{Approved: true, ApproverID: "alice"}) + }() + + approved, by, timedOut, err := h.AwaitDecision(context.Background(), reqID, 5*time.Second) + require.NoError(t, err) + assert.True(t, approved) + assert.False(t, timedOut) + assert.Equal(t, "alice", by) +} + +// TestAwaitDecision_LoginHumanStep_CapturesTypedCode is the C7 runner-host crux: +// an approval that carries a free-text code (the human's login code) is captured on +// the host so humanStepRequester.RequestCode can read it back — the carrier leg +// that lets the runner type the code into the page. The code is NOT part of +// AwaitDecision's return (approved/by/timedOut only), so it never flows through a +// path a log or the model reads; it rides out-of-band on the host and is +// take-cleared. +func TestAwaitDecision_LoginHumanStep_CapturesTypedCode(t *testing.T) { + orch := approval.New() + l := loginHumanStepLoop(orch, nil) + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk("enter the emailed code")) + require.NoError(t, err) + + go func() { + time.Sleep(10 * time.Millisecond) + orch.DeliverDecision(reqID, approval.Decision{Approved: true, ApproverID: "alice", ResponseText: "123456"}) + }() + + approved, by, timedOut, err := h.AwaitDecision(context.Background(), reqID, 5*time.Second) + require.NoError(t, err) + assert.True(t, approved) + assert.False(t, timedOut) + assert.Equal(t, "alice", by) + + // The code was captured on the host, take-and-clear. + assert.Equal(t, "123456", h.takeHumanStepCode(), "the typed code must be captured on the host for RequestCode to read") + assert.Empty(t, h.takeHumanStepCode(), "the code must be cleared after the first read (never lingers)") +} + +// TestAwaitDecision_LoginHumanStep_DenyCapturesNoCode proves a deny captures no +// code — a declined card types nothing even if (malformed) text rode along. +func TestAwaitDecision_LoginHumanStep_DenyCapturesNoCode(t *testing.T) { + orch := approval.New() + l := loginHumanStepLoop(orch, nil) + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk("enter the code")) + require.NoError(t, err) + + go func() { + time.Sleep(10 * time.Millisecond) + orch.DeliverDecision(reqID, approval.Decision{Approved: false, ApproverID: "bob", ResponseText: "should-not-type"}) + }() + + approved, _, _, err := h.AwaitDecision(context.Background(), reqID, 5*time.Second) + require.NoError(t, err) + assert.False(t, approved) + assert.Empty(t, h.takeHumanStepCode(), "a denied card must capture no code") +} + +// TestAwaitDecision_LoginHumanStep_Deny proves an explicit decline resumes as +// approved=false (not a timeout). +func TestAwaitDecision_LoginHumanStep_Deny(t *testing.T) { + orch := approval.New() + l := loginHumanStepLoop(orch, nil) + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk("approve the push")) + require.NoError(t, err) + + go func() { + time.Sleep(10 * time.Millisecond) + orch.DeliverDecision(reqID, approval.Decision{Approved: false, ApproverID: "bob"}) + }() + + approved, by, timedOut, err := h.AwaitDecision(context.Background(), reqID, 5*time.Second) + require.NoError(t, err) + assert.False(t, approved) + assert.False(t, timedOut) + assert.Equal(t, "bob", by) +} + +// TestAwaitDecision_LoginHumanStep_TimeoutFailsClosed is Review Focus 5: a lapsed +// confirm resolves timedOut=true / approved=false (the call does NOT silently +// succeed) AND clears channelsd's surface with an interaction_applied(expired), +// so the browser card never strands with live buttons. +func TestAwaitDecision_LoginHumanStep_TimeoutFailsClosed(t *testing.T) { + orch := approval.New() + var applied []channelevents.Envelope + l := loginHumanStepLoop(orch, nil) + l.TimeoutAppliedPublish = func(_ context.Context, _, _ string, env channelevents.Envelope) error { + applied = append(applied, env) + return nil + } + h := newRunnerHost(l, hostSession{Namespace: "ns", Name: "s"}) + + reqID, err := h.PublishApproval(context.Background(), loginHumanStepAsk("approve the push")) + require.NoError(t, err) + + // No decision delivered: the deadline elapses. A timeout is a sticky deny, + // never a Halt. + approved, _, timedOut, err := h.AwaitDecision(context.Background(), reqID, 20*time.Millisecond) + require.NoError(t, err) + assert.True(t, timedOut, "a lapsed confirm must report timedOut") + assert.False(t, approved, "a lapsed confirm must NOT silently succeed (fail-closed)") + + require.Len(t, applied, 1, "timeout must publish exactly one applied envelope to clear channelsd's surface") + assert.Equal(t, channelevents.KindInteractionApplied, applied[0].Kind) + var pl channelevents.InteractionAppliedPayload + require.NoError(t, json.Unmarshal(applied[0].Payload, &pl)) + assert.Equal(t, categories.LoginHumanStep, pl.Category) + assert.Equal(t, reqID, pl.RequestRef) + assert.Equal(t, channelevents.OutcomeExpired, pl.Outcome) +} diff --git a/pkg/agent/runner/humanstep_deps.go b/pkg/agent/runner/humanstep_deps.go new file mode 100644 index 00000000..c1ecdf0a --- /dev/null +++ b/pkg/agent/runner/humanstep_deps.go @@ -0,0 +1,179 @@ +package runner + +// humanstep_deps.go implements the runner-side browser.HumanStepRequester that a +// browser toolbox's request_human_step tool depends on. It mirrors +// preferences_deps.go exactly — binds ONE approvalHost per Confirm round-trip and +// publishes/awaits through it (the pendingApprovals-lives-on-the-host invariant, +// see hostBinder's doc) — and, like preferencesSaver, it WRITES NOTHING: the +// confirm's authority IS the human click, re-checked server-side by the +// login_human_step category's DecideApprovers policy before the applied envelope +// ever resumes the tool. +// +// Confirm publishes a login_human_step approval and blocks; Captcha publishes a +// one-way notice and does not block (there is no decision leg — the agent has +// reported it cannot proceed). + +import ( + "context" + "fmt" + "time" + + browsertool "github.com/authzed/openagentprimitives/pkg/agent/tool/browser" + "github.com/authzed/openagentprimitives/pkg/platform/pipeline" +) + +// defaultHumanStepTimeout is the Confirm wait when neither an explicit timeout +// nor a bound host that can resolve the session's tiered approval timeout +// (timeoutResolver) is available. Matches resolvedApprovalTimeout's own fallback +// so an un-resolvable session still waits a sane window rather than 0. +const defaultHumanStepTimeout = 10 * time.Minute + +// humanStepRequester implements browser.HumanStepRequester by binding ONE +// approvalHost per Confirm and publishing/awaiting through it. loopFn reaches the +// live Loop for the non-blocking Captcha notice publish. +type humanStepRequester struct { + binder hostBinder + loopFn func() *Loop + timeout time.Duration // resolved via the bound host's timeoutResolver, else defaultHumanStepTimeout, when <= 0 +} + +var _ browsertool.HumanStepRequester = (*humanStepRequester)(nil) + +// NewLateBoundHumanStepRequester is the production adapter: a humanStepRequester +// over a lateBoundHost, so internal/cmd/runner can wire it into +// browser.Synthesize before the session's Loop exists. loopFn is the same +// atomic-pointer accessor main.go threads through PreferenceSaver / LeakageGate / +// etc. The bound *runnerHost's own ResolveTimeout supplies the tiered approval +// window (timeout 0 here). +func NewLateBoundHumanStepRequester(loopFn func() *Loop) browsertool.HumanStepRequester { + return &humanStepRequester{binder: &lateBoundHost{loopFn: loopFn}, loopFn: loopFn} +} + +// NewHumanStepRequester constructs the adapter over an already-live host and Loop +// — the test seam (any future caller holding a live *runnerHost). timeout <= 0 +// defers to the host's timeoutResolver (if any) or defaultHumanStepTimeout. +func NewHumanStepRequester(host approvalHost, loop *Loop, timeout time.Duration) browsertool.HumanStepRequester { + return &humanStepRequester{binder: staticHost{h: host}, loopFn: func() *Loop { return loop }, timeout: timeout} +} + +// Confirm implements browser.HumanStepRequester: bind the ONE host this +// round-trip runs against, publish the login_human_step card, then block for the +// decision ON THAT SAME HOST. It writes nothing — the approved bool alone tells +// request_human_step whether the person completed the step. A timeout surfaces as +// TimedOut (fail-closed: never a silent success). +func (s *humanStepRequester) Confirm(ctx context.Context, prompt string) (browsertool.HumanStepOutcome, error) { + if s.binder == nil { + return browsertool.HumanStepOutcome{}, fmt.Errorf("human step unavailable (session not ready)") + } + host, err := s.binder.BindHost() + if err != nil { + return browsertool.HumanStepOutcome{}, err + } + + reqID, err := host.PublishApproval(ctx, pipeline.ApprovalAsk{ + Kind: "login_human_step", + Summary: prompt, + Payload: map[string]any{"prompt": prompt}, + }) + if err != nil { + return browsertool.HumanStepOutcome{}, err + } + + timeout := s.timeout + if timeout <= 0 { + if tr, ok := host.(timeoutResolver); ok { + timeout = tr.ResolveTimeout() + } else { + timeout = defaultHumanStepTimeout + } + } + + approved, by, timedOut, err := host.AwaitDecision(ctx, reqID, timeout) + if err != nil { + return browsertool.HumanStepOutcome{}, err + } + return browsertool.HumanStepOutcome{ + Approved: approved, + Denied: !approved && !timedOut, + TimedOut: timedOut, + DecidedBy: by, + }, nil +} + +// humanStepCodeHost is the OPTIONAL capability the bound approvalHost satisfies to +// return the human's typed login code captured during the login_human_step +// code round-trip. Mirrors timeoutResolver's optional-capability shape: *runnerHost +// implements it (takeHumanStepCode); a test fake implements it only when the +// scenario drives a code. The confirm path never reads it. +type humanStepCodeHost interface { + takeHumanStepCode() string +} + +// RequestCode implements browser.HumanStepRequester: publish the login_human_step +// card (identical to Confirm's — the card is the same category; the code half just +// expects a free-text answer back), block for the decision ON THE SAME HOST, then +// read the human's typed code off that host. Writes nothing; the caller types the +// code into the page. A timeout is fail-closed (TimedOut, no code). +func (s *humanStepRequester) RequestCode(ctx context.Context, prompt string) (browsertool.HumanStepCodeOutcome, error) { + if s.binder == nil { + return browsertool.HumanStepCodeOutcome{}, fmt.Errorf("human step unavailable (session not ready)") + } + host, err := s.binder.BindHost() + if err != nil { + return browsertool.HumanStepCodeOutcome{}, err + } + + reqID, err := host.PublishApproval(ctx, pipeline.ApprovalAsk{ + Kind: "login_human_step", + Summary: prompt, + Payload: map[string]any{"prompt": prompt}, + }) + if err != nil { + return browsertool.HumanStepCodeOutcome{}, err + } + + timeout := s.timeout + if timeout <= 0 { + if tr, ok := host.(timeoutResolver); ok { + timeout = tr.ResolveTimeout() + } else { + timeout = defaultHumanStepTimeout + } + } + + approved, by, timedOut, err := host.AwaitDecision(ctx, reqID, timeout) + if err != nil { + return browsertool.HumanStepCodeOutcome{}, err + } + + // The code rides back out-of-band on the SAME host (one host per round-trip — + // see hostBinder), captured by AwaitDecision's login_human_step case. Read it + // ONLY on an approval; take-and-clear so the secret does not linger. + var code string + if approved { + if cr, ok := host.(humanStepCodeHost); ok { + code = cr.takeHumanStepCode() + } + } + + return browsertool.HumanStepCodeOutcome{ + Approved: approved, + Denied: !approved && !timedOut, + TimedOut: timedOut, + DecidedBy: by, + Code: code, + }, nil +} + +// Captcha implements browser.HumanStepRequester: surface the one-way +// login_captcha notice on the live Loop. It does not block and writes nothing. +func (s *humanStepRequester) Captcha(ctx context.Context, prompt string) error { + if s.loopFn == nil { + return fmt.Errorf("human step captcha unavailable (session not ready)") + } + l := s.loopFn() + if l == nil { + return fmt.Errorf("human step captcha unavailable (session not ready)") + } + return l.publishLoginCaptchaNotice(ctx, prompt) +} diff --git a/pkg/agent/runner/humanstep_deps_test.go b/pkg/agent/runner/humanstep_deps_test.go new file mode 100644 index 00000000..c3c440d4 --- /dev/null +++ b/pkg/agent/runner/humanstep_deps_test.go @@ -0,0 +1,208 @@ +package runner + +// humanstep_deps_test.go is the TDD suite for humanStepRequester.Confirm: a fake +// approvalHost (shared with preferences_deps_test.go) drives the publish/await +// round-trip without a real Loop/orchestrator, proving the exact login_human_step +// wire contract and the approve/deny/timeout outcome mapping. + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + browsertool "github.com/authzed/openagentprimitives/pkg/agent/tool/browser" +) + +// TestHumanStepRequester_Confirm_PublishesLoginHumanStepAsk pins the wire +// contract: Confirm publishes exactly one ask of kind login_human_step carrying +// the prompt. +func TestHumanStepRequester_Confirm_PublishesLoginHumanStepAsk(t *testing.T) { + host := &fakeApprovalHost{approved: true, by: "alice-canonical"} + req := NewHumanStepRequester(host, nil, time.Second) + + _, err := req.Confirm(context.Background(), "approve the push on your phone") + require.NoError(t, err) + + require.Equal(t, 1, host.publishCall, "Confirm must publish exactly one ask") + assert.Equal(t, "login_human_step", host.gotAsk.Kind) + assert.Equal(t, "approve the push on your phone", host.gotAsk.Summary) + + gotJSON, merr := json.Marshal(host.gotAsk.Payload) + require.NoError(t, merr) + assert.JSONEq(t, `{"prompt":"approve the push on your phone"}`, string(gotJSON)) +} + +// TestHumanStepRequester_Confirm_OutcomeMapping is the approve/deny/timeout +// table: each maps to the documented HumanStepOutcome shape — a timeout is +// TimedOut (fail-closed), never a silent success and never miscounted as a deny. +func TestHumanStepRequester_Confirm_OutcomeMapping(t *testing.T) { + cases := []struct { + name string + approved bool + by string + timedOut bool + want browsertool.HumanStepOutcome + }{ + { + name: "approve: Approved with the decider's id", + approved: true, by: "YWxpY2U", + want: browsertool.HumanStepOutcome{Approved: true, DecidedBy: "YWxpY2U"}, + }, + { + name: "deny: Denied with the decider's id", + approved: false, by: "Ym9i", + want: browsertool.HumanStepOutcome{Denied: true, DecidedBy: "Ym9i"}, + }, + { + name: "timeout: TimedOut, nobody decided (fail-closed, not a deny)", + timedOut: true, + want: browsertool.HumanStepOutcome{TimedOut: true}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host := &fakeApprovalHost{approved: tc.approved, by: tc.by, timedOut: tc.timedOut} + req := NewHumanStepRequester(host, nil, time.Second) + + got, err := req.Confirm(context.Background(), "approve the push") + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestHumanStepRequester_RequestCode_PublishesLoginHumanStepAsk pins the wire +// contract: RequestCode publishes the SAME login_human_step ask Confirm does (the +// card is one category; the code half just expects free text back). +func TestHumanStepRequester_RequestCode_PublishesLoginHumanStepAsk(t *testing.T) { + host := &fakeApprovalHost{approved: true, by: "alice-canonical", code: "123456"} + req := NewHumanStepRequester(host, nil, time.Second) + + _, err := req.RequestCode(context.Background(), "enter the emailed code") + require.NoError(t, err) + + require.Equal(t, 1, host.publishCall, "RequestCode must publish exactly one ask") + assert.Equal(t, "login_human_step", host.gotAsk.Kind) + assert.Equal(t, "enter the emailed code", host.gotAsk.Summary) + + gotJSON, merr := json.Marshal(host.gotAsk.Payload) + require.NoError(t, merr) + assert.JSONEq(t, `{"prompt":"enter the emailed code"}`, string(gotJSON)) +} + +// TestHumanStepRequester_RequestCode_OutcomeMapping is the approve/deny/timeout +// table for the code path. On an approve the typed code is read off the host (and +// take-cleared); a deny or timeout reads no code at all — the field the runner +// would type into stays empty, fail-closed. +func TestHumanStepRequester_RequestCode_OutcomeMapping(t *testing.T) { + cases := []struct { + name string + approved bool + by string + timedOut bool + hostCode string + want browsertool.HumanStepCodeOutcome + wantTakeCalls int + }{ + { + name: "approve: Approved with the decider's id and the typed code", + approved: true, by: "YWxpY2U", hostCode: "123456", + want: browsertool.HumanStepCodeOutcome{Approved: true, DecidedBy: "YWxpY2U", Code: "123456"}, + wantTakeCalls: 1, + }, + { + name: "deny: Denied, and NO code is read (nothing to type)", + approved: false, by: "Ym9i", hostCode: "123456", + want: browsertool.HumanStepCodeOutcome{Denied: true, DecidedBy: "Ym9i"}, + wantTakeCalls: 0, + }, + { + name: "timeout: TimedOut, no code read (fail-closed)", + timedOut: true, hostCode: "123456", + want: browsertool.HumanStepCodeOutcome{TimedOut: true}, + wantTakeCalls: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host := &fakeApprovalHost{approved: tc.approved, by: tc.by, timedOut: tc.timedOut, code: tc.hostCode} + req := NewHumanStepRequester(host, nil, time.Second) + + got, err := req.RequestCode(context.Background(), "enter the code") + require.NoError(t, err) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantTakeCalls, host.takeCodeCall, "the code must be read off the host only on an approval") + }) + } +} + +// TestHumanStepRequester_RequestCode_PublishErrorPropagates proves a publish +// failure surfaces and AwaitDecision is never reached — parity with Confirm. +func TestHumanStepRequester_RequestCode_PublishErrorPropagates(t *testing.T) { + host := &fakeApprovalHost{publishErr: errors.New("no resolvable approvers")} + req := NewHumanStepRequester(host, nil, time.Second) + + _, err := req.RequestCode(context.Background(), "enter the code") + require.Error(t, err) + assert.Contains(t, err.Error(), "no resolvable approvers") + assert.Equal(t, 0, host.awaitCall, "AwaitDecision must never run after a publish error") +} + +// TestHumanStepRequester_Confirm_PublishErrorPropagates proves a publish failure +// (e.g. no resolvable approvers) surfaces rather than being swallowed into a +// zero-value outcome, and AwaitDecision is never reached. +func TestHumanStepRequester_Confirm_PublishErrorPropagates(t *testing.T) { + host := &fakeApprovalHost{publishErr: errors.New("no resolvable approvers")} + req := NewHumanStepRequester(host, nil, time.Second) + + _, err := req.Confirm(context.Background(), "approve the push") + require.Error(t, err) + assert.Contains(t, err.Error(), "no resolvable approvers") + assert.Equal(t, 0, host.awaitCall, "AwaitDecision must never run after a publish error") +} + +// TestHumanStepRequester_Confirm_ZeroTimeout_PrefersHostResolvedTimeout proves +// "one window, one source": a 0 timeout defers to the host's tiered +// resolvedApprovalTimeout (timeoutResolver), not a private constant. +func TestHumanStepRequester_Confirm_ZeroTimeout_PrefersHostResolvedTimeout(t *testing.T) { + host := &resolvingFakeApprovalHost{ + fakeApprovalHost: fakeApprovalHost{approved: true}, + resolved: 42 * time.Second, + } + req := NewHumanStepRequester(host, nil, 0) + + _, err := req.Confirm(context.Background(), "approve the push") + require.NoError(t, err) + assert.Equal(t, 42*time.Second, host.gotTimeout) +} + +// TestHumanStepRequester_Confirm_ZeroTimeout_FallsBackToDefault proves a host +// that does not resolve a timeout still gets a sane, non-zero wait. +func TestHumanStepRequester_Confirm_ZeroTimeout_FallsBackToDefault(t *testing.T) { + host := &fakeApprovalHost{approved: true} + req := NewHumanStepRequester(host, nil, 0) + + _, err := req.Confirm(context.Background(), "approve the push") + require.NoError(t, err) + assert.Equal(t, defaultHumanStepTimeout, host.gotTimeout) +} + +// TestNewLateBoundHumanStepRequester_LoopNotReady_FailsClosed proves the +// production adapter refuses loudly (never nil-panics) when Confirm or Captcha +// fires before the Loop is stored. +func TestNewLateBoundHumanStepRequester_LoopNotReady_FailsClosed(t *testing.T) { + req := NewLateBoundHumanStepRequester(func() *Loop { return nil }) + + _, err := req.Confirm(context.Background(), "approve the push") + require.Error(t, err) + assert.Contains(t, err.Error(), "not ready") + + cerr := req.Captcha(context.Background(), "there is a captcha") + require.Error(t, cerr) + assert.Contains(t, cerr.Error(), "not ready") +} diff --git a/pkg/agent/runner/login_captcha_notice.go b/pkg/agent/runner/login_captcha_notice.go new file mode 100644 index 00000000..782d5088 --- /dev/null +++ b/pkg/agent/runner/login_captcha_notice.go @@ -0,0 +1,72 @@ +package runner + +// login_captcha_notice.go implements the login_captcha notice producer: the +// one-way FYI a browser toolbox's request_human_step surfaces when the agent hits +// a step it cannot complete on its own (a CAPTCHA, or any challenge with no +// actionable kind). Unlike login_human_step it has NO decision leg — there is no +// Done button to resume the agent; a person takes over in the browser out of +// band. Mirrors completionbypass.go's notice shape: fixed TRUSTED copy, the +// model-authored prompt carried inert in Excerpt, delivered as an addressee-less +// participants broadcast. + +import ( + "context" + "fmt" + "strings" + + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" + "github.com/authzed/openagentprimitives/pkg/channels/notice" + "github.com/authzed/openagentprimitives/pkg/x/stringsx" +) + +// maxCaptchaPromptRunes caps the model-authored prompt rendered in the notice's +// inert Excerpt. +const maxCaptchaPromptRunes = 500 + +// publishLoginCaptchaNotice builds + publishes the login_captcha notice on the +// session's OUT subject via the generic interaction_request path (same as +// leakage_notice.go). Best-effort at the call site: a session with no channel +// (kubectl-driven) has no InteractionRequestPublish wired, which is reported as +// an error here and surfaced by request_human_step to the model rather than +// swallowed. +func (l *Loop) publishLoginCaptchaNotice(ctx context.Context, prompt string) error { + if l.InteractionRequestPublish == nil { + return fmt.Errorf("login captcha notice unavailable (interaction publish hook not configured)") + } + sessNS, sessName := l.SessionKey.Namespace, l.SessionKey.Name + sess := channelevents.SessionRef{Namespace: sessNS, Name: sessName} + + // Lead/Body/NextStep are fixed TRUSTED copy (rendered as live markup). The + // model-authored prompt is untrusted and travels in Excerpt, rendered inert. + // NextStep is mandatory for ToneUnavailable (notice.Payload enforces it). + n := notice.New(categories.LoginCaptcha, notice.Args{ + Lead: "A sign-in step needs a person", + Body: "The agent reached a step it can't complete on its own — a CAPTCHA or similar challenge — and needs someone to take over in the browser session.", + NextStep: "Complete the step in the browser, then ask the agent to continue.", + Excerpt: captchaNoticeExcerpt(prompt), + Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceParticipants}, + }) + pl, err := n.Payload(sess, newRequestID()) + if err != nil { + return fmt.Errorf("build login captcha notice: %w", err) + } + env, err := channelevents.BuildEnvelope(sessNS, sessName, channelevents.KindInteractionRequest, pl) + if err != nil { + return fmt.Errorf("build login captcha notice envelope: %w", err) + } + return l.InteractionRequestPublish(ctx, sessNS, sessName, env) +} + +// captchaNoticeExcerpt carries the model-authored prompt inert, rune-capped. An +// empty prompt yields no excerpt. +func captchaNoticeExcerpt(prompt string) *channelevents.InteractionExcerpt { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil + } + return &channelevents.InteractionExcerpt{ + Label: "What the agent reported", + Content: stringsx.CapRunes(prompt, maxCaptchaPromptRunes), + } +} diff --git a/pkg/agent/runner/login_captcha_notice_test.go b/pkg/agent/runner/login_captcha_notice_test.go new file mode 100644 index 00000000..90bf679a --- /dev/null +++ b/pkg/agent/runner/login_captcha_notice_test.go @@ -0,0 +1,64 @@ +package runner + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" + "github.com/authzed/openagentprimitives/pkg/memory" +) + +// TestPublishLoginCaptchaNotice_PublishesNotice proves the CAPTCHA path surfaces a +// one-way login_captcha notice: category login_captcha, nil Actions (read-only), +// a NextStep (ToneUnavailable requires one), and the model-authored report +// carried inert in the Excerpt — never in the trusted Lead/Body. +func TestPublishLoginCaptchaNotice_PublishesNotice(t *testing.T) { + const report = "a reCAPTCHA appeared solve it" + var published channelevents.Envelope + l := &Loop{ + Status: LocalStatusPatcher(), + SessionKey: memory.NamespacedName{Namespace: "ns", Name: "s"}, + InteractionRequestPublish: func(_ context.Context, ns, name string, env channelevents.Envelope) error { + assert.Equal(t, "ns", ns) + assert.Equal(t, "s", name) + published = env + return nil + }, + } + + err := l.publishLoginCaptchaNotice(context.Background(), report) + require.NoError(t, err) + + require.Equal(t, channelevents.KindInteractionRequest, published.Kind) + var pl channelevents.InteractionRequestPayload + require.NoError(t, json.Unmarshal(published.Payload, &pl)) + + assert.Equal(t, categories.LoginCaptcha, pl.Category) + assert.Nil(t, pl.Actions, "a captcha is a one-way NOTICE: no Done button, no decision leg") + assert.NotEmpty(t, pl.NextStep, "ToneUnavailable requires a nextStep telling the reader to take over") + assert.NotContains(t, pl.Lead, "", "the model's report must never land in the trusted Lead") + assert.NotContains(t, pl.Body, "", "nor in the trusted Body") + require.NotNil(t, pl.Excerpt, "the model's report rides the inert excerpt") + assert.Contains(t, pl.Excerpt.Content, "", "the report is carried verbatim, inert") + assert.Equal(t, channelevents.AudienceParticipants, pl.Audience.Scope) + assert.NoError(t, pl.Validate()) +} + +// TestPublishLoginCaptchaNotice_NoPublisher_Errors proves a session with no +// channel surface reports the failure rather than silently dropping it — the tool +// then tells the model, per no-silent-errors. +func TestPublishLoginCaptchaNotice_NoPublisher_Errors(t *testing.T) { + l := &Loop{ + Status: LocalStatusPatcher(), + SessionKey: memory.NamespacedName{Namespace: "ns", Name: "s"}, + // InteractionRequestPublish deliberately nil. + } + err := l.publishLoginCaptchaNotice(context.Background(), "a captcha appeared") + require.Error(t, err) + assert.Contains(t, err.Error(), "publish hook") +} diff --git a/pkg/agent/runner/loop_deps.go b/pkg/agent/runner/loop_deps.go index 4702077a..081fbb77 100644 --- a/pkg/agent/runner/loop_deps.go +++ b/pkg/agent/runner/loop_deps.go @@ -738,6 +738,16 @@ type Loop struct { // link — exactly the pre-existing behavior. PlanGateResourceDisplays map[string]plangate.ResourceDisplay + // PlanGateWebHostIdentityLabels maps a web_host slot's host to the bound + // identity login's display label, from + // AgentSession.status.resolvedBrowserToolboxes[].Catalog (see + // BrowserHostIdentityLabels). Display only: the plan gate threads it + // straight into every card so the browser toolbox's acting line can name + // the signing identity ("act on aa.com, signing in as