From b508bb951ef69940e87f98446fc1ffe5823d54cc Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Mon, 13 Jul 2026 14:13:54 +0300 Subject: [PATCH] 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 e846e62dd..640c05f90 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 1a28265d7..0772957a1 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) |