diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cac8c498..0411cf620 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 e78dfe89c..5838c2205 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 68fbaa3db..49c8602b3 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 a73a056d8..327c698b1 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 488037066..96eeefa30 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.