diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b95d150..4cac8c498 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 742380bd8..e78dfe89c 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 bb44144d1..fc198354b 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 2b7ebca39..68fbaa3db 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 fc38d769a..6bd06fc90 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 000000000..e4a99d725 --- /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 311c99567..4ff8a9354 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 904563f79..000000000 --- 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 a1621841c..086f0a40d 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 85f4e6cc3..09f139a3e 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 db2a76e79..11b3f9090 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 56008a212..a73a056d8 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 891c3a72a..488037066 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 4e03b643b..2aca34872 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 2ecf46d76..72887f32b 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 398baa832..66a9aa2a0 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 d67853e0b..96ebb920f 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 312d1f30b..4f8e557c8 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 ce2cf9ab7..c778aa24f 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 2114acaca..0a79f77dc 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 b93a1a0cc..d7b217d25 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 075d2bf2d..64f09f5c1 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 15daec1f8..99d206a2e 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 f3d3bde58..6f5fa7a81 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 0b80bc7a7..bbdb3a11e 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 8cdbc30f7..bfdc08797 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