From 69e3b57d9dd1ac6a5ddb12209dd6766006965fd1 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Wed, 8 Jul 2026 20:36:02 +0300 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20guard=20seam=20=E2=80=94=20decisi?= =?UTF-8?q?on=20function,=20registration=20wrapping,=20grant-carrying=20re?= =?UTF-8?q?play,=20boot=20conformance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every privileged action crossing the container or channel boundary now passes one decision function — guard() in the new src/guard/ leaf — before it executes: allow | hold | deny (hub: engineering/requirements/guarded-actions + engineering/discovery/guarded-actions-decisions, phase 2). - Registration-derived catalog: registry.register() derives one entry per ncl command (dotted action names stamped by registerResource); module-edge guard.ts adapters register the domain baselines (agents.create, a2a.send incl. the agent_message_policies hold, self_mod.*, senders.admit, channels.register). The baseline is the decision — policy-as-data (tighten-only rules) is deferred to phase 3. - All four handler registries wrap at registration: dispatch consults the guard; guarded delivery actions (create_agent, install_packages, add_mcp_server) store only the precheck → guard → handler wrapper (the raw handler is never stored; spec-less re-registration throws); response handlers + message interceptors take guard specs (channel registration's click + free-text name capture). - Grant-carrying replay: approved continuations re-enter their entry point with the verified approval row as the grant (ApprovalHandlerContext.approval). A grant satisfies a hold, never a deny — live-row + approvalAction + grantMatches checks; the forgeable approved:true boolean is deleted. - Boot conformance: the registry walk (src/guard-conformance.ts) runs in CI and at boot — an unmapped privileged registration stops the host with a banner (skill-installed code never runs this repo's CI). Baselines are main's behavior verbatim (host trusted-caller, cli_scope allowlist, create_agent scope branch, a2a ACL order, unconditional self-mod hold, unknown_sender_policy, channel click auth incl. the anchor-group approver). Sender/channel holds stay on their own tables; guard holds map onto the existing requestApproval options (approverUserId). Deliberate outcome changes inherent to the replay semantics (called out in CHANGELOG too): (a) a2a approve-then-revoke no longer delivers — the structural baseline re-runs live on replay; (b) forged, already-consumed, or mismatched grants refuse instead of executing; (c) the channel-name free-text reply re-checks approver eligibility at reply time. The D1/D2/D4 click-auth fixes are deliberately NOT here — they belong to the approval-contract PR (D2's host fallback at dispatch replay is preserved verbatim). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + CLAUDE.md | 1 + src/cli/crud.ts | 6 + src/cli/dispatch.test.ts | 82 +++++++++ src/cli/dispatch.ts | 85 +++++----- src/cli/guard.ts | 86 ++++++++++ src/cli/registry.ts | 13 ++ src/delivery-actions.test.ts | 20 +++ src/delivery.ts | 120 +++++++++++-- src/guard-conformance.ts | 106 ++++++++++++ src/guard/catalog.ts | 53 ++++++ src/guard/conformance.test.ts | 89 ++++++++++ src/guard/guard.test.ts | 149 +++++++++++++++++ src/guard/guard.ts | 65 +++++++ src/guard/index.ts | 10 ++ src/guard/types.ts | 51 ++++++ src/index.ts | 7 + src/modules/agent-to-agent/agent-route.ts | 96 ++++++----- .../agent-to-agent/create-agent.test.ts | 158 +++++++++++++++--- src/modules/agent-to-agent/create-agent.ts | 89 ++++------ src/modules/agent-to-agent/guard.ts | 88 ++++++++++ src/modules/agent-to-agent/index.ts | 31 ++-- .../agent-to-agent/message-gate.test.ts | 92 ++++++++-- src/modules/agent-to-agent/message-gate.ts | 11 +- src/modules/approvals/primitive.ts | 6 + src/modules/approvals/response-handler.ts | 2 +- .../permissions/channel-approval.test.ts | 108 +++++++++++- src/modules/permissions/guard.ts | 54 ++++++ src/modules/permissions/index.ts | 108 +++++++----- src/modules/self-mod/apply.ts | 39 +++-- src/modules/self-mod/guard.ts | 32 ++++ src/modules/self-mod/index.ts | 50 ++++-- src/modules/self-mod/request.ts | 48 ++++-- src/response-registry.ts | 54 +++++- src/router.ts | 38 ++++- 35 files changed, 1750 insertions(+), 299 deletions(-) create mode 100644 src/cli/guard.ts create mode 100644 src/guard-conformance.ts create mode 100644 src/guard/catalog.ts create mode 100644 src/guard/conformance.test.ts create mode 100644 src/guard/guard.test.ts create mode 100644 src/guard/guard.ts create mode 100644 src/guard/index.ts create mode 100644 src/guard/types.ts create mode 100644 src/modules/agent-to-agent/guard.ts create mode 100644 src/modules/permissions/guard.ts create mode 100644 src/modules/self-mod/guard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29565d034a2..19e19fbab1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] +- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction, and a registry-walking conformance test fails CI on any unmapped mutating entry. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) if any privileged command or delivery action is registered without a guard-catalog mapping. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the same registry walk the conformance test uses (`src/guard-conformance.ts`, one shared exemption list) now runs in `main()` before the host accepts a message, and a bad registration crashes at skill-install time instead of running unguarded. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). - **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host. diff --git a/CLAUDE.md b/CLAUDE.md index 6fb08fdaa0d..58831437f9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | +| `src/guard/` | Privileged-action decision seam: `guard()` → allow \| hold \| deny. Domain-free leaf (decision function + registration-derived action catalog); domain baselines register from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions). All four handler registries wrap at registration; approved replays carry the approval row as a grant and re-check the structural baseline. Policy-as-data (tighten-only rule sources) is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 45d64b879e1..75dd55cc12a 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -327,6 +327,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.list) { register({ name: `${def.plural}-list`, + action: `${def.plural}.list`, description: `List all ${def.plural}.`, access: def.operations.list, resource: def.plural, @@ -339,6 +340,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.get) { register({ name: `${def.plural}-get`, + action: `${def.plural}.get`, description: `Get a ${def.name} by ID.`, access: def.operations.get, resource: def.plural, @@ -351,6 +353,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.create) { register({ name: `${def.plural}-create`, + action: `${def.plural}.create`, description: `Create a new ${def.name}.`, access: def.operations.create, resource: def.plural, @@ -362,6 +365,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.update) { register({ name: `${def.plural}-update`, + action: `${def.plural}.update`, description: `Update a ${def.name}.`, access: def.operations.update, resource: def.plural, @@ -373,6 +377,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.delete) { register({ name: `${def.plural}-delete`, + action: `${def.plural}.delete`, description: `Delete a ${def.name}.`, access: def.operations.delete, resource: def.plural, @@ -389,6 +394,7 @@ export function registerResource(def: ResourceDef): void { const declared = op.args; register({ name: `${def.plural}-${verb.replace(/ /g, '-')}`, + action: `${def.plural}.${verb.replace(/ /g, '.')}`, description: op.description, access: op.access, resource: def.plural, diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts index 4da3c1dca1a..558154d3770 100644 --- a/src/cli/dispatch.test.ts +++ b/src/cli/dispatch.test.ts @@ -9,6 +9,7 @@ const approvalState = vi.hoisted(() => ({ | ((args: { session: unknown; payload: Record; + approval: Record; userId: string; notify: (text: string) => void; }) => Promise), @@ -18,6 +19,7 @@ const approvalState = vi.hoisted(() => ({ handler: (args: { session: unknown; payload: Record; + approval: Record; userId: string; notify: (text: string) => void; }) => Promise, @@ -43,8 +45,11 @@ vi.mock('../db/agent-groups.js', () => ({ })); const mockGetSession = vi.fn(); +// The guard's grant check re-fetches the approval row to prove it's live. +const mockGetPendingApproval = vi.fn(); vi.mock('../db/sessions.js', () => ({ getSession: (...args: unknown[]) => mockGetSession(...args), + getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args), })); // dispatch's post-handler looks up the resource's `scopeField` via getResource. @@ -495,10 +500,20 @@ describe('CLI scope enforcement', () => { callerContext: ctx, }); + // The approve path hands the handler the live approval row — the grant + // the replay carries back into dispatch. + const grantRow = { + approval_id: 'appr-t1', + action: 'cli_command', + payload: JSON.stringify(approval.payload), + }; + mockGetPendingApproval.mockReturnValue(grantRow); + expect(approvalState.approvalHandler).toBeTypeOf('function'); await approvalState.approvalHandler!({ session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' }, payload: approval.payload, + approval: grantRow, userId: 'telegram:admin', notify: vi.fn(), }); @@ -507,6 +522,73 @@ describe('CLI scope enforcement', () => { expect(approvalState.requestApproval).toHaveBeenCalledTimes(1); }); + // --- Grant-carrying replay (the `approved: true` boolean no longer exists) --- + + it('replay with a dead grant (row deleted) refuses instead of re-holding', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' }); + mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' }); + + const ctx = agentCtx(); + await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx); + const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record }; + + mockGetPendingApproval.mockReturnValue(undefined); // resolution already deleted the row + const notify = vi.fn(); + await approvalState.approvalHandler!({ + session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' }, + payload: approval.payload, + approval: { approval_id: 'appr-dead', action: 'cli_command', payload: JSON.stringify(approval.payload) }, + userId: 'telegram:admin', + notify, + }); + + expect(approvalState.observedContexts).toHaveLength(0); // handler never ran + expect(approvalState.requestApproval).toHaveBeenCalledTimes(1); // no second card + expect(notify.mock.calls[0][0]).toContain('failed'); + }); + + it("a grant approved for one command doesn't transfer to another", async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + // A live cli_command row, but held for a DIFFERENT command. + const grantRow = { + approval_id: 'appr-other', + action: 'cli_command', + payload: JSON.stringify({ frame: { command: 'members-add' } }), + }; + mockGetPendingApproval.mockReturnValue(grantRow); + + const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), { + grant: grantRow as never, + }); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('grant'); + } + expect(approvalState.observedContexts).toHaveLength(0); + }); + + it('a fabricated grant object without a live row is refused', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + mockGetPendingApproval.mockReturnValue(undefined); + + const forged = { + approval_id: 'appr-forged', + action: 'cli_command', + payload: JSON.stringify({ frame: { command: 'approval-context-command' } }), + }; + const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), { + grant: forged as never, + }); + + expect(resp.ok).toBe(false); + if (!resp.ok) expect(resp.error.code).toBe('forbidden'); + expect(approvalState.requestApproval).not.toHaveBeenCalled(); // refusal, not a fresh hold + }); + // --- Post-handler filtering --- it('group: groups list filters out other groups', async () => { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5ea9f65c370..99ca7b18dba 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -3,23 +3,38 @@ * the per-session DB poller (container caller) call dispatch() with the * same frame and a transport-supplied CallerContext. * - * Approval gating for risky calls from the container is the only branch - * that differs by caller. Host callers and `open` commands run inline. + * Every command passes the guard before its handler runs — the decision + * (allow / hold / deny) comes from the command's catalog entry, derived at + * registration (see cli/guard.ts). Dispatch keeps the mechanics: arg + * auto-fill, the sessions-get existence oracle, `--help` interception, + * parseArgs, and post-handler row filtering. An approved replay re-enters + * here carrying the verified approval row as its grant — the guard re-checks + * the structural baseline live, and the `approved: true` boolean no longer + * exists. */ import { getContainerConfig } from '../db/container-configs.js'; import { getAgentGroup } from '../db/agent-groups.js'; import { getSession } from '../db/sessions.js'; +import { guard, type GuardActor } from '../guard/index.js'; import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; +import type { PendingApproval } from '../types.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { getResource } from './crud.js'; +import { commandGuardAction } from './guard.js'; import { listVerbs, renderVerbHelp } from './help-render.js'; -import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js'; +import { listCommands, lookup } from './registry.js'; type DispatchOptions = { - /** True when a command is being replayed after approval. */ - approved?: boolean; + /** Verified approval row when a command is replayed after approval. */ + grant?: PendingApproval; }; +function actorFor(ctx: CallerContext): GuardActor { + return ctx.caller === 'host' + ? { kind: 'host' } + : { kind: 'agent', agentGroupId: ctx.agentGroupId, sessionId: ctx.sessionId }; +} + export async function dispatch( req: RequestFrame, ctx: CallerContext, @@ -54,43 +69,13 @@ export async function dispatch( return err(req.id, 'unknown-command', unknownCommandMessage(req.command)); } - // CLI scope enforcement for agent callers + // Group-scope mechanics for agent callers (visibility, not policy — the + // allow/hold/deny decisions live in the guard baseline, cli/guard.ts). if (ctx.caller === 'agent') { const configRow = getContainerConfig(ctx.agentGroupId); const cliScope = configRow?.cli_scope ?? 'group'; - if (cliScope === 'disabled') { - return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.'); - } - if (cliScope === 'group') { - // Only allow whitelisted resources and general commands (no resource, like help) - if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) { - return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`); - } - - // Enforce group scope on all agent-group-related args. - // Different resources use different arg names for the agent group ID. - // Only check --id for resources where it IS the agent group ID. - const groupArgs = ['agent_group_id', 'group'] as const; - for (const key of groupArgs) { - if (req.args[key] && req.args[key] !== ctx.agentGroupId) { - return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.'); - } - } - if ( - (cmd.resource === 'groups' || cmd.resource === 'destinations') && - req.args.id && - req.args.id !== ctx.agentGroupId - ) { - return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.'); - } - - // Block cli_scope changes from group-scoped agents (privilege escalation) - if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) { - return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.'); - } - // Auto-fill agent-group-related args so the agent doesn't need // to pass its own group ID explicitly. const fill: Record = { @@ -116,9 +101,20 @@ export async function dispatch( } } + const decision = guard({ + action: commandGuardAction(cmd), + actor: actorFor(ctx), + payload: req.args, + grant: opts.grant ?? null, + }); + + if (decision.effect === 'deny') { + return err(req.id, 'forbidden', decision.reason); + } + // `--help` interception: answer with the command's generated help instead of - // executing. Placed after scope enforcement (a group-scoped agent can't probe - // forbidden resources) and BEFORE approval gating — asking for help on an + // executing. Placed after the guard's deny (a group-scoped agent can't probe + // forbidden resources) and BEFORE hold execution — asking for help on an // approval-gated verb must never mint an approval card. if (req.args.help === true) { // Carry the help text in `human` too, so both clients print it verbatim @@ -127,7 +123,12 @@ export async function dispatch( return { id: req.id, ok: true, data: helpText, human: helpText }; } - if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) { + if (decision.effect === 'hold') { + if (ctx.caller !== 'agent') { + // Holds only arise for agent callers; anything else is a guard bug — + // fail closed rather than card a ghost. + return err(req.id, 'forbidden', decision.reason); + } const session = getSession(ctx.sessionId); if (!session) { return err(req.id, 'handler-error', 'Session not found.'); @@ -215,10 +216,10 @@ export async function dispatch( } } -registerApprovalHandler('cli_command', async ({ payload, notify }) => { +registerApprovalHandler('cli_command', async ({ payload, approval, notify }) => { const frame = payload.frame as RequestFrame; const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' }; - const response = await dispatch(frame, callerContext, { approved: true }); + const response = await dispatch(frame, callerContext, { grant: approval }); if (response.ok) { const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); diff --git a/src/cli/guard.ts b/src/cli/guard.ts new file mode 100644 index 00000000000..2b7ebca393c --- /dev/null +++ b/src/cli/guard.ts @@ -0,0 +1,86 @@ +/** + * CLI guard adapter — the command registry's catalog derivation and + * structural baseline, moved verbatim out of dispatch.ts (guarded-actions + * phase 2). Declaration is registration: registry.register() derives one + * catalog entry per command from the CommandDef itself; no second file is + * edited when a command is added. + * + * The baseline carries today's decisions exactly: + * host caller → allow (the 0600 socket is the auth story — in code, + * unremovable by data); + * cli_scope 'disabled' → deny; 'group' → resource allowlist, cross-group + * arg denial, cli_scope-change denial; + * access 'approval' for agent callers → hold for the group's admin chain. + * + * Arg auto-fill, the sessions-get existence oracle, and post-handler row + * filtering stay in dispatch.ts — mechanics, not policy. + */ +import { getContainerConfig } from '../db/container-configs.js'; +import { ALLOW, DENY, HOLD, type GuardedActionSpec, type GuardInput } from '../guard/index.js'; +import { GROUP_SCOPE_RESOURCES, type CommandDef } from './registry.js'; + +/** Dotted catalog action name for a command. */ +export function commandGuardAction(cmd: Pick): string { + return cmd.action ?? `cli.${cmd.name}`; +} + +/** Catalog entry derived from a CommandDef at registration time. */ +export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec { + return { + action: commandGuardAction(cmd), + approvalAction: cmd.access === 'approval' ? 'cli_command' : undefined, + // Bind a cli_command grant to the exact command it was approved for. + grantMatches: (grant) => { + try { + const payload = JSON.parse(grant.payload) as { frame?: { command?: string } }; + return payload.frame?.command === cmd.name; + } catch { + return false; + } + }, + baseline: (input) => commandBaseline(cmd, input), + }; +} + +function commandBaseline(cmd: CommandDef, input: GuardInput) { + const { actor } = input; + if (actor.kind === 'host') return ALLOW('host caller (trusted socket)'); + if (actor.kind !== 'agent') return DENY('CLI commands accept host or agent callers only.'); + + const args = input.payload; + const cliScope = getContainerConfig(actor.agentGroupId)?.cli_scope ?? 'group'; + + if (cliScope === 'disabled') { + return DENY('CLI access is disabled for this agent group.'); + } + + if (cliScope === 'group') { + // Only allow whitelisted resources and general commands (no resource, like help) + if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) { + return DENY(`CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`); + } + + // Enforce group scope on all agent-group-related args. + // Different resources use different arg names for the agent group ID. + // Only check --id for resources where it IS the agent group ID. + for (const key of ['agent_group_id', 'group'] as const) { + if (args[key] && args[key] !== actor.agentGroupId) { + return DENY('CLI access is scoped to this agent group.'); + } + } + if ((cmd.resource === 'groups' || cmd.resource === 'destinations') && args.id && args.id !== actor.agentGroupId) { + return DENY('CLI access is scoped to this agent group.'); + } + + // Block cli_scope changes from group-scoped agents (privilege escalation) + if (args.cli_scope !== undefined || args['cli-scope'] !== undefined) { + return DENY('Cannot change cli_scope from a group-scoped agent.'); + } + } + + if (cmd.access === 'approval') { + return HOLD(`agent-initiated "${cmd.name}" requires admin approval`); + } + + return ALLOW('open command'); +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts index e108e114321..40941099e41 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -8,6 +8,8 @@ * registers the help commands, so the registry is populated before the host's * CLI server accepts connections. */ +import { registerGuardedAction } from '../guard/index.js'; +import { commandGuardSpec } from './guard.js'; import type { CallerContext } from './frame.js'; /** @@ -23,6 +25,13 @@ export type CommandDef = { name: string; description: string; access: Access; + /** + * Dotted guard-catalog action name (e.g. `roles.grant`, + * `groups.config.add-mcp-server`). Set by registerResource from the + * resource + verb; commands registered directly (help) fall back to + * `cli.`. + */ + action?: string; /** * The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher * only lets an agent run commands whose `resource` is on the whitelist @@ -58,6 +67,10 @@ export function register(def: CommandDef): void { throw new Error(`CLI command "${def.name}" already registered`); } registry.set(def.name, def as CommandDef); + // Declaration is registration: every command gets a guard-catalog entry + // derived from its own definition — dispatch consults the guard, and the + // conformance test walks the registry against the catalog. + registerGuardedAction(commandGuardSpec(def as CommandDef)); } export function lookup(name: string): CommandDef | undefined { diff --git a/src/delivery-actions.test.ts b/src/delivery-actions.test.ts index 6a89506cd5c..f9054978bd9 100644 --- a/src/delivery-actions.test.ts +++ b/src/delivery-actions.test.ts @@ -35,4 +35,24 @@ describe('delivery action registry', () => { registerDeliveryAction('test_overwrite_action', second); expect(getDeliveryAction('test_overwrite_action')).toBe(second); }); + + it('refuses to replace a guard-wrapped action with an unguarded handler', () => { + registerDeliveryAction('test_guarded_overwrite', async () => {}, { + guardAction: 'test.guarded-overwrite', + requestHold: async () => {}, + }); + + // Disarming the guard by re-registering without a spec must throw — + // otherwise the catalog (and the conformance walk) would still report + // the action guarded while the live path runs unguarded. + expect(() => registerDeliveryAction('test_guarded_overwrite', async () => {})).toThrow(/disarm the guard/); + + // Re-registering WITH a spec stays allowed (a legitimate replacement + // keeps the action guarded). + registerDeliveryAction('test_guarded_overwrite', async () => {}, { + guardAction: 'test.guarded-overwrite', + requestHold: async () => {}, + }); + expect(getDeliveryAction('test_guarded_overwrite')).toBeDefined(); + }); }); diff --git a/src/delivery.ts b/src/delivery.ts index d9a0d15f270..0017c8d126a 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -20,12 +20,13 @@ import { markDeliveryFailed, migrateDeliveredTable, } from './db/session-db.js'; +import { guard } from './guard/index.js'; import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js'; import type { OutboundFile } from './channels/adapter.js'; -import type { Session } from './types.js'; +import type { PendingApproval, Session } from './types.js'; const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; @@ -393,14 +394,18 @@ async function deliverMessage( * Delivery action registry. * * Modules register handlers for system-kind outbound message actions via - * `registerDeliveryAction`. Core checks the registry first in - * `handleSystemAction` and falls through to the inline switch when no - * handler is registered. The switch will shrink as modules are extracted - * (scheduling, approvals, agent-to-agent) and eventually only its default - * branch remains. + * `registerDeliveryAction`. Unknown actions log "Unknown system action". * - * Default when no handler registered and the switch doesn't match: log - * "Unknown system action" and return. + * Privileged delivery actions (create_agent, install_packages, + * add_mcp_server) register with a guard spec: the registry wraps the handler + * so the guard's decision — allow / hold / deny — stands between the + * container's outbound row and the handler body, and the wrapped path is the + * only path (the raw handler is never stored). On approve, the continuation + * re-enters the same wrapped entry carrying the approval row as its grant + * (`reenterGuardedDeliveryAction`), so the structural baseline is re-checked + * live. Plain actions (scheduling self-actions, the cli_request bridge — its + * inner commands are guarded at dispatch) register without a spec and are + * not catalog actions. */ export type DeliveryActionHandler = ( content: Record, @@ -408,13 +413,99 @@ export type DeliveryActionHandler = ( inDb: Database.Database, ) => Promise; -const actionHandlers = new Map(); +/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */ +export type GuardedDeliveryHandler = (content: Record, session: Session) => Promise; + +export interface DeliveryGuardSpec { + /** Dotted guard-catalog action consulted before the handler runs. */ + guardAction: string; + /** + * Domain validation that runs before the guard — malformed requests are + * answered (notify) without ever creating a hold. Return false to stop. + */ + precheck?: (content: Record, session: Session) => boolean | Promise; + /** Create the hold (the domain's requestApproval call — card text lives with the domain). */ + requestHold: (content: Record, session: Session) => Promise; + /** Tell the requester about a deny. */ + onDeny?: (content: Record, session: Session, reason: string) => void; +} -export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void { +const actionHandlers = new Map(); +const guardedActions = new Map(); + +export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void; +export function registerDeliveryAction(action: string, handler: GuardedDeliveryHandler, spec: DeliveryGuardSpec): void; +export function registerDeliveryAction( + action: string, + handler: DeliveryActionHandler | GuardedDeliveryHandler, + spec?: DeliveryGuardSpec, +): void { if (actionHandlers.has(action)) { + // Replacing a guard-wrapped action with an unguarded handler would + // disarm the guard while the catalog (and the conformance walk) still + // report it guarded — refuse. A skill that wants to extend a guarded + // action must compose at the module's exported functions instead, or + // re-register with a guard spec of its own. + if (!spec && guardedActions.has(action)) { + throw new Error( + `delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`, + ); + } log.warn('Delivery action handler overwritten', { action }); } - actionHandlers.set(action, handler); + if (!spec) { + actionHandlers.set(action, handler as DeliveryActionHandler); + return; + } + guardedActions.set(action, { spec, handler: handler as GuardedDeliveryHandler }); + actionHandlers.set(action, (content, session) => runGuardedDeliveryAction(action, content, session, null)); +} + +async function runGuardedDeliveryAction( + action: string, + content: Record, + session: Session, + grant: PendingApproval | null, +): Promise { + const entry = guardedActions.get(action); + if (!entry) { + log.warn('Unknown guarded delivery action', { action }); + return; + } + const { spec, handler } = entry; + + if (spec.precheck && !(await spec.precheck(content, session))) return; + + const decision = guard({ + action: spec.guardAction, + actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id }, + payload: content, + grant, + }); + + if (decision.effect === 'deny') { + log.warn('Delivery action denied by guard', { action, reason: decision.reason }); + spec.onDeny?.(content, session, decision.reason); + return; + } + if (decision.effect === 'hold') { + await spec.requestHold(content, session); + return; + } + await handler(content, session); +} + +/** + * Approve continuation for a guard-wrapped delivery action: re-enter the + * wrapped entry with the approval row as the grant. The guard treats the + * grant as hold-satisfied but re-runs the structural baseline, so + * approve-then-revoke does not execute. Domains register this as their + * approval handler in the same line that registers the action. + */ +export function reenterGuardedDeliveryAction(action: string) { + return async (ctx: { session: Session; payload: Record; approval: PendingApproval }) => { + await runGuardedDeliveryAction(action, ctx.payload, ctx.session, ctx.approval); + }; } /** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */ @@ -422,6 +513,13 @@ export function getDeliveryAction(action: string): DeliveryActionHandler | undef return actionHandlers.get(action); } +/** Registered delivery actions with their guard mapping, for the conformance test. */ +export function listDeliveryActions(): { action: string; guardAction: string | null }[] { + return [...actionHandlers.keys()] + .sort() + .map((action) => ({ action, guardAction: guardedActions.get(action)?.spec.guardAction ?? null })); +} + /** * Handle system actions from the container agent. * These are written to messages_out because the container can't write to inbound.db. diff --git a/src/guard-conformance.ts b/src/guard-conformance.ts new file mode 100644 index 00000000000..8cbf562befa --- /dev/null +++ b/src/guard-conformance.ts @@ -0,0 +1,106 @@ +/** + * Guard conformance — the non-bypass invariant, shared by CI and boot. + * + * CI only protects code that goes through the repo's CI, but NanoClaw's + * extension model is skill-installed code: /add-* skills copy modules into + * the user's tree and register handlers on machines where the test suite + * never runs. Running the same walk at boot turns the conformance test's + * guarantee into a runtime invariant at exactly that trust boundary: every + * registration is an import-time side effect, so by the time main() runs + * the registries are complete and an unmapped privileged entry is + * detectable before the host accepts a single message. + * + * Fail-closed: the host refuses to start (the upgrade-tripwire posture). + * A conformance failure is code mis-composition — fixable with the host + * down — and it surfaces at skill-install time, when the installing agent + * is watching, instead of running unguarded until someone runs pnpm test. + * + * Limit: the walk verifies DECLARED mappings ("every delivery action is + * guarded or explicitly exempt") — it cannot infer which actions are + * privileged. That declaration stays on the author; the exemption list + * below is the loud, reviewable escape hatch. + */ +import { commandGuardAction } from './cli/guard.js'; +import { listCommands } from './cli/registry.js'; +import { listDeliveryActions } from './delivery.js'; +import { getGuardedAction } from './guard/index.js'; +import { log } from './log.js'; + +/** + * Delivery actions that deliberately carry no guard mapping (the declared + * exemption class, per the guarded-actions design decision 1): + * - scheduling self-actions — an agent mutating only its own task rows; + * not a privileged action class (yet). + * - cli_request — the transport bridge into dispatch(); every inner + * command is guarded at dispatch, so the envelope carries no privilege. + */ +export const EXEMPT_DELIVERY_ACTIONS = new Set([ + 'schedule_task', + 'cancel_task', + 'pause_task', + 'resume_task', + 'update_task', + 'cli_request', +]); + +/** Walk the live registries against the guard catalog. Empty = conformant. */ +export function guardConformanceViolations(): string[] { + const violations: string[] = []; + + for (const cmd of listCommands()) { + const entry = getGuardedAction(commandGuardAction(cmd)); + if (!entry) { + violations.push(`command "${cmd.name}" has no guard-catalog entry`); + continue; + } + if (cmd.access === 'approval' && entry.approvalAction !== 'cli_command') { + violations.push(`mutating command "${cmd.name}" maps to a catalog entry that cannot hold`); + } + } + + for (const { action, guardAction } of listDeliveryActions()) { + if (guardAction === null) { + if (!EXEMPT_DELIVERY_ACTIONS.has(action)) { + violations.push(`delivery action "${action}" is neither guard-mapped nor on the declared exemption list`); + } + continue; + } + if (!getGuardedAction(guardAction)) { + violations.push(`delivery action "${action}" maps to unregistered guard action "${guardAction}"`); + } + } + + return violations; +} + +/** + * Boot check: refuse to start when any privileged registration is unmapped. + * Call after all import-time registrations (any point in main()). + */ +export function enforceGuardConformance(): void { + const violations = guardConformanceViolations(); + if (violations.length === 0) return; + + console.error( + [ + '', + '='.repeat(64), + 'NanoClaw stopped: guard conformance failure', + '='.repeat(64), + 'A privileged registration is not mapped to the guard catalog —', + 'it would run with no allow/hold/deny decision. This usually means', + 'a skill (or local change) registered a command or delivery action', + 'without a guard spec.', + '', + ...violations.map((v) => ` - ${v}`), + '', + 'Fix the registration (pass a guard spec / derive a catalog entry),', + 'or — only for genuinely unprivileged self-actions — add it to', + 'EXEMPT_DELIVERY_ACTIONS in src/guard-conformance.ts.', + '='.repeat(64), + '', + ].join('\n'), + ); + log.error('Guard conformance failure — refusing to start', { violations }); + process.exit(1); +} diff --git a/src/guard/catalog.ts b/src/guard/catalog.ts new file mode 100644 index 00000000000..1a00d4e3e00 --- /dev/null +++ b/src/guard/catalog.ts @@ -0,0 +1,53 @@ +/** + * The action catalog — the enforcement boundary. + * + * An action either is in the catalog (and passes a decision) or is not (and + * needs none — reads, scheduling self-actions). Declaration is registration: + * entries are derived at the registries' registration sites (command + * registry, delivery actions, response handlers, interceptors, module + * edges), never maintained in a second file. The conformance test walks the + * registries against this catalog so an unmapped privileged action fails CI. + */ +import { log } from '../log.js'; +import type { GuardDecision, GuardInput } from './types.js'; +import type { PendingApproval } from '../types.js'; + +export interface GuardedActionSpec { + /** Dotted action name — the catalog key. */ + action: string; + /** + * Today's structural checks for this action, verbatim — the only source of + * allow. Runs on every consult, including approved replays (a grant + * satisfies a hold, never a deny). + */ + baseline: (input: GuardInput) => GuardDecision; + /** + * The pending_approvals.action its holds resolve through — a grant is only + * accepted when its row carries this action. Omit for actions that can + * never be held (deny/allow-only baselines). + */ + approvalAction?: string; + /** + * Extra domain binding between a grant and the replayed input (e.g. the + * a2a target must match the held message). Runs in addition to the + * approvalAction + live-row checks. + */ + grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean; +} + +const catalog = new Map(); + +export function registerGuardedAction(spec: GuardedActionSpec): void { + if (catalog.has(spec.action)) { + log.warn('Guarded action re-registered (overwriting)', { action: spec.action }); + } + catalog.set(spec.action, spec); +} + +export function getGuardedAction(action: string): GuardedActionSpec | undefined { + return catalog.get(action); +} + +export function listGuardedActions(): GuardedActionSpec[] { + return [...catalog.values()].sort((a, b) => a.action.localeCompare(b.action)); +} diff --git a/src/guard/conformance.test.ts b/src/guard/conformance.test.ts new file mode 100644 index 00000000000..bc945546d10 --- /dev/null +++ b/src/guard/conformance.test.ts @@ -0,0 +1,89 @@ +/** + * Guard conformance — the non-bypass invariant, checked structurally. + * + * Walks the real command registry and the real delivery-action registry + * (loaded via their production barrels) against the guard catalog. The walk + * itself lives in src/guard-conformance.ts and runs twice: here in CI, and + * at every boot (enforceGuardConformance in index.ts refuses to start on a + * violation) — CI can't see skill-installed registrations, the boot check + * can. A new privileged command or delivery action cannot quietly ship + * ungated: registration derives the catalog entry, and this walk makes + * drift loud. + * + * The declared exemption classes live with the walk + * (EXEMPT_DELIVERY_ACTIONS): scheduling self-actions and the cli_request + * transport bridge (its inner commands are guarded at dispatch). Reads + * (list/get/help) are catalog-mapped via registration too, but their + * baselines allow; the mutating set is what MUST be mapped. + */ +import { describe, expect, it } from 'vitest'; + +// Production barrels — side-effect imports populate the real registries. +import '../cli/commands/index.js'; +import '../modules/index.js'; +import '../cli/delivery-action.js'; + +import { listCommands } from '../cli/registry.js'; +import { commandGuardAction } from '../cli/guard.js'; +import { listDeliveryActions, registerDeliveryAction } from '../delivery.js'; +import { EXEMPT_DELIVERY_ACTIONS, guardConformanceViolations } from '../guard-conformance.js'; +import { getGuardedAction } from './catalog.js'; + +describe('guard conformance', () => { + it('the full walk (shared with the boot check) reports zero violations', () => { + expect(guardConformanceViolations()).toEqual([]); + }); + + it('every mutating ncl command maps to a guard catalog entry that can hold', () => { + const mutating = listCommands().filter((cmd) => cmd.access === 'approval'); + expect(mutating.length).toBeGreaterThan(0); + + const unmapped = mutating.filter((cmd) => { + const entry = getGuardedAction(commandGuardAction(cmd)); + return !entry || entry.approvalAction !== 'cli_command'; + }); + expect(unmapped.map((c) => c.name)).toEqual([]); + }); + + it('every registered command (reads included) has a catalog entry — denied reads still surface as denials', () => { + const unmapped = listCommands().filter((cmd) => !getGuardedAction(commandGuardAction(cmd))); + expect(unmapped.map((c) => c.name)).toEqual([]); + }); + + it('every delivery action is guard-mapped or on the declared exemption list', () => { + const actions = listDeliveryActions(); + expect(actions.length).toBeGreaterThan(0); + + const unmapped = actions.filter( + ({ action, guardAction }) => guardAction === null && !EXEMPT_DELIVERY_ACTIONS.has(action), + ); + expect(unmapped.map((a) => a.action)).toEqual([]); + + const danglingCatalog = actions.filter(({ guardAction }) => guardAction !== null && !getGuardedAction(guardAction)); + expect(danglingCatalog.map((a) => a.action)).toEqual([]); + }); + + it('the privileged delivery actions are the guarded ones', () => { + const guarded = Object.fromEntries( + listDeliveryActions() + .filter((a) => a.guardAction !== null) + .map((a) => [a.action, a.guardAction]), + ); + expect(guarded).toEqual({ + create_agent: 'agents.create', + install_packages: 'self_mod.install_packages', + add_mcp_server: 'self_mod.add_mcp_server', + }); + }); + + // KEEP LAST: registers a rogue action into the shared per-worker registry, + // so every walk after this point sees the violation. + it('the walk names an unguarded, non-exempt delivery action (what the boot check refuses on)', () => { + registerDeliveryAction('test_rogue_privileged_action', async () => {}); + + const violations = guardConformanceViolations(); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('test_rogue_privileged_action'); + expect(violations[0]).toContain('neither guard-mapped nor on the declared exemption list'); + }); +}); diff --git a/src/guard/guard.test.ts b/src/guard/guard.test.ts new file mode 100644 index 00000000000..d072e95372d --- /dev/null +++ b/src/guard/guard.test.ts @@ -0,0 +1,149 @@ +/** + * Guard decision-function unit tests: the baseline is the decision (allow / + * hold / deny returned as-is, non-catalog actions allow), grant semantics + * (satisfies holds, never denies; invalid → refuse), and the fail-closed + * posture on a throwing baseline. + * + * Uses synthetic catalog actions registered per test — the registry is + * per-worker module state with no reset, so action names are unique. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { guard } from './guard.js'; +import { registerGuardedAction } from './catalog.js'; +import { ALLOW, DENY, HOLD, type GuardInput } from './types.js'; + +const mockGetPendingApproval = vi.fn(); +vi.mock('../db/sessions.js', () => ({ + getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args), +})); +vi.mock('../log.js', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const AGENT = { kind: 'agent', agentGroupId: 'ag-1', sessionId: 'sess-1' } as const; + +function input(action: string, extra: Partial = {}): GuardInput { + return { action, actor: AGENT, payload: {}, ...extra }; +} + +beforeEach(() => { + mockGetPendingApproval.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('the baseline is the decision', () => { + it('non-catalog action → allow', () => { + expect(guard(input('test.unregistered-read')).effect).toBe('allow'); + }); + + it('baseline allow → allow', () => { + registerGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') }); + expect(guard(input('t.allow1')).effect).toBe('allow'); + }); + + it('baseline hold → hold, default approver chain', () => { + registerGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') }); + const d = guard(input('t.hold1')); + expect(d.effect).toBe('hold'); + if (d.effect === 'hold') { + expect(d.reason).toBe('needs approval'); + expect(d.approverUserId).toBeUndefined(); + } + }); + + it('baseline hold → hold, carrying a named approver', () => { + registerGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') }); + const d = guard(input('t.hold2')); + expect(d.effect).toBe('hold'); + if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana'); + }); + + it('baseline deny → deny, carrying the reason', () => { + registerGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') }); + const d = guard(input('t.deny1')); + expect(d.effect).toBe('deny'); + if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized'); + }); +}); + +describe('grants', () => { + const grantRow = (action: string) => + ({ approval_id: 'appr-1', action, payload: '{}' }) as unknown as NonNullable; + + it('a valid live grant satisfies a hold', () => { + registerGuardedAction({ + action: 't.g1', + approvalAction: 'g1_approved', + baseline: () => HOLD('b'), + }); + const grant = grantRow('g1_approved'); + mockGetPendingApproval.mockReturnValue(grant); + expect(guard(input('t.g1', { grant })).effect).toBe('allow'); + }); + + it('a grant never satisfies a deny — the baseline is re-checked live', () => { + registerGuardedAction({ action: 't.g2', approvalAction: 'g2_approved', baseline: () => DENY('revoked since') }); + const grant = grantRow('g2_approved'); + mockGetPendingApproval.mockReturnValue(grant); + const d = guard(input('t.g2', { grant })); + expect(d.effect).toBe('deny'); + if (d.effect === 'deny') expect(d.reason).toBe('revoked since'); + }); + + it('a dead grant (row deleted) refuses instead of re-holding', () => { + registerGuardedAction({ + action: 't.g3', + approvalAction: 'g3_approved', + baseline: () => HOLD('b'), + }); + mockGetPendingApproval.mockReturnValue(undefined); + const d = guard(input('t.g3', { grant: grantRow('g3_approved') })); + expect(d.effect).toBe('deny'); + }); + + it("a grant for a different action doesn't transfer", () => { + registerGuardedAction({ + action: 't.g4', + approvalAction: 'g4_approved', + baseline: () => HOLD('b'), + }); + const grant = grantRow('other_action'); + mockGetPendingApproval.mockReturnValue(grant); + expect(guard(input('t.g4', { grant })).effect).toBe('deny'); + }); + + it('a domain grantMatches binding can refuse a payload mismatch', () => { + registerGuardedAction({ + action: 't.g5', + approvalAction: 'g5_approved', + grantMatches: () => false, + baseline: () => HOLD('b'), + }); + const grant = grantRow('g5_approved'); + mockGetPendingApproval.mockReturnValue(grant); + expect(guard(input('t.g5', { grant })).effect).toBe('deny'); + }); + + it('a grant on an already-allowed action is a no-op', () => { + registerGuardedAction({ action: 't.g6', approvalAction: 'g6_approved', baseline: () => ALLOW('ok') }); + const grant = grantRow('g6_approved'); + mockGetPendingApproval.mockReturnValue(grant); + expect(guard(input('t.g6', { grant })).effect).toBe('allow'); + }); +}); + +describe('fail-closed posture', () => { + it('a throwing baseline denies', () => { + registerGuardedAction({ + action: 't.f1', + baseline: () => { + throw new Error('boom'); + }, + }); + expect(guard(input('t.f1')).effect).toBe('deny'); + }); +}); diff --git a/src/guard/guard.ts b/src/guard/guard.ts new file mode 100644 index 00000000000..b38a3ccad1c --- /dev/null +++ b/src/guard/guard.ts @@ -0,0 +1,65 @@ +/** + * guard() — the one decision function every privileged action consults. + * + * The decision is the catalog entry's structural baseline — today's code + * checks, registered per action at the module edges. Non-catalog actions + * (reads, scheduling self-actions) allow. Policy-as-data (tighten-only rule + * sources composing with the baseline) is deliberately deferred to phase 3 + * of the guarded-actions design, where the generalized rules table arrives + * with its first operator-visible consumer; until then the one policy table + * (agent_message_policies) is consulted inside the a2a.send baseline. + * + * Grants: an approved replay carries the verified approval row. A valid + * grant (live pending row whose action matches the catalog entry's approval + * action, plus any domain binding) satisfies a hold — the human already + * decided — but NEVER a deny: the baseline is re-checked live, so + * approve-then-revoke no longer executes. A grant that is present but + * invalid fails closed to deny (no second card). + * + * The guard itself fails closed: a throwing baseline denies. + */ +import { getPendingApproval } from '../db/sessions.js'; +import { log } from '../log.js'; +import { getGuardedAction } from './catalog.js'; +import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js'; + +export function guard(input: GuardInput): GuardDecision { + const entry = getGuardedAction(input.action); + + let decision: GuardDecision; + try { + decision = entry ? entry.baseline(input) : ALLOW('non-catalog action'); + } catch (err) { + log.error('Guard evaluation threw — failing closed', { action: input.action, err }); + return DENY('guard failure (failing closed)'); + } + + if (!input.grant || decision.effect !== 'hold') { + // A grant never loosens a deny (the baseline re-check is live), and a + // grant on an already-allowed action is a no-op. + return decision; + } + + // An invalid grant on a replay is a refusal, not a fresh hold — approved + // replays must execute exactly once. + if (entry && grantSatisfies(input, entry.approvalAction, entry.grantMatches)) { + return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`); + } + return DENY('replay carried an invalid or mismatched grant'); +} + +function grantSatisfies( + input: GuardInput, + approvalAction: string | undefined, + grantMatches: ((grant: NonNullable, input: GuardInput) => boolean) | undefined, +): boolean { + const grant = input.grant; + if (!grant || !approvalAction) return false; + if (grant.action !== approvalAction) return false; + // The row must still be live — resolution deletes it, so a grant can only + // execute once and a fabricated row object doesn't pass. + const live = getPendingApproval(grant.approval_id); + if (!live || live.action !== approvalAction) return false; + if (grantMatches && !grantMatches(grant, input)) return false; + return true; +} diff --git a/src/guard/index.ts b/src/guard/index.ts new file mode 100644 index 00000000000..7bf897e1acc --- /dev/null +++ b/src/guard/index.ts @@ -0,0 +1,10 @@ +/** + * Guard — the privileged-action decision seam (guarded-actions phase 2). + * + * See the guarded-actions decisions doc on the team hub. One decision + * function (guard.ts) and a registration-derived action catalog (catalog.ts). + * Domain-free leaf: domain baselines register from the domain modules' edges. + */ +export { guard } from './guard.js'; +export { registerGuardedAction, getGuardedAction, listGuardedActions, type GuardedActionSpec } from './catalog.js'; +export { ALLOW, DENY, HOLD, type GuardActor, type GuardDecision, type GuardInput } from './types.js'; diff --git a/src/guard/types.ts b/src/guard/types.ts new file mode 100644 index 00000000000..4925effa779 --- /dev/null +++ b/src/guard/types.ts @@ -0,0 +1,51 @@ +/** + * Guard vocabulary — the decision seam every privileged action passes. + * + * The guard is a domain-free leaf: this module may import the DB read layer, + * config, log, and shared types — never src/cli/* or src/modules/*. Domain + * knowledge (what an action's structural baseline checks) arrives via + * registration: catalog entries (catalog.ts) are registered by the domain + * modules at their module edges. + */ +import type { PendingApproval } from '../types.js'; + +/** Who is attempting the action. Mirrors the CLI CallerContext + click identities. */ +export type GuardActor = + | { kind: 'host' } + | { kind: 'agent'; agentGroupId: string; sessionId?: string } + | { kind: 'human'; userId: string } + | { kind: 'system' }; + +export interface GuardInput { + /** Dotted catalog action name, e.g. 'roles.grant', 'agents.create', 'a2a.send'. */ + action: string; + actor: GuardActor; + /** Domain resource reference, e.g. { from, to } for a2a.send. */ + resource?: Record; + /** Action arguments — what the card summarizes and rules may later match on. */ + payload: Record; + /** + * Verified approval row carried by an approved replay. A valid grant + * satisfies a hold (the human already decided) but never a deny — the + * structural baseline is re-checked live on every replay. + */ + grant?: PendingApproval | null; +} + +export type GuardDecision = + | { effect: 'allow'; reason: string } + | { effect: 'hold'; reason: string; approverUserId?: string } + | { effect: 'deny'; reason: string }; + +export const ALLOW = (reason: string): GuardDecision => ({ effect: 'allow', reason }); +export const DENY = (reason: string): GuardDecision => ({ effect: 'deny', reason }); +/** + * approverUserId names an exclusive approver for the hold (the a2a policy + * row's named approver). Absent, the hold goes to the approvals primitive's + * default chain (scoped admins → global admins → owners). + */ +export const HOLD = (reason: string, approverUserId?: string): GuardDecision => ({ + effect: 'hold', + reason, + approverUserId, +}); diff --git a/src/index.ts b/src/index.ts index 72887f32be8..2ecf46d763c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; +import { enforceGuardConformance } from './guard-conformance.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; import { routeInbound } from './router.js'; import { log } from './log.js'; @@ -69,6 +70,12 @@ async function main(): Promise { // outside the sanctioned path (raw `git pull` instead of /update-nanoclaw). enforceUpgradeTripwire(); + // 0.6 Guard conformance — every import-time registration has already run; + // refuse to start if any privileged command / delivery action is unmapped + // (CI can't see skill-installed code — this makes the invariant hold at + // the boundary where third-party registrations enter). + enforceGuardConformance(); + // 1. Init central DB const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 5ad169a1444..feb52ad590e 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -27,14 +27,15 @@ import { getAgentGroup } from '../../db/agent-groups.js'; import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; +import { guard } from '../../guard/index.js'; import { log } from '../../log.js'; import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; -import type { Session } from '../../types.js'; +import type { PendingApproval, Session } from '../../types.js'; import { requestApproval } from '../approvals/index.js'; -import { hasDestination } from './db/agent-destinations.js'; -import { getMessagePolicy } from './db/agent-message-policies.js'; +import { A2A_MESSAGE_GATE_ACTION } from './guard.js'; export { isSafeAttachmentName }; +export { A2A_MESSAGE_GATE_ACTION } from './guard.js'; export interface ForwardedAttachment { name: string; @@ -230,56 +231,65 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session; } -export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise { +export async function routeAgentMessage( + msg: RoutableAgentMessage, + session: Session, + opts: { grant?: PendingApproval } = {}, +): Promise { const sourceAgentGroupId = session.agent_group_id; const targetAgentGroupId = msg.platform_id; if (!targetAgentGroupId) { throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`); } - const isSelf = targetAgentGroupId === sourceAgentGroupId; - if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) { - throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`); - } - if (!getAgentGroup(targetAgentGroupId)) { - throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); + + // The a2a.send baseline (guard.ts) carries the checks verbatim in their + // original order: destination ACL deny, target-exists deny, self-send + // allow, agent_message_policies hold. An approved replay carries the + // grant — the hold is satisfied but the structure is re-checked live, so + // revoking a destination between hold and approve blocks delivery. + const decision = guard({ + action: 'a2a.send', + actor: { kind: 'agent', agentGroupId: sourceAgentGroupId, sessionId: session.id }, + resource: { from: sourceAgentGroupId, to: targetAgentGroupId }, + payload: { id: msg.id, platform_id: targetAgentGroupId, content: msg.content, in_reply_to: msg.in_reply_to }, + grant: opts.grant ?? null, + }); + + if (decision.effect === 'deny') { + throw new Error(decision.reason); } // Gated edge: hold the message and return (not throw) so the delivery loop - // consumes the outbound row; `applyA2aMessageGate` re-routes it on approve. - if (!isSelf) { - const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId); - if (policy) { - const { approver } = policy; - const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId; - const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId; - await requestApproval({ - session, - agentName: sourceName, - action: A2A_MESSAGE_GATE_ACTION, - approverUserId: approver, - title: 'Message approval', - question: buildGateQuestion(sourceName, targetName, msg.content), - payload: { - id: msg.id, - platform_id: targetAgentGroupId, - content: msg.content, - in_reply_to: msg.in_reply_to, - }, - }); - log.info('Agent message held for approval', { - from: sourceAgentGroupId, - to: targetAgentGroupId, - msgId: msg.id, - }); - return; - } + // consumes the outbound row; `applyA2aMessageGate` re-enters here with the + // grant on approve. + if (decision.effect === 'hold') { + const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId; + const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId; + await requestApproval({ + session, + agentName: sourceName, + action: A2A_MESSAGE_GATE_ACTION, + approverUserId: decision.approverUserId, + title: 'Message approval', + question: buildGateQuestion(sourceName, targetName, msg.content), + payload: { + id: msg.id, + platform_id: targetAgentGroupId, + content: msg.content, + in_reply_to: msg.in_reply_to, + }, + }); + log.info('Agent message held for approval', { + from: sourceAgentGroupId, + to: targetAgentGroupId, + msgId: msg.id, + }); + return; } await performAgentRoute(msg, session, targetAgentGroupId); } -export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate'; - const GATE_CARD_BODY_MAX = 1500; function parseMessageContent(contentStr: string): { text: string; files: string[] } { @@ -308,9 +318,11 @@ function buildGateQuestion(sourceName: string, targetName: string, contentStr: s /** * Cross-session route: pick the target session, forward files, write to its - * inbound DB, wake it. Authorization is the caller's responsibility. + * inbound DB, wake it. Module-private — the only door is routeAgentMessage's + * guard decision (the approve continuation re-enters with a grant rather + * than calling this directly). */ -export async function performAgentRoute( +async function performAgentRoute( msg: RoutableAgentMessage, session: Session, targetAgentGroupId: string, diff --git a/src/modules/agent-to-agent/create-agent.test.ts b/src/modules/agent-to-agent/create-agent.test.ts index f9f6219c784..d67853e0bcb 100644 --- a/src/modules/agent-to-agent/create-agent.test.ts +++ b/src/modules/agent-to-agent/create-agent.test.ts @@ -2,26 +2,48 @@ * Tests for create_agent host-side authorization. * * Regression guard for the audit finding: `create_agent` is a privileged - * central-DB write with no host-side authz. The fix authorizes by CLI scope — - * trusted owner agent groups ('global') create directly; confined groups - * ('group', the default and the prompt-injection victim) must get admin - * approval. These tests pin that branch decision. + * central-DB write with no host-side authz. Authorization is the guard's + * `agents.create` baseline — trusted owner agent groups ('global') create + * directly; confined groups ('group', the default and the prompt-injection + * victim) hold for admin approval. These tests drive the REAL wrapped + * delivery action (the only reachable path) and the approve continuation's + * grant-carrying re-entry. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Session } from '../../types.js'; +import type { PendingApproval, Session } from '../../types.js'; // Mocks for the collaborators the branch decides between / depends on. -const mockRequestApproval = vi.fn().mockResolvedValue(undefined); -const mockGetContainerConfig = vi.fn(); -const mockCreateAgentGroup = vi.fn(); -const mockInitGroupFilesystem = vi.fn(); -const mockUpdateScalars = vi.fn(); -const mockWriteDestinations = vi.fn(); -const mockNotifyWrite = vi.fn(); +// vi.hoisted: the module barrel import below runs before this file's const +// initializers, and the mock factories close over this state. +const { + mockRequestApproval, + mockGetContainerConfig, + mockCreateAgentGroup, + mockInitGroupFilesystem, + mockUpdateScalars, + mockWriteDestinations, + mockNotifyWrite, + liveApprovals, + approvalHandlers, +} = vi.hoisted(() => ({ + mockRequestApproval: vi.fn().mockResolvedValue(undefined), + mockGetContainerConfig: vi.fn(), + mockCreateAgentGroup: vi.fn(), + mockInitGroupFilesystem: vi.fn(), + mockUpdateScalars: vi.fn(), + mockWriteDestinations: vi.fn(), + mockNotifyWrite: vi.fn(), + liveApprovals: new Map(), + approvalHandlers: new Map) => Promise>(), +})); vi.mock('../approvals/index.js', () => ({ requestApproval: (...a: unknown[]) => mockRequestApproval(...a), + notifyAgent: vi.fn(), + registerApprovalHandler: (action: string, handler: (ctx: Record) => Promise) => { + approvalHandlers.set(action, handler); + }, })); vi.mock('../../db/container-configs.js', () => ({ getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a), @@ -42,36 +64,81 @@ vi.mock('./write-destinations.js', () => ({ vi.mock('./db/agent-destinations.js', () => ({ getDestinationByName: () => undefined, createDestination: vi.fn(), + hasDestination: () => true, normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'), })); // notifyAgent writes to the session inbound.db + wakes the container; stub both. +// delivery.ts and agent-route.ts pull more session-manager exports at import time. vi.mock('../../session-manager.js', () => ({ writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a), + openInboundDb: vi.fn(), + openOutboundDb: vi.fn(), + clearOutbox: vi.fn(), + readOutboxFiles: vi.fn().mockReturnValue([]), + resolveSession: vi.fn(), + sessionDir: vi.fn().mockReturnValue('/tmp/nowhere'), + inboundDbPath: vi.fn().mockReturnValue('/tmp/nowhere/inbound.db'), })); vi.mock('../../container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), })); vi.mock('../../db/sessions.js', () => ({ getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }), + getPendingApproval: (id: string) => liveApprovals.get(id), + getRunningSessions: () => [], + getActiveSessions: () => [], + createPendingQuestion: vi.fn(), })); -import { handleCreateAgent } from './create-agent.js'; +// The a2a module barrel registers ./guard.js (catalog entries) and the +// guard-wrapped create_agent delivery action — the path under test. +import './index.js'; +import { getDeliveryAction } from '../../delivery.js'; const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session; +async function runCreateAgent(content: Record): Promise { + const wrapped = getDeliveryAction('create_agent'); + expect(wrapped).toBeDefined(); + await wrapped!(content, SESSION, undefined as never); +} + +function liveGrant(approvalId: string, payload: Record): PendingApproval { + const row = { + approval_id: approvalId, + session_id: SESSION.id, + request_id: approvalId, + action: 'create_agent', + payload: JSON.stringify(payload), + created_at: new Date().toISOString(), + agent_group_id: 'ag-1', + channel_type: null, + platform_id: null, + platform_message_id: null, + expires_at: null, + status: 'pending', + title: '', + options_json: '[]', + approver_user_id: null, + } as PendingApproval; + liveApprovals.set(approvalId, row); + return row; +} + beforeEach(() => { vi.clearAllMocks(); + liveApprovals.clear(); }); afterEach(() => { vi.restoreAllMocks(); }); -describe('handleCreateAgent — scope-based authorization', () => { +describe('create_agent — guard-based authorization (wrapped delivery action)', () => { it('global scope: creates directly, no approval requested', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); - await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION); + await runCreateAgent({ name: 'Scout', instructions: 'help' }); expect(mockRequestApproval).not.toHaveBeenCalled(); expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1); @@ -84,7 +151,7 @@ describe('handleCreateAgent — scope-based authorization', () => { // dropping the inheritance leaves the child provider-less (→ claude). mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' }); - await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION); + await runCreateAgent({ name: 'Scout', instructions: 'help' }); expect(mockInitGroupFilesystem).toHaveBeenCalledWith( expect.anything(), @@ -96,7 +163,7 @@ describe('handleCreateAgent — scope-based authorization', () => { it('claude creator leaves the child provider unset (built-in default)', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider - await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION); + await runCreateAgent({ name: 'Scout', instructions: 'help' }); expect(mockUpdateScalars).not.toHaveBeenCalled(); }); @@ -104,7 +171,7 @@ describe('handleCreateAgent — scope-based authorization', () => { it('group scope (default): requires approval, does NOT create directly', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); - await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION); + await runCreateAgent({ name: 'Scout', instructions: 'help' }); expect(mockRequestApproval).toHaveBeenCalledTimes(1); expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' }); @@ -115,7 +182,7 @@ describe('handleCreateAgent — scope-based authorization', () => { it('missing config: fails closed to approval (no direct create)', async () => { mockGetContainerConfig.mockReturnValue(undefined); - await handleCreateAgent({ name: 'Scout' }, SESSION); + await runCreateAgent({ name: 'Scout' }); expect(mockRequestApproval).toHaveBeenCalledTimes(1); expect(mockCreateAgentGroup).not.toHaveBeenCalled(); @@ -124,7 +191,7 @@ describe('handleCreateAgent — scope-based authorization', () => { it('disabled/other scope: requires approval', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' }); - await handleCreateAgent({ name: 'Scout' }, SESSION); + await runCreateAgent({ name: 'Scout' }); expect(mockRequestApproval).toHaveBeenCalledTimes(1); expect(mockCreateAgentGroup).not.toHaveBeenCalled(); @@ -133,9 +200,58 @@ describe('handleCreateAgent — scope-based authorization', () => { it('empty name: neither creates nor requests approval', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); - await handleCreateAgent({ name: '' }, SESSION); + await runCreateAgent({ name: '' }); expect(mockRequestApproval).not.toHaveBeenCalled(); expect(mockCreateAgentGroup).not.toHaveBeenCalled(); }); }); + +describe('create_agent — approved replay (grant-carrying re-entry)', () => { + it('valid grant executes exactly once — baseline hold is satisfied, create runs', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + const payload = { name: 'Scout', instructions: 'help' }; + const approval = liveGrant('appr-ca-1', payload); + + const continuation = approvalHandlers.get('create_agent'); + expect(continuation).toBeDefined(); + await continuation!({ session: SESSION, payload, approval, userId: 'telegram:admin', notify: vi.fn() }); + + expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1); + expect(mockRequestApproval).not.toHaveBeenCalled(); // no second card + }); + + it('dead grant (row already resolved) refuses the replay', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + const payload = { name: 'Scout', instructions: 'help' }; + const approval = liveGrant('appr-ca-2', payload); + liveApprovals.delete('appr-ca-2'); // resolution consumed the row + + await approvalHandlers.get('create_agent')!({ + session: SESSION, + payload, + approval, + userId: 'telegram:admin', + notify: vi.fn(), + }); + + expect(mockCreateAgentGroup).not.toHaveBeenCalled(); + expect(mockRequestApproval).not.toHaveBeenCalled(); // refused, not re-held + }); + + it('mismatched grant (approved for a different name) refuses the replay', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + const approval = liveGrant('appr-ca-3', { name: 'OtherAgent' }); + + await approvalHandlers.get('create_agent')!({ + session: SESSION, + payload: { name: 'Scout' }, + approval, + userId: 'telegram:admin', + notify: vi.fn(), + }); + + expect(mockCreateAgentGroup).not.toHaveBeenCalled(); + expect(mockRequestApproval).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/agent-to-agent/create-agent.ts b/src/modules/agent-to-agent/create-agent.ts index b8044da390d..312d1f30b17 100644 --- a/src/modules/agent-to-agent/create-agent.ts +++ b/src/modules/agent-to-agent/create-agent.ts @@ -1,16 +1,18 @@ /** - * `create_agent` delivery-action handler. + * `create_agent` delivery-action bodies. * * SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups, * container_configs, agent_destinations) and scaffolds host filesystem state — * a privileged operation a confined container is otherwise architecturally * barred from. The container's MCP tool gate is inside the (untrusted) * container and is trivially bypassed by writing the outbound system row - * directly, so authorization MUST be enforced host-side. Trusted owner agent - * groups (CLI scope 'global') create directly; every other (confined) group - * requires admin approval via `requestApproval` — matching `ncl groups create` - * (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the - * creation on approve; `performCreateAgent` is the shared body. + * directly, so authorization MUST be enforced host-side: the delivery + * registry wraps this action with the guard, whose `agents.create` baseline + * (./guard.ts) is the old cli_scope branch verbatim — trusted global-scope + * groups allow, everything else (including unknown config, fail-closed) + * holds for admin approval. On approve the continuation re-enters the + * wrapped action with the approval row as its grant and `createAgent` runs. + * `performCreateAgent` is the module-private body. */ import path from 'path'; @@ -23,7 +25,7 @@ import { initGroupFilesystem } from '../../group-init.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { AgentGroup, Session } from '../../types.js'; -import { requestApproval, type ApprovalHandler } from '../approvals/index.js'; +import { requestApproval } from '../approvals/index.js'; import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js'; import { writeDestinations } from './write-destinations.js'; @@ -43,41 +45,27 @@ function notifyAgent(session: Session, text: string): void { } } -/** - * Delivery-action entry. - * - * Authorization depends on the calling group's CLI scope: - * - `global` (set by init-first-agent for trusted owner agent groups): - * create immediately. create_agent is the intended primitive for these - * privileged agents, and an approval tap on every sub-agent spawn would be - * needless friction. - * - anything else (the default `group` scope — the realistic - * prompt-injection victim): require an admin to approve before any - * central-DB write. `applyCreateAgent` runs on approve. - * Unknown/missing config fails closed to the approval path. - */ -export async function handleCreateAgent(content: Record, session: Session): Promise { +/** Guard precheck: malformed requests are answered without ever creating a hold. */ +export function validateCreateAgent(content: Record, session: Session): boolean { const name = typeof content.name === 'string' ? content.name : ''; - const instructions = typeof content.instructions === 'string' ? content.instructions : null; - if (!name) { notifyAgent(session, 'create_agent failed: name is required.'); - return; + return false; } - - const sourceGroup = getAgentGroup(session.agent_group_id); - if (!sourceGroup) { + if (!getAgentGroup(session.agent_group_id)) { notifyAgent(session, 'create_agent failed: source agent group not found.'); log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name }); - return; + return false; } + return true; +} - const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group'; - if (cliScope === 'global') { - // Trusted owner agent group — create directly, then notify (+wake) it. - await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text)); - return; - } +/** Guard hold: card the requesting group's admin chain. */ +export async function requestCreateAgentHold(content: Record, session: Session): Promise { + const name = typeof content.name === 'string' ? content.name : ''; + const instructions = typeof content.instructions === 'string' ? content.instructions : null; + const sourceGroup = getAgentGroup(session.agent_group_id); + if (!sourceGroup) return; await requestApproval({ session, @@ -89,35 +77,22 @@ export async function handleCreateAgent(content: Record, sessio }); } -/** - * Approval handler: performs the creation once an admin approves a request from - * a confined (non-global) agent group. `session` is the requesting parent. - */ -export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => { - const name = typeof payload.name === 'string' ? payload.name : ''; - const instructions = typeof payload.instructions === 'string' ? payload.instructions : null; - - if (!name) { - notify('create_agent approved but the request had no name.'); - return; - } - +/** Guard allow body: performs the creation (fresh global-scope call or approved replay). */ +export async function createAgent(content: Record, session: Session): Promise { + const name = typeof content.name === 'string' ? content.name : ''; + const instructions = typeof content.instructions === 'string' ? content.instructions : null; const sourceGroup = getAgentGroup(session.agent_group_id); - if (!sourceGroup) { - notify('create_agent approved but the source agent group no longer exists.'); - log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name }); - return; - } + if (!name || !sourceGroup) return; // precheck already answered the requester - await performCreateAgent(name, instructions, session, sourceGroup, notify); -}; + await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text)); +} /** * Core creation: writes the new agent group + bidirectional destinations and * scaffolds its filesystem, then reports via `notify`. Authorization is the - * CALLER's responsibility (the global-scope shortcut in handleCreateAgent or - * admin approval via applyCreateAgent) — never call this from an unauthorized - * path, as it performs privileged central-DB writes a confined container is + * CALLER's responsibility (the guard's agents.create decision) — never call + * this from an unauthorized path, as it performs privileged central-DB + * writes a confined container is * otherwise barred from. */ async function performCreateAgent( diff --git a/src/modules/agent-to-agent/guard.ts b/src/modules/agent-to-agent/guard.ts new file mode 100644 index 00000000000..7404058042c --- /dev/null +++ b/src/modules/agent-to-agent/guard.ts @@ -0,0 +1,88 @@ +/** + * Agent-to-agent guard adapter — the module's catalog entries, composed at + * the module edge (imported by ./index.ts). + * + * agents.create — the cli_scope branch moved verbatim out of + * create-agent.ts: `global` scope creates directly (create_agent is the + * intended primitive for trusted owner agent groups); anything else — the + * default `group` scope, and unknown/missing config, fail-closed — holds for + * the requesting group's admin chain. + * + * a2a.send — the decision moved verbatim out of routeAgentMessage, in its + * original check order: a missing destination row denies; a missing target + * group denies; self-sends allow without a destination row; an + * agent_message_policies row for the (from, to) pair holds for the row's + * named approver. The ghost-policy edge (policy row with no destination row) + * denies — the destination check precedes the policy check, exactly today's + * outcome. Policy rows can only tighten (hold), never allow: absence of a + * row falls through to the structural checks. + */ +import { getAgentGroup } from '../../db/agent-groups.js'; +import { getContainerConfig } from '../../db/container-configs.js'; +import { ALLOW, DENY, HOLD, registerGuardedAction } from '../../guard/index.js'; +import { hasDestination } from './db/agent-destinations.js'; +import { getMessagePolicy } from './db/agent-message-policies.js'; + +/** + * pending_approvals action string for held a2a messages. Lives here (not in + * agent-route.ts) so agent-route can import this adapter — loading the + * consult site guarantees its catalog entry is registered — without a cycle. + */ +export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate'; + +registerGuardedAction({ + action: 'agents.create', + approvalAction: 'create_agent', + // Bind a create_agent grant to the name that was approved. + grantMatches: (grant, input) => { + try { + return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name; + } catch { + return false; + } + }, + baseline: (input) => { + if (input.actor.kind !== 'agent') return DENY('create_agent is a container-originated action.'); + const cliScope = getContainerConfig(input.actor.agentGroupId)?.cli_scope ?? 'group'; + if (cliScope === 'global') { + // Trusted owner agent group — an approval tap on every sub-agent spawn + // would be needless friction. + return ALLOW('trusted global-scope agent group'); + } + // The realistic prompt-injection victim (default `group` scope) — and any + // unknown config value, fail-closed — requires an admin before any + // central-DB write. + return HOLD('agent-initiated create_agent requires admin approval'); + }, +}); + +registerGuardedAction({ + action: 'a2a.send', + approvalAction: A2A_MESSAGE_GATE_ACTION, + // Bind an a2a grant to the exact held message target. + grantMatches: (grant, input) => { + try { + return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to; + } catch { + return false; + } + }, + baseline: (input) => { + if (input.actor.kind !== 'agent') return DENY('agent-to-agent send requires an agent actor'); + const from = input.actor.agentGroupId; + const to = input.resource?.to ?? ''; + const isSelf = to === from; + if (!isSelf && !hasDestination(from, 'agent', to)) { + return DENY(`unauthorized agent-to-agent: ${from} has no destination for ${to}`); + } + if (!getAgentGroup(to)) { + return DENY(`target agent group ${to} not found for message ${String(input.payload.id)}`); + } + if (isSelf) return ALLOW('self-send'); + const policy = getMessagePolicy(from, to); + if (policy) { + return HOLD(`a2a message policy ${from}→${to} holds for ${policy.approver}`, policy.approver); + } + return ALLOW('destination grant exists'); + }, +}); diff --git a/src/modules/agent-to-agent/index.ts b/src/modules/agent-to-agent/index.ts index 95dfd36c679..fa9047ecb61 100644 --- a/src/modules/agent-to-agent/index.ts +++ b/src/modules/agent-to-agent/index.ts @@ -1,13 +1,14 @@ /** * Agent-to-agent module — inter-agent messaging and on-demand agent creation. * - * Registers one delivery action (`create_agent`) plus its matching approval - * handler — `create_agent` writes central-DB state, so confined (non-global) - * groups require admin approval (the delivery action queues the request; - * `applyCreateAgent` runs on approve); trusted global-scope groups create - * directly. The sibling `channel_type === 'agent'` routing path is NOT a system - * action — core `delivery.ts` dispatches into `./agent-route.js` via a dynamic - * import when it sees `msg.channel_type === 'agent'`. + * Registers its guard-catalog entries (./guard.js) and one guard-wrapped + * delivery action (`create_agent`) — `create_agent` writes central-DB state, + * so the guard's agents.create baseline holds confined (non-global) groups + * for admin approval while trusted global-scope groups create directly; the + * approval handler re-enters the wrapped action carrying the approval row as + * its grant. The sibling `channel_type === 'agent'` routing path is NOT a + * system action — core `delivery.ts` dispatches into `./agent-route.js` via + * a dynamic import when it sees `msg.channel_type === 'agent'`. * * Host integration points: * - `src/container-runner.ts::spawnContainer` dynamically imports @@ -20,13 +21,19 @@ * system action logs "Unknown system action", `channel_type='agent'` messages * throw because the module isn't installed. */ -import { registerDeliveryAction } from '../../delivery.js'; -import { registerApprovalHandler } from '../approvals/index.js'; +import './guard.js'; +import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js'; +import { notifyAgent, registerApprovalHandler } from '../approvals/index.js'; import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js'; -import { applyCreateAgent, handleCreateAgent } from './create-agent.js'; +import { createAgent, requestCreateAgentHold, validateCreateAgent } from './create-agent.js'; import { applyA2aMessageGate } from './message-gate.js'; -registerDeliveryAction('create_agent', handleCreateAgent); -registerApprovalHandler('create_agent', applyCreateAgent); +registerDeliveryAction('create_agent', createAgent, { + guardAction: 'agents.create', + precheck: validateCreateAgent, + requestHold: requestCreateAgentHold, + onDeny: (_content, session, reason) => notifyAgent(session, `create_agent denied: ${reason}`), +}); +registerApprovalHandler('create_agent', reenterGuardedDeliveryAction('create_agent')); registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate); diff --git a/src/modules/agent-to-agent/message-gate.test.ts b/src/modules/agent-to-agent/message-gate.test.ts index fa1a485f686..fd9d5c3a82d 100644 --- a/src/modules/agent-to-agent/message-gate.test.ts +++ b/src/modules/agent-to-agent/message-gate.test.ts @@ -2,16 +2,17 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import './guard.js'; // register the a2a.send catalog entry (incl. the policy hold) import { routeAgentMessage } from './agent-route.js'; import { createDestination, deleteDestination, deleteAllDestinationsTouching } from './db/agent-destinations.js'; import { getMessagePolicy, removeMessagePolicy, setMessagePolicy } from './db/agent-message-policies.js'; import { applyA2aMessageGate } from './message-gate.js'; import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; import { getDb } from '../../db/connection.js'; -import { createSession } from '../../db/sessions.js'; +import { createPendingApproval, createSession, deletePendingApproval, getPendingApproval } from '../../db/sessions.js'; import { requestApproval } from '../approvals/index.js'; import { initSessionFolder, inboundDbPath } from '../../session-manager.js'; -import type { Session } from '../../types.js'; +import type { PendingApproval, Session } from '../../types.js'; vi.mock('../../container-runner.js', () => ({ wakeContainer: vi.fn().mockResolvedValue(undefined), @@ -67,6 +68,23 @@ function makeSession(id: string, agentGroupId: string): Session { }; } +/** Seed a live a2a hold row (what requestApproval writes) and return it as the grant. */ +function seedA2aHold(approvalId: string, payload: Record): PendingApproval { + createPendingApproval({ + approval_id: approvalId, + session_id: 'sess-A', + request_id: approvalId, + action: 'a2a_message_gate', + payload: JSON.stringify(payload), + created_at: now(), + agent_group_id: A, + title: 'Message approval', + options_json: '[]', + approver_user_id: 'telegram:dana', + }); + return getPendingApproval(approvalId)!; +} + describe('agent message policies', () => { let SA: Session; let SB: Session; @@ -129,7 +147,7 @@ describe('agent message policies', () => { expect(requestApproval).not.toHaveBeenCalled(); }); - it('policy present → holds the message and requests approval from the policy approver scoped to the target', async () => { + it('policy present → holds the message and requests approval from the policy approver', async () => { setMessagePolicy(A, B, 'telegram:dana', now()); await routeAgentMessage( @@ -139,7 +157,7 @@ describe('agent message policies', () => { // Held: nothing routed to B. expect(readInbound(B, SB.id)).toHaveLength(0); - // One approval requested, to the policy's approver, scoped to the target group. + // One approval requested, to the policy's approver. expect(requestApproval).toHaveBeenCalledTimes(1); const opts = vi.mocked(requestApproval).mock.calls[0][0]; expect(opts.action).toBe('a2a_message_gate'); @@ -158,21 +176,71 @@ describe('agent message policies', () => { expect(readInbound(A, SA.id)).toHaveLength(1); }); - // ── approve handler re-routes the held message ── + it('ghost policy (policy row, no destination row) still denies — deny beats the policy hold', async () => { + deleteDestination(A, 'b'); // removes A→B — the destination ACL now denies + setMessagePolicy(A, B, 'telegram:dana', now()); // ...but a stale policy row remains + + await expect( + routeAgentMessage({ id: 'ghost', platform_id: B, content: JSON.stringify({ text: 'x' }), in_reply_to: null }, SA), + ).rejects.toThrow(/unauthorized agent-to-agent/); + expect(requestApproval).not.toHaveBeenCalled(); + expect(readInbound(B, SB.id)).toHaveLength(0); + }); + + // ── approve handler re-enters the guarded route with the grant ── + + it('applyA2aMessageGate delivers the held message to the target (valid grant)', async () => { + setMessagePolicy(A, B, 'telegram:dana', now()); + const payload = { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null }; + const approval = seedA2aHold('appr-a2a-1', payload); - it('applyA2aMessageGate delivers the held message to the target', async () => { const notify = vi.fn(); - await applyA2aMessageGate({ - session: SA, - userId: 'slack:dana', - notify, - payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null }, - }); + await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval }); const bRows = readInbound(B, SB.id); expect(bRows).toHaveLength(1); expect(JSON.parse(bRows[0].content).text).toBe('approved!'); expect(notify).not.toHaveBeenCalled(); + // The hold is satisfied by the grant — no second card. + expect(requestApproval).not.toHaveBeenCalled(); + }); + + it('destination revoked between hold and approve → the approved replay is blocked', async () => { + setMessagePolicy(A, B, 'telegram:dana', now()); + const payload = { id: 'held-2', platform_id: B, content: JSON.stringify({ text: 'stale' }), in_reply_to: null }; + const approval = seedA2aHold('appr-a2a-2', payload); + + deleteDestination(A, 'b'); // revoke A→B while the card is pending + + await expect( + applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), + ).rejects.toThrow(/unauthorized agent-to-agent/); + expect(readInbound(B, SB.id)).toHaveLength(0); + }); + + it('mismatched grant (held for another target) refuses the replay', async () => { + setMessagePolicy(A, B, 'telegram:dana', now()); + // Grant was approved for a message to A (different target than the replay). + const approval = seedA2aHold('appr-a2a-3', { id: 'other', platform_id: A, content: '{}', in_reply_to: null }); + const payload = { id: 'held-3', platform_id: B, content: JSON.stringify({ text: 'swap' }), in_reply_to: null }; + + await expect( + applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), + ).rejects.toThrow(/invalid or mismatched grant/); + expect(readInbound(B, SB.id)).toHaveLength(0); + }); + + it('a grant only works while its row is live (executes once)', async () => { + setMessagePolicy(A, B, 'telegram:dana', now()); + const payload = { id: 'held-4', platform_id: B, content: JSON.stringify({ text: 'once' }), in_reply_to: null }; + const approval = seedA2aHold('appr-a2a-4', payload); + + deletePendingApproval(approval.approval_id); // resolution already consumed the row + + await expect( + applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), + ).rejects.toThrow(/invalid or mismatched grant/); + expect(readInbound(B, SB.id)).toHaveLength(0); }); // ── ghost-gate cleanup ── diff --git a/src/modules/agent-to-agent/message-gate.ts b/src/modules/agent-to-agent/message-gate.ts index 74c20c4cb22..b93a1a0cc3f 100644 --- a/src/modules/agent-to-agent/message-gate.ts +++ b/src/modules/agent-to-agent/message-gate.ts @@ -1,9 +1,9 @@ /** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */ import { log } from '../../log.js'; import type { ApprovalHandler } from '../approvals/index.js'; -import { performAgentRoute, type RoutableAgentMessage } from './agent-route.js'; +import { routeAgentMessage, type RoutableAgentMessage } from './agent-route.js'; -export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, notify }) => { +export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, approval, notify }) => { const { id, platform_id, content, in_reply_to } = payload; if (typeof platform_id !== 'string' || !platform_id) { notify('Message approved but the target agent group was missing from the request.'); @@ -18,7 +18,12 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, n in_reply_to: typeof in_reply_to === 'string' ? in_reply_to : null, }; - await performAgentRoute(msg, session, platform_id); + // One replay semantics: re-enter the guarded route carrying the approval + // row as the grant. The policy hold is satisfied, but the structural + // baseline runs live — un-wiring the pair between hold and approve now + // blocks delivery (the throw surfaces via the response handler's + // "approved, but applying it failed" notify). + await routeAgentMessage(msg, session, { grant: approval }); log.info('Held agent message delivered after approval', { from: session.agent_group_id, to: platform_id, diff --git a/src/modules/approvals/primitive.ts b/src/modules/approvals/primitive.ts index 2d3028a64dc..48047e676dc 100644 --- a/src/modules/approvals/primitive.ts +++ b/src/modules/approvals/primitive.ts @@ -59,6 +59,12 @@ const APPROVAL_OPTIONS: RawOption[] = [ export interface ApprovalHandlerContext { session: Session; payload: Record; + /** + * The verified approval row — the grant an approved continuation carries + * when it re-enters its guarded entry point. Still live here; resolution + * deletes it after the handler returns, so a grant executes exactly once. + */ + approval: PendingApproval; /** User ID of the admin who approved. Empty string if unknown. */ userId: string; /** Send a system chat message to the requesting agent's session. */ diff --git a/src/modules/approvals/response-handler.ts b/src/modules/approvals/response-handler.ts index 52fc705efea..79014597ece 100644 --- a/src/modules/approvals/response-handler.ts +++ b/src/modules/approvals/response-handler.ts @@ -112,7 +112,7 @@ async function handleRegisteredApproval( const payload = JSON.parse(approval.payload); try { - await handler({ session, payload, userId, notify }); + await handler({ session, payload, approval, userId, notify }); log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId }); } catch (err) { log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err }); diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index 02e87d373c4..ac4f6ce98dd 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -54,7 +54,11 @@ vi.mock('./user-dm.js', () => ({ vi.mock('../../config.js', async () => { const actual = await vi.importActual('../../config.js'); - return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' }; + return { + ...actual, + DATA_DIR: '/tmp/nanoclaw-test-channel-approval', + GROUPS_DIR: '/tmp/nanoclaw-test-channel-approval/groups', + }; }); const TEST_DIR = '/tmp/nanoclaw-test-channel-approval'; @@ -438,6 +442,108 @@ describe('unknown-channel registration flow', () => { .c; expect(stillPending).toBe(1); }); + + it('create new agent: the free-text name reply creates the group and wires the channel', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { getDb } = await import('../../db/connection.js'); + + await routeInbound(groupMention('chat-create-new')); + await new Promise((r) => setTimeout(r, 10)); + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; + + // Owner clicks "Connect new agent" → name prompt lands in their DM. + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'new_agent', + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // Owner replies with the agent name in the same DM — the guarded + // interceptor allows (still an eligible approver) and creates. + await routeInbound({ + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + message: { + id: 'name-reply-1', + kind: 'chat' as const, + content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Newbie' }), + timestamp: now(), + }, + }); + + const created = getDb().prepare("SELECT id FROM agent_groups WHERE name = 'Newbie'").get() as + | { id: string } + | undefined; + expect(created).toBeDefined(); + const mgaCount = ( + getDb() + .prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?') + .get(pending.messaging_group_id, created!.id) as { c: number } + ).c; + expect(mgaCount).toBe(1); + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }) + .c; + expect(stillPending).toBe(0); + }); + + it('a name reply after the registration vanished is consumed without creating anything', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { getDb } = await import('../../db/connection.js'); + + await routeInbound(groupMention('chat-vanished')); + await new Promise((r) => setTimeout(r, 10)); + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'new_agent', + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); + if (claimed) break; + } + + // The registration disappears between the click and the reply (rejected + // from another card, group delete cascade, …) — the guard's baseline no + // longer finds a pending registration, so the reply must not create. + getDb() + .prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?') + .run(pending.messaging_group_id); + + const agentGroupsBefore = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c; + await routeInbound({ + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + message: { + id: 'name-reply-2', + kind: 'chat' as const, + content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Ghost' }), + timestamp: now(), + }, + }); + + const agentGroupsAfter = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c; + expect(agentGroupsAfter).toBe(agentGroupsBefore); + const mgaCount = (getDb().prepare('SELECT COUNT(*) AS c FROM messaging_group_agents').get() as { c: number }).c; + expect(mgaCount).toBe(0); + }); }); describe('no-owner / no-agent failure modes', () => { diff --git a/src/modules/permissions/guard.ts b/src/modules/permissions/guard.ts new file mode 100644 index 00000000000..91cface4653 --- /dev/null +++ b/src/modules/permissions/guard.ts @@ -0,0 +1,54 @@ +/** + * Permissions guard adapter — the module's catalog entries, composed at the + * module edge (imported by ./index.ts). + * + * senders.admit — the `unknown_sender_policy` switch moved verbatim out of + * handleUnknownSender: `public` allows (short-circuited before the gate + * anyway), `request_approval` holds, `strict` denies. The hold is executed by + * the caller through the module's own pending_sender_approvals flow (card, + * in-flight dedup) — not the approvals primitive — so this entry has no + * approvalAction: the approve continuation adds the member and replays + * routeInbound, which then passes the gate structurally via membership, no + * grant needed. + * + * channels.register — click/reply authorization for the channel-registration + * flow, verbatim from today's response handler: the delivered approver, or an + * admin of the pending row's anchor agent group. Consulted by the wrapped + * response handler (card clicks) and the wrapped name-capture interceptor + * (free-text replies), so a privilege revoked mid-flow is re-checked at each + * step. + */ +import { ALLOW, DENY, HOLD, registerGuardedAction } from '../../guard/index.js'; +import { getPendingChannelApproval } from './db/pending-channel-approvals.js'; +import { hasAdminPrivilege } from './db/user-roles.js'; + +registerGuardedAction({ + action: 'senders.admit', + baseline: (input) => { + const policy = input.payload.policy; + if (policy === 'public') return ALLOW('public messaging group'); + if (policy === 'request_approval') { + return HOLD( + `unknown sender requires admin approval on messaging group ${String(input.payload.messagingGroupId)}`, + ); + } + return DENY('unknown sender on a strict messaging group'); + }, +}); + +registerGuardedAction({ + action: 'channels.register', + baseline: (input) => { + if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies'); + const questionId = typeof input.payload.questionId === 'string' ? input.payload.questionId : ''; + const row = getPendingChannelApproval(questionId); + if (!row) return DENY(`no pending channel registration for ${questionId || '(missing questionId)'}`); + if ( + input.actor.userId && + (input.actor.userId === row.approver_user_id || hasAdminPrivilege(input.actor.userId, row.agent_group_id)) + ) { + return ALLOW('delivered approver or anchor-group admin'); + } + return DENY('not an eligible channel-registration approver'); + }, +}); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 6a6f19b43d7..4ead62019f3 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -32,6 +32,8 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; +import './guard.js'; +import { guard } from '../../guard/index.js'; import { canAccessAgentGroup } from './access.js'; import { buildAgentSelectionOptions, @@ -129,43 +131,50 @@ function handleUnknownSender( agent_group_id: agentGroupId, }; - if (mg.unknown_sender_policy === 'strict') { - log.info('MESSAGE DROPPED — unknown sender (strict policy)', { + // The admission decision is the guard's senders.admit baseline (./guard.ts) + // — unknown_sender_policy verbatim: strict → deny, request_approval → hold, + // public → allow (short-circuited before the gate). Drop-recording and the + // hold creation stay here. + const decision = guard({ + action: 'senders.admit', + actor: userId ? { kind: 'human', userId } : { kind: 'system' }, + payload: { messagingGroupId: mg.id, agentGroupId, - userId, - accessReason, - }); - recordDroppedMessage(dropRecord); - return; - } + senderIdentity: userId, + policy: mg.unknown_sender_policy, + }, + }); + + if (decision.effect === 'allow') return; // 'public' — handled before the gate; fall through silently. - if (mg.unknown_sender_policy === 'request_approval') { - log.info('MESSAGE DROPPED — unknown sender (approval requested)', { + log.info( + decision.effect === 'hold' + ? 'MESSAGE DROPPED — unknown sender (approval requested)' + : 'MESSAGE DROPPED — unknown sender (strict policy)', + { messagingGroupId: mg.id, agentGroupId, userId, accessReason, - }); - recordDroppedMessage(dropRecord); - // Fire-and-forget; pick-approver + delivery + row-insert are all async. - // If it fails it logs internally — the user's message still stays dropped - // either way. Requires a resolved userId (senderResolver populates users - // row before the gate fires); if we got here without one, there's nothing - // to identify for approval and we just stay in the "silent strict" branch. - if (userId) { - requestSenderApproval({ - messagingGroupId: mg.id, - agentGroupId, - senderIdentity: userId, - senderName, - event, - }).catch((err) => log.error('Sender-approval flow threw', { err })); - } - return; + }, + ); + recordDroppedMessage(dropRecord); + + // Fire-and-forget; pick-approver + delivery + row-insert are all async. + // If it fails it logs internally — the user's message still stays dropped + // either way. Requires a resolved userId (senderResolver populates users + // row before the gate fires); if we got here without one, there's nothing + // to identify for approval and we just drop silently. + if (decision.effect === 'hold' && userId) { + requestSenderApproval({ + messagingGroupId: mg.id, + agentGroupId, + senderIdentity: userId, + senderName, + event, + }).catch((err) => log.error('Sender-approval flow threw', { err })); } - - // 'public' should have been handled before the gate; fall through silently. } setSenderResolver(extractAndUpsertUser); @@ -311,21 +320,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< const row = getPendingChannelApproval(payload.questionId); if (!row) return false; + // Click authorization is the guard's channels.register baseline (./guard.ts), + // consulted by the response-registry wrapper before this handler runs. const clickerId = payload.userId ? payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}` : null; - const isAuthorized = - clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); - if (!isAuthorized) { - log.warn('Channel registration click rejected — unauthorized clicker', { - messagingGroupId: row.messaging_group_id, - clickerId, - expectedApprover: row.approver_user_id, - }); - return true; - } + if (!clickerId) return true; // unreachable behind the guard wrapper; fail closed const approverId = clickerId; // ── Reject / Cancel ── @@ -515,13 +517,19 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< return true; } -registerResponseHandler(handleChannelApprovalResponse); +registerResponseHandler(handleChannelApprovalResponse, { + action: 'channels.register', + claims: (payload) => getPendingChannelApproval(payload.questionId) !== undefined, +}); // ── Free-text name interceptor ── // Captures the next DM from an approver who clicked "Create new agent", -// creates the agent immediately, wires the channel, and replays. +// creates the agent immediately, wires the channel, and replays. The router +// wraps it with the guard: the free-texter must still be an eligible +// channel-registration approver at reply time — a privilege revoked between +// the click and the reply now denies, and the arming is disarmed. -registerMessageInterceptor(async (event: InboundEvent): Promise => { +const captureAgentNameReply = async (event: InboundEvent): Promise => { const userId = extractAndUpsertUser(event); if (!userId) return false; @@ -629,4 +637,20 @@ registerMessageInterceptor(async (event: InboundEvent): Promise => { } } return true; +}; + +registerMessageInterceptor(captureAgentNameReply, { + action: 'channels.register', + claims: (event) => { + const userId = extractAndUpsertUser(event); + if (!userId) return null; + const pending = awaitingNameInput.get(userId); + if (!pending) return null; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return null; + return { actor: { kind: 'human', userId }, payload: { questionId: pending.channelMgId } }; + }, + onDeny: (event) => { + const userId = extractAndUpsertUser(event); + if (userId) awaitingNameInput.delete(userId); + }, }); diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index c5318ffbfb6..f3d3bde580e 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -1,11 +1,12 @@ /** - * Approval handlers for self-modification actions. + * Guarded handler bodies for self-modification actions. * - * The approvals module calls these when an admin clicks Approve on a - * pending_approvals row whose action matches. Each handler mutates the - * container config in the DB, rebuilds/kills the container as needed, - * and writes an on_wake message so the fresh container picks up where - * the old one left off. + * The delivery registry's guard wrapper runs these only on `allow` — which, + * for self-mod, means an approved replay carrying a valid grant (the + * baseline holds unconditionally from the container path; see ./guard.ts). + * Each body mutates the container config in the DB, rebuilds/kills the + * container as needed, and writes an on_wake message so the fresh container + * picks up where the old one left off. * * install_packages: update DB + rebuild image + kill container + on_wake. * add_mcp_server: update DB + kill container + on_wake. @@ -17,18 +18,19 @@ import { getSession } from '../../db/sessions.js'; import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; -import type { ApprovalHandler } from '../approvals/index.js'; +import type { Session } from '../../types.js'; +import { notifyAgent } from '../approvals/index.js'; -export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => { +export async function applyInstallPackages(payload: Record, session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - notify('install_packages approved but agent group missing.'); + notifyAgent(session, 'install_packages approved but agent group missing.'); return; } const configRow = getContainerConfig(agentGroup.id); if (!configRow) { - notify('install_packages approved but container config missing.'); + notifyAgent(session, 'install_packages approved but container config missing.'); return; } @@ -52,7 +54,7 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, ...((payload.apt as string[] | undefined) || []), ...((payload.npm as string[] | undefined) || []), ].join(', '); - log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); + log.info('Package install approved', { agentGroupId: session.agent_group_id }); try { await buildAgentGroupImage(session.agent_group_id); writeSessionMessage(session.agent_group_id, session.id, { @@ -75,23 +77,24 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, }); log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); } catch (e) { - notify( + notifyAgent( + session, `Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Tell the user — an admin will need to retry the install_packages request or inspect the build logs.`, ); log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e }); } -}; +} -export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => { +export async function applyAddMcpServer(payload: Record, session: Session): Promise { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - notify('add_mcp_server approved but agent group missing.'); + notifyAgent(session, 'add_mcp_server approved but agent group missing.'); return; } const configRow = getContainerConfig(agentGroup.id); if (!configRow) { - notify('add_mcp_server approved but container config missing.'); + notifyAgent(session, 'add_mcp_server approved but container config missing.'); return; } @@ -122,5 +125,5 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use const s = getSession(session.id); if (s) wakeContainer(s); }); - log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId }); -}; + log.info('MCP server add approved', { agentGroupId: session.agent_group_id }); +} diff --git a/src/modules/self-mod/guard.ts b/src/modules/self-mod/guard.ts new file mode 100644 index 00000000000..85faa349270 --- /dev/null +++ b/src/modules/self-mod/guard.ts @@ -0,0 +1,32 @@ +/** + * Self-mod guard adapter — the module's catalog entries, composed at the + * module edge (imported by ./index.ts). + * + * The structural baseline is today's behavior verbatim: from the container + * path, self-modification is held unconditionally for the agent group's + * admin chain. (The equivalent host-side mutations — `ncl groups config + * add-package` etc. — are separate catalog actions derived from the command + * registry.) + */ +import { DENY, HOLD, registerGuardedAction, type GuardInput } from '../../guard/index.js'; + +function selfModBaseline(label: string) { + return (input: GuardInput) => { + if (input.actor.kind !== 'agent') { + return DENY(`${label} is a container-originated action.`); + } + return HOLD(`${label} always requires admin approval from the container path`); + }; +} + +registerGuardedAction({ + action: 'self_mod.install_packages', + approvalAction: 'install_packages', + baseline: selfModBaseline('install_packages'), +}); + +registerGuardedAction({ + action: 'self_mod.add_mcp_server', + approvalAction: 'add_mcp_server', + baseline: selfModBaseline('add_mcp_server'), +}); diff --git a/src/modules/self-mod/index.ts b/src/modules/self-mod/index.ts index e1f49e212b3..f5dd53251f6 100644 --- a/src/modules/self-mod/index.ts +++ b/src/modules/self-mod/index.ts @@ -2,29 +2,51 @@ * Self-modification module — admin-approved container mutations. * * Optional tier. Depends on the approvals default module for the request/ - * handler plumbing. On install the module registers: - * - Two delivery actions (install_packages, add_mcp_server) that validate - * input and queue an approval via requestApproval(). - * - Two matching approval handlers that run on approve and perform the - * complete follow-up: - * install_packages → update container.json, rebuild image, kill + * handler plumbing and on the guard for the decision. On install the module + * registers: + * - Its guard-catalog entries (./guard.ts): unconditional hold from the + * container path. + * - Two guard-wrapped delivery actions (install_packages, add_mcp_server): + * validation runs as the wrapper's precheck, the hold builders card the + * admin, and the handler bodies (./apply.ts) run only on allow — i.e. on + * an approved replay: + * install_packages → update container_configs, rebuild image, kill * container (next wake respawns on the new image), schedule a * verify-and-report follow-up prompt. - * add_mcp_server → update container.json, kill container. No image + * add_mcp_server → update container_configs, kill container. No image * rebuild — bun runs TS directly, so the new MCP server is wired * by the next container start. + * - Two approval handlers that re-enter the wrapped actions with the + * approval row as the grant (one replay semantics — the guard re-checks + * the structural baseline live). * * Without this module: the MCP tools in the container still write outbound * system messages with these actions, but delivery logs "Unknown system * action" and drops them. Admin never sees a card; nothing changes. */ -import { registerDeliveryAction } from '../../delivery.js'; -import { registerApprovalHandler } from '../approvals/index.js'; +import './guard.js'; +import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js'; +import { notifyAgent, registerApprovalHandler } from '../approvals/index.js'; import { applyAddMcpServer, applyInstallPackages } from './apply.js'; -import { handleAddMcpServer, handleInstallPackages } from './request.js'; +import { + requestAddMcpServerHold, + requestInstallPackagesHold, + validateAddMcpServer, + validateInstallPackages, +} from './request.js'; -registerDeliveryAction('install_packages', handleInstallPackages); -registerDeliveryAction('add_mcp_server', handleAddMcpServer); +registerDeliveryAction('install_packages', applyInstallPackages, { + guardAction: 'self_mod.install_packages', + precheck: validateInstallPackages, + requestHold: requestInstallPackagesHold, + onDeny: (_content, session, reason) => notifyAgent(session, `install_packages denied: ${reason}`), +}); +registerDeliveryAction('add_mcp_server', applyAddMcpServer, { + guardAction: 'self_mod.add_mcp_server', + precheck: validateAddMcpServer, + requestHold: requestAddMcpServerHold, + onDeny: (_content, session, reason) => notifyAgent(session, `add_mcp_server denied: ${reason}`), +}); -registerApprovalHandler('install_packages', applyInstallPackages); -registerApprovalHandler('add_mcp_server', applyAddMcpServer); +registerApprovalHandler('install_packages', reenterGuardedDeliveryAction('install_packages')); +registerApprovalHandler('add_mcp_server', reenterGuardedDeliveryAction('add_mcp_server')); diff --git a/src/modules/self-mod/request.ts b/src/modules/self-mod/request.ts index 6cd7f05c9c3..21e558c18ff 100644 --- a/src/modules/self-mod/request.ts +++ b/src/modules/self-mod/request.ts @@ -1,12 +1,12 @@ /** - * Delivery-action handlers for agent-initiated self-modification requests. + * Validation + hold-request builders for agent-initiated self-modification. * * Two actions the container can write into messages_out (via the self-mod - * MCP tools): install_packages, add_mcp_server. Each one validates input - * and queues an approval request. The admin's approval triggers the - * matching approval handler in ./apply.ts, which also performs the - * required follow-up (rebuild+restart for install_packages, restart-only - * for add_mcp_server). + * MCP tools): install_packages, add_mcp_server. The delivery registry wraps + * each one with the guard (see ./guard.ts — unconditional hold from the + * container path): validation here runs as the wrapper's precheck, and the + * hold builders create the approval card when the guard holds. On approve, + * the continuation re-enters the wrapped action and ./apply.ts runs. * * Host-side sanitization for install_packages is defense-in-depth — the MCP * tool validates first. Both layers matter: the DB row carries the payload @@ -17,40 +17,48 @@ import { log } from '../../log.js'; import type { Session } from '../../types.js'; import { notifyAgent, requestApproval } from '../approvals/index.js'; -export async function handleInstallPackages(content: Record, session: Session): Promise { +export function validateInstallPackages(content: Record, session: Session): boolean { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { notifyAgent(session, 'install_packages failed: agent group not found.'); - return; + return false; } const apt = (content.apt as string[]) || []; const npm = (content.npm as string[]) || []; - const reason = (content.reason as string) || ''; const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; const MAX_PACKAGES = 20; if (apt.length + npm.length === 0) { notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.'); - return; + return false; } if (apt.length + npm.length > MAX_PACKAGES) { notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`); - return; + return false; } const invalidApt = apt.find((p) => !APT_RE.test(p)); if (invalidApt) { notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`); log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt }); - return; + return false; } const invalidNpm = npm.find((p) => !NPM_RE.test(p)); if (invalidNpm) { notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`); log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm }); - return; + return false; } + return true; +} + +export async function requestInstallPackagesHold(content: Record, session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) return; + const apt = (content.apt as string[]) || []; + const npm = (content.npm as string[]) || []; + const reason = (content.reason as string) || ''; const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', '); await requestApproval({ @@ -63,18 +71,26 @@ export async function handleInstallPackages(content: Record, se }); } -export async function handleAddMcpServer(content: Record, session: Session): Promise { +export function validateAddMcpServer(content: Record, session: Session): boolean { const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { notifyAgent(session, 'add_mcp_server failed: agent group not found.'); - return; + return false; } const serverName = content.name as string; const command = content.command as string; if (!serverName || !command) { notifyAgent(session, 'add_mcp_server failed: name and command are required.'); - return; + return false; } + return true; +} + +export async function requestAddMcpServerHold(content: Record, session: Session): Promise { + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) return; + const serverName = content.name as string; + const command = content.command as string; await requestApproval({ session, agentName: agentGroup.name, diff --git a/src/response-registry.ts b/src/response-registry.ts index 60e04c998be..57c42e511d1 100644 --- a/src/response-registry.ts +++ b/src/response-registry.ts @@ -7,10 +7,19 @@ * which triggers module registrations that would otherwise happen before * index.ts's own const initializers have run. * - * Keep this file dependency-free (log.js is fine, but nothing from - * modules/* or index.ts itself). Any file imported here must not in turn - * import from src/index.ts, or the cycle returns. + * Keep this file dependency-free (log.js and the guard leaf are fine, but + * nothing from modules/* or index.ts itself). Any file imported here must + * not in turn import from src/index.ts, or the cycle returns. + * + * A handler whose click performs a privileged operation registers with a + * guard spec: the registry wraps it so the guard's decision stands between + * the click and the handler, and the wrapped path is the only path. `claims` + * is the handler's own claim test (does this questionId belong to me?) so an + * unauthorized click is claimed-and-dropped without stealing other handlers' + * responses. */ +import { guard, type GuardActor } from './guard/index.js'; +import { log } from './log.js'; export interface ResponsePayload { questionId: string; @@ -23,10 +32,45 @@ export interface ResponsePayload { export type ResponseHandler = (payload: ResponsePayload) => Promise; +export interface ResponseGuardSpec { + /** Dotted guard-catalog action consulted before the handler runs. */ + action: string; + /** Would this handler claim the response? (Its own row lookup.) */ + claims: (payload: ResponsePayload) => boolean; +} + const responseHandlers: ResponseHandler[] = []; -export function registerResponseHandler(handler: ResponseHandler): void { - responseHandlers.push(handler); +function responseActor(payload: ResponsePayload): GuardActor { + if (!payload.userId) return { kind: 'human', userId: '' }; + const userId = payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`; + return { kind: 'human', userId }; +} + +export function registerResponseHandler(handler: ResponseHandler, guardSpec?: ResponseGuardSpec): void { + if (!guardSpec) { + responseHandlers.push(handler); + return; + } + responseHandlers.push(async (payload) => { + if (!guardSpec.claims(payload)) return false; + const decision = guard({ + action: guardSpec.action, + actor: responseActor(payload), + payload: { questionId: payload.questionId, value: payload.value }, + }); + if (decision.effect !== 'allow') { + // Claim the response so it's not unclaimed-logged, but do nothing. + log.warn('Response click rejected by guard', { + action: guardSpec.action, + questionId: payload.questionId, + userId: payload.userId, + reason: decision.reason, + }); + return true; + } + return handler(payload); + }); } export function getResponseHandlers(): readonly ResponseHandler[] { diff --git a/src/router.ts b/src/router.ts index d39c6a06457..d89db027264 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,6 +20,7 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; +import { guard, type GuardActor } from './guard/index.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { createMessagingGroup, @@ -117,13 +118,46 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { * Used by modules to capture free-text DM replies during multi-step approval * flows — the permissions module (agent naming during channel registration) * and the approvals module (reject-with-reason capture). + * + * An interceptor whose capture performs a privileged operation (the + * channel-registration name capture creates an agent group + wiring) + * registers with a guard spec: the registry wraps it so the guard's decision + * stands between the free-text reply and the handler. `claims` returns the + * guard consult for events the interceptor would act on (null = not mine, + * pass through); a deny consumes the message without acting. */ export type MessageInterceptorFn = (event: InboundEvent) => Promise; +export interface InterceptorGuardSpec { + /** Dotted guard-catalog action consulted before the interceptor acts. */ + action: string; + /** The guard consult for events this interceptor would act on; null = not mine. */ + claims: (event: InboundEvent) => { actor: GuardActor; payload: Record } | null; + /** Domain cleanup when the guard denies (e.g. disarm the capture). */ + onDeny?: (event: InboundEvent) => void; +} + const messageInterceptors: MessageInterceptorFn[] = []; -export function registerMessageInterceptor(fn: MessageInterceptorFn): void { - messageInterceptors.push(fn); +export function registerMessageInterceptor(fn: MessageInterceptorFn, guardSpec?: InterceptorGuardSpec): void { + if (!guardSpec) { + messageInterceptors.push(fn); + return; + } + messageInterceptors.push(async (event) => { + const consult = guardSpec.claims(event); + if (!consult) return fn(event); + const decision = guard({ action: guardSpec.action, actor: consult.actor, payload: consult.payload }); + if (decision.effect !== 'allow') { + log.warn('Interceptor capture rejected by guard — consuming without acting', { + action: guardSpec.action, + reason: decision.reason, + }); + guardSpec.onDeny?.(event); + return true; + } + return fn(event); + }); } /** From 35ca4e14d2e2fceda716d355c0f6ca968bcd6e3c Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Wed, 8 Jul 2026 21:10:24 +0300 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20rename=20src/guard/catalog.ts?= =?UTF-8?q?=20=E2=86=92=20guard-actions.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File + internal map (catalog → guardedActions). The concept stays "the action catalog" in prose (the term the requirements/decisions docs and the conformance banner use); exported symbols (registerGuardedAction, GuardedActionSpec, …) unchanged. Co-Authored-By: Claude Fable 5 --- src/guard/conformance.test.ts | 2 +- src/guard/{catalog.ts => guard-actions.ts} | 10 +++++----- src/guard/guard.test.ts | 2 +- src/guard/guard.ts | 2 +- src/guard/index.ts | 10 ++++++++-- src/guard/types.ts | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) rename src/guard/{catalog.ts => guard-actions.ts} (87%) diff --git a/src/guard/conformance.test.ts b/src/guard/conformance.test.ts index bc945546d10..7f476321a57 100644 --- a/src/guard/conformance.test.ts +++ b/src/guard/conformance.test.ts @@ -27,7 +27,7 @@ import { listCommands } from '../cli/registry.js'; import { commandGuardAction } from '../cli/guard.js'; import { listDeliveryActions, registerDeliveryAction } from '../delivery.js'; import { EXEMPT_DELIVERY_ACTIONS, guardConformanceViolations } from '../guard-conformance.js'; -import { getGuardedAction } from './catalog.js'; +import { getGuardedAction } from './guard-actions.js'; describe('guard conformance', () => { it('the full walk (shared with the boot check) reports zero violations', () => { diff --git a/src/guard/catalog.ts b/src/guard/guard-actions.ts similarity index 87% rename from src/guard/catalog.ts rename to src/guard/guard-actions.ts index 1a00d4e3e00..32ce0da090c 100644 --- a/src/guard/catalog.ts +++ b/src/guard/guard-actions.ts @@ -35,19 +35,19 @@ export interface GuardedActionSpec { grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean; } -const catalog = new Map(); +const guardedActions = new Map(); export function registerGuardedAction(spec: GuardedActionSpec): void { - if (catalog.has(spec.action)) { + if (guardedActions.has(spec.action)) { log.warn('Guarded action re-registered (overwriting)', { action: spec.action }); } - catalog.set(spec.action, spec); + guardedActions.set(spec.action, spec); } export function getGuardedAction(action: string): GuardedActionSpec | undefined { - return catalog.get(action); + return guardedActions.get(action); } export function listGuardedActions(): GuardedActionSpec[] { - return [...catalog.values()].sort((a, b) => a.action.localeCompare(b.action)); + return [...guardedActions.values()].sort((a, b) => a.action.localeCompare(b.action)); } diff --git a/src/guard/guard.test.ts b/src/guard/guard.test.ts index d072e95372d..88cf203a871 100644 --- a/src/guard/guard.test.ts +++ b/src/guard/guard.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { guard } from './guard.js'; -import { registerGuardedAction } from './catalog.js'; +import { registerGuardedAction } from './guard-actions.js'; import { ALLOW, DENY, HOLD, type GuardInput } from './types.js'; const mockGetPendingApproval = vi.fn(); diff --git a/src/guard/guard.ts b/src/guard/guard.ts index b38a3ccad1c..55bed0223eb 100644 --- a/src/guard/guard.ts +++ b/src/guard/guard.ts @@ -20,7 +20,7 @@ */ import { getPendingApproval } from '../db/sessions.js'; import { log } from '../log.js'; -import { getGuardedAction } from './catalog.js'; +import { getGuardedAction } from './guard-actions.js'; import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js'; export function guard(input: GuardInput): GuardDecision { diff --git a/src/guard/index.ts b/src/guard/index.ts index 7bf897e1acc..89781dfb35f 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -2,9 +2,15 @@ * Guard — the privileged-action decision seam (guarded-actions phase 2). * * See the guarded-actions decisions doc on the team hub. One decision - * function (guard.ts) and a registration-derived action catalog (catalog.ts). + * function (guard.ts) and a registration-derived action catalog + * (guard-actions.ts). * Domain-free leaf: domain baselines register from the domain modules' edges. */ export { guard } from './guard.js'; -export { registerGuardedAction, getGuardedAction, listGuardedActions, type GuardedActionSpec } from './catalog.js'; +export { + registerGuardedAction, + getGuardedAction, + listGuardedActions, + type GuardedActionSpec, +} from './guard-actions.js'; export { ALLOW, DENY, HOLD, type GuardActor, type GuardDecision, type GuardInput } from './types.js'; diff --git a/src/guard/types.ts b/src/guard/types.ts index 4925effa779..03233a42f61 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -4,7 +4,7 @@ * The guard is a domain-free leaf: this module may import the DB read layer, * config, log, and shared types — never src/cli/* or src/modules/*. Domain * knowledge (what an action's structural baseline checks) arrives via - * registration: catalog entries (catalog.ts) are registered by the domain + * registration: catalog entries (guard-actions.ts) are registered by the domain * modules at their module edges. */ import type { PendingApproval } from '../types.js'; From f87f82c52855a2bb987d8b92d18d33d152316e93 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Wed, 8 Jul 2026 23:12:03 +0300 Subject: [PATCH 03/10] =?UTF-8?q?refactor(guard):=20consults=20carry=20the?= =?UTF-8?q?=20defined=20action=20value=20=E2=80=94=20delete=20the=20regist?= =?UTF-8?q?ry=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's weak point was the wiring: catalog entries registered by side-effect import, consult sites naming them by string, a map miss resolving to ALLOW, and a boot walk that could only see 2 of the 4 registries (response handlers and interceptors erased their specs into closures). Dropping one unreferenced import silently disarmed channel-registration click-auth. Make the broken wiring unconstructible instead of detected: - defineGuardedAction returns a branded GuardedAction value; guard(action, input) takes the value, not a name. A dropped module-edge import or a typo'd action is now a compile error; there is no lookup and no fail-open branch. A forged value (outside TS) is denied at runtime. - Every registry (delivery actions, response handlers, interceptors) requires a guard spec or an explicit unguarded() declaration at the registration site — unguarded-by-omission is not representable, and the justification travels with the registration (grep "unguarded(" for the complete inventory). CLI commands derive their guard inside register(), so a command cannot exist without one. - guardConformanceViolations() and EXEMPT_DELIVERY_ACTIONS are gone. The boot check shrinks to the one cross-registry invariant the compiler can't see: every holding action has a registered approve continuation (grantContinuationGaps), same fail-closed refuse-to-start posture. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +- CLAUDE.md | 2 +- src/cli/delivery-action.ts | 91 ++++++++-------- src/cli/dispatch.ts | 6 +- src/cli/registry.ts | 18 +++- src/delivery-actions.test.ts | 31 ++++-- src/delivery.ts | 113 ++++++++++---------- src/guard-conformance.ts | 122 ++++++++-------------- src/guard/conformance.test.ts | 117 ++++++++++----------- src/guard/guard-actions.ts | 54 +++++++--- src/guard/guard.test.ts | 79 ++++++++------ src/guard/guard.ts | 59 ++++++----- src/guard/index.ts | 24 +++-- src/guard/types.ts | 21 +++- src/modules/agent-to-agent/agent-route.ts | 5 +- src/modules/agent-to-agent/guard.ts | 6 +- src/modules/agent-to-agent/index.ts | 4 +- src/modules/approvals/index.ts | 6 +- src/modules/approvals/reason-capture.ts | 6 +- src/modules/interactive/index.ts | 6 +- src/modules/permissions/guard.ts | 6 +- src/modules/permissions/index.ts | 18 ++-- src/modules/self-mod/guard.ts | 6 +- src/modules/self-mod/index.ts | 6 +- src/response-registry.ts | 23 ++-- src/router.ts | 23 ++-- 26 files changed, 466 insertions(+), 390 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e19fbab1b..01b6c028101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction, and a registry-walking conformance test fails CI on any unmapped mutating entry. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) -- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) if any privileged command or delivery action is registered without a guard-catalog mapping. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the same registry walk the conformance test uses (`src/guard-conformance.ts`, one shared exemption list) now runs in `main()` before the host accepts a message, and a bad registration crashes at skill-install time instead of running unguarded. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. +- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction: every registration takes a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) when a guarded action that can hold has no registered approve continuation — the one cross-registry invariant the type system can't see, since catalog entries and approval handlers register from different modules. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the check (`src/guard-conformance.ts`) runs in `main()` before the host accepts a message, and a mis-composition crashes at skill-install time instead of at the first approved card. The old registry walk is deleted: everything it detected is now unconstructible at the registration API. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). - **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host. diff --git a/CLAUDE.md b/CLAUDE.md index 58831437f9c..325d8d06d31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard()` → allow \| hold \| deny. Domain-free leaf (decision function + registration-derived action catalog); domain baselines register from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions). All four handler registries wrap at registration; approved replays carry the approval row as a grant and re-check the structural baseline. Policy-as-data (tighten-only rule sources) is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. All four handler registries require a guard spec or an explicit `unguarded()` declaration at the registration site. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/cli/delivery-action.ts b/src/cli/delivery-action.ts index 5c693be6f1c..5a759cb21de 100644 --- a/src/cli/delivery-action.ts +++ b/src/cli/delivery-action.ts @@ -8,52 +8,57 @@ import type Database from 'better-sqlite3'; import { registerDeliveryAction } from '../delivery.js'; +import { unguarded } from '../guard/index.js'; import { insertMessage } from '../db/session-db.js'; import { log } from '../log.js'; import { dispatch } from './dispatch.js'; import type { RequestFrame } from './frame.js'; import type { Session } from '../types.js'; -registerDeliveryAction('cli_request', async (content, session, inDb) => { - const requestId = content.requestId as string; - const command = content.command as string; - const args = (content.args as Record) ?? {}; - - if (!requestId || !command) { - log.warn('cli_request missing requestId or command', { sessionId: session.id }); - return; - } - - const req: RequestFrame = { id: requestId, command, args }; - const ctx = { - caller: 'agent' as const, - sessionId: session.id, - agentGroupId: session.agent_group_id, - messagingGroupId: session.messaging_group_id ?? '', - }; - - log.info('CLI request from agent', { requestId, command, sessionId: session.id }); - - const response = await dispatch(req, ctx); - - // Write response to inbound.db so the container can read it. - // trigger=0: don't wake the agent — this is an inline response to a tool call. - insertMessage(inDb, { - id: `cli-resp-${requestId}`, - kind: 'system', - timestamp: new Date().toISOString(), - platformId: null, - channelType: null, - threadId: null, - content: JSON.stringify({ - type: 'cli_response', - requestId, - frame: response, - }), - processAfter: null, - recurrence: null, - trigger: 0, - }); - - log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id }); -}); +registerDeliveryAction( + 'cli_request', + async (content, session, inDb) => { + const requestId = content.requestId as string; + const command = content.command as string; + const args = (content.args as Record) ?? {}; + + if (!requestId || !command) { + log.warn('cli_request missing requestId or command', { sessionId: session.id }); + return; + } + + const req: RequestFrame = { id: requestId, command, args }; + const ctx = { + caller: 'agent' as const, + sessionId: session.id, + agentGroupId: session.agent_group_id, + messagingGroupId: session.messaging_group_id ?? '', + }; + + log.info('CLI request from agent', { requestId, command, sessionId: session.id }); + + const response = await dispatch(req, ctx); + + // Write response to inbound.db so the container can read it. + // trigger=0: don't wake the agent — this is an inline response to a tool call. + insertMessage(inDb, { + id: `cli-resp-${requestId}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: null, + channelType: null, + threadId: null, + content: JSON.stringify({ + type: 'cli_response', + requestId, + frame: response, + }), + processAfter: null, + recurrence: null, + trigger: 0, + }); + + log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id }); + }, + unguarded('transport envelope — every inner command is guarded at dispatch'), +); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 99ca7b18dba..bb44144d162 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -20,9 +20,8 @@ import { registerApprovalHandler, requestApproval } from '../modules/approvals/i import type { PendingApproval } from '../types.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { getResource } from './crud.js'; -import { commandGuardAction } from './guard.js'; import { listVerbs, renderVerbHelp } from './help-render.js'; -import { listCommands, lookup } from './registry.js'; +import { commandGuard, listCommands, lookup } from './registry.js'; type DispatchOptions = { /** Verified approval row when a command is replayed after approval. */ @@ -101,8 +100,7 @@ export async function dispatch( } } - const decision = guard({ - action: commandGuardAction(cmd), + const decision = guard(commandGuard(cmd.name), { actor: actorFor(ctx), payload: req.args, grant: opts.grant ?? null, diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 40941099e41..8a436ec3257 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -8,7 +8,7 @@ * registers the help commands, so the registry is populated before the host's * CLI server accepts connections. */ -import { registerGuardedAction } from '../guard/index.js'; +import { defineGuardedAction, type GuardedAction } from '../guard/index.js'; import { commandGuardSpec } from './guard.js'; import type { CallerContext } from './frame.js'; @@ -61,6 +61,7 @@ export type CommandDef = { }; const registry = new Map(); +const commandGuards = new Map(); export function register(def: CommandDef): void { if (registry.has(def.name)) { @@ -68,9 +69,18 @@ export function register(def: CommandDef): void { } registry.set(def.name, def as CommandDef); // Declaration is registration: every command gets a guard-catalog entry - // derived from its own definition — dispatch consults the guard, and the - // conformance test walks the registry against the catalog. - registerGuardedAction(commandGuardSpec(def as CommandDef)); + // derived from its own definition, in the same call that registers it — a + // command cannot exist without a guard, and dispatch consults it by value. + commandGuards.set(def.name, defineGuardedAction(commandGuardSpec(def as CommandDef))); +} + +/** The guard defined for a registered command — total for anything register() accepted. */ +export function commandGuard(name: string): GuardedAction { + const g = commandGuards.get(name); + if (!g) { + throw new Error(`CLI command "${name}" has no guard — was it registered through register()?`); + } + return g; } export function lookup(name: string): CommandDef | undefined { diff --git a/src/delivery-actions.test.ts b/src/delivery-actions.test.ts index f9054978bd9..fc38d769ae7 100644 --- a/src/delivery-actions.test.ts +++ b/src/delivery-actions.test.ts @@ -4,7 +4,9 @@ * `registerDeliveryAction` is the hook modules use to handle system-kind * outbound messages; `getDeliveryAction` is the read side that makes those * registrations behavior-testable. Goes red if either half of the registry - * is removed or the two stop sharing the same map. + * is removed or the two stop sharing the same map. Every registration now + * carries a guard spec or an explicit unguarded() declaration — + * omission is a type error. */ import { describe, it, expect, vi } from 'vitest'; @@ -16,11 +18,14 @@ vi.mock('./container-runner.js', () => ({ })); import { registerDeliveryAction, getDeliveryAction, type DeliveryActionHandler } from './delivery.js'; +import { defineGuardedAction, HOLD, unguarded } from './guard/index.js'; + +const testUnguarded = unguarded('test — registry mechanics only'); describe('delivery action registry', () => { it('getDeliveryAction returns the handler registerDeliveryAction registered', () => { const handler: DeliveryActionHandler = async () => {}; - registerDeliveryAction('test_registry_action', handler); + registerDeliveryAction('test_registry_action', handler, testUnguarded); expect(getDeliveryAction('test_registry_action')).toBe(handler); }); @@ -31,26 +36,32 @@ describe('delivery action registry', () => { it('re-registering an action overwrites the previous handler', () => { const first: DeliveryActionHandler = async () => {}; const second: DeliveryActionHandler = async () => {}; - registerDeliveryAction('test_overwrite_action', first); - registerDeliveryAction('test_overwrite_action', second); + registerDeliveryAction('test_overwrite_action', first, testUnguarded); + registerDeliveryAction('test_overwrite_action', second, testUnguarded); expect(getDeliveryAction('test_overwrite_action')).toBe(second); }); it('refuses to replace a guard-wrapped action with an unguarded handler', () => { + const guardAction = defineGuardedAction({ + action: 'test.guarded-overwrite', + baseline: () => HOLD('t'), + }); registerDeliveryAction('test_guarded_overwrite', async () => {}, { - guardAction: 'test.guarded-overwrite', + guardAction, requestHold: async () => {}, }); - // Disarming the guard by re-registering without a spec must throw — - // otherwise the catalog (and the conformance walk) would still report - // the action guarded while the live path runs unguarded. - expect(() => registerDeliveryAction('test_guarded_overwrite', async () => {})).toThrow(/disarm the guard/); + // Disarming the guard by re-registering unguarded must throw — otherwise + // the action's catalog entry would still exist while the live path runs + // unguarded. + expect(() => registerDeliveryAction('test_guarded_overwrite', async () => {}, testUnguarded)).toThrow( + /disarm the guard/, + ); // Re-registering WITH a spec stays allowed (a legitimate replacement // keeps the action guarded). registerDeliveryAction('test_guarded_overwrite', async () => {}, { - guardAction: 'test.guarded-overwrite', + guardAction, requestHold: async () => {}, }); expect(getDeliveryAction('test_guarded_overwrite')).toBeDefined(); diff --git a/src/delivery.ts b/src/delivery.ts index 0017c8d126a..344a4514097 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -20,7 +20,7 @@ import { markDeliveryFailed, migrateDeliveredTable, } from './db/session-db.js'; -import { guard } from './guard/index.js'; +import { guard, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; @@ -397,15 +397,16 @@ async function deliverMessage( * `registerDeliveryAction`. Unknown actions log "Unknown system action". * * Privileged delivery actions (create_agent, install_packages, - * add_mcp_server) register with a guard spec: the registry wraps the handler - * so the guard's decision — allow / hold / deny — stands between the - * container's outbound row and the handler body, and the wrapped path is the - * only path (the raw handler is never stored). On approve, the continuation - * re-enters the same wrapped entry carrying the approval row as its grant - * (`reenterGuardedDeliveryAction`), so the structural baseline is re-checked - * live. Plain actions (scheduling self-actions, the cli_request bridge — its - * inner commands are guarded at dispatch) register without a spec and are - * not catalog actions. + * add_mcp_server) register with a guard spec: every path to the handler body + * — dispatch, approved replay, test lookup — goes through the guard consult + * (allow / hold / deny), so there is no unguarded route to it. On approve, + * the continuation re-enters the same entry carrying the approval row as its + * grant (`reenterGuardedDeliveryAction`), so the structural baseline is + * re-checked live. Plain actions (scheduling self-actions, the cli_request + * bridge — its inner commands are guarded at dispatch) register with an + * explicit `unguarded()` declaration instead of a spec — omission is + * not representable, so the decision to run unguarded is visible, and + * justified, at the registration site. */ export type DeliveryActionHandler = ( content: Record, @@ -417,8 +418,8 @@ export type DeliveryActionHandler = ( export type GuardedDeliveryHandler = (content: Record, session: Session) => Promise; export interface DeliveryGuardSpec { - /** Dotted guard-catalog action consulted before the handler runs. */ - guardAction: string; + /** Guard action consulted before the handler runs — the defined value, not a name. */ + guardAction: GuardedAction; /** * Domain validation that runs before the guard — malformed requests are * answered (notify) without ever creating a hold. Return false to stop. @@ -430,54 +431,53 @@ export interface DeliveryGuardSpec { onDeny?: (content: Record, session: Session, reason: string) => void; } -const actionHandlers = new Map(); -const guardedActions = new Map(); +type DeliveryEntry = + | { guard: Unguarded; handler: DeliveryActionHandler } + | { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler }; -export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void; +const deliveryActions = new Map(); + +function isUnguardedEntry(entry: DeliveryEntry): entry is Extract { + return 'reason' in entry.guard; +} + +export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void; export function registerDeliveryAction(action: string, handler: GuardedDeliveryHandler, spec: DeliveryGuardSpec): void; export function registerDeliveryAction( action: string, handler: DeliveryActionHandler | GuardedDeliveryHandler, - spec?: DeliveryGuardSpec, + guardDecl: DeliveryGuardSpec | Unguarded, ): void { - if (actionHandlers.has(action)) { + const existing = deliveryActions.get(action); + if (existing) { // Replacing a guard-wrapped action with an unguarded handler would - // disarm the guard while the catalog (and the conformance walk) still - // report it guarded — refuse. A skill that wants to extend a guarded - // action must compose at the module's exported functions instead, or - // re-register with a guard spec of its own. - if (!spec && guardedActions.has(action)) { + // disarm the guard while its catalog entry still exists — refuse. A + // skill that wants to extend a guarded action must compose at the + // module's exported functions instead, or re-register with a guard spec + // of its own. + if ('reason' in guardDecl && !isUnguardedEntry(existing)) { throw new Error( `delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`, ); } log.warn('Delivery action handler overwritten', { action }); } - if (!spec) { - actionHandlers.set(action, handler as DeliveryActionHandler); - return; - } - guardedActions.set(action, { spec, handler: handler as GuardedDeliveryHandler }); - actionHandlers.set(action, (content, session) => runGuardedDeliveryAction(action, content, session, null)); + // The overloads pair each handler shape with its declaration; the merged + // implementation signature erases that pairing, hence the one cast. + deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry); } -async function runGuardedDeliveryAction( +async function runGuarded( action: string, + entry: Extract, content: Record, session: Session, grant: PendingApproval | null, ): Promise { - const entry = guardedActions.get(action); - if (!entry) { - log.warn('Unknown guarded delivery action', { action }); - return; - } - const { spec, handler } = entry; - + const spec = entry.guard; if (spec.precheck && !(await spec.precheck(content, session))) return; - const decision = guard({ - action: spec.guardAction, + const decision = guard(spec.guardAction, { actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id }, payload: content, grant, @@ -492,32 +492,37 @@ async function runGuardedDeliveryAction( await spec.requestHold(content, session); return; } - await handler(content, session); + await entry.handler(content, session); } /** * Approve continuation for a guard-wrapped delivery action: re-enter the - * wrapped entry with the approval row as the grant. The guard treats the - * grant as hold-satisfied but re-runs the structural baseline, so - * approve-then-revoke does not execute. Domains register this as their - * approval handler in the same line that registers the action. + * entry with the approval row as the grant. The guard treats the grant as + * hold-satisfied but re-runs the structural baseline, so approve-then-revoke + * does not execute. Domains register this as their approval handler in the + * same line that registers the action. */ export function reenterGuardedDeliveryAction(action: string) { return async (ctx: { session: Session; payload: Record; approval: PendingApproval }) => { - await runGuardedDeliveryAction(action, ctx.payload, ctx.session, ctx.approval); + const entry = deliveryActions.get(action); + if (!entry || isUnguardedEntry(entry)) { + log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action }); + return; + } + await runGuarded(action, entry, ctx.payload, ctx.session, ctx.approval); }; } -/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */ +/** + * The invocable for a registered action — the raw handler for unguarded + * entries, the guard-consulting path for guarded ones. Dispatch and tests + * both come through here; there is no route around the guard. + */ export function getDeliveryAction(action: string): DeliveryActionHandler | undefined { - return actionHandlers.get(action); -} - -/** Registered delivery actions with their guard mapping, for the conformance test. */ -export function listDeliveryActions(): { action: string; guardAction: string | null }[] { - return [...actionHandlers.keys()] - .sort() - .map((action) => ({ action, guardAction: guardedActions.get(action)?.spec.guardAction ?? null })); + const entry = deliveryActions.get(action); + if (!entry) return undefined; + if (isUnguardedEntry(entry)) return entry.handler; + return (content, session) => runGuarded(action, entry, content, session, null); } /** @@ -533,7 +538,7 @@ async function handleSystemAction( const action = content.action as string; log.info('System action from agent', { sessionId: session.id, action }); - const registered = actionHandlers.get(action); + const registered = getDeliveryAction(action); if (registered) { await registered(content, session, inDb); return; diff --git a/src/guard-conformance.ts b/src/guard-conformance.ts index 8cbf562befa..4f7a3fbcd2c 100644 --- a/src/guard-conformance.ts +++ b/src/guard-conformance.ts @@ -1,85 +1,49 @@ /** - * Guard conformance — the non-bypass invariant, shared by CI and boot. + * Boot-time guard sanity — the one cross-registry invariant left to check + * after all import-time registrations have run. * - * CI only protects code that goes through the repo's CI, but NanoClaw's - * extension model is skill-installed code: /add-* skills copy modules into - * the user's tree and register handlers on machines where the test suite - * never runs. Running the same walk at boot turns the conformance test's - * guarantee into a runtime invariant at exactly that trust boundary: every - * registration is an import-time side effect, so by the time main() runs - * the registries are complete and an unmapped privileged entry is - * detectable before the host accepts a single message. + * The old registry walk is gone: everything it detected is now + * unconstructible at the API level. + * - A consult site cannot name a missing catalog entry — guard() takes the + * GuardedAction VALUE returned by defineGuardedAction, so a dropped + * module-edge import or a typo'd action is a compile error (and a forged + * value is denied at runtime), not a silent allow. + * - A handler cannot register unguarded by omission — every registry + * (delivery actions, response handlers, interceptors, CLI commands) + * requires a guard spec or an explicit unguarded() declaration + * at the registration site. * - * Fail-closed: the host refuses to start (the upgrade-tripwire posture). - * A conformance failure is code mis-composition — fixable with the host - * down — and it surfaces at skill-install time, when the installing agent - * is watching, instead of running unguarded until someone runs pnpm test. - * - * Limit: the walk verifies DECLARED mappings ("every delivery action is - * guarded or explicitly exempt") — it cannot infer which actions are - * privileged. That declaration stays on the author; the exemption list - * below is the loud, reviewable escape hatch. + * What remains is completeness ACROSS registries: a guarded action that + * holds via `approvalAction` needs a registered approval handler, or an + * approved card resolves into nothing — the hold has no continuation. That + * pairing only exists once every module has loaded (catalog entries and + * approval handlers register from different modules), so it stays a boot + * check with the fail-closed posture: the host refuses to start, surfacing + * the mis-composition at skill-install time instead of at the first + * approved card. */ -import { commandGuardAction } from './cli/guard.js'; -import { listCommands } from './cli/registry.js'; -import { listDeliveryActions } from './delivery.js'; -import { getGuardedAction } from './guard/index.js'; +import { listGuardedActions } from './guard/index.js'; import { log } from './log.js'; +import { getApprovalHandler } from './modules/approvals/primitive.js'; -/** - * Delivery actions that deliberately carry no guard mapping (the declared - * exemption class, per the guarded-actions design decision 1): - * - scheduling self-actions — an agent mutating only its own task rows; - * not a privileged action class (yet). - * - cli_request — the transport bridge into dispatch(); every inner - * command is guarded at dispatch, so the envelope carries no privilege. - */ -export const EXEMPT_DELIVERY_ACTIONS = new Set([ - 'schedule_task', - 'cancel_task', - 'pause_task', - 'resume_task', - 'update_task', - 'cli_request', -]); - -/** Walk the live registries against the guard catalog. Empty = conformant. */ -export function guardConformanceViolations(): string[] { - const violations: string[] = []; - - for (const cmd of listCommands()) { - const entry = getGuardedAction(commandGuardAction(cmd)); - if (!entry) { - violations.push(`command "${cmd.name}" has no guard-catalog entry`); - continue; - } - if (cmd.access === 'approval' && entry.approvalAction !== 'cli_command') { - violations.push(`mutating command "${cmd.name}" maps to a catalog entry that cannot hold`); - } - } - - for (const { action, guardAction } of listDeliveryActions()) { - if (guardAction === null) { - if (!EXEMPT_DELIVERY_ACTIONS.has(action)) { - violations.push(`delivery action "${action}" is neither guard-mapped nor on the declared exemption list`); - } - continue; - } - if (!getGuardedAction(guardAction)) { - violations.push(`delivery action "${action}" maps to unregistered guard action "${guardAction}"`); - } - } - - return violations; +/** Holding actions with no approve continuation. Empty = conformant. */ +export function grantContinuationGaps(): string[] { + return listGuardedActions() + .filter((spec) => spec.approvalAction && !getApprovalHandler(spec.approvalAction)) + .map( + (spec) => + `guarded action "${spec.action}" holds via approval action "${spec.approvalAction}" ` + + 'but no approval handler is registered — an approved hold would have no continuation', + ); } /** - * Boot check: refuse to start when any privileged registration is unmapped. + * Boot check: refuse to start when a holding action has no continuation. * Call after all import-time registrations (any point in main()). */ export function enforceGuardConformance(): void { - const violations = guardConformanceViolations(); - if (violations.length === 0) return; + const gaps = grantContinuationGaps(); + if (gaps.length === 0) return; console.error( [ @@ -87,20 +51,20 @@ export function enforceGuardConformance(): void { '='.repeat(64), 'NanoClaw stopped: guard conformance failure', '='.repeat(64), - 'A privileged registration is not mapped to the guard catalog —', - 'it would run with no allow/hold/deny decision. This usually means', - 'a skill (or local change) registered a command or delivery action', - 'without a guard spec.', + 'A guarded action can hold for approval, but no approval handler is', + 'registered for its approval action — an admin could click Approve', + 'and nothing would execute. This usually means a module (or skill)', + 'defined a holding baseline without registering its continuation.', '', - ...violations.map((v) => ` - ${v}`), + ...gaps.map((g) => ` - ${g}`), '', - 'Fix the registration (pass a guard spec / derive a catalog entry),', - 'or — only for genuinely unprivileged self-actions — add it to', - 'EXEMPT_DELIVERY_ACTIONS in src/guard-conformance.ts.', + 'Register the approval handler (registerApprovalHandler) in the same', + 'module that defines the guarded action, or drop approvalAction from', + 'the definition if the action can never hold.', '='.repeat(64), '', ].join('\n'), ); - log.error('Guard conformance failure — refusing to start', { violations }); + log.error('Guard conformance failure — refusing to start', { gaps }); process.exit(1); } diff --git a/src/guard/conformance.test.ts b/src/guard/conformance.test.ts index 7f476321a57..a1621841cfa 100644 --- a/src/guard/conformance.test.ts +++ b/src/guard/conformance.test.ts @@ -1,20 +1,15 @@ /** - * Guard conformance — the non-bypass invariant, checked structurally. + * Guard conformance — the boot invariant, checked with the real registries. * - * Walks the real command registry and the real delivery-action registry - * (loaded via their production barrels) against the guard catalog. The walk - * itself lives in src/guard-conformance.ts and runs twice: here in CI, and - * at every boot (enforceGuardConformance in index.ts refuses to start on a - * violation) — CI can't see skill-installed registrations, the boot check - * can. A new privileged command or delivery action cannot quietly ship - * ungated: registration derives the catalog entry, and this walk makes - * drift loud. - * - * The declared exemption classes live with the walk - * (EXEMPT_DELIVERY_ACTIONS): scheduling self-actions and the cli_request - * transport bridge (its inner commands are guarded at dispatch). Reads - * (list/get/help) are catalog-mapped via registration too, but their - * baselines allow; the mutating set is what MUST be mapped. + * The old registry walk is gone: an unmapped consult or an undeclared + * unguarded registration is now unconstructible — guard() takes the defined + * GuardedAction value (a dropped module-edge import or typo'd name is a + * compile error), and every registry requires a guard spec or an explicit + * unguarded() declaration. What's left to verify structurally is the + * cross-registry pairing the compiler can't see: every holding action has a + * registered approve continuation. The check runs here in CI and at every + * boot (enforceGuardConformance refuses to start) — CI can't see + * skill-installed registrations, the boot check can. */ import { describe, expect, it } from 'vitest'; @@ -22,68 +17,68 @@ import { describe, expect, it } from 'vitest'; import '../cli/commands/index.js'; import '../modules/index.js'; import '../cli/delivery-action.js'; +import '../cli/dispatch.js'; // registers the cli_command approval handler -import { listCommands } from '../cli/registry.js'; -import { commandGuardAction } from '../cli/guard.js'; -import { listDeliveryActions, registerDeliveryAction } from '../delivery.js'; -import { EXEMPT_DELIVERY_ACTIONS, guardConformanceViolations } from '../guard-conformance.js'; -import { getGuardedAction } from './guard-actions.js'; +import { commandGuard, listCommands } from '../cli/registry.js'; +import { grantContinuationGaps } from '../guard-conformance.js'; +import { getApprovalHandler } from '../modules/approvals/primitive.js'; +import { defineGuardedAction, listGuardedActions } from './guard-actions.js'; +import { HOLD } from './types.js'; describe('guard conformance', () => { - it('the full walk (shared with the boot check) reports zero violations', () => { - expect(guardConformanceViolations()).toEqual([]); + it('the grant-continuation check (shared with the boot check) reports zero gaps', () => { + expect(grantContinuationGaps()).toEqual([]); }); - it('every mutating ncl command maps to a guard catalog entry that can hold', () => { + it('every holding action pairs with a registered approval handler', () => { + const holding = listGuardedActions().filter((spec) => spec.approvalAction); + expect(holding.length).toBeGreaterThan(0); + + const dangling = holding.filter((spec) => !getApprovalHandler(spec.approvalAction as string)); + expect(dangling.map((s) => s.action)).toEqual([]); + }); + + it('every mutating ncl command derives a guard that holds via cli_command', () => { const mutating = listCommands().filter((cmd) => cmd.access === 'approval'); expect(mutating.length).toBeGreaterThan(0); - const unmapped = mutating.filter((cmd) => { - const entry = getGuardedAction(commandGuardAction(cmd)); - return !entry || entry.approvalAction !== 'cli_command'; - }); - expect(unmapped.map((c) => c.name)).toEqual([]); + const wrong = mutating.filter((cmd) => commandGuard(cmd.name).approvalAction !== 'cli_command'); + expect(wrong.map((c) => c.name)).toEqual([]); }); - it('every registered command (reads included) has a catalog entry — denied reads still surface as denials', () => { - const unmapped = listCommands().filter((cmd) => !getGuardedAction(commandGuardAction(cmd))); - expect(unmapped.map((c) => c.name)).toEqual([]); + it('the domain catalog entries are defined once the module barrels load', () => { + const actions = new Set(listGuardedActions().map((s) => s.action)); + for (const expected of [ + 'agents.create', + 'a2a.send', + 'self_mod.install_packages', + 'self_mod.add_mcp_server', + 'senders.admit', + 'channels.register', + ]) { + expect(actions.has(expected), `catalog is missing "${expected}"`).toBe(true); + } }); - it('every delivery action is guard-mapped or on the declared exemption list', () => { - const actions = listDeliveryActions(); - expect(actions.length).toBeGreaterThan(0); - - const unmapped = actions.filter( - ({ action, guardAction }) => guardAction === null && !EXEMPT_DELIVERY_ACTIONS.has(action), + it('defining the same action twice throws — names are the catalog key', () => { + defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') }); + expect(() => defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') })).toThrow( + /already defined/, ); - expect(unmapped.map((a) => a.action)).toEqual([]); - - const danglingCatalog = actions.filter(({ guardAction }) => guardAction !== null && !getGuardedAction(guardAction)); - expect(danglingCatalog.map((a) => a.action)).toEqual([]); }); - it('the privileged delivery actions are the guarded ones', () => { - const guarded = Object.fromEntries( - listDeliveryActions() - .filter((a) => a.guardAction !== null) - .map((a) => [a.action, a.guardAction]), - ); - expect(guarded).toEqual({ - create_agent: 'agents.create', - install_packages: 'self_mod.install_packages', - add_mcp_server: 'self_mod.add_mcp_server', + // KEEP LAST: defines a holding action with no continuation into the shared + // per-worker catalog, so every gap check after this point sees it. + it('the check names a holding action with no approve continuation (what boot refuses on)', () => { + defineGuardedAction({ + action: 'test.dangling-hold', + approvalAction: 'test_dangling_hold_approved', + baseline: () => HOLD('always'), }); - }); - - // KEEP LAST: registers a rogue action into the shared per-worker registry, - // so every walk after this point sees the violation. - it('the walk names an unguarded, non-exempt delivery action (what the boot check refuses on)', () => { - registerDeliveryAction('test_rogue_privileged_action', async () => {}); - const violations = guardConformanceViolations(); - expect(violations).toHaveLength(1); - expect(violations[0]).toContain('test_rogue_privileged_action'); - expect(violations[0]).toContain('neither guard-mapped nor on the declared exemption list'); + const gaps = grantContinuationGaps(); + expect(gaps).toHaveLength(1); + expect(gaps[0]).toContain('test.dangling-hold'); + expect(gaps[0]).toContain('no approval handler'); }); }); diff --git a/src/guard/guard-actions.ts b/src/guard/guard-actions.ts index 32ce0da090c..85f4e6cc35e 100644 --- a/src/guard/guard-actions.ts +++ b/src/guard/guard-actions.ts @@ -1,19 +1,23 @@ /** * The action catalog — the enforcement boundary. * - * An action either is in the catalog (and passes a decision) or is not (and - * needs none — reads, scheduling self-actions). Declaration is registration: - * entries are derived at the registries' registration sites (command - * registry, delivery actions, response handlers, interceptors, module - * edges), never maintained in a second file. The conformance test walks the - * registries against this catalog so an unmapped privileged action fails CI. + * An action either is defined here (and every consult passes its decision) + * or cannot be consulted at all: guard() takes the GuardedAction VALUE + * returned by defineGuardedAction, so the wiring between a consult site and + * its baseline is a symbol reference the compiler checks. A dropped + * module-edge import or a typo'd action name is a build error, not a + * runtime fail-open — there is no lookup that can miss. + * + * Definitions are still recorded by name so boot can enumerate them: the + * grant-continuation check (src/guard-conformance.ts) pairs every holding + * action with its registered approval handler, and duplicate names are + * refused at definition time (grants match on the name). */ -import { log } from '../log.js'; import type { GuardDecision, GuardInput } from './types.js'; import type { PendingApproval } from '../types.js'; export interface GuardedActionSpec { - /** Dotted action name — the catalog key. */ + /** Dotted action name, e.g. 'roles.grant', 'agents.create', 'a2a.send'. */ action: string; /** * Today's structural checks for this action, verbatim — the only source of @@ -35,19 +39,35 @@ export interface GuardedActionSpec { grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean; } -const guardedActions = new Map(); +declare const guardedActionBrand: unique symbol; +/** + * A defined guarded action — only defineGuardedAction can mint one. The + * brand makes the type nominal: a hand-rolled { action, baseline } object + * does not typecheck at a consult site, and fails the runtime check too. + */ +export type GuardedAction = Readonly & { readonly [guardedActionBrand]: true }; + +const defined = new Map(); +const minted = new WeakSet(); -export function registerGuardedAction(spec: GuardedActionSpec): void { - if (guardedActions.has(spec.action)) { - log.warn('Guarded action re-registered (overwriting)', { action: spec.action }); +export function defineGuardedAction(spec: GuardedActionSpec): GuardedAction { + if (defined.has(spec.action)) { + throw new Error(`guarded action "${spec.action}" is already defined — action names are the catalog key`); } - guardedActions.set(spec.action, spec); + const def = Object.freeze({ ...spec }) as GuardedAction; + minted.add(def); + defined.set(spec.action, def); + return def; } -export function getGuardedAction(action: string): GuardedActionSpec | undefined { - return guardedActions.get(action); +/** + * Runtime backstop for callers outside the type system (plain JS, casts): + * only values minted by defineGuardedAction pass — guard() denies the rest. + */ +export function isGuardedAction(value: unknown): value is GuardedAction { + return typeof value === 'object' && value !== null && minted.has(value); } -export function listGuardedActions(): GuardedActionSpec[] { - return [...guardedActions.values()].sort((a, b) => a.action.localeCompare(b.action)); +export function listGuardedActions(): GuardedAction[] { + return [...defined.values()].sort((a, b) => a.action.localeCompare(b.action)); } diff --git a/src/guard/guard.test.ts b/src/guard/guard.test.ts index 88cf203a871..db2a76e7982 100644 --- a/src/guard/guard.test.ts +++ b/src/guard/guard.test.ts @@ -1,16 +1,16 @@ /** * Guard decision-function unit tests: the baseline is the decision (allow / - * hold / deny returned as-is, non-catalog actions allow), grant semantics - * (satisfies holds, never denies; invalid → refuse), and the fail-closed - * posture on a throwing baseline. + * hold / deny returned as-is), grant semantics (satisfies holds, never + * denies; invalid → refuse), the runtime backstop against forged action + * values, and the fail-closed posture on a throwing baseline. * - * Uses synthetic catalog actions registered per test — the registry is - * per-worker module state with no reset, so action names are unique. + * Uses synthetic actions defined per test — the catalog is per-worker module + * state with no reset, so action names are unique. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { guard } from './guard.js'; -import { registerGuardedAction } from './guard-actions.js'; +import { defineGuardedAction, type GuardedAction } from './guard-actions.js'; import { ALLOW, DENY, HOLD, type GuardInput } from './types.js'; const mockGetPendingApproval = vi.fn(); @@ -23,8 +23,8 @@ vi.mock('../log.js', () => ({ const AGENT = { kind: 'agent', agentGroupId: 'ag-1', sessionId: 'sess-1' } as const; -function input(action: string, extra: Partial = {}): GuardInput { - return { action, actor: AGENT, payload: {}, ...extra }; +function input(extra: Partial = {}): GuardInput { + return { actor: AGENT, payload: {}, ...extra }; } beforeEach(() => { @@ -36,18 +36,14 @@ afterEach(() => { }); describe('the baseline is the decision', () => { - it('non-catalog action → allow', () => { - expect(guard(input('test.unregistered-read')).effect).toBe('allow'); - }); - it('baseline allow → allow', () => { - registerGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') }); - expect(guard(input('t.allow1')).effect).toBe('allow'); + const action = defineGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') }); + expect(guard(action, input()).effect).toBe('allow'); }); it('baseline hold → hold, default approver chain', () => { - registerGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') }); - const d = guard(input('t.hold1')); + const action = defineGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') }); + const d = guard(action, input()); expect(d.effect).toBe('hold'); if (d.effect === 'hold') { expect(d.reason).toBe('needs approval'); @@ -56,18 +52,25 @@ describe('the baseline is the decision', () => { }); it('baseline hold → hold, carrying a named approver', () => { - registerGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') }); - const d = guard(input('t.hold2')); + const action = defineGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') }); + const d = guard(action, input()); expect(d.effect).toBe('hold'); if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana'); }); it('baseline deny → deny, carrying the reason', () => { - registerGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') }); - const d = guard(input('t.deny1')); + const action = defineGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') }); + const d = guard(action, input()); expect(d.effect).toBe('deny'); if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized'); }); + + it('a forged action value (not from defineGuardedAction) is denied', () => { + const forged = { action: 't.forged', baseline: () => ALLOW('never vetted') } as unknown as GuardedAction; + const d = guard(forged, input()); + expect(d.effect).toBe('deny'); + if (d.effect === 'deny') expect(d.reason).toContain('undefined action'); + }); }); describe('grants', () => { @@ -75,49 +78,53 @@ describe('grants', () => { ({ approval_id: 'appr-1', action, payload: '{}' }) as unknown as NonNullable; it('a valid live grant satisfies a hold', () => { - registerGuardedAction({ + const action = defineGuardedAction({ action: 't.g1', approvalAction: 'g1_approved', baseline: () => HOLD('b'), }); const grant = grantRow('g1_approved'); mockGetPendingApproval.mockReturnValue(grant); - expect(guard(input('t.g1', { grant })).effect).toBe('allow'); + expect(guard(action, input({ grant })).effect).toBe('allow'); }); it('a grant never satisfies a deny — the baseline is re-checked live', () => { - registerGuardedAction({ action: 't.g2', approvalAction: 'g2_approved', baseline: () => DENY('revoked since') }); + const action = defineGuardedAction({ + action: 't.g2', + approvalAction: 'g2_approved', + baseline: () => DENY('revoked since'), + }); const grant = grantRow('g2_approved'); mockGetPendingApproval.mockReturnValue(grant); - const d = guard(input('t.g2', { grant })); + const d = guard(action, input({ grant })); expect(d.effect).toBe('deny'); if (d.effect === 'deny') expect(d.reason).toBe('revoked since'); }); it('a dead grant (row deleted) refuses instead of re-holding', () => { - registerGuardedAction({ + const action = defineGuardedAction({ action: 't.g3', approvalAction: 'g3_approved', baseline: () => HOLD('b'), }); mockGetPendingApproval.mockReturnValue(undefined); - const d = guard(input('t.g3', { grant: grantRow('g3_approved') })); + const d = guard(action, input({ grant: grantRow('g3_approved') })); expect(d.effect).toBe('deny'); }); it("a grant for a different action doesn't transfer", () => { - registerGuardedAction({ + const action = defineGuardedAction({ action: 't.g4', approvalAction: 'g4_approved', baseline: () => HOLD('b'), }); const grant = grantRow('other_action'); mockGetPendingApproval.mockReturnValue(grant); - expect(guard(input('t.g4', { grant })).effect).toBe('deny'); + expect(guard(action, input({ grant })).effect).toBe('deny'); }); it('a domain grantMatches binding can refuse a payload mismatch', () => { - registerGuardedAction({ + const action = defineGuardedAction({ action: 't.g5', approvalAction: 'g5_approved', grantMatches: () => false, @@ -125,25 +132,29 @@ describe('grants', () => { }); const grant = grantRow('g5_approved'); mockGetPendingApproval.mockReturnValue(grant); - expect(guard(input('t.g5', { grant })).effect).toBe('deny'); + expect(guard(action, input({ grant })).effect).toBe('deny'); }); it('a grant on an already-allowed action is a no-op', () => { - registerGuardedAction({ action: 't.g6', approvalAction: 'g6_approved', baseline: () => ALLOW('ok') }); + const action = defineGuardedAction({ + action: 't.g6', + approvalAction: 'g6_approved', + baseline: () => ALLOW('ok'), + }); const grant = grantRow('g6_approved'); mockGetPendingApproval.mockReturnValue(grant); - expect(guard(input('t.g6', { grant })).effect).toBe('allow'); + expect(guard(action, input({ grant })).effect).toBe('allow'); }); }); describe('fail-closed posture', () => { it('a throwing baseline denies', () => { - registerGuardedAction({ + const action = defineGuardedAction({ action: 't.f1', baseline: () => { throw new Error('boom'); }, }); - expect(guard(input('t.f1')).effect).toBe('deny'); + expect(guard(action, input()).effect).toBe('deny'); }); }); diff --git a/src/guard/guard.ts b/src/guard/guard.ts index 55bed0223eb..56008a212b4 100644 --- a/src/guard/guard.ts +++ b/src/guard/guard.ts @@ -1,36 +1,47 @@ /** * guard() — the one decision function every privileged action consults. * - * The decision is the catalog entry's structural baseline — today's code - * checks, registered per action at the module edges. Non-catalog actions - * (reads, scheduling self-actions) allow. Policy-as-data (tighten-only rule - * sources composing with the baseline) is deliberately deferred to phase 3 - * of the guarded-actions design, where the generalized rules table arrives - * with its first operator-visible consumer; until then the one policy table + * The decision is the action's structural baseline — today's code checks, + * defined per action at the module edges. The consult site holds the + * GuardedAction value itself (defineGuardedAction), so there is no name + * lookup and no fail-open path for an unknown action: an unwired consult is + * a compile error, and a value that didn't come from defineGuardedAction is + * denied at runtime. Policy-as-data (tighten-only rule sources composing + * with the baseline) is deliberately deferred to phase 3 of the + * guarded-actions design, where the generalized rules table arrives with its + * first operator-visible consumer; until then the one policy table * (agent_message_policies) is consulted inside the a2a.send baseline. * * Grants: an approved replay carries the verified approval row. A valid - * grant (live pending row whose action matches the catalog entry's approval - * action, plus any domain binding) satisfies a hold — the human already - * decided — but NEVER a deny: the baseline is re-checked live, so - * approve-then-revoke no longer executes. A grant that is present but - * invalid fails closed to deny (no second card). + * grant (live pending row whose action matches the entry's approval action, + * plus any domain binding) satisfies a hold — the human already decided — + * but NEVER a deny: the baseline is re-checked live, so approve-then-revoke + * no longer executes. A grant that is present but invalid fails closed to + * deny (no second card). * * The guard itself fails closed: a throwing baseline denies. */ import { getPendingApproval } from '../db/sessions.js'; import { log } from '../log.js'; -import { getGuardedAction } from './guard-actions.js'; +import { isGuardedAction, type GuardedAction } from './guard-actions.js'; import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js'; -export function guard(input: GuardInput): GuardDecision { - const entry = getGuardedAction(input.action); +export function guard(action: GuardedAction, input: GuardInput): GuardDecision { + if (!isGuardedAction(action)) { + // JS-level backstop — the branded type already forbids this. A + // hand-rolled object must not carry a baseline never vetted at + // definition time. + log.error('Guard consulted with an undefined action — failing closed', { + action: (action as { action?: unknown } | null)?.action, + }); + return DENY('guard consulted with an undefined action (failing closed)'); + } let decision: GuardDecision; try { - decision = entry ? entry.baseline(input) : ALLOW('non-catalog action'); + decision = action.baseline(input); } catch (err) { - log.error('Guard evaluation threw — failing closed', { action: input.action, err }); + log.error('Guard evaluation threw — failing closed', { action: action.action, err }); return DENY('guard failure (failing closed)'); } @@ -42,24 +53,20 @@ export function guard(input: GuardInput): GuardDecision { // An invalid grant on a replay is a refusal, not a fresh hold — approved // replays must execute exactly once. - if (entry && grantSatisfies(input, entry.approvalAction, entry.grantMatches)) { + if (grantSatisfies(action, input)) { return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`); } return DENY('replay carried an invalid or mismatched grant'); } -function grantSatisfies( - input: GuardInput, - approvalAction: string | undefined, - grantMatches: ((grant: NonNullable, input: GuardInput) => boolean) | undefined, -): boolean { +function grantSatisfies(action: GuardedAction, input: GuardInput): boolean { const grant = input.grant; - if (!grant || !approvalAction) return false; - if (grant.action !== approvalAction) return false; + if (!grant || !action.approvalAction) return false; + if (grant.action !== action.approvalAction) return false; // The row must still be live — resolution deletes it, so a grant can only // execute once and a fabricated row object doesn't pass. const live = getPendingApproval(grant.approval_id); - if (!live || live.action !== approvalAction) return false; - if (grantMatches && !grantMatches(grant, input)) return false; + if (!live || live.action !== action.approvalAction) return false; + if (action.grantMatches && !action.grantMatches(grant, input)) return false; return true; } diff --git a/src/guard/index.ts b/src/guard/index.ts index 89781dfb35f..4d58ccaf9fd 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -2,15 +2,27 @@ * Guard — the privileged-action decision seam (guarded-actions phase 2). * * See the guarded-actions decisions doc on the team hub. One decision - * function (guard.ts) and a registration-derived action catalog - * (guard-actions.ts). - * Domain-free leaf: domain baselines register from the domain modules' edges. + * function (guard.ts) and a definition-derived action catalog + * (guard-actions.ts). Consults carry the GuardedAction value returned by + * defineGuardedAction — never a name to look up — so mis-wiring is a build + * error, not a runtime fail-open. + * Domain-free leaf: domain baselines are defined at the domain modules' edges. */ export { guard } from './guard.js'; export { - registerGuardedAction, - getGuardedAction, + defineGuardedAction, + isGuardedAction, listGuardedActions, + type GuardedAction, type GuardedActionSpec, } from './guard-actions.js'; -export { ALLOW, DENY, HOLD, type GuardActor, type GuardDecision, type GuardInput } from './types.js'; +export { + ALLOW, + DENY, + HOLD, + unguarded, + type GuardActor, + type GuardDecision, + type GuardInput, + type Unguarded, +} from './types.js'; diff --git a/src/guard/types.ts b/src/guard/types.ts index 03233a42f61..960ed353ae1 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -4,8 +4,9 @@ * The guard is a domain-free leaf: this module may import the DB read layer, * config, log, and shared types — never src/cli/* or src/modules/*. Domain * knowledge (what an action's structural baseline checks) arrives via - * registration: catalog entries (guard-actions.ts) are registered by the domain - * modules at their module edges. + * definition: domain modules call defineGuardedAction (guard-actions.ts) at + * their module edges and pass the returned value to every consult and + * registration site — the wiring is a symbol reference the compiler checks. */ import type { PendingApproval } from '../types.js'; @@ -17,8 +18,6 @@ export type GuardActor = | { kind: 'system' }; export interface GuardInput { - /** Dotted catalog action name, e.g. 'roles.grant', 'agents.create', 'a2a.send'. */ - action: string; actor: GuardActor; /** Domain resource reference, e.g. { from, to } for a2a.send. */ resource?: Record; @@ -32,6 +31,20 @@ export interface GuardInput { grant?: PendingApproval | null; } +declare const unguardedBrand: unique symbol; +/** + * A registration that deliberately carries no guard. Omission is not + * representable — every registry requires either a guard spec or this + * marker, so the decision to run unguarded is visible, and justified, in + * the diff that registers the handler. The reason travels with the + * registration; `grep "unguarded("` is the complete inventory. + */ +export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true }; + +export function unguarded(reason: string): Unguarded { + return Object.freeze({ reason }) as Unguarded; +} + export type GuardDecision = | { effect: 'allow'; reason: string } | { effect: 'hold'; reason: string; approverUserId?: string } diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index feb52ad590e..398baa8328f 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -32,7 +32,7 @@ import { log } from '../../log.js'; import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { PendingApproval, Session } from '../../types.js'; import { requestApproval } from '../approvals/index.js'; -import { A2A_MESSAGE_GATE_ACTION } from './guard.js'; +import { A2A_MESSAGE_GATE_ACTION, a2aSend } from './guard.js'; export { isSafeAttachmentName }; export { A2A_MESSAGE_GATE_ACTION } from './guard.js'; @@ -247,8 +247,7 @@ export async function routeAgentMessage( // allow, agent_message_policies hold. An approved replay carries the // grant — the hold is satisfied but the structure is re-checked live, so // revoking a destination between hold and approve blocks delivery. - const decision = guard({ - action: 'a2a.send', + const decision = guard(a2aSend, { actor: { kind: 'agent', agentGroupId: sourceAgentGroupId, sessionId: session.id }, resource: { from: sourceAgentGroupId, to: targetAgentGroupId }, payload: { id: msg.id, platform_id: targetAgentGroupId, content: msg.content, in_reply_to: msg.in_reply_to }, diff --git a/src/modules/agent-to-agent/guard.ts b/src/modules/agent-to-agent/guard.ts index 7404058042c..ce2cf9ab7f1 100644 --- a/src/modules/agent-to-agent/guard.ts +++ b/src/modules/agent-to-agent/guard.ts @@ -19,7 +19,7 @@ */ import { getAgentGroup } from '../../db/agent-groups.js'; import { getContainerConfig } from '../../db/container-configs.js'; -import { ALLOW, DENY, HOLD, registerGuardedAction } from '../../guard/index.js'; +import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js'; import { hasDestination } from './db/agent-destinations.js'; import { getMessagePolicy } from './db/agent-message-policies.js'; @@ -30,7 +30,7 @@ import { getMessagePolicy } from './db/agent-message-policies.js'; */ export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate'; -registerGuardedAction({ +export const agentsCreate = defineGuardedAction({ action: 'agents.create', approvalAction: 'create_agent', // Bind a create_agent grant to the name that was approved. @@ -56,7 +56,7 @@ registerGuardedAction({ }, }); -registerGuardedAction({ +export const a2aSend = defineGuardedAction({ action: 'a2a.send', approvalAction: A2A_MESSAGE_GATE_ACTION, // Bind an a2a grant to the exact held message target. diff --git a/src/modules/agent-to-agent/index.ts b/src/modules/agent-to-agent/index.ts index fa9047ecb61..2114acacaaa 100644 --- a/src/modules/agent-to-agent/index.ts +++ b/src/modules/agent-to-agent/index.ts @@ -21,15 +21,15 @@ * system action logs "Unknown system action", `channel_type='agent'` messages * throw because the module isn't installed. */ -import './guard.js'; import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js'; import { notifyAgent, registerApprovalHandler } from '../approvals/index.js'; import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js'; import { createAgent, requestCreateAgentHold, validateCreateAgent } from './create-agent.js'; +import { agentsCreate } from './guard.js'; import { applyA2aMessageGate } from './message-gate.js'; registerDeliveryAction('create_agent', createAgent, { - guardAction: 'agents.create', + guardAction: agentsCreate, precheck: validateCreateAgent, requestHold: requestCreateAgentHold, onDeny: (_content, session, reason) => notifyAgent(session, `create_agent denied: ${reason}`), diff --git a/src/modules/approvals/index.ts b/src/modules/approvals/index.ts index 8dcb274f5f7..924145b911e 100644 --- a/src/modules/approvals/index.ts +++ b/src/modules/approvals/index.ts @@ -23,6 +23,7 @@ * + approval handlers via this module's public API. */ import { onDeliveryAdapterReady } from '../../delivery.js'; +import { unguarded } from '../../guard/index.js'; import { registerResponseHandler, onShutdown } from '../../response-registry.js'; import { handleApprovalsResponse } from './response-handler.js'; import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js'; @@ -34,7 +35,10 @@ export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } // loads reason-capture.js, registering its message-interceptor on import. export { sweepAwaitingReasonRejects } from './reason-capture.js'; -registerResponseHandler(handleApprovalsResponse); +registerResponseHandler( + handleApprovalsResponse, + unguarded('self-authorizing — resolves each pending_approvals row against its own approver rules before dispatching'), +); onDeliveryAdapterReady((adapter) => { startOneCLIApprovalHandler(adapter); diff --git a/src/modules/approvals/reason-capture.ts b/src/modules/approvals/reason-capture.ts index 79724a277db..03dcf6074fa 100644 --- a/src/modules/approvals/reason-capture.ts +++ b/src/modules/approvals/reason-capture.ts @@ -20,6 +20,7 @@ */ import type { InboundEvent } from '../../channels/adapter.js'; import { getDeliveryAdapter } from '../../delivery.js'; +import { unguarded } from '../../guard/index.js'; import { deletePendingApproval, getExpiredAwaitingReasonApprovals, @@ -151,7 +152,10 @@ export async function captureReasonReply(event: InboundEvent): Promise return true; } -registerMessageInterceptor(captureReasonReply); +registerMessageInterceptor( + captureReasonReply, + unguarded('self-authorizing — only captures a DM from the admin whose reject click armed it (keyed by userId + DM)'), +); /** * Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin diff --git a/src/modules/interactive/index.ts b/src/modules/interactive/index.ts index 324adbe7e50..3aada6fa18a 100644 --- a/src/modules/interactive/index.ts +++ b/src/modules/interactive/index.ts @@ -13,6 +13,7 @@ import { getDb, hasTable } from '../../db/connection.js'; import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; +import { unguarded } from '../../guard/index.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; @@ -56,4 +57,7 @@ async function handleInteractiveResponse(payload: ResponsePayload): Promise { const policy = input.payload.policy; @@ -36,7 +36,7 @@ registerGuardedAction({ }, }); -registerGuardedAction({ +export const channelsRegister = defineGuardedAction({ action: 'channels.register', baseline: (input) => { if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies'); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 4ead62019f3..abc64f6d864 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -32,8 +32,8 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; -import './guard.js'; -import { guard } from '../../guard/index.js'; +import { guard, unguarded } from '../../guard/index.js'; +import { channelsRegister, sendersAdmit } from './guard.js'; import { canAccessAgentGroup } from './access.js'; import { buildAgentSelectionOptions, @@ -135,8 +135,7 @@ function handleUnknownSender( // — unknown_sender_policy verbatim: strict → deny, request_approval → hold, // public → allow (short-circuited before the gate). Drop-recording and the // hold creation stay here. - const decision = guard({ - action: 'senders.admit', + const decision = guard(sendersAdmit, { actor: userId ? { kind: 'human', userId } : { kind: 'system' }, payload: { messagingGroupId: mg.id, @@ -294,7 +293,12 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise getPendingChannelApproval(payload.questionId) !== undefined, }); @@ -640,7 +644,7 @@ const captureAgentNameReply = async (event: InboundEvent): Promise => { }; registerMessageInterceptor(captureAgentNameReply, { - action: 'channels.register', + action: channelsRegister, claims: (event) => { const userId = extractAndUpsertUser(event); if (!userId) return null; diff --git a/src/modules/self-mod/guard.ts b/src/modules/self-mod/guard.ts index 85faa349270..0b80bc7a7de 100644 --- a/src/modules/self-mod/guard.ts +++ b/src/modules/self-mod/guard.ts @@ -8,7 +8,7 @@ * add-package` etc. — are separate catalog actions derived from the command * registry.) */ -import { DENY, HOLD, registerGuardedAction, type GuardInput } from '../../guard/index.js'; +import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js'; function selfModBaseline(label: string) { return (input: GuardInput) => { @@ -19,13 +19,13 @@ function selfModBaseline(label: string) { }; } -registerGuardedAction({ +export const selfModInstallPackages = defineGuardedAction({ action: 'self_mod.install_packages', approvalAction: 'install_packages', baseline: selfModBaseline('install_packages'), }); -registerGuardedAction({ +export const selfModAddMcpServer = defineGuardedAction({ action: 'self_mod.add_mcp_server', approvalAction: 'add_mcp_server', baseline: selfModBaseline('add_mcp_server'), diff --git a/src/modules/self-mod/index.ts b/src/modules/self-mod/index.ts index f5dd53251f6..8cdbc30f726 100644 --- a/src/modules/self-mod/index.ts +++ b/src/modules/self-mod/index.ts @@ -24,10 +24,10 @@ * system messages with these actions, but delivery logs "Unknown system * action" and drops them. Admin never sees a card; nothing changes. */ -import './guard.js'; import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js'; import { notifyAgent, registerApprovalHandler } from '../approvals/index.js'; import { applyAddMcpServer, applyInstallPackages } from './apply.js'; +import { selfModAddMcpServer, selfModInstallPackages } from './guard.js'; import { requestAddMcpServerHold, requestInstallPackagesHold, @@ -36,13 +36,13 @@ import { } from './request.js'; registerDeliveryAction('install_packages', applyInstallPackages, { - guardAction: 'self_mod.install_packages', + guardAction: selfModInstallPackages, precheck: validateInstallPackages, requestHold: requestInstallPackagesHold, onDeny: (_content, session, reason) => notifyAgent(session, `install_packages denied: ${reason}`), }); registerDeliveryAction('add_mcp_server', applyAddMcpServer, { - guardAction: 'self_mod.add_mcp_server', + guardAction: selfModAddMcpServer, precheck: validateAddMcpServer, requestHold: requestAddMcpServerHold, onDeny: (_content, session, reason) => notifyAgent(session, `add_mcp_server denied: ${reason}`), diff --git a/src/response-registry.ts b/src/response-registry.ts index 57c42e511d1..bb7cd8c2170 100644 --- a/src/response-registry.ts +++ b/src/response-registry.ts @@ -16,9 +16,11 @@ * the click and the handler, and the wrapped path is the only path. `claims` * is the handler's own claim test (does this questionId belong to me?) so an * unauthorized click is claimed-and-dropped without stealing other handlers' - * responses. + * responses. The guard argument is not optional — a handler that runs + * unguarded must declare so with `unguarded()`, at the registration + * site, in the diff that adds it. */ -import { guard, type GuardActor } from './guard/index.js'; +import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; export interface ResponsePayload { @@ -33,8 +35,8 @@ export interface ResponsePayload { export type ResponseHandler = (payload: ResponsePayload) => Promise; export interface ResponseGuardSpec { - /** Dotted guard-catalog action consulted before the handler runs. */ - action: string; + /** Guard action consulted before the handler runs — the defined value, not a name. */ + action: GuardedAction; /** Would this handler claim the response? (Its own row lookup.) */ claims: (payload: ResponsePayload) => boolean; } @@ -47,22 +49,23 @@ function responseActor(payload: ResponsePayload): GuardActor { return { kind: 'human', userId }; } -export function registerResponseHandler(handler: ResponseHandler, guardSpec?: ResponseGuardSpec): void { - if (!guardSpec) { +export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void { + if ('reason' in guardDecl) { + // Explicitly declared unguarded — the carried reason is the reviewable record. responseHandlers.push(handler); return; } + const spec = guardDecl; responseHandlers.push(async (payload) => { - if (!guardSpec.claims(payload)) return false; - const decision = guard({ - action: guardSpec.action, + if (!spec.claims(payload)) return false; + const decision = guard(spec.action, { actor: responseActor(payload), payload: { questionId: payload.questionId, value: payload.value }, }); if (decision.effect !== 'allow') { // Claim the response so it's not unclaimed-logged, but do nothing. log.warn('Response click rejected by guard', { - action: guardSpec.action, + action: spec.action.action, questionId: payload.questionId, userId: payload.userId, reason: decision.reason, diff --git a/src/router.ts b/src/router.ts index d89db027264..dbca8c2182f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,7 +20,7 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; -import { guard, type GuardActor } from './guard/index.js'; +import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { createMessagingGroup, @@ -124,13 +124,15 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { * registers with a guard spec: the registry wraps it so the guard's decision * stands between the free-text reply and the handler. `claims` returns the * guard consult for events the interceptor would act on (null = not mine, - * pass through); a deny consumes the message without acting. + * pass through); a deny consumes the message without acting. The guard + * argument is not optional — an interceptor that runs unguarded must declare + * so with `unguarded()` at the registration site. */ export type MessageInterceptorFn = (event: InboundEvent) => Promise; export interface InterceptorGuardSpec { - /** Dotted guard-catalog action consulted before the interceptor acts. */ - action: string; + /** Guard action consulted before the interceptor acts — the defined value, not a name. */ + action: GuardedAction; /** The guard consult for events this interceptor would act on; null = not mine. */ claims: (event: InboundEvent) => { actor: GuardActor; payload: Record } | null; /** Domain cleanup when the guard denies (e.g. disarm the capture). */ @@ -139,18 +141,23 @@ export interface InterceptorGuardSpec { const messageInterceptors: MessageInterceptorFn[] = []; -export function registerMessageInterceptor(fn: MessageInterceptorFn, guardSpec?: InterceptorGuardSpec): void { - if (!guardSpec) { +export function registerMessageInterceptor( + fn: MessageInterceptorFn, + guardDecl: InterceptorGuardSpec | Unguarded, +): void { + if ('reason' in guardDecl) { + // Explicitly declared unguarded — the carried reason is the reviewable record. messageInterceptors.push(fn); return; } + const guardSpec = guardDecl; messageInterceptors.push(async (event) => { const consult = guardSpec.claims(event); if (!consult) return fn(event); - const decision = guard({ action: guardSpec.action, actor: consult.actor, payload: consult.payload }); + const decision = guard(guardSpec.action, { actor: consult.actor, payload: consult.payload }); if (decision.effect !== 'allow') { log.warn('Interceptor capture rejected by guard — consuming without acting', { - action: guardSpec.action, + action: guardSpec.action.action, reason: decision.reason, }); guardSpec.onDeny?.(event); From a9e5231039042183451776475a44f9c6424c1030 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Thu, 9 Jul 2026 11:21:55 +0300 Subject: [PATCH 04/10] =?UTF-8?q?refactor(guard):=20one=20runtime=20discri?= =?UTF-8?q?minator=20for=20Unguarded=20=E2=80=94=20isUnguarded()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unguarded brand becomes a real (module-private) symbol and isUnguarded() its exported type guard, replacing three hand-rolled `'reason' in guardDecl` checks (delivery, router, response-registry) plus the one inside isUnguardedEntry. Only unguarded() can mint the brand now, so a look-alike { reason } object — or a guard spec that someday grows a `reason` field — can't pass as an unguarded declaration at runtime. Co-Authored-By: Claude Fable 5 --- src/delivery.ts | 6 +++--- src/guard/index.ts | 1 + src/guard/types.ts | 14 ++++++++++++-- src/response-registry.ts | 4 ++-- src/router.ts | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/delivery.ts b/src/delivery.ts index 344a4514097..eab594dbf6b 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -20,7 +20,7 @@ import { markDeliveryFailed, migrateDeliveredTable, } from './db/session-db.js'; -import { guard, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; @@ -438,7 +438,7 @@ type DeliveryEntry = const deliveryActions = new Map(); function isUnguardedEntry(entry: DeliveryEntry): entry is Extract { - return 'reason' in entry.guard; + return isUnguarded(entry.guard); } export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void; @@ -455,7 +455,7 @@ export function registerDeliveryAction( // skill that wants to extend a guarded action must compose at the // module's exported functions instead, or re-register with a guard spec // of its own. - if ('reason' in guardDecl && !isUnguardedEntry(existing)) { + if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) { throw new Error( `delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`, ); diff --git a/src/guard/index.ts b/src/guard/index.ts index 4d58ccaf9fd..891c3a72a6b 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -20,6 +20,7 @@ export { ALLOW, DENY, HOLD, + isUnguarded, unguarded, type GuardActor, type GuardDecision, diff --git a/src/guard/types.ts b/src/guard/types.ts index 960ed353ae1..93499a547dd 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -31,7 +31,7 @@ export interface GuardInput { grant?: PendingApproval | null; } -declare const unguardedBrand: unique symbol; +const unguardedBrand = Symbol('unguarded'); /** * A registration that deliberately carries no guard. Omission is not * representable — every registry requires either a guard spec or this @@ -42,7 +42,17 @@ declare const unguardedBrand: unique symbol; export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true }; export function unguarded(reason: string): Unguarded { - return Object.freeze({ reason }) as Unguarded; + return Object.freeze({ reason, [unguardedBrand]: true as const }); +} + +/** + * The one runtime discriminator for guard declarations. The brand symbol is + * module-private, so `unguarded()` is the only mint — a look-alike + * `{ reason }` object (or a guard spec that someday grows a `reason` field) + * doesn't pass. + */ +export function isUnguarded(decl: object): decl is Unguarded { + return unguardedBrand in decl; } export type GuardDecision = diff --git a/src/response-registry.ts b/src/response-registry.ts index bb7cd8c2170..59c8ba884dd 100644 --- a/src/response-registry.ts +++ b/src/response-registry.ts @@ -20,7 +20,7 @@ * unguarded must declare so with `unguarded()`, at the registration * site, in the diff that adds it. */ -import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; export interface ResponsePayload { @@ -50,7 +50,7 @@ function responseActor(payload: ResponsePayload): GuardActor { } export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void { - if ('reason' in guardDecl) { + if (isUnguarded(guardDecl)) { // Explicitly declared unguarded — the carried reason is the reviewable record. responseHandlers.push(handler); return; diff --git a/src/router.ts b/src/router.ts index dbca8c2182f..6b7e33ffa13 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,7 +20,7 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; -import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { createMessagingGroup, @@ -145,7 +145,7 @@ export function registerMessageInterceptor( fn: MessageInterceptorFn, guardDecl: InterceptorGuardSpec | Unguarded, ): void { - if ('reason' in guardDecl) { + if (isUnguarded(guardDecl)) { // Explicitly declared unguarded — the carried reason is the reviewable record. messageInterceptors.push(fn); return; From fe235c7ca09d9c33fcf6cebc57dda1f852f91022 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Sat, 11 Jul 2026 22:16:17 +0300 Subject: [PATCH 05/10] =?UTF-8?q?refactor(guard):=20unwrap=20the=20broadca?= =?UTF-8?q?st=20registries=20=E2=80=94=20response=20handlers=20and=20inter?= =?UTF-8?q?ceptors=20consult=20inline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerResponseHandler and registerMessageInterceptor return to main's single-argument shape: the guard-spec/unguarded declaration machinery (claims, onDeny, the registry wrappers) is deleted. The declaration requirement stays where registration is keyed — every ncl command derives its guard inside register(), and registerDeliveryAction demands a spec or unguarded() — while broadcast hooks gate inline at the point of privilege, like the a2a route and the unknown-sender gate: - handleChannelApprovalResponse consults guard(channelsRegister) inline where main had the click-auth if — same decision, same timing. - The free-text name capture returns to main verbatim: the click arms it, the reply is not re-authorized (the body's own pending-row re-fetch still refuses a vanished registration). Behavior delta (c) is withdrawn — the deliberate deltas are back to (a) approve-then-revoke and (b) grant refusals. Also gone with the wrappers: the claims/body predicate duplication (the same early-exits existed twice and could drift apart) and the repeated extractAndUpsertUser upsert per captured event. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- src/delivery.ts | 4 +- src/guard-conformance.ts | 8 +-- src/guard/types.ts | 11 ++-- src/modules/approvals/index.ts | 6 +- src/modules/approvals/reason-capture.ts | 6 +- src/modules/interactive/index.ts | 6 +- .../permissions/channel-approval.test.ts | 6 +- src/modules/permissions/index.ts | 55 +++++++----------- src/response-registry.ts | 57 ++----------------- src/router.ts | 45 +-------------- 12 files changed, 47 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b6c028101..a8b95d150bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction: every registration takes a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) - **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) when a guarded action that can hold has no registered approve continuation — the one cross-registry invariant the type system can't see, since catalog entries and approval handlers register from different modules. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the check (`src/guard-conformance.ts`) runs in `main()` before the host accepts a message, and a mis-composition crashes at skill-install time instead of at the first approved card. The old registry walk is deleted: everything it detected is now unconstructible at the registration API. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). diff --git a/CLAUDE.md b/CLAUDE.md index 325d8d06d31..742380bd8ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. All four handler registries require a guard spec or an explicit `unguarded()` declaration at the registration site. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/delivery.ts b/src/delivery.ts index eab594dbf6b..311c9956736 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -402,8 +402,8 @@ async function deliverMessage( * (allow / hold / deny), so there is no unguarded route to it. On approve, * the continuation re-enters the same entry carrying the approval row as its * grant (`reenterGuardedDeliveryAction`), so the structural baseline is - * re-checked live. Plain actions (scheduling self-actions, the cli_request - * bridge — its inner commands are guarded at dispatch) register with an + * re-checked live. Plain actions (the cli_request bridge — its inner + * commands are guarded at dispatch) register with an * explicit `unguarded()` declaration instead of a spec — omission is * not representable, so the decision to run unguarded is visible, and * justified, at the registration site. diff --git a/src/guard-conformance.ts b/src/guard-conformance.ts index 4f7a3fbcd2c..904563f7970 100644 --- a/src/guard-conformance.ts +++ b/src/guard-conformance.ts @@ -8,10 +8,10 @@ * GuardedAction VALUE returned by defineGuardedAction, so a dropped * module-edge import or a typo'd action is a compile error (and a forged * value is denied at runtime), not a silent allow. - * - A handler cannot register unguarded by omission — every registry - * (delivery actions, response handlers, interceptors, CLI commands) - * requires a guard spec or an explicit unguarded() declaration - * at the registration site. + * - A privileged handler cannot register unguarded by omission — the + * delivery-action registry requires a guard spec or an explicit + * unguarded() declaration, and every ncl command derives its + * guard inside register(). * * What remains is completeness ACROSS registries: a guarded action that * holds via `approvalAction` needs a registered approval handler, or an diff --git a/src/guard/types.ts b/src/guard/types.ts index 93499a547dd..4e03b643bb5 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -33,11 +33,12 @@ export interface GuardInput { const unguardedBrand = Symbol('unguarded'); /** - * A registration that deliberately carries no guard. Omission is not - * representable — every registry requires either a guard spec or this - * marker, so the decision to run unguarded is visible, and justified, in - * the diff that registers the handler. The reason travels with the - * registration; `grep "unguarded("` is the complete inventory. + * A registration that deliberately carries no guard. Where a registry takes + * a declaration (delivery actions), omission is not representable — + * registration requires either a guard spec or this marker, so the decision + * to run unguarded is visible, and justified, in the diff that registers + * the handler. The reason travels with the registration; + * `grep "unguarded("` is the complete inventory. */ export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true }; diff --git a/src/modules/approvals/index.ts b/src/modules/approvals/index.ts index 924145b911e..8dcb274f5f7 100644 --- a/src/modules/approvals/index.ts +++ b/src/modules/approvals/index.ts @@ -23,7 +23,6 @@ * + approval handlers via this module's public API. */ import { onDeliveryAdapterReady } from '../../delivery.js'; -import { unguarded } from '../../guard/index.js'; import { registerResponseHandler, onShutdown } from '../../response-registry.js'; import { handleApprovalsResponse } from './response-handler.js'; import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js'; @@ -35,10 +34,7 @@ export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } // loads reason-capture.js, registering its message-interceptor on import. export { sweepAwaitingReasonRejects } from './reason-capture.js'; -registerResponseHandler( - handleApprovalsResponse, - unguarded('self-authorizing — resolves each pending_approvals row against its own approver rules before dispatching'), -); +registerResponseHandler(handleApprovalsResponse); onDeliveryAdapterReady((adapter) => { startOneCLIApprovalHandler(adapter); diff --git a/src/modules/approvals/reason-capture.ts b/src/modules/approvals/reason-capture.ts index 03dcf6074fa..79724a277db 100644 --- a/src/modules/approvals/reason-capture.ts +++ b/src/modules/approvals/reason-capture.ts @@ -20,7 +20,6 @@ */ import type { InboundEvent } from '../../channels/adapter.js'; import { getDeliveryAdapter } from '../../delivery.js'; -import { unguarded } from '../../guard/index.js'; import { deletePendingApproval, getExpiredAwaitingReasonApprovals, @@ -152,10 +151,7 @@ export async function captureReasonReply(event: InboundEvent): Promise return true; } -registerMessageInterceptor( - captureReasonReply, - unguarded('self-authorizing — only captures a DM from the admin whose reject click armed it (keyed by userId + DM)'), -); +registerMessageInterceptor(captureReasonReply); /** * Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin diff --git a/src/modules/interactive/index.ts b/src/modules/interactive/index.ts index 3aada6fa18a..324adbe7e50 100644 --- a/src/modules/interactive/index.ts +++ b/src/modules/interactive/index.ts @@ -13,7 +13,6 @@ import { getDb, hasTable } from '../../db/connection.js'; import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; -import { unguarded } from '../../guard/index.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; @@ -57,7 +56,4 @@ async function handleInteractiveResponse(payload: ResponsePayload): Promise { if (claimed) break; } - // Owner replies with the agent name in the same DM — the guarded - // interceptor allows (still an eligible approver) and creates. + // Owner replies with the agent name in the same DM — the interceptor + // captures it and creates. await routeInbound({ channelType: 'telegram', platformId: 'dm-owner', @@ -520,7 +520,7 @@ describe('unknown-channel registration flow', () => { } // The registration disappears between the click and the reply (rejected - // from another card, group delete cascade, …) — the guard's baseline no + // from another card, group delete cascade, …) — the interceptor no // longer finds a pending registration, so the reply must not create. getDb() .prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?') diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index abc64f6d864..15daec1f8c0 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -32,7 +32,7 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; -import { guard, unguarded } from '../../guard/index.js'; +import { guard } from '../../guard/index.js'; import { channelsRegister, sendersAdmit } from './guard.js'; import { canAccessAgentGroup } from './access.js'; import { @@ -293,12 +293,7 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise getPendingChannelApproval(payload.questionId) !== undefined, -}); +registerResponseHandler(handleChannelApprovalResponse); // ── Free-text name interceptor ── // Captures the next DM from an approver who clicked "Create new agent", -// creates the agent immediately, wires the channel, and replays. The router -// wraps it with the guard: the free-texter must still be an eligible -// channel-registration approver at reply time — a privilege revoked between -// the click and the reply now denies, and the arming is disarmed. +// creates the agent immediately, wires the channel, and replays. -const captureAgentNameReply = async (event: InboundEvent): Promise => { +registerMessageInterceptor(async (event: InboundEvent): Promise => { const userId = extractAndUpsertUser(event); if (!userId) return false; @@ -641,20 +642,4 @@ const captureAgentNameReply = async (event: InboundEvent): Promise => { } } return true; -}; - -registerMessageInterceptor(captureAgentNameReply, { - action: channelsRegister, - claims: (event) => { - const userId = extractAndUpsertUser(event); - if (!userId) return null; - const pending = awaitingNameInput.get(userId); - if (!pending) return null; - if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return null; - return { actor: { kind: 'human', userId }, payload: { questionId: pending.channelMgId } }; - }, - onDeny: (event) => { - const userId = extractAndUpsertUser(event); - if (userId) awaitingNameInput.delete(userId); - }, }); diff --git a/src/response-registry.ts b/src/response-registry.ts index 59c8ba884dd..60e04c998be 100644 --- a/src/response-registry.ts +++ b/src/response-registry.ts @@ -7,21 +7,10 @@ * which triggers module registrations that would otherwise happen before * index.ts's own const initializers have run. * - * Keep this file dependency-free (log.js and the guard leaf are fine, but - * nothing from modules/* or index.ts itself). Any file imported here must - * not in turn import from src/index.ts, or the cycle returns. - * - * A handler whose click performs a privileged operation registers with a - * guard spec: the registry wraps it so the guard's decision stands between - * the click and the handler, and the wrapped path is the only path. `claims` - * is the handler's own claim test (does this questionId belong to me?) so an - * unauthorized click is claimed-and-dropped without stealing other handlers' - * responses. The guard argument is not optional — a handler that runs - * unguarded must declare so with `unguarded()`, at the registration - * site, in the diff that adds it. + * Keep this file dependency-free (log.js is fine, but nothing from + * modules/* or index.ts itself). Any file imported here must not in turn + * import from src/index.ts, or the cycle returns. */ -import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; -import { log } from './log.js'; export interface ResponsePayload { questionId: string; @@ -34,46 +23,10 @@ export interface ResponsePayload { export type ResponseHandler = (payload: ResponsePayload) => Promise; -export interface ResponseGuardSpec { - /** Guard action consulted before the handler runs — the defined value, not a name. */ - action: GuardedAction; - /** Would this handler claim the response? (Its own row lookup.) */ - claims: (payload: ResponsePayload) => boolean; -} - const responseHandlers: ResponseHandler[] = []; -function responseActor(payload: ResponsePayload): GuardActor { - if (!payload.userId) return { kind: 'human', userId: '' }; - const userId = payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`; - return { kind: 'human', userId }; -} - -export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void { - if (isUnguarded(guardDecl)) { - // Explicitly declared unguarded — the carried reason is the reviewable record. - responseHandlers.push(handler); - return; - } - const spec = guardDecl; - responseHandlers.push(async (payload) => { - if (!spec.claims(payload)) return false; - const decision = guard(spec.action, { - actor: responseActor(payload), - payload: { questionId: payload.questionId, value: payload.value }, - }); - if (decision.effect !== 'allow') { - // Claim the response so it's not unclaimed-logged, but do nothing. - log.warn('Response click rejected by guard', { - action: spec.action.action, - questionId: payload.questionId, - userId: payload.userId, - reason: decision.reason, - }); - return true; - } - return handler(payload); - }); +export function registerResponseHandler(handler: ResponseHandler): void { + responseHandlers.push(handler); } export function getResponseHandlers(): readonly ResponseHandler[] { diff --git a/src/router.ts b/src/router.ts index 6b7e33ffa13..d39c6a06457 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,7 +20,6 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; -import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { createMessagingGroup, @@ -118,53 +117,13 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { * Used by modules to capture free-text DM replies during multi-step approval * flows — the permissions module (agent naming during channel registration) * and the approvals module (reject-with-reason capture). - * - * An interceptor whose capture performs a privileged operation (the - * channel-registration name capture creates an agent group + wiring) - * registers with a guard spec: the registry wraps it so the guard's decision - * stands between the free-text reply and the handler. `claims` returns the - * guard consult for events the interceptor would act on (null = not mine, - * pass through); a deny consumes the message without acting. The guard - * argument is not optional — an interceptor that runs unguarded must declare - * so with `unguarded()` at the registration site. */ export type MessageInterceptorFn = (event: InboundEvent) => Promise; -export interface InterceptorGuardSpec { - /** Guard action consulted before the interceptor acts — the defined value, not a name. */ - action: GuardedAction; - /** The guard consult for events this interceptor would act on; null = not mine. */ - claims: (event: InboundEvent) => { actor: GuardActor; payload: Record } | null; - /** Domain cleanup when the guard denies (e.g. disarm the capture). */ - onDeny?: (event: InboundEvent) => void; -} - const messageInterceptors: MessageInterceptorFn[] = []; -export function registerMessageInterceptor( - fn: MessageInterceptorFn, - guardDecl: InterceptorGuardSpec | Unguarded, -): void { - if (isUnguarded(guardDecl)) { - // Explicitly declared unguarded — the carried reason is the reviewable record. - messageInterceptors.push(fn); - return; - } - const guardSpec = guardDecl; - messageInterceptors.push(async (event) => { - const consult = guardSpec.claims(event); - if (!consult) return fn(event); - const decision = guard(guardSpec.action, { actor: consult.actor, payload: consult.payload }); - if (decision.effect !== 'allow') { - log.warn('Interceptor capture rejected by guard — consuming without acting', { - action: guardSpec.action.action, - reason: decision.reason, - }); - guardSpec.onDeny?.(event); - return true; - } - return fn(event); - }); +export function registerMessageInterceptor(fn: MessageInterceptorFn): void { + messageInterceptors.push(fn); } /** From f24edc2c3bbf8aea088f21537e398fb1b0b4f9df Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Sat, 11 Jul 2026 23:54:23 +0300 Subject: [PATCH 06/10] refactor(guard): drop boot conformance, rename the spec vocabulary, extract runGuarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three trims to the seam, no behavior change (713/713 green): - Boot-time conformance check deleted (src/guard-conformance.ts + its main() call). The invariant — every holding action has a registered approve continuation — is already covered in-tree by the conformance test, and at runtime response-handler.ts handles a missing continuation loudly (warn + "approved, but no handler is installed" notify + row cleanup). Refusing to boot bought no safety over that and could brick the host on a mis-installed skill. - GuardedActionSpec vocabulary renamed to read as what each field does: baseline → decide (the decision function — allow | hold | deny), approvalAction → grantActionName (the pending_approvals.action string a grant's row must carry; "Name" because at consult sites `action` means the branded GuardedAction value), grantMatches → grantCoversRequest. Comment prose follows ("structural baseline" → the checks / the decision). - runGuarded + DeliveryGuardSpec extracted to src/delivery-guard.ts. delivery.ts is a high-traffic file for forks: the registry itself (tagged entries, spec-or-unguarded registration overloads, disarm-throw, one-door getDeliveryAction, grant-carrying reenter) stays there, evolved in place — only the consult pipeline (precheck → guard → deny/hold/allow) and the spec type move out; runGuarded takes spec + handler explicitly so the new file has no import back into delivery.ts. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +- CLAUDE.md | 3 +- src/cli/dispatch.ts | 4 +- src/cli/guard.ts | 12 ++-- src/delivery-actions.test.ts | 2 +- src/delivery-guard.ts | 63 +++++++++++++++++ src/delivery.ts | 58 ++------------- src/guard-conformance.ts | 70 ------------------- src/guard/conformance.test.ts | 42 +++-------- src/guard/guard-actions.ts | 22 +++--- src/guard/guard.test.ts | 58 +++++++-------- src/guard/guard.ts | 24 +++---- src/guard/index.ts | 2 +- src/guard/types.ts | 4 +- src/index.ts | 7 -- src/modules/agent-to-agent/agent-route.ts | 2 +- .../agent-to-agent/create-agent.test.ts | 4 +- src/modules/agent-to-agent/create-agent.ts | 2 +- src/modules/agent-to-agent/guard.ts | 12 ++-- src/modules/agent-to-agent/index.ts | 2 +- src/modules/agent-to-agent/message-gate.ts | 2 +- src/modules/permissions/guard.ts | 6 +- src/modules/permissions/index.ts | 4 +- src/modules/self-mod/apply.ts | 2 +- src/modules/self-mod/guard.ts | 12 ++-- src/modules/self-mod/index.ts | 2 +- 26 files changed, 174 insertions(+), 251 deletions(-) create mode 100644 src/delivery-guard.ts delete mode 100644 src/guard-conformance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b95d150bc..4cac8c49865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) -- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) when a guarded action that can hold has no registered approve continuation — the one cross-registry invariant the type system can't see, since catalog entries and approval handlers register from different modules. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the check (`src/guard-conformance.ts`) runs in `main()` before the host accepts a message, and a mis-composition crashes at skill-install time instead of at the first approved card. The old registry walk is deleted: everything it detected is now unconstructible at the registration API. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. +- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.) - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). - **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host. diff --git a/CLAUDE.md b/CLAUDE.md index 742380bd8ea..e78dfe89c46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,11 +62,12 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown | | `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake | | `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) | +| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` | | `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence | | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index bb44144d162..fc198354ba3 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -9,7 +9,7 @@ * auto-fill, the sessions-get existence oracle, `--help` interception, * parseArgs, and post-handler row filtering. An approved replay re-enters * here carrying the verified approval row as its grant — the guard re-checks - * the structural baseline live, and the `approved: true` boolean no longer + * the structural checks live, and the `approved: true` boolean no longer * exists. */ import { getContainerConfig } from '../db/container-configs.js'; @@ -69,7 +69,7 @@ export async function dispatch( } // Group-scope mechanics for agent callers (visibility, not policy — the - // allow/hold/deny decisions live in the guard baseline, cli/guard.ts). + // allow/hold/deny decisions live in the guard decision, cli/guard.ts). if (ctx.caller === 'agent') { const configRow = getContainerConfig(ctx.agentGroupId); const cliScope = configRow?.cli_scope ?? 'group'; diff --git a/src/cli/guard.ts b/src/cli/guard.ts index 2b7ebca393c..68fbaa3db83 100644 --- a/src/cli/guard.ts +++ b/src/cli/guard.ts @@ -1,11 +1,11 @@ /** * CLI guard adapter — the command registry's catalog derivation and - * structural baseline, moved verbatim out of dispatch.ts (guarded-actions + * structural decision, moved verbatim out of dispatch.ts (guarded-actions * phase 2). Declaration is registration: registry.register() derives one * catalog entry per command from the CommandDef itself; no second file is * edited when a command is added. * - * The baseline carries today's decisions exactly: + * The decide fn carries today's decisions exactly: * host caller → allow (the 0600 socket is the auth story — in code, * unremovable by data); * cli_scope 'disabled' → deny; 'group' → resource allowlist, cross-group @@ -28,9 +28,9 @@ export function commandGuardAction(cmd: Pick): st export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec { return { action: commandGuardAction(cmd), - approvalAction: cmd.access === 'approval' ? 'cli_command' : undefined, + grantActionName: cmd.access === 'approval' ? 'cli_command' : undefined, // Bind a cli_command grant to the exact command it was approved for. - grantMatches: (grant) => { + grantCoversRequest: (grant) => { try { const payload = JSON.parse(grant.payload) as { frame?: { command?: string } }; return payload.frame?.command === cmd.name; @@ -38,11 +38,11 @@ export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec { return false; } }, - baseline: (input) => commandBaseline(cmd, input), + decide: (input) => commandDecide(cmd, input), }; } -function commandBaseline(cmd: CommandDef, input: GuardInput) { +function commandDecide(cmd: CommandDef, input: GuardInput) { const { actor } = input; if (actor.kind === 'host') return ALLOW('host caller (trusted socket)'); if (actor.kind !== 'agent') return DENY('CLI commands accept host or agent callers only.'); diff --git a/src/delivery-actions.test.ts b/src/delivery-actions.test.ts index fc38d769ae7..6bd06fc9067 100644 --- a/src/delivery-actions.test.ts +++ b/src/delivery-actions.test.ts @@ -44,7 +44,7 @@ describe('delivery action registry', () => { it('refuses to replace a guard-wrapped action with an unguarded handler', () => { const guardAction = defineGuardedAction({ action: 'test.guarded-overwrite', - baseline: () => HOLD('t'), + decide: () => HOLD('t'), }); registerDeliveryAction('test_guarded_overwrite', async () => {}, { guardAction, diff --git a/src/delivery-guard.ts b/src/delivery-guard.ts new file mode 100644 index 00000000000..e4a99d72554 --- /dev/null +++ b/src/delivery-guard.ts @@ -0,0 +1,63 @@ +/** + * The guard-consult path for privileged delivery actions. + * + * The registry itself — registration, lookup, approved-replay re-entry — + * stays in delivery.ts, close to main's shape. This file holds the new + * guard logic: the spec a privileged registration carries, and runGuarded, + * the precheck → guard → deny/hold/allow pipeline every consult runs. + */ +import { guard, type GuardedAction } from './guard/index.js'; +import { log } from './log.js'; +import type { PendingApproval, Session } from './types.js'; + +/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */ +export type GuardedDeliveryHandler = (content: Record, session: Session) => Promise; + +export interface DeliveryGuardSpec { + /** Guard action consulted before the handler runs — the defined value, not a name. */ + guardAction: GuardedAction; + /** + * Domain validation that runs before the guard — malformed requests are + * answered (notify) without ever creating a hold. Return false to stop. + */ + precheck?: (content: Record, session: Session) => boolean | Promise; + /** Create the hold (the domain's requestApproval call — card text lives with the domain). */ + requestHold: (content: Record, session: Session) => Promise; + /** Tell the requester about a deny. */ + onDeny?: (content: Record, session: Session, reason: string) => void; +} + +/** + * Run a guarded delivery action: precheck, consult the guard, then route the + * decision — deny → onDeny, hold → requestHold, allow → handler. A fresh + * dispatch passes grant=null; an approved replay passes the approval row, + * which satisfies a hold but never a deny (the structural checks re-run + * live, so approve-then-revoke does not execute). + */ +export async function runGuarded( + action: string, + spec: DeliveryGuardSpec, + handler: GuardedDeliveryHandler, + content: Record, + session: Session, + grant: PendingApproval | null, +): Promise { + if (spec.precheck && !(await spec.precheck(content, session))) return; + + const decision = guard(spec.guardAction, { + actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id }, + payload: content, + grant, + }); + + if (decision.effect === 'deny') { + log.warn('Delivery action denied by guard', { action, reason: decision.reason }); + spec.onDeny?.(content, session, decision.reason); + return; + } + if (decision.effect === 'hold') { + await spec.requestHold(content, session); + return; + } + await handler(content, session); +} diff --git a/src/delivery.ts b/src/delivery.ts index 311c9956736..4ff8a935448 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -20,7 +20,8 @@ import { markDeliveryFailed, migrateDeliveredTable, } from './db/session-db.js'; -import { guard, isUnguarded, type GuardedAction, type Unguarded } from './guard/index.js'; +import { runGuarded, type DeliveryGuardSpec, type GuardedDeliveryHandler } from './delivery-guard.js'; +import { isUnguarded, type Unguarded } from './guard/index.js'; import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; @@ -401,8 +402,8 @@ async function deliverMessage( * — dispatch, approved replay, test lookup — goes through the guard consult * (allow / hold / deny), so there is no unguarded route to it. On approve, * the continuation re-enters the same entry carrying the approval row as its - * grant (`reenterGuardedDeliveryAction`), so the structural baseline is - * re-checked live. Plain actions (the cli_request bridge — its inner + * grant (`reenterGuardedDeliveryAction`), so the structural checks are + * re-run live. Plain actions (the cli_request bridge — its inner * commands are guarded at dispatch) register with an * explicit `unguarded()` declaration instead of a spec — omission is * not representable, so the decision to run unguarded is visible, and @@ -414,23 +415,6 @@ export type DeliveryActionHandler = ( inDb: Database.Database, ) => Promise; -/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */ -export type GuardedDeliveryHandler = (content: Record, session: Session) => Promise; - -export interface DeliveryGuardSpec { - /** Guard action consulted before the handler runs — the defined value, not a name. */ - guardAction: GuardedAction; - /** - * Domain validation that runs before the guard — malformed requests are - * answered (notify) without ever creating a hold. Return false to stop. - */ - precheck?: (content: Record, session: Session) => boolean | Promise; - /** Create the hold (the domain's requestApproval call — card text lives with the domain). */ - requestHold: (content: Record, session: Session) => Promise; - /** Tell the requester about a deny. */ - onDeny?: (content: Record, session: Session, reason: string) => void; -} - type DeliveryEntry = | { guard: Unguarded; handler: DeliveryActionHandler } | { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler }; @@ -467,38 +451,10 @@ export function registerDeliveryAction( deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry); } -async function runGuarded( - action: string, - entry: Extract, - content: Record, - session: Session, - grant: PendingApproval | null, -): Promise { - const spec = entry.guard; - if (spec.precheck && !(await spec.precheck(content, session))) return; - - const decision = guard(spec.guardAction, { - actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id }, - payload: content, - grant, - }); - - if (decision.effect === 'deny') { - log.warn('Delivery action denied by guard', { action, reason: decision.reason }); - spec.onDeny?.(content, session, decision.reason); - return; - } - if (decision.effect === 'hold') { - await spec.requestHold(content, session); - return; - } - await entry.handler(content, session); -} - /** * Approve continuation for a guard-wrapped delivery action: re-enter the * entry with the approval row as the grant. The guard treats the grant as - * hold-satisfied but re-runs the structural baseline, so approve-then-revoke + * hold-satisfied but re-runs the structural checks, so approve-then-revoke * does not execute. Domains register this as their approval handler in the * same line that registers the action. */ @@ -509,7 +465,7 @@ export function reenterGuardedDeliveryAction(action: string) { log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action }); return; } - await runGuarded(action, entry, ctx.payload, ctx.session, ctx.approval); + await runGuarded(action, entry.guard, entry.handler, ctx.payload, ctx.session, ctx.approval); }; } @@ -522,7 +478,7 @@ export function getDeliveryAction(action: string): DeliveryActionHandler | undef const entry = deliveryActions.get(action); if (!entry) return undefined; if (isUnguardedEntry(entry)) return entry.handler; - return (content, session) => runGuarded(action, entry, content, session, null); + return (content, session) => runGuarded(action, entry.guard, entry.handler, content, session, null); } /** diff --git a/src/guard-conformance.ts b/src/guard-conformance.ts deleted file mode 100644 index 904563f7970..00000000000 --- a/src/guard-conformance.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Boot-time guard sanity — the one cross-registry invariant left to check - * after all import-time registrations have run. - * - * The old registry walk is gone: everything it detected is now - * unconstructible at the API level. - * - A consult site cannot name a missing catalog entry — guard() takes the - * GuardedAction VALUE returned by defineGuardedAction, so a dropped - * module-edge import or a typo'd action is a compile error (and a forged - * value is denied at runtime), not a silent allow. - * - A privileged handler cannot register unguarded by omission — the - * delivery-action registry requires a guard spec or an explicit - * unguarded() declaration, and every ncl command derives its - * guard inside register(). - * - * What remains is completeness ACROSS registries: a guarded action that - * holds via `approvalAction` needs a registered approval handler, or an - * approved card resolves into nothing — the hold has no continuation. That - * pairing only exists once every module has loaded (catalog entries and - * approval handlers register from different modules), so it stays a boot - * check with the fail-closed posture: the host refuses to start, surfacing - * the mis-composition at skill-install time instead of at the first - * approved card. - */ -import { listGuardedActions } from './guard/index.js'; -import { log } from './log.js'; -import { getApprovalHandler } from './modules/approvals/primitive.js'; - -/** Holding actions with no approve continuation. Empty = conformant. */ -export function grantContinuationGaps(): string[] { - return listGuardedActions() - .filter((spec) => spec.approvalAction && !getApprovalHandler(spec.approvalAction)) - .map( - (spec) => - `guarded action "${spec.action}" holds via approval action "${spec.approvalAction}" ` + - 'but no approval handler is registered — an approved hold would have no continuation', - ); -} - -/** - * Boot check: refuse to start when a holding action has no continuation. - * Call after all import-time registrations (any point in main()). - */ -export function enforceGuardConformance(): void { - const gaps = grantContinuationGaps(); - if (gaps.length === 0) return; - - console.error( - [ - '', - '='.repeat(64), - 'NanoClaw stopped: guard conformance failure', - '='.repeat(64), - 'A guarded action can hold for approval, but no approval handler is', - 'registered for its approval action — an admin could click Approve', - 'and nothing would execute. This usually means a module (or skill)', - 'defined a holding baseline without registering its continuation.', - '', - ...gaps.map((g) => ` - ${g}`), - '', - 'Register the approval handler (registerApprovalHandler) in the same', - 'module that defines the guarded action, or drop approvalAction from', - 'the definition if the action can never hold.', - '='.repeat(64), - '', - ].join('\n'), - ); - log.error('Guard conformance failure — refusing to start', { gaps }); - process.exit(1); -} diff --git a/src/guard/conformance.test.ts b/src/guard/conformance.test.ts index a1621841cfa..086f0a40db3 100644 --- a/src/guard/conformance.test.ts +++ b/src/guard/conformance.test.ts @@ -1,15 +1,15 @@ /** - * Guard conformance — the boot invariant, checked with the real registries. + * Guard conformance — checked with the real registries. * * The old registry walk is gone: an unmapped consult or an undeclared * unguarded registration is now unconstructible — guard() takes the defined * GuardedAction value (a dropped module-edge import or typo'd name is a - * compile error), and every registry requires a guard spec or an explicit - * unguarded() declaration. What's left to verify structurally is the + * compile error), and the keyed registries require a guard spec or an + * explicit unguarded() declaration. What's left to verify is the * cross-registry pairing the compiler can't see: every holding action has a - * registered approve continuation. The check runs here in CI and at every - * boot (enforceGuardConformance refuses to start) — CI can't see - * skill-installed registrations, the boot check can. + * registered approve continuation. (At runtime a missing continuation is + * handled loudly at click time — the requester is told no handler is + * installed; this test keeps the tree from shipping that state.) */ import { describe, expect, it } from 'vitest'; @@ -20,21 +20,16 @@ import '../cli/delivery-action.js'; import '../cli/dispatch.js'; // registers the cli_command approval handler import { commandGuard, listCommands } from '../cli/registry.js'; -import { grantContinuationGaps } from '../guard-conformance.js'; import { getApprovalHandler } from '../modules/approvals/primitive.js'; import { defineGuardedAction, listGuardedActions } from './guard-actions.js'; import { HOLD } from './types.js'; describe('guard conformance', () => { - it('the grant-continuation check (shared with the boot check) reports zero gaps', () => { - expect(grantContinuationGaps()).toEqual([]); - }); - it('every holding action pairs with a registered approval handler', () => { - const holding = listGuardedActions().filter((spec) => spec.approvalAction); + const holding = listGuardedActions().filter((spec) => spec.grantActionName); expect(holding.length).toBeGreaterThan(0); - const dangling = holding.filter((spec) => !getApprovalHandler(spec.approvalAction as string)); + const dangling = holding.filter((spec) => !getApprovalHandler(spec.grantActionName as string)); expect(dangling.map((s) => s.action)).toEqual([]); }); @@ -42,7 +37,7 @@ describe('guard conformance', () => { const mutating = listCommands().filter((cmd) => cmd.access === 'approval'); expect(mutating.length).toBeGreaterThan(0); - const wrong = mutating.filter((cmd) => commandGuard(cmd.name).approvalAction !== 'cli_command'); + const wrong = mutating.filter((cmd) => commandGuard(cmd.name).grantActionName !== 'cli_command'); expect(wrong.map((c) => c.name)).toEqual([]); }); @@ -61,24 +56,9 @@ describe('guard conformance', () => { }); it('defining the same action twice throws — names are the catalog key', () => { - defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') }); - expect(() => defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') })).toThrow( + defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') }); + expect(() => defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') })).toThrow( /already defined/, ); }); - - // KEEP LAST: defines a holding action with no continuation into the shared - // per-worker catalog, so every gap check after this point sees it. - it('the check names a holding action with no approve continuation (what boot refuses on)', () => { - defineGuardedAction({ - action: 'test.dangling-hold', - approvalAction: 'test_dangling_hold_approved', - baseline: () => HOLD('always'), - }); - - const gaps = grantContinuationGaps(); - expect(gaps).toHaveLength(1); - expect(gaps[0]).toContain('test.dangling-hold'); - expect(gaps[0]).toContain('no approval handler'); - }); }); diff --git a/src/guard/guard-actions.ts b/src/guard/guard-actions.ts index 85f4e6cc35e..09f139a3ed0 100644 --- a/src/guard/guard-actions.ts +++ b/src/guard/guard-actions.ts @@ -4,14 +4,14 @@ * An action either is defined here (and every consult passes its decision) * or cannot be consulted at all: guard() takes the GuardedAction VALUE * returned by defineGuardedAction, so the wiring between a consult site and - * its baseline is a symbol reference the compiler checks. A dropped + * its decide fn is a symbol reference the compiler checks. A dropped * module-edge import or a typo'd action name is a build error, not a * runtime fail-open — there is no lookup that can miss. * - * Definitions are still recorded by name so boot can enumerate them: the - * grant-continuation check (src/guard-conformance.ts) pairs every holding - * action with its registered approval handler, and duplicate names are - * refused at definition time (grants match on the name). + * Definitions are still recorded by name so the catalog can be enumerated: + * the conformance test pairs every holding action with its registered + * approval handler, and duplicate names are refused at definition time + * (grants match on the name). */ import type { GuardDecision, GuardInput } from './types.js'; import type { PendingApproval } from '../types.js'; @@ -24,25 +24,25 @@ export interface GuardedActionSpec { * allow. Runs on every consult, including approved replays (a grant * satisfies a hold, never a deny). */ - baseline: (input: GuardInput) => GuardDecision; + decide: (input: GuardInput) => GuardDecision; /** * The pending_approvals.action its holds resolve through — a grant is only * accepted when its row carries this action. Omit for actions that can - * never be held (deny/allow-only baselines). + * never be held (deny/allow-only decisions). */ - approvalAction?: string; + grantActionName?: string; /** * Extra domain binding between a grant and the replayed input (e.g. the * a2a target must match the held message). Runs in addition to the - * approvalAction + live-row checks. + * grantActionName + live-row checks. */ - grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean; + grantCoversRequest?: (grant: PendingApproval, input: GuardInput) => boolean; } declare const guardedActionBrand: unique symbol; /** * A defined guarded action — only defineGuardedAction can mint one. The - * brand makes the type nominal: a hand-rolled { action, baseline } object + * brand makes the type nominal: a hand-rolled { action, decide } object * does not typecheck at a consult site, and fails the runtime check too. */ export type GuardedAction = Readonly & { readonly [guardedActionBrand]: true }; diff --git a/src/guard/guard.test.ts b/src/guard/guard.test.ts index db2a76e7982..11b3f909057 100644 --- a/src/guard/guard.test.ts +++ b/src/guard/guard.test.ts @@ -1,8 +1,8 @@ /** - * Guard decision-function unit tests: the baseline is the decision (allow / + * Guard decision-function unit tests: decide is the decision (allow / * hold / deny returned as-is), grant semantics (satisfies holds, never * denies; invalid → refuse), the runtime backstop against forged action - * values, and the fail-closed posture on a throwing baseline. + * values, and the fail-closed posture on a throwing decide. * * Uses synthetic actions defined per test — the catalog is per-worker module * state with no reset, so action names are unique. @@ -35,14 +35,14 @@ afterEach(() => { vi.clearAllMocks(); }); -describe('the baseline is the decision', () => { - it('baseline allow → allow', () => { - const action = defineGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') }); +describe('decide is the decision', () => { + it('decide allow → allow', () => { + const action = defineGuardedAction({ action: 't.allow1', decide: () => ALLOW('ok') }); expect(guard(action, input()).effect).toBe('allow'); }); - it('baseline hold → hold, default approver chain', () => { - const action = defineGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') }); + it('decide hold → hold, default approver chain', () => { + const action = defineGuardedAction({ action: 't.hold1', decide: () => HOLD('needs approval') }); const d = guard(action, input()); expect(d.effect).toBe('hold'); if (d.effect === 'hold') { @@ -51,22 +51,22 @@ describe('the baseline is the decision', () => { } }); - it('baseline hold → hold, carrying a named approver', () => { - const action = defineGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') }); + it('decide hold → hold, carrying a named approver', () => { + const action = defineGuardedAction({ action: 't.hold2', decide: () => HOLD('policy row', 'telegram:dana') }); const d = guard(action, input()); expect(d.effect).toBe('hold'); if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana'); }); - it('baseline deny → deny, carrying the reason', () => { - const action = defineGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') }); + it('decide deny → deny, carrying the reason', () => { + const action = defineGuardedAction({ action: 't.deny1', decide: () => DENY('structurally unauthorized') }); const d = guard(action, input()); expect(d.effect).toBe('deny'); if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized'); }); it('a forged action value (not from defineGuardedAction) is denied', () => { - const forged = { action: 't.forged', baseline: () => ALLOW('never vetted') } as unknown as GuardedAction; + const forged = { action: 't.forged', decide: () => ALLOW('never vetted') } as unknown as GuardedAction; const d = guard(forged, input()); expect(d.effect).toBe('deny'); if (d.effect === 'deny') expect(d.reason).toContain('undefined action'); @@ -80,19 +80,19 @@ describe('grants', () => { it('a valid live grant satisfies a hold', () => { const action = defineGuardedAction({ action: 't.g1', - approvalAction: 'g1_approved', - baseline: () => HOLD('b'), + grantActionName: 'g1_approved', + decide: () => HOLD('b'), }); const grant = grantRow('g1_approved'); mockGetPendingApproval.mockReturnValue(grant); expect(guard(action, input({ grant })).effect).toBe('allow'); }); - it('a grant never satisfies a deny — the baseline is re-checked live', () => { + it('a grant never satisfies a deny — the checks re-run live', () => { const action = defineGuardedAction({ action: 't.g2', - approvalAction: 'g2_approved', - baseline: () => DENY('revoked since'), + grantActionName: 'g2_approved', + decide: () => DENY('revoked since'), }); const grant = grantRow('g2_approved'); mockGetPendingApproval.mockReturnValue(grant); @@ -104,8 +104,8 @@ describe('grants', () => { it('a dead grant (row deleted) refuses instead of re-holding', () => { const action = defineGuardedAction({ action: 't.g3', - approvalAction: 'g3_approved', - baseline: () => HOLD('b'), + grantActionName: 'g3_approved', + decide: () => HOLD('b'), }); mockGetPendingApproval.mockReturnValue(undefined); const d = guard(action, input({ grant: grantRow('g3_approved') })); @@ -115,20 +115,20 @@ describe('grants', () => { it("a grant for a different action doesn't transfer", () => { const action = defineGuardedAction({ action: 't.g4', - approvalAction: 'g4_approved', - baseline: () => HOLD('b'), + grantActionName: 'g4_approved', + decide: () => HOLD('b'), }); const grant = grantRow('other_action'); mockGetPendingApproval.mockReturnValue(grant); expect(guard(action, input({ grant })).effect).toBe('deny'); }); - it('a domain grantMatches binding can refuse a payload mismatch', () => { + it('a domain grantCoversRequest binding can refuse a payload mismatch', () => { const action = defineGuardedAction({ action: 't.g5', - approvalAction: 'g5_approved', - grantMatches: () => false, - baseline: () => HOLD('b'), + grantActionName: 'g5_approved', + grantCoversRequest: () => false, + decide: () => HOLD('b'), }); const grant = grantRow('g5_approved'); mockGetPendingApproval.mockReturnValue(grant); @@ -138,8 +138,8 @@ describe('grants', () => { it('a grant on an already-allowed action is a no-op', () => { const action = defineGuardedAction({ action: 't.g6', - approvalAction: 'g6_approved', - baseline: () => ALLOW('ok'), + grantActionName: 'g6_approved', + decide: () => ALLOW('ok'), }); const grant = grantRow('g6_approved'); mockGetPendingApproval.mockReturnValue(grant); @@ -148,10 +148,10 @@ describe('grants', () => { }); describe('fail-closed posture', () => { - it('a throwing baseline denies', () => { + it('a throwing decide denies', () => { const action = defineGuardedAction({ action: 't.f1', - baseline: () => { + decide: () => { throw new Error('boom'); }, }); diff --git a/src/guard/guard.ts b/src/guard/guard.ts index 56008a212b4..a73a056d8c8 100644 --- a/src/guard/guard.ts +++ b/src/guard/guard.ts @@ -1,25 +1,25 @@ /** * guard() — the one decision function every privileged action consults. * - * The decision is the action's structural baseline — today's code checks, + * The decision is the action's decide fn — today's code checks, * defined per action at the module edges. The consult site holds the * GuardedAction value itself (defineGuardedAction), so there is no name * lookup and no fail-open path for an unknown action: an unwired consult is * a compile error, and a value that didn't come from defineGuardedAction is * denied at runtime. Policy-as-data (tighten-only rule sources composing - * with the baseline) is deliberately deferred to phase 3 of the + * with the decision) is deliberately deferred to phase 3 of the * guarded-actions design, where the generalized rules table arrives with its * first operator-visible consumer; until then the one policy table - * (agent_message_policies) is consulted inside the a2a.send baseline. + * (agent_message_policies) is consulted inside a2a.send's decide. * * Grants: an approved replay carries the verified approval row. A valid * grant (live pending row whose action matches the entry's approval action, * plus any domain binding) satisfies a hold — the human already decided — - * but NEVER a deny: the baseline is re-checked live, so approve-then-revoke + * but NEVER a deny: the checks re-run live, so approve-then-revoke * no longer executes. A grant that is present but invalid fails closed to * deny (no second card). * - * The guard itself fails closed: a throwing baseline denies. + * The guard itself fails closed: a throwing decide denies. */ import { getPendingApproval } from '../db/sessions.js'; import { log } from '../log.js'; @@ -29,7 +29,7 @@ import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js'; export function guard(action: GuardedAction, input: GuardInput): GuardDecision { if (!isGuardedAction(action)) { // JS-level backstop — the branded type already forbids this. A - // hand-rolled object must not carry a baseline never vetted at + // hand-rolled object must not carry a decide fn never vetted at // definition time. log.error('Guard consulted with an undefined action — failing closed', { action: (action as { action?: unknown } | null)?.action, @@ -39,14 +39,14 @@ export function guard(action: GuardedAction, input: GuardInput): GuardDecision { let decision: GuardDecision; try { - decision = action.baseline(input); + decision = action.decide(input); } catch (err) { log.error('Guard evaluation threw — failing closed', { action: action.action, err }); return DENY('guard failure (failing closed)'); } if (!input.grant || decision.effect !== 'hold') { - // A grant never loosens a deny (the baseline re-check is live), and a + // A grant never loosens a deny (the checks re-run live), and a // grant on an already-allowed action is a no-op. return decision; } @@ -61,12 +61,12 @@ export function guard(action: GuardedAction, input: GuardInput): GuardDecision { function grantSatisfies(action: GuardedAction, input: GuardInput): boolean { const grant = input.grant; - if (!grant || !action.approvalAction) return false; - if (grant.action !== action.approvalAction) return false; + if (!grant || !action.grantActionName) return false; + if (grant.action !== action.grantActionName) return false; // The row must still be live — resolution deletes it, so a grant can only // execute once and a fabricated row object doesn't pass. const live = getPendingApproval(grant.approval_id); - if (!live || live.action !== action.approvalAction) return false; - if (action.grantMatches && !action.grantMatches(grant, input)) return false; + if (!live || live.action !== action.grantActionName) return false; + if (action.grantCoversRequest && !action.grantCoversRequest(grant, input)) return false; return true; } diff --git a/src/guard/index.ts b/src/guard/index.ts index 891c3a72a6b..4880370661b 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -6,7 +6,7 @@ * (guard-actions.ts). Consults carry the GuardedAction value returned by * defineGuardedAction — never a name to look up — so mis-wiring is a build * error, not a runtime fail-open. - * Domain-free leaf: domain baselines are defined at the domain modules' edges. + * Domain-free leaf: domain decisions are defined at the domain modules' edges. */ export { guard } from './guard.js'; export { diff --git a/src/guard/types.ts b/src/guard/types.ts index 4e03b643bb5..2aca3487204 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -3,7 +3,7 @@ * * The guard is a domain-free leaf: this module may import the DB read layer, * config, log, and shared types — never src/cli/* or src/modules/*. Domain - * knowledge (what an action's structural baseline checks) arrives via + * knowledge (what an action's decide fn checks) arrives via * definition: domain modules call defineGuardedAction (guard-actions.ts) at * their module edges and pass the returned value to every consult and * registration site — the wiring is a symbol reference the compiler checks. @@ -26,7 +26,7 @@ export interface GuardInput { /** * Verified approval row carried by an approved replay. A valid grant * satisfies a hold (the human already decided) but never a deny — the - * structural baseline is re-checked live on every replay. + * structural checks re-run live on every replay. */ grant?: PendingApproval | null; } diff --git a/src/index.ts b/src/index.ts index 2ecf46d763c..72887f32be8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js'; -import { enforceGuardConformance } from './guard-conformance.js'; import { startHostSweep, stopHostSweep } from './host-sweep.js'; import { routeInbound } from './router.js'; import { log } from './log.js'; @@ -70,12 +69,6 @@ async function main(): Promise { // outside the sanctioned path (raw `git pull` instead of /update-nanoclaw). enforceUpgradeTripwire(); - // 0.6 Guard conformance — every import-time registration has already run; - // refuse to start if any privileged command / delivery action is unmapped - // (CI can't see skill-installed code — this makes the invariant hold at - // the boundary where third-party registrations enter). - enforceGuardConformance(); - // 1. Init central DB const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 398baa8328f..66a9aa2a068 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -242,7 +242,7 @@ export async function routeAgentMessage( throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`); } - // The a2a.send baseline (guard.ts) carries the checks verbatim in their + // The a2a.send decision (guard.ts) carries the checks verbatim in their // original order: destination ACL deny, target-exists deny, self-send // allow, agent_message_policies hold. An approved replay carries the // grant — the hold is satisfied but the structure is re-checked live, so diff --git a/src/modules/agent-to-agent/create-agent.test.ts b/src/modules/agent-to-agent/create-agent.test.ts index d67853e0bcb..96ebb920f6c 100644 --- a/src/modules/agent-to-agent/create-agent.test.ts +++ b/src/modules/agent-to-agent/create-agent.test.ts @@ -3,7 +3,7 @@ * * Regression guard for the audit finding: `create_agent` is a privileged * central-DB write with no host-side authz. Authorization is the guard's - * `agents.create` baseline — trusted owner agent groups ('global') create + * `agents.create` decision — trusted owner agent groups ('global') create * directly; confined groups ('group', the default and the prompt-injection * victim) hold for admin approval. These tests drive the REAL wrapped * delivery action (the only reachable path) and the approve continuation's @@ -208,7 +208,7 @@ describe('create_agent — guard-based authorization (wrapped delivery action)', }); describe('create_agent — approved replay (grant-carrying re-entry)', () => { - it('valid grant executes exactly once — baseline hold is satisfied, create runs', async () => { + it('valid grant executes exactly once — decide hold is satisfied, create runs', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); const payload = { name: 'Scout', instructions: 'help' }; const approval = liveGrant('appr-ca-1', payload); diff --git a/src/modules/agent-to-agent/create-agent.ts b/src/modules/agent-to-agent/create-agent.ts index 312d1f30b17..4f8e557c8c5 100644 --- a/src/modules/agent-to-agent/create-agent.ts +++ b/src/modules/agent-to-agent/create-agent.ts @@ -7,7 +7,7 @@ * barred from. The container's MCP tool gate is inside the (untrusted) * container and is trivially bypassed by writing the outbound system row * directly, so authorization MUST be enforced host-side: the delivery - * registry wraps this action with the guard, whose `agents.create` baseline + * registry wraps this action with the guard, whose `agents.create` decision * (./guard.ts) is the old cli_scope branch verbatim — trusted global-scope * groups allow, everything else (including unknown config, fail-closed) * holds for admin approval. On approve the continuation re-enters the diff --git a/src/modules/agent-to-agent/guard.ts b/src/modules/agent-to-agent/guard.ts index ce2cf9ab7f1..c778aa24f26 100644 --- a/src/modules/agent-to-agent/guard.ts +++ b/src/modules/agent-to-agent/guard.ts @@ -32,16 +32,16 @@ export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate'; export const agentsCreate = defineGuardedAction({ action: 'agents.create', - approvalAction: 'create_agent', + grantActionName: 'create_agent', // Bind a create_agent grant to the name that was approved. - grantMatches: (grant, input) => { + grantCoversRequest: (grant, input) => { try { return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name; } catch { return false; } }, - baseline: (input) => { + decide: (input) => { if (input.actor.kind !== 'agent') return DENY('create_agent is a container-originated action.'); const cliScope = getContainerConfig(input.actor.agentGroupId)?.cli_scope ?? 'group'; if (cliScope === 'global') { @@ -58,16 +58,16 @@ export const agentsCreate = defineGuardedAction({ export const a2aSend = defineGuardedAction({ action: 'a2a.send', - approvalAction: A2A_MESSAGE_GATE_ACTION, + grantActionName: A2A_MESSAGE_GATE_ACTION, // Bind an a2a grant to the exact held message target. - grantMatches: (grant, input) => { + grantCoversRequest: (grant, input) => { try { return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to; } catch { return false; } }, - baseline: (input) => { + decide: (input) => { if (input.actor.kind !== 'agent') return DENY('agent-to-agent send requires an agent actor'); const from = input.actor.agentGroupId; const to = input.resource?.to ?? ''; diff --git a/src/modules/agent-to-agent/index.ts b/src/modules/agent-to-agent/index.ts index 2114acacaaa..0a79f77dc7f 100644 --- a/src/modules/agent-to-agent/index.ts +++ b/src/modules/agent-to-agent/index.ts @@ -3,7 +3,7 @@ * * Registers its guard-catalog entries (./guard.js) and one guard-wrapped * delivery action (`create_agent`) — `create_agent` writes central-DB state, - * so the guard's agents.create baseline holds confined (non-global) groups + * so the guard's agents.create decision holds confined (non-global) groups * for admin approval while trusted global-scope groups create directly; the * approval handler re-enters the wrapped action carrying the approval row as * its grant. The sibling `channel_type === 'agent'` routing path is NOT a diff --git a/src/modules/agent-to-agent/message-gate.ts b/src/modules/agent-to-agent/message-gate.ts index b93a1a0cc3f..d7b217d252b 100644 --- a/src/modules/agent-to-agent/message-gate.ts +++ b/src/modules/agent-to-agent/message-gate.ts @@ -20,7 +20,7 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, a // One replay semantics: re-enter the guarded route carrying the approval // row as the grant. The policy hold is satisfied, but the structural - // baseline runs live — un-wiring the pair between hold and approve now + // checks run live — un-wiring the pair between hold and approve now // blocks delivery (the throw surfaces via the response handler's // "approved, but applying it failed" notify). await routeAgentMessage(msg, session, { grant: approval }); diff --git a/src/modules/permissions/guard.ts b/src/modules/permissions/guard.ts index 075d2bf2d5c..64f09f5c16f 100644 --- a/src/modules/permissions/guard.ts +++ b/src/modules/permissions/guard.ts @@ -7,7 +7,7 @@ * anyway), `request_approval` holds, `strict` denies. The hold is executed by * the caller through the module's own pending_sender_approvals flow (card, * in-flight dedup) — not the approvals primitive — so this entry has no - * approvalAction: the approve continuation adds the member and replays + * grantActionName: the approve continuation adds the member and replays * routeInbound, which then passes the gate structurally via membership, no * grant needed. * @@ -24,7 +24,7 @@ import { hasAdminPrivilege } from './db/user-roles.js'; export const sendersAdmit = defineGuardedAction({ action: 'senders.admit', - baseline: (input) => { + decide: (input) => { const policy = input.payload.policy; if (policy === 'public') return ALLOW('public messaging group'); if (policy === 'request_approval') { @@ -38,7 +38,7 @@ export const sendersAdmit = defineGuardedAction({ export const channelsRegister = defineGuardedAction({ action: 'channels.register', - baseline: (input) => { + decide: (input) => { if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies'); const questionId = typeof input.payload.questionId === 'string' ? input.payload.questionId : ''; const row = getPendingChannelApproval(questionId); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 15daec1f8c0..99d206a2e11 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -131,7 +131,7 @@ function handleUnknownSender( agent_group_id: agentGroupId, }; - // The admission decision is the guard's senders.admit baseline (./guard.ts) + // The admission decision is the guard's senders.admit decision (./guard.ts) // — unknown_sender_policy verbatim: strict → deny, request_approval → hold, // public → allow (short-circuited before the gate). Drop-recording and the // hold creation stay here. @@ -319,7 +319,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< const row = getPendingChannelApproval(payload.questionId); if (!row) return false; - // Click authorization is the guard's channels.register baseline (./guard.ts): + // Click authorization is the guard's channels.register decision (./guard.ts): // the delivered approver, or an admin of the pending row's anchor agent group. const clickerId = payload.userId ? payload.userId.includes(':') diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index f3d3bde580e..6f5fa7a813c 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -3,7 +3,7 @@ * * The delivery registry's guard wrapper runs these only on `allow` — which, * for self-mod, means an approved replay carrying a valid grant (the - * baseline holds unconditionally from the container path; see ./guard.ts). + * decision holds unconditionally from the container path; see ./guard.ts). * Each body mutates the container config in the DB, rebuilds/kills the * container as needed, and writes an on_wake message so the fresh container * picks up where the old one left off. diff --git a/src/modules/self-mod/guard.ts b/src/modules/self-mod/guard.ts index 0b80bc7a7de..bbdb3a11e07 100644 --- a/src/modules/self-mod/guard.ts +++ b/src/modules/self-mod/guard.ts @@ -2,7 +2,7 @@ * Self-mod guard adapter — the module's catalog entries, composed at the * module edge (imported by ./index.ts). * - * The structural baseline is today's behavior verbatim: from the container + * The decision is today's behavior verbatim: from the container * path, self-modification is held unconditionally for the agent group's * admin chain. (The equivalent host-side mutations — `ncl groups config * add-package` etc. — are separate catalog actions derived from the command @@ -10,7 +10,7 @@ */ import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js'; -function selfModBaseline(label: string) { +function selfModDecide(label: string) { return (input: GuardInput) => { if (input.actor.kind !== 'agent') { return DENY(`${label} is a container-originated action.`); @@ -21,12 +21,12 @@ function selfModBaseline(label: string) { export const selfModInstallPackages = defineGuardedAction({ action: 'self_mod.install_packages', - approvalAction: 'install_packages', - baseline: selfModBaseline('install_packages'), + grantActionName: 'install_packages', + decide: selfModDecide('install_packages'), }); export const selfModAddMcpServer = defineGuardedAction({ action: 'self_mod.add_mcp_server', - approvalAction: 'add_mcp_server', - baseline: selfModBaseline('add_mcp_server'), + grantActionName: 'add_mcp_server', + decide: selfModDecide('add_mcp_server'), }); diff --git a/src/modules/self-mod/index.ts b/src/modules/self-mod/index.ts index 8cdbc30f726..bfdc08797bc 100644 --- a/src/modules/self-mod/index.ts +++ b/src/modules/self-mod/index.ts @@ -18,7 +18,7 @@ * by the next container start. * - Two approval handlers that re-enter the wrapped actions with the * approval row as the grant (one replay semantics — the guard re-checks - * the structural baseline live). + * the structural checks live). * * Without this module: the MCP tools in the container still write outbound * system messages with these actions, but delivery logs "Unknown system From 790a7a6dfd3fd8ccd0dec27180f944a6c3b54dbe Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Sat, 11 Jul 2026 23:58:45 +0300 Subject: [PATCH 07/10] docs: drop internal design-process references from the guard change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public tree shouldn't point at internal artifacts: phase numbering ("guarded-actions phase 2/3") and the team-hub decisions-doc pointer are gone from the CHANGELOG bullet, the CLAUDE.md guard row, and the guard file headers. The facts stay ("policy-as-data is deliberately deferred — a generalized rules table can arrive later"); only the internal roadmap vocabulary goes. Scope: lines this branch introduced only. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- src/cli/guard.ts | 4 ++-- src/guard/guard.ts | 8 ++++---- src/guard/index.ts | 7 +++---- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cac8c49865..0411cf6206a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) - **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.) - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). diff --git a/CLAUDE.md b/CLAUDE.md index e78dfe89c46..5838c220562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/cli/guard.ts b/src/cli/guard.ts index 68fbaa3db83..49c8602b328 100644 --- a/src/cli/guard.ts +++ b/src/cli/guard.ts @@ -1,7 +1,7 @@ /** * CLI guard adapter — the command registry's catalog derivation and - * structural decision, moved verbatim out of dispatch.ts (guarded-actions - * phase 2). Declaration is registration: registry.register() derives one + * structural decision, moved verbatim out of dispatch.ts. + * Declaration is registration: registry.register() derives one * catalog entry per command from the CommandDef itself; no second file is * edited when a command is added. * diff --git a/src/guard/guard.ts b/src/guard/guard.ts index a73a056d8c8..327c698b1ea 100644 --- a/src/guard/guard.ts +++ b/src/guard/guard.ts @@ -7,10 +7,10 @@ * lookup and no fail-open path for an unknown action: an unwired consult is * a compile error, and a value that didn't come from defineGuardedAction is * denied at runtime. Policy-as-data (tighten-only rule sources composing - * with the decision) is deliberately deferred to phase 3 of the - * guarded-actions design, where the generalized rules table arrives with its - * first operator-visible consumer; until then the one policy table - * (agent_message_policies) is consulted inside a2a.send's decide. + * with the decision) is deliberately deferred — a generalized rules table + * can arrive later, with its first operator-visible consumer; until then + * the one policy table (agent_message_policies) is consulted inside + * a2a.send's decide. * * Grants: an approved replay carries the verified approval row. A valid * grant (live pending row whose action matches the entry's approval action, diff --git a/src/guard/index.ts b/src/guard/index.ts index 4880370661b..96eeefa30fe 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -1,9 +1,8 @@ /** - * Guard — the privileged-action decision seam (guarded-actions phase 2). + * Guard — the privileged-action decision seam. * - * See the guarded-actions decisions doc on the team hub. One decision - * function (guard.ts) and a definition-derived action catalog - * (guard-actions.ts). Consults carry the GuardedAction value returned by + * One decision function (guard.ts) and a definition-derived action + * catalog (guard-actions.ts). Consults carry the GuardedAction value returned by * defineGuardedAction — never a name to look up — so mis-wiring is a build * error, not a runtime fail-open. * Domain-free leaf: domain decisions are defined at the domain modules' edges. From c1445876a606e67682e022bff79a87681b9d2a35 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Sun, 12 Jul 2026 10:15:44 +0300 Subject: [PATCH 08/10] =?UTF-8?q?docs(guard):=20the=20name=20reply=20is=20?= =?UTF-8?q?not=20re-authorized=20=E2=80=94=20stop=20claiming=20it=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channelsRegister docstring and the CLAUDE.md guard row still described the withdrawn reply-time re-check (behavior delta (c), removed in fe235c7): the docstring claimed the decision is consulted by the name-capture interceptor "so a privilege revoked mid-flow is re-checked at each step", and the CLAUDE.md row said message interceptors consult the guard inline. Neither is true — channels.register is consulted only by the card-click handler, and the free-text reply deliberately authorizes off the click (main's behavior). Both docs now state that explicitly, including the consequence: a privilege revoked between click and reply still completes the flow. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/modules/permissions/guard.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f356508e1b..1a28265d7b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | diff --git a/src/modules/permissions/guard.ts b/src/modules/permissions/guard.ts index 64f09f5c16f..810a9cf3f45 100644 --- a/src/modules/permissions/guard.ts +++ b/src/modules/permissions/guard.ts @@ -11,12 +11,13 @@ * routeInbound, which then passes the gate structurally via membership, no * grant needed. * - * channels.register — click/reply authorization for the channel-registration - * flow, verbatim from today's response handler: the delivered approver, or an - * admin of the pending row's anchor agent group. Consulted by the wrapped - * response handler (card clicks) and the wrapped name-capture interceptor - * (free-text replies), so a privilege revoked mid-flow is re-checked at each - * step. + * channels.register — click authorization for the channel-registration flow, + * verbatim from today's response handler: the delivered approver, or an + * admin of the pending row's anchor agent group. Consulted inline by the + * card-click response handler only. The free-text name reply is deliberately + * NOT re-authorized (main's behavior): the click arms the capture and stands + * as the auth — a privilege revoked between the click and the reply still + * completes the flow. */ import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js'; import { getPendingChannelApproval } from './db/pending-channel-approvals.js'; From 82d9171985fd3bbb844e43366177953e280cc9bc Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Sun, 12 Jul 2026 21:56:38 +0300 Subject: [PATCH 09/10] fix(guard): an approved-then-refused a2a replay is a policy outcome, not a crash (GS-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime testing (PR #2986 validation, finding GS-002) showed the approve-after-revoke refusal — the PR's own deliberate behavior delta — surfacing as "ERROR Approval handler threw" with a stack trace, and the requester being told the apply "failed". Enforcement was correct; the telemetry dressed an expected guard refusal as a runtime failure. The a2a route's deny now throws a typed GuardDenyError (guard leaf), and applyA2aMessageGate catches exactly that: the requester is notified "Message approved, but not delivered — no longer authorized: " and the host logs a warning. Anything else still propagates to the response handler's crash path. Covers all three replay refusals the same way: destination revoked while pending, mismatched grant, already-consumed grant. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- src/guard/index.ts | 1 + src/guard/types.ts | 13 +++++++++ src/modules/agent-to-agent/agent-route.ts | 4 +-- .../agent-to-agent/message-gate.test.ts | 27 +++++++++++-------- src/modules/agent-to-agent/message-gate.ts | 24 ++++++++++++++--- 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8f2ab782d..e846e62dde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) +- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live; the refusal is an expected policy outcome — the requester is told "approved, but not delivered" and the host logs a warning, not a handler crash); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) - **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.) - [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter. - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. diff --git a/src/guard/index.ts b/src/guard/index.ts index 96eeefa30fe..909ce5724ac 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -18,6 +18,7 @@ export { export { ALLOW, DENY, + GuardDenyError, HOLD, isUnguarded, unguarded, diff --git a/src/guard/types.ts b/src/guard/types.ts index 2aca3487204..121ed39edea 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -73,3 +73,16 @@ export const HOLD = (reason: string, approverUserId?: string): GuardDecision => reason, approverUserId, }); + +/** + * A guard deny travelling as an exception — for flows whose entry point + * signals refusal by throwing (the a2a route). Catching it lets a caller + * distinguish "the guard refused, as designed" (report to the requester, + * log a warning) from a real runtime failure (crash path, stack trace). + */ +export class GuardDenyError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'GuardDenyError'; + } +} diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 66a9aa2a068..a2b45dad4b2 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -27,7 +27,7 @@ import { getAgentGroup } from '../../db/agent-groups.js'; import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; -import { guard } from '../../guard/index.js'; +import { GuardDenyError, guard } from '../../guard/index.js'; import { log } from '../../log.js'; import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { PendingApproval, Session } from '../../types.js'; @@ -255,7 +255,7 @@ export async function routeAgentMessage( }); if (decision.effect === 'deny') { - throw new Error(decision.reason); + throw new GuardDenyError(decision.reason); } // Gated edge: hold the message and return (not throw) so the delivery loop diff --git a/src/modules/agent-to-agent/message-gate.test.ts b/src/modules/agent-to-agent/message-gate.test.ts index fd9d5c3a82d..98bd380c3f9 100644 --- a/src/modules/agent-to-agent/message-gate.test.ts +++ b/src/modules/agent-to-agent/message-gate.test.ts @@ -205,29 +205,33 @@ describe('agent message policies', () => { expect(requestApproval).not.toHaveBeenCalled(); }); - it('destination revoked between hold and approve → the approved replay is blocked', async () => { + it('destination revoked between hold and approve → refused cleanly, requester told, nothing delivered', async () => { setMessagePolicy(A, B, 'telegram:dana', now()); const payload = { id: 'held-2', platform_id: B, content: JSON.stringify({ text: 'stale' }), in_reply_to: null }; const approval = seedA2aHold('appr-a2a-2', payload); deleteDestination(A, 'b'); // revoke A→B while the card is pending - await expect( - applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), - ).rejects.toThrow(/unauthorized agent-to-agent/); + const notify = vi.fn(); + // An expected policy refusal — resolves (no throw), so the response + // handler never records it as a handler crash. + await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval }); + expect(readInbound(B, SB.id)).toHaveLength(0); + expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*no destination for/)); }); - it('mismatched grant (held for another target) refuses the replay', async () => { + it('mismatched grant (held for another target) refuses the replay cleanly', async () => { setMessagePolicy(A, B, 'telegram:dana', now()); // Grant was approved for a message to A (different target than the replay). const approval = seedA2aHold('appr-a2a-3', { id: 'other', platform_id: A, content: '{}', in_reply_to: null }); const payload = { id: 'held-3', platform_id: B, content: JSON.stringify({ text: 'swap' }), in_reply_to: null }; - await expect( - applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), - ).rejects.toThrow(/invalid or mismatched grant/); + const notify = vi.fn(); + await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval }); + expect(readInbound(B, SB.id)).toHaveLength(0); + expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*invalid or mismatched grant/)); }); it('a grant only works while its row is live (executes once)', async () => { @@ -237,10 +241,11 @@ describe('agent message policies', () => { deletePendingApproval(approval.approval_id); // resolution already consumed the row - await expect( - applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }), - ).rejects.toThrow(/invalid or mismatched grant/); + const notify = vi.fn(); + await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval }); + expect(readInbound(B, SB.id)).toHaveLength(0); + expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*invalid or mismatched grant/)); }); // ── ghost-gate cleanup ── diff --git a/src/modules/agent-to-agent/message-gate.ts b/src/modules/agent-to-agent/message-gate.ts index d7b217d252b..93f7d799174 100644 --- a/src/modules/agent-to-agent/message-gate.ts +++ b/src/modules/agent-to-agent/message-gate.ts @@ -1,4 +1,5 @@ /** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */ +import { GuardDenyError } from '../../guard/index.js'; import { log } from '../../log.js'; import type { ApprovalHandler } from '../approvals/index.js'; import { routeAgentMessage, type RoutableAgentMessage } from './agent-route.js'; @@ -20,10 +21,25 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, a // One replay semantics: re-enter the guarded route carrying the approval // row as the grant. The policy hold is satisfied, but the structural - // checks run live — un-wiring the pair between hold and approve now - // blocks delivery (the throw surfaces via the response handler's - // "approved, but applying it failed" notify). - await routeAgentMessage(msg, session, { grant: approval }); + // checks run live — a deny here (destination revoked while the card was + // pending, dead or mismatched grant) is an EXPECTED policy outcome, not a + // crash: tell the requester, log a warning, and let anything else keep the + // response handler's failure path. + try { + await routeAgentMessage(msg, session, { grant: approval }); + } catch (err) { + if (err instanceof GuardDenyError) { + log.warn('Approved a2a replay refused by the guard', { + from: session.agent_group_id, + to: platform_id, + msgId: msg.id, + reason: err.message, + }); + notify(`Message approved, but not delivered — no longer authorized: ${err.message}`); + return; + } + throw err; + } log.info('Held agent message delivered after approval', { from: session.agent_group_id, to: platform_id, From b508bb951ef69940e87f98446fc1ffe5823d54cc Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Mon, 13 Jul 2026 14:13:54 +0300 Subject: [PATCH 10/10] docs: shrink the guard CHANGELOG bullets and CLAUDE.md rows to house length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard-seam CHANGELOG bullet was a design document; it now reads like the neighboring entries — what changed, the two behavior deltas, done. Same for the src/guard/ and src/delivery-guard.ts Key Files rows. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++-- CLAUDE.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e846e62dde9..640c05f903a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] -- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded()` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live; the refusal is an expected policy outcome — the requester is told "approved, but not delivered" and the host logs a warning, not a handler crash); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.) -- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.) +- **The guard seam.** Every privileged action crossing the container or channel boundary now passes one decision function — `guard()` in `src/guard/` — before it executes: `allow`, `hold` (the existing approval flows), or `deny`. Today's checks are preserved verbatim as each action's decision; ncl commands and delivery actions cannot register without a guard, and approved replays re-enter carrying the approval row as a **grant** (the forgeable `approved: true` boolean is deleted) with the checks re-run live. Two deliberate outcome changes: **(a)** approving a held a2a message after its destination was revoked no longer delivers it — the requester is told "approved, but not delivered" and the host logs a warning; **(b)** a forged, already-consumed, or mismatched grant refuses the replay instead of executing. +- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws instead of silently disarming the guard. - [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter. - **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates. - [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md). diff --git a/CLAUDE.md b/CLAUDE.md index 1a28265d7b4..0772957a1eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,12 +62,12 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown | | `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake | | `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) | -| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` | +| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the guard-consult pipeline for privileged delivery actions (registry stays in `delivery.ts`) | | `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence | | `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | | `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | | `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup | -| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded()`); broadcast hooks stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` | +| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) define each action's decision; ncl commands + delivery actions demand a guard at registration; approved replays carry the approval row as a grant and re-run the checks. Conformance test: `src/guard/conformance.test.ts` | | `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | | `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |