Compare commits

..

13 Commits

Author SHA1 Message Date
gavrielc 4a3e01cab8 Merge branch 'main' into feat/lean-harness-defaults 2026-07-13 16:32:28 +03:00
gavrielc b52c8c6dca Update src/group-init.settings.test.ts 2026-07-13 16:32:00 +03:00
gavrielc 69913f23ec Update CHANGELOG.md 2026-07-13 16:31:48 +03:00
gavrielc 1de7dba2b7 Update src/group-init.ts 2026-07-13 16:31:40 +03:00
gavrielc 37f60c831d Update src/group-init.ts 2026-07-13 16:31:27 +03:00
gavrielc 6bf3b3929e Update src/group-init.settings.test.ts 2026-07-13 16:31:16 +03:00
github-actions[bot] 2d3257c601 chore: bump version to 2.1.47 2026-07-13 11:24:03 +00:00
github-actions[bot] a467359d86 docs: update token count to 240k tokens · 120% of context window 2026-07-13 11:24:00 +00:00
Moshe Krupper 40d26893f8 Guard seam: one decision function for every privileged action (guarded-actions phase 2) (#2986)
* feat: guard seam — decision function, registration wrapping, grant-carrying replay, boot conformance

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 <noreply@anthropic.com>

* refactor: rename src/guard/catalog.ts → guard-actions.ts

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 <noreply@anthropic.com>

* refactor(guard): consults carry the defined action value — delete the registry walk

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(<reason>) 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 <noreply@anthropic.com>

* refactor(guard): one runtime discriminator for Unguarded — isUnguarded()

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 <noreply@anthropic.com>

* refactor(guard): unwrap the broadcast registries — response handlers and interceptors consult inline

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(<reason>) — 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 <noreply@anthropic.com>

* refactor(guard): drop boot conformance, rename the spec vocabulary, extract runGuarded

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 <noreply@anthropic.com>

* docs: drop internal design-process references from the guard change

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 <noreply@anthropic.com>

* docs(guard): the name reply is not re-authorized — stop claiming it is

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 <noreply@anthropic.com>

* fix(guard): an approved-then-refused a2a replay is a policy outcome, not a crash (GS-002)

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: <reason>"
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 <noreply@anthropic.com>

* docs: shrink the guard CHANGELOG bullets and CLAUDE.md rows to house length

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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
2026-07-13 14:23:45 +03:00
gavrielc 9390037761 Merge pull request #3021 from nanocoai/whatsapp-shared-warning
Warn before connecting a shared WhatsApp number
2026-07-13 12:51:23 +03:00
Gabi Simons 4f5440d2ab feat: lean harness defaults for new agent groups
New groups' settings.json no longer enables Claude Code's experimental
agent-teams feature and sets disableWorkflows: both overlap NanoClaw's
own systems (a2a messaging; host-side orchestration), and together they
are ~20% of every turn's context (live-measured: ~38,900 -> ~30,300
tokens on a fresh session). DesignSync and ReportFindings — desktop/UI
tools that cannot function headless — join the fixed disallow list.

Existing groups are untouched: their settings.json already exists and
is only ever patched additively, never regenerated. Re-enabling either
feature for one group is a one-file edit to that group's settings.json
plus a group restart — and it sticks, because nothing reconciles the
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:52:13 +03:00
Koshkoshinsk ef5d09680a fix: render WhatsApp warning title cleanly 2026-07-12 13:22:28 +03:00
Koshkoshinsk 33b33b84b9 fix: warn before shared WhatsApp setup 2026-07-12 13:05:36 +03:00
73 changed files with 2002 additions and 1977 deletions
+31 -5
View File
@@ -7,6 +7,34 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
## Number safety check (required)
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
Otherwise, use `AskUserQuestion`:
**Which WhatsApp number will NanoClaw use?**
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
- **Shared / personal number** — the user's existing everyday WhatsApp number
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
If `NUMBER_MODE=shared`, show this warning exactly:
> ⚠️ **Risk to your WhatsApp account**
>
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
>
> We strongly recommend using a separate, dedicated number for NanoClaw.
Then use `AskUserQuestion`:
- **Go back and use a dedicated number (Recommended)**
- **I understand the risk — continue with my shared number**
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
@@ -92,7 +120,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
### Check current state
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
@@ -209,9 +237,7 @@ The adapter behaves fundamentally differently depending on whether the linked nu
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
- **Dedicated number** — a separate phone/SIM for the assistant
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
@@ -224,7 +250,7 @@ ASSISTANT_HAS_OWN_NUMBER=false
### Update path: existing install, flag unset
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
Suggest a default by comparing the authed number against the wired DM chat:
+2 -2
View File
@@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Per-group harness-capability toggles — new groups default lean, existing groups unchanged.** Claude Code ships built-in features NanoClaw already replaces its own way: **agent teams** (overlaps `create_agent` + destinations; multiplies separately-billed agents invisibly) and the **Workflow** tool (duplicates NanoClaw's orchestration; largest per-turn tool schema). Both are now **OFF by default for newly-created groups** (and `DesignSync` + `ReportFindings`, desktop/UI-only tools, are fixed-off) — cutting ~20% of per-turn context. **This is non-breaking:** a new `container_configs.harness_capabilities` column (migration 020) *grandfathers every existing group* to its prior behavior (teams + Workflow on), so upgrading changes nothing for current agents. Defaults live in `src/harness-capabilities.ts`; the host reconciles each group's settings.json at spawn from the resolved state. Toggle per group with `ncl groups config update --id <g> --harness-capabilities 'agent-teams=on,workflow=off'` (+ `ncl groups restart`); opt an existing group into the lean defaults the same way. Details: [docs/harness-capabilities.md](docs/harness-capabilities.md).
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list, **not an availability filter** — interleaved wire captures show no allowlist effect on the stable tool surface, and `bypassPermissions` moots its permission role. (The surface itself shows per-query variance in conditional tools on this CLI pin — the drift fixture records the flicker set separately so regeneration cannot flake on it — so neither `allowedTools` nor `disallowedTools` alone should be relied on to shape what the model sees — the runner's PreToolUse hook is the deterministic block.) No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code CLI **or SDK** pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
- **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^(n1) 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 `<message to>` 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).
+3 -2
View File
@@ -62,10 +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 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. 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) |
@@ -73,7 +75,6 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/harness-capabilities.ts` | Harness-capability registry — `agent-teams`/`workflow` toggles (both default off), resolved into `container.json`; settings reconciler lives in `group-init.ts`. See [docs/harness-capabilities.md](docs/harness-capabilities.md) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
@@ -142,7 +143,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` and `harness_capabilities` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
+1 -1
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { configFromRaw } from './config.js';
describe('configFromRaw harnessCapabilities', () => {
it('treats a missing field as a pre-capability host — legacy all-on, not all-off', () => {
// Update-window skew: the bind-mounted runner source is already
// capability-aware while the still-running old host wrote container.json
// without the field. Those groups ran with teams + Workflow on; defaulting
// to {} here would regress them to all-off until the host restarts.
expect(configFromRaw({}).harnessCapabilities).toEqual({ 'agent-teams': 'on', workflow: 'on' });
});
it('passes an explicit host-resolved map through untouched', () => {
const caps = { 'agent-teams': 'off', workflow: 'off' };
expect(configFromRaw({ harnessCapabilities: caps }).harnessCapabilities).toEqual(caps);
});
});
+11 -26
View File
@@ -18,37 +18,12 @@ export interface RunnerConfig {
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
/** Resolved harness-capability map (host-resolved). Missing → legacy all-on (pre-capability host). */
harnessCapabilities: Record<string, string>;
}
const DEFAULT_MAX_MESSAGES = 10;
// Pre-capability behavior. A container.json without `harnessCapabilities` was
// written by an older host (capability-aware hosts always emit the full
// resolved map, never omit it) — and under that host every group ran with
// teams + Workflow on. Defaulting to {} here would regress those groups to
// all-off during the update window where the bind-mounted runner source is
// already new but the still-running old host wrote the config file.
const LEGACY_HARNESS_CAPABILITIES: Record<string, string> = { 'agent-teams': 'on', workflow: 'on' };
let _config: RunnerConfig | null = null;
/** Map raw container.json fields to a RunnerConfig, applying per-field defaults. */
export function configFromRaw(raw: Record<string, unknown>): RunnerConfig {
return {
provider: (raw.provider as string) || 'claude',
assistantName: (raw.assistantName as string) || '',
groupName: (raw.groupName as string) || '',
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
harnessCapabilities: (raw.harnessCapabilities as Record<string, string>) ?? LEGACY_HARNESS_CAPABILITIES,
};
}
/**
* Load config from container.json. Called once at startup.
* Falls back to sensible defaults for any missing field.
@@ -63,7 +38,17 @@ export function loadConfig(): RunnerConfig {
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
}
_config = configFromRaw(raw);
_config = {
provider: (raw.provider as string) || 'claude',
assistantName: (raw.assistantName as string) || '',
groupName: (raw.groupName as string) || '',
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;
}
-1
View File
@@ -94,7 +94,6 @@ async function main(): Promise<void> {
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
harnessCapabilities: config.harnessCapabilities,
});
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
@@ -4,8 +4,6 @@ Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent grou
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"``sales-briefing-a25c`); without it ids are `t-<hex>`.
Note: your in-session task-list tools (TaskCreate/TaskUpdate/TaskList) are per-conversation scratch for organizing the current turn's work — they are unrelated to `ncl tasks` scheduled tasks and do not survive the session.
Common commands:
```bash
@@ -1,74 +0,0 @@
/**
* Harness-capability mapping in the Claude provider: the disallow list is the
* fixed set plus capability-driven entries (fail closed), and the PreToolUse
* hook blocks exactly that list. Pure — no SDK, no DB (the hook's
* container_state write is try/caught by design).
*/
import { describe, expect, it } from 'bun:test';
import { ClaudeProvider, SDK_DISALLOWED_TOOLS, buildDisallowedTools, createPreToolUseHook } from './claude.js';
type LooseHook = (input: unknown) => Promise<Record<string, unknown>>;
describe('buildDisallowedTools', () => {
it('fails closed: absent/empty/off/garbage all include Workflow plus the fixed set', () => {
for (const caps of [undefined, {}, { workflow: 'off' }, { workflow: 'garbage' }]) {
const list = buildDisallowedTools(caps);
for (const fixed of SDK_DISALLOWED_TOOLS) expect(list).toContain(fixed);
expect(list).toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('ReportFindings');
}
});
it('workflow=on removes only Workflow', () => {
const list = buildDisallowedTools({ workflow: 'on' });
expect(list).not.toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('CronCreate');
});
it('agent-teams has no runner mechanism and never changes the list', () => {
expect(buildDisallowedTools({ 'agent-teams': 'on' })).toEqual(buildDisallowedTools({ 'agent-teams': 'off' }));
});
});
describe('createPreToolUseHook', () => {
it('blocks a listed tool, with the redirect in the model-visible fields', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Workflow', tool_input: {} });
expect(res.decision).toBe('block');
// The CLI feeds `reason` / permissionDecisionReason back to the model on a
// deny — stopReason is only surfaced with continue:false (turn-ending).
expect(String(res.reason)).toContain('nanoclaw equivalent');
const specific = res.hookSpecificOutput as Record<string, unknown>;
expect(specific.permissionDecision).toBe('deny');
expect(String(specific.permissionDecisionReason)).toContain('nanoclaw equivalent');
});
it('passes an unlisted tool through', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Bash', tool_input: { timeout: 1000 } });
expect(res.continue).toBe(true);
});
});
describe('ClaudeProvider instance wiring', () => {
// Guards the seam the pure-helper tests can't see: the provider must
// actually BUILD its blocklist from the capability map it was constructed
// with (a revert to the static SDK_DISALLOWED_TOOLS constant at the query
// site would pass every other test in this file).
it('builds its disallow list and hook blocklist from the constructor capabilities', async () => {
const off = new ClaudeProvider({ harnessCapabilities: { workflow: 'off' } });
const on = new ClaudeProvider({ harnessCapabilities: { workflow: 'on' } });
expect(off['disallowedTools']).toContain('Workflow');
expect(on['disallowedTools']).not.toContain('Workflow');
expect(on['disallowedTools']).toContain('DesignSync');
const offHook = off['preToolUseHook'] as unknown as LooseHook;
const onHook = on['preToolUseHook'] as unknown as LooseHook;
expect((await offHook({ tool_name: 'Workflow', tool_input: {} })).decision).toBe('block');
expect((await onHook({ tool_name: 'Workflow', tool_input: {} })).continue).toBe(true);
});
});
@@ -1,92 +0,0 @@
/**
* Drift guard for the harness tool surface. sdk-tools-baseline.json is a wire
* capture of every tool the pinned CLI can offer under our configuration
* (regenerate with dump-sdk-tools.ts — instructions in its header). These
* tests catch upstream renames/removals when the claude-code pin moves:
* bumping container/cli-tools.json fails the version assertion until the
* fixture is regenerated and the lists below are re-verified.
*/
import fs from 'fs';
import { describe, expect, it } from 'bun:test';
import cliTools from '../../../cli-tools.json';
import { SDK_DISALLOWED_TOOLS, TOOL_ALLOWLIST } from './claude.js';
import baseline from './sdk-tools-baseline.json';
/**
* Disallow entries that do NOT exist on the pinned CLI in headless SDK mode
* (wire-verified: never offered, in both string and streaming input modes).
* Kept in SDK_DISALLOWED_TOOLS as drift insurance — if an upstream version
* starts offering one, the fixture regeneration surfaces it here and the
* entry moves out of this set.
*/
const KNOWN_ABSENT_DISALLOWED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
const installedSdkVersion = (
JSON.parse(
fs.readFileSync(new URL('../../node_modules/@anthropic-ai/claude-agent-sdk/package.json', import.meta.url), 'utf8'),
) as { version: string }
).version;
// Membership checks run against stable variant: a tool that flickers on
// this pin (see dump-sdk-tools.ts header) is still a real tool.
const baselineTools = new Set<string>([...baseline.tools, ...baseline.variantTools]);
describe('sdk tool-surface drift guard', () => {
it('fixture matches the pinned claude-code CLI version', () => {
const pin = cliTools.find((t) => t.name === '@anthropic-ai/claude-code')?.version;
expect(baseline.cliVersion).toBe(pin);
});
it('fixture matches the installed Agent SDK version', () => {
// The SDK is a caret-free pinned dep; a bump must be captured deliberately.
expect(baseline.sdkVersion).toBe(installedSdkVersion);
});
it('allowedTools has no surface effect: the stable cores match across interleaved rounds', () => {
// `tools`/`toolsBare` are STABLE cores over N interleaved captures per
// mode; per-query flicker (see the dump-sdk-tools.ts header) lands in
// `variantTools` instead of here. Passing TOOL_ALLOWLIST has never been
// observed to filter or promote a stable tool under bypassPermissions —
// if a future CLI makes allowedTools an availability filter, the cores
// diverge in every round and this fails deterministically.
expect([...baseline.tools].sort()).toEqual([...baseline.toolsBare].sort());
});
it('every allowlist entry names a real tool on this surface', () => {
for (const name of TOOL_ALLOWLIST) {
expect(baselineTools.has(name), `allowlist entry '${name}' not in captured surface`).toBe(true);
}
});
it('every disallow entry is either a real tool or documented drift insurance', () => {
for (const name of SDK_DISALLOWED_TOOLS) {
const real = baselineTools.has(name);
const insurance = KNOWN_ABSENT_DISALLOWED.includes(name);
expect(
real || insurance,
`disallow entry '${name}' is neither on the surface nor in KNOWN_ABSENT_DISALLOWED`,
).toBe(true);
}
});
it('drift-insurance entries are still absent from the surface', () => {
for (const name of KNOWN_ABSENT_DISALLOWED) {
expect(
baselineTools.has(name),
`'${name}' now exists on the surface — move it out of KNOWN_ABSENT_DISALLOWED and re-verify its disposition`,
).toBe(false);
}
});
it('capability-managed tools exist on the surface', () => {
// Workflow (the `workflow` capability) and DesignSync (fixed-off) must be
// real tools for the disallow/settings mechanisms to be doing anything.
for (const name of ['Workflow', 'DesignSync']) {
expect(baselineTools.has(name), `'${name}' vanished from the surface — re-audit its capability mapping`).toBe(
true,
);
}
});
});
+48 -101
View File
@@ -25,10 +25,10 @@ function log(msg: string): void {
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
// Code UI affordances; in a headless container they'd appear stuck.
// - DesignSync: desktop design-tool integration — nothing to sync with in a
// headless container, and a ~9.3KB/turn schema (wire-measured).
// - ReportFindings: code-review-reporting UI affordance with no headless host
// surface to receive it (~1.9KB/turn schema).
export const SDK_DISALLOWED_TOOLS = [
// headless container (~9.3KB/turn schema).
// - ReportFindings: code-review-reporting UI affordance with no headless
// host surface to receive it (~1.9KB/turn schema).
const SDK_DISALLOWED_TOOLS = [
'CronCreate',
'CronDelete',
'CronList',
@@ -42,60 +42,30 @@ export const SDK_DISALLOWED_TOOLS = [
'ReportFindings',
];
/**
* Configurable capabilities that block a harness tool when OFF, mapped to the
* tool name. This is the runner half of the capability contract (the host half
* is the settings reconciler); it is genuinely runner-specific because
* `disallowedTools` is a Claude concept the host tree can't name. The block is
* defense-in-depth — the primary OFF mechanism is the host-reconciled settings
* key — covering a malformed or hand-reverted settings.json.
*/
const CAPABILITY_DISALLOWS: Record<string, string> = {
workflow: 'Workflow',
};
/**
* Effective disallow list for a provider instance: the fixed set plus, for each
* capability in CAPABILITY_DISALLOWS whose resolved state is not explicitly
* `on`, its tool. Absent or unexpected state fails closed (tool stays blocked).
* `caps` is the resolved map from container.json (host-resolved: only known
* keys, valid values).
*/
export function buildDisallowedTools(caps?: Record<string, string>): string[] {
const list = [...SDK_DISALLOWED_TOOLS];
for (const [key, tool] of Object.entries(CAPABILITY_DISALLOWS)) {
if (caps?.[key] !== 'on') list.push(tool);
}
return list;
}
// Pre-approved tool set for NanoClaw agent containers. `allowedTools` is a
// permission auto-approve list, NOT an availability filter: interleaved wire
// captures show no allowlist effect on the stable tool surface (the fixture's
// stable cores are equal; claude.tools.test.ts asserts it), and its permission
// role is moot under this runner's `bypassPermissions`. The surface itself has
// per-query variance in conditional tools (see dump-sdk-tools.ts header), so
// never rely on this list — or on `disallowedTools` alone — to shape what the
// model sees. Kept as the accurate pre-approval set for a
// hypothetical non-bypass mode, and because the per-server `mcpAllowPattern`
// entries derived at the call site are retained (MCP invocation-gating under
// non-bypass modes is unverified). Exported for the fixture regenerator and
// tests.
export const TOOL_ALLOWLIST = [
'Agent',
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
// at the call site from the registered `mcpServers` map so that any server
// added via `add_mcp_server` (or wired in container.json directly) is
// reachable to the agent — without this, the SDK's allowedTools filter
// silently drops every MCP namespace not listed here.
const TOOL_ALLOWLIST = [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'NotebookEdit',
'Read',
'SendMessage',
'Skill',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'WebFetch',
'WebSearch',
'Write',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
];
// MCP server names are sanitized by the SDK when forming tool prefixes:
@@ -190,46 +160,31 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
}
/**
* PreToolUse hook factory: record the current tool + its declared timeout so
* the host sweep can widen its stuck tolerance while Bash is running a
* long-declared script. Defense-in-depth: if a disallowed tool slips through
* somehow, block the call here instead of letting the agent hang. A factory
* (like createPreCompactHook) because the blocklist is per-instance — it
* depends on the group's resolved harness capabilities.
* PreToolUse hook: record the current tool + its declared timeout so the host
* sweep can widen its stuck tolerance while Bash is running a long-declared
* script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow,
* block the call here instead of letting the agent hang.
*/
export function createPreToolUseHook(blockedTools: Iterable<string>): HookCallback {
const blocked = new Set(blockedTools);
return async (input) => {
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
const toolName = i.tool_name ?? '';
if (blocked.has(toolName)) {
// `reason` (legacy) / permissionDecisionReason (modern) are what the CLI
// feeds back to the model on a PreToolUse deny. `stopReason` would only
// surface with `continue:false`, which ends the whole turn — the agent
// must instead see the redirect and carry on with nanoclaw's tools.
const reason = `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`;
return {
decision: 'block',
reason,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
} as unknown as ReturnType<HookCallback>;
}
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
// tool: no declared timeout.
const declaredTimeoutMs =
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
try {
setContainerToolInFlight(toolName, declaredTimeoutMs);
} catch (err) {
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
}
return { continue: true };
};
}
const preToolUseHook: HookCallback = async (input) => {
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
const toolName = i.tool_name ?? '';
if (SDK_DISALLOWED_TOOLS.includes(toolName)) {
return {
decision: 'block',
stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`,
} as unknown as ReturnType<HookCallback>;
}
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
// tool: no declared timeout.
const declaredTimeoutMs =
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
try {
setContainerToolInFlight(toolName, declaredTimeoutMs);
} catch (err) {
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
}
return { continue: true };
};
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
const postToolUseHook: HookCallback = async () => {
@@ -391,8 +346,6 @@ export class ClaudeProvider implements AgentProvider {
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
private disallowedTools: string[];
private preToolUseHook: HookCallback;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
@@ -404,12 +357,6 @@ export class ClaudeProvider implements AgentProvider {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
};
// The resolved harness-capability map in container.json is host-resolved:
// only known keys with valid values reach here, so the runner never has to
// reason about unknown keys. Blocklist + hook are per-instance but immutable
// after construction — build both once.
this.disallowedTools = buildDisallowedTools(options.harnessCapabilities);
this.preToolUseHook = createPreToolUseHook(this.disallowedTools);
}
isSessionInvalid(err: unknown): boolean {
@@ -470,7 +417,7 @@ export class ClaudeProvider implements AgentProvider {
...TOOL_ALLOWLIST,
...Object.keys(this.mcpServers).map(mcpAllowPattern),
],
disallowedTools: this.disallowedTools,
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -480,7 +427,7 @@ export class ClaudeProvider implements AgentProvider {
settingSources: ['project', 'user', 'local'],
mcpServers: this.mcpServers,
hooks: {
PreToolUse: [{ hooks: [this.preToolUseHook] }],
PreToolUse: [{ hooks: [preToolUseHook] }],
PostToolUse: [{ hooks: [postToolUseHook] }],
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
@@ -1,182 +0,0 @@
/**
* Regenerates sdk-tools-baseline.json the bare SDK tool-surface fixture
* asserted by claude.tools.test.ts.
*
* Must run INSIDE the agent container image (the pinned CLI binary only
* exists there). From the repo root:
*
* docker run --rm --network none \
* -v "$PWD/container/agent-runner/src":/app/src:ro \
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
* > container/agent-runner/src/providers/sdk-tools-baseline.json
*
* MEASURED NONDETERMINISM on the pinned CLI (2.1.197): across query
* invocations with byte-identical options even inside one process (a)
* conditional tools (Glob, Grep) flicker in and out of the surface, and (b)
* `disallowedTools` sometimes strips flag-gated tools (Workflow, DesignSync,
* EnterWorktree) and sometimes ignores them entirely; each single query is
* internally coherent. Consequences: schema-stripping via disallowedTools is
* BEST-EFFORT the deterministic enforcement is the runner's PreToolUse
* hook, which blocks the invocation regardless of whether the schema shipped.
*
* Because the variance is per-query, a single capture pair cannot isolate the
* allowlist's effect. So: ROUNDS interleaved captures per mode (allowlist /
* bare), then split each mode into its STABLE core (tools present in every
* capture of that mode) and the VARIANT set (present in some but not all
* captures across both modes):
* - `tools` : stable core WITH the production TOOL_ALLOWLIST.
* - `toolsBare` : stable core with no allowedTools.
* - `variantTools` : the flicker set, recorded for membership checks.
* - `toolsDisallowProbe` : one capture with disallowedTools=[DISALLOW_PROBE]
* a diagnostic of which disallow behavior this regen observed (no test
* asserts stripping; a fixed assertion on a nondeterministic mechanism
* would be a coin-flip).
* The drift test asserts tools === toolsBare on the STABLE cores: no allowlist
* effect has ever been observed there, so the list is permission-layer only
* (moot under bypassPermissions). If a CLI/SDK bump makes the allowlist shape
* the surface, the stable cores diverge across every round by construction
* and the assertion fails deterministically instead of flaking.
*
* Agent-teams is enabled via a temp settings.json (wire-verified: settings
* env strictly beats SDK options env).
*
* Zero API traffic: ANTHROPIC_BASE_URL points at an in-process stub answering
* 401; the full tools array rides on the first /v1/messages request, captured
* before the run dies on the auth error. The fixture records WIRE tool names
* (the SDK init message reports legacy aliases, e.g. `Task` for wire `Agent`
* do not swap this to an init capture).
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { TOOL_ALLOWLIST } from './claude.js';
let requests: string[] = [];
let captured: (() => void) | null = null;
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const url = new URL(req.url);
const body = await req.text();
if (url.pathname.includes('/messages')) {
requests.push(body);
captured?.();
}
return new Response(
JSON.stringify({ type: 'error', error: { type: 'authentication_error', message: 'fixture-capture-stub' } }),
{ status: 401, headers: { 'content-type': 'application/json' } },
);
},
});
const HOME = '/tmp/dump-sdk-tools-home';
const CWD = '/tmp/dump-sdk-tools-ws';
fs.mkdirSync(`${HOME}/.claude`, { recursive: true });
fs.mkdirSync(CWD, { recursive: true });
fs.writeFileSync(
`${HOME}/.claude/settings.json`,
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
);
/**
* Diagnostic probe for disallowedTools: a flag-gated tool whose stripping is
* nondeterministic on the current pin (see header). The fixture records what
* this regen run observed; future pins can be compared against it.
*/
export const DISALLOW_PROBE = 'Workflow';
/** Run one capture and return the sorted wire tool names. */
async function capture(opts?: { allowedTools?: string[]; disallowedTools?: string[] }): Promise<string[]> {
requests = [];
const firstRequest = new Promise<void>((resolve) => {
captured = resolve;
});
const q = query({
prompt: 'fixture capture: reply with one word',
options: {
cwd: CWD,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const },
env: {
...process.env,
HOME,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`,
ANTHROPIC_API_KEY: 'fixture-dummy-key',
ANTHROPIC_AUTH_TOKEN: undefined,
},
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['user'],
...(opts?.allowedTools ? { allowedTools: opts.allowedTools } : {}),
...(opts?.disallowedTools ? { disallowedTools: opts.disallowedTools } : {}),
},
});
void (async () => {
try {
for await (const _m of q) {
/* drain until the auth error kills the run */
}
} catch {
/* expected: 401 from the stub */
}
})();
await Promise.race([firstRequest, Bun.sleep(75_000)]);
await Bun.sleep(1_500); // let retries land so we can pick the largest body
if (requests.length === 0) {
console.error('[dump-sdk-tools] no /v1/messages request captured');
process.exit(1);
}
const biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
return [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
}
// Interleaved so a drifting gate state biases both modes equally (see header).
const ROUNDS = 3;
const withRuns: string[][] = [];
const bareRuns: string[][] = [];
for (let i = 0; i < ROUNDS; i++) {
withRuns.push(await capture({ allowedTools: TOOL_ALLOWLIST }));
bareRuns.push(await capture());
}
const toolsDisallowProbe = await capture({ disallowedTools: [DISALLOW_PROBE] });
const stable = (runs: string[][]): string[] => runs[0].filter((t) => runs.every((r) => r.includes(t))).sort();
const union = (runs: string[][]): Set<string> => new Set(runs.flat());
const tools = stable(withRuns);
const toolsBare = stable(bareRuns);
const stableSet = new Set([...tools, ...toolsBare]);
const variantTools = [...union([...withRuns, ...bareRuns])].filter((t) => !stableSet.has(t)).sort();
const cliVersionRaw = execFileSync('/pnpm/claude', ['--version'], { encoding: 'utf8' }).trim();
const cliVersion = cliVersionRaw.split(/\s+/)[0];
const sdkVersion = (
JSON.parse(fs.readFileSync('/app/node_modules/@anthropic-ai/claude-agent-sdk/package.json', 'utf8')) as {
version: string;
}
).version;
console.log(
JSON.stringify(
{
cliVersion,
sdkVersion,
capturedAt: new Date().toISOString(),
capture: `wire names; stable cores over ${ROUNDS} interleaved rounds per mode (tools=production allowlist, toolsBare=no allowedTools); variantTools=flicker set; toolsDisallowProbe=single capture with disallowedTools:[probe]; teams on`,
rounds: ROUNDS,
disallowProbe: DISALLOW_PROBE,
tools,
toolsBare,
variantTools,
toolsDisallowProbe,
},
null,
2,
),
);
process.exit(0);
@@ -1,96 +0,0 @@
{
"cliVersion": "2.1.197",
"sdkVersion": "0.3.197",
"capturedAt": "2026-07-12T11:43:58.342Z",
"capture": "wire names; stable cores over 3 interleaved rounds per mode (tools=production allowlist, toolsBare=no allowedTools); variantTools=flicker set; toolsDisallowProbe=single capture with disallowedTools:[probe]; teams on",
"rounds": 3,
"disallowProbe": "Workflow",
"tools": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
],
"toolsBare": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
],
"variantTools": [],
"toolsDisallowProbe": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
]
}
@@ -79,12 +79,6 @@ export interface ProviderOptions {
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
/**
* RESOLVED harness-capability map from container.json (key 'on'|'off',
* resolved host-side against code defaults). Providers map keys to their
* own mechanisms and may ignore the map entirely; absent means all-off.
*/
harnessCapabilities?: Record<string, string>;
}
export interface QueryInput {
+7 -10
View File
@@ -148,14 +148,11 @@ class ClaudeProvider implements AgentProvider {
systemPrompt: input.systemContext?.instructions
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
: undefined,
// Permission auto-approve list (NOT an availability filter — wire
// captures show no allowlist effect on the offered tool surface, and
// bypassPermissions moots its permission role). Kept accurate for a
// hypothetical non-bypass mode; the `mcp__<server>__*` patterns are
// retained because MCP invocation-gating under non-bypass modes is
// unverified. See sdk-tools-baseline.json + claude.tools.test.ts.
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
// server — without the explicit MCP patterns the SDK's allowedTools
// filter silently drops every MCP namespace.
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
disallowedTools: this.disallowedTools, // fixed set + capability-driven entries (buildDisallowedTools)
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
effort: this.effort,
@@ -164,7 +161,7 @@ class ClaudeProvider implements AgentProvider {
settingSources: ['project', 'user', 'local'],
mcpServers: this.mcpServers,
hooks: {
PreToolUse: [{ hooks: [this.preToolUseHook] }], // per-instance blocklist from the group's resolved capabilities
PreToolUse: [{ hooks: [preToolUseHook] }],
PostToolUse: [{ hooks: [postToolUseHook] }],
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
@@ -198,8 +195,8 @@ SDK message (so the idle timer stays honest) and maps recognized messages to `Pr
**Claude-specific behavior inside the provider:**
- `MessageStream` for async iterable input (push-based follow-ups)
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
- `TOOL_ALLOWLIST` (Agent, Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Skill, …) — a permission auto-approve list, not an availability filter (moot under `bypassPermissions`; wire-verified, see `claude.tools.test.ts`) — extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; the effective disallow list is `buildDisallowedTools(caps)`: the fixed `SDK_DISALLOWED_TOOLS` (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree, DesignSync, ReportFindings) plus capability-driven entries — `Workflow` unless the group's resolved `workflow` capability is `on` (see [harness-capabilities.md](harness-capabilities.md)). Schema-stripping via `disallowedTools` is best-effort on the pinned CLI; the PreToolUse hook is the deterministic block
- **PreToolUse hook** (built per provider instance by `createPreToolUseHook` from the same effective disallow list) records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, deterministically blocks any disallowed-tool call that slips through, feeding the model a redirect to the nanoclaw equivalent. It does **not** sanitize bash env vars — there is no such hook.
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
- **PreCompact** hook archives the transcript to `conversations/` before compaction
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
-4
View File
@@ -320,14 +320,12 @@ CREATE TABLE container_configs (
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
harness_capabilities TEXT NOT NULL DEFAULT '{}', -- sparse overrides: {"agent-teams"|"workflow": "on"|"off"}
updated_at TEXT NOT NULL
);
```
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
- `harness_capabilities` stores per-group overrides only; code defaults + resolution live in `src/harness-capabilities.ts` (see [harness-capabilities.md](harness-capabilities.md))
### 1.16 `pending_sender_approvals`
@@ -427,8 +425,6 @@ Several early migrations were later renamed/retired and replaced by "module" fil
| 16 | `messaging-group-instance` | `016-messaging-group-instance.ts` | `messaging_groups` gets an `instance` column (adapter-instance dimension); table recreate (`disableForeignKeys: true`) backfills `instance = channel_type` on every existing row and relaxes the `UNIQUE` to `(channel_type, platform_id, instance)` |
| 17 | `agent-message-policies` | `017-agent-message-policies.ts` | `agent_message_policies` (see §1.18) |
| 18 | `approvals-approver-user-id` | `018-approvals-approver-user-id.ts` | `pending_approvals.approver_user_id` — names a single required approver for a2a message-gate policies |
| 19 | `wiring-threads-override` | `019-wiring-threads.ts` | `ALTER TABLE messaging_group_agents ADD COLUMN threads` — per-wiring thread-policy override (NULL = inherit the adapter declaration) |
| 20 | `harness-capabilities` | `020-harness-capabilities.ts` | `ALTER TABLE container_configs ADD COLUMN harness_capabilities` — per-group harness toggles (see [harness-capabilities.md](harness-capabilities.md)); grandfathers existing rows to `{"agent-teams":"on","workflow":"on"}` |
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
-48
View File
@@ -1,48 +0,0 @@
# Harness capabilities
NanoClaw disables harness-native features that overlap its own systems, and exposes a small per-group toggle surface for the ones where both states are meaningful. Policy (keys, defaults, resolution) lives host-side in [`src/harness-capabilities.ts`](../src/harness-capabilities.ts); per-group overrides live in the `harness_capabilities` column of `container_configs`; mechanisms live in the agent runner and the settings reconciler ([`src/group-init.ts`](../src/group-init.ts)).
## Capability table
| Capability | Key | Default | Mechanism |
|---|---|---|---|
| Agent teams (experimental multi-agent coordination inside one session) | `agent-teams` | **off** | Settings reconciler adds/removes `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` in the group's `settings.json` on every spawn. On the pinned CLI, settings env strictly beats SDK options env, so the settings file is the only working switch. |
| Workflow tool (in-session multi-agent orchestration scripts) | `workflow` | **off** | Reconciler sets `disableWorkflows: true` (removes the tool and its agent-types catalog — ~26KB/turn); the runner also adds `Workflow` to `disallowedTools` + PreToolUse hook as a backstop. |
| Cron/scheduling (`CronCreate/CronDelete/CronList`, `ScheduleWakeup`) | — | fixed off | `disallowedTools` + hook. NanoClaw's `ncl tasks` is the authoritative scheduler. |
| `AskUserQuestion` | — | fixed off | `disallowedTools` (returns a placeholder headless; `ask_user_question` is the real mechanism). |
| Plan/worktree modes | — | fixed off | `disallowedTools` (broken headless). |
| `DesignSync` | — | fixed off | `disallowedTools` (desktop design-tool integration; nothing to sync with in a container; ~9.3KB/turn schema). |
| `ReportFindings` | — | fixed off | `disallowedTools` + hook (code-review-reporting UI affordance with no headless surface to receive it; ~1.9KB/turn schema). |
| Task list (`TaskCreate/…`), subagents (`Agent`), web (`WebSearch/WebFetch`) | — | fixed on | No NanoClaw overlap. Harness task lists are per-session scratch — not NanoClaw scheduled tasks. |
Toggling:
```bash
ncl groups config get --id <group-id> # shows raw overrides + resolved view
ncl groups config update --id <group-id> --harness-capabilities 'agent-teams=on'
ncl groups config update --id <group-id> --harness-capabilities 'workflow=on,agent-teams=default'
ncl groups restart --id <group-id> # apply
```
`default` clears the per-group override (it is never stored). For group-scoped containers (`cli_scope: group`, the default) harness-capability changes through `ncl` are rejected outright — like `cli_scope`, the sanctioned/persistent path is operator-only. A `cli_scope: global` container (owner agents set up via `/init-first-agent`) is unrestricted by design and goes through the normal CLI flow — treat approvals from such agents accordingly.
### Enforcement strength (be precise about the boundary)
- **`workflow` off** layers three mechanisms: the reconciled `disableWorkflows` settings key (primary — removes the tool and its agent-types catalog), the runner's PreToolUse hook (deterministic — blocks any Workflow invocation regardless of what shipped in context), and a runner-side `disallowedTools` entry. Schema-stripping via `disallowedTools` is **best-effort on the pinned CLI**: wire measurement shows it strips flag-gated tools on some query invocations and not others (see the [`dump-sdk-tools.ts`](../container/agent-runner/src/providers/dump-sdk-tools.ts) header), which is why the hook — not the strip — is the functional guarantee. The same applies to the fixed-off `DesignSync`/`ReportFindings` schemas: usually stripped, always invocation-blocked.
- **`agent-teams` off** has only one mechanism: the absence of the env key from the group's `settings.json`. That file is mounted **read-write** into the container (the CLI needs to write transcripts there), and `settingSources` also loads project/local settings from the agent-writable workspace — and workspace files **persist across respawns**. An agent that writes the teams key into its own user-scope settings is corrected at the next spawn; one that writes it into a workspace project/local settings file re-enables teams **until an operator removes that file**, because the reconciler manages only the user-scope file. Treat `agent-teams=off` as **configuration hygiene**, not a hard adversarial boundary — the real trust boundary remains the container sandbox + OneCLI. A planned follow-up will mount the managed settings source read-only and constrain `settingSources` to close this.
## Upgrade behavior — non-breaking (grandfathered)
Before this feature every group ran with agent teams on and Workflow available. Migration 020 **grandfathers every existing group** to that prior state — it stamps `{"agent-teams":"on","workflow":"on"}` onto each row that exists at upgrade time, and the startup backfill stamps the same state onto legacy groups whose config row is only created at boot — so **upgrading changes nothing for your current agents**. Only newly-created groups (and every group on a fresh install) get the lean defaults via the column default `{}`. The runner applies the same rule to a `container.json` missing the capability field (written by a pre-upgrade host mid-update): legacy all-on, so nothing flips off before the host restarts.
- **Verify after upgrade**: `ncl groups config get --id <g>` shows `harness_capabilities_resolved` with `{"state": "on", "source": "override"}` for both keys on pre-existing groups; their `settings.json` keeps the teams env key and has no `disableWorkflows`.
- **If you had hand-edited a group's `settings.json`** (e.g. deleted the teams env key yourself — the only pre-upgrade off-switch): the grandfather stamps the pre-feature *defaults*, and the reconciler now owns the managed keys, so the first post-upgrade spawn will re-add the teams key. Re-apply your intent the supported way: `ncl groups config update --id <g> --harness-capabilities 'agent-teams=off'`. Unmanaged keys in the file are never touched.
- **Opt an existing group into the lean defaults** (to get the ~20%/turn saving): `ncl groups config update --id <g> --harness-capabilities 'agent-teams=off,workflow=off'` then `ncl groups restart --id <g>`.
- **Re-enable on a new group**: `ncl groups config update --id <g> --harness-capabilities 'agent-teams=on,workflow=on'` then restart. The `ncl` command is the rollback — per group, no code changes.
Why the defaults are off for new groups: agent teams overlaps NanoClaw's a2a (`create_agent` + destinations), is experimental upstream, and multiplies separately-billed agents invisibly to ops; Workflow is redundant with NanoClaw's orchestration and is the single largest tool schema on every turn.
## Notes for forks
- If your fork patched `SDK_DISALLOWED_TOOLS` in `container/agent-runner/src/providers/claude.ts`: the fixed list still lives there, but per-group state now composes through `buildDisallowedTools()` — re-apply your patch to the fixed list, or express it as capability keys if it fits.
- The measured numbers above are for `@anthropic-ai/claude-code` 2.1.197 / SDK 0.3.197. When bumping the pin, `claude.tools.test.ts` fails until you regenerate the tool-surface fixture: run [`dump-sdk-tools.ts`](../container/agent-runner/src/providers/dump-sdk-tools.ts) inside the agent image (invocation in its header) and re-verify the allow/disallow lists against the new surface.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.46",
"version": "2.1.47",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="232k tokens, 116% of context window">
<title>232k tokens, 116% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="240k tokens, 120% of context window">
<title>240k tokens, 120% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
<text x="71" y="14">232k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">240k</text>
<text x="71" y="14">240k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

-5
View File
@@ -9,7 +9,6 @@ import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js';
import { ensureContainerConfig } from '../src/db/container-configs.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
@@ -36,10 +35,6 @@ if (!getAgentGroup(AGENT_GROUP_ID)) {
} else {
console.log('Agent group already exists:', AGENT_GROUP_ID);
}
// Provision the config row at creation — a group left without one can't
// spawn until the next host boot, and the startup backfill would then
// mis-grandfather this genuinely new group to the pre-capability defaults.
ensureContainerConfig(AGENT_GROUP_ID);
// Messaging group
if (!getMessagingGroup(MESSAGING_GROUP_ID)) {
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
brightSelect: vi.fn(),
note: vi.fn(),
userInput: vi.fn(),
}));
vi.mock('../lib/bright-select.js', () => ({
brightSelect: mocks.brightSelect,
}));
vi.mock('../lib/theme.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/theme.js')>()),
note: mocks.note,
}));
vi.mock('../logs.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../logs.js')>()),
userInput: mocks.userInput,
}));
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
import { runWhatsAppChannel } from './whatsapp.js';
describe('WhatsApp shared-number risk gate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining('temporarily suspend or permanently ban that number'),
'Risk to your WhatsApp account',
);
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
});
it('does not show the warning for a dedicated number', async () => {
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).not.toHaveBeenCalled();
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
});
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).toHaveBeenCalledOnce();
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
});
});
+29 -13
View File
@@ -6,8 +6,8 @@
*
* 1. Ask whether the agent gets a dedicated number or shares the
* operator's personal one. Personal interception screen that spells
* out self-chat-only mode and steers toward alternatives (default is
* back to channel selection)
* out the account-ban risk and self-chat-only mode (default is switching
* to a dedicated number)
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 3. If pairing-code: collect the phone number
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
@@ -68,7 +68,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
let mode: 'dedicated' | 'shared' = ownership;
if (mode === 'shared') {
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
if (proceed === 'dedicated') mode = 'dedicated';
}
const method = await askAuthMethod();
@@ -121,7 +121,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
// Chatting from the bot's own number IS the shared-number setup —
// route through the same interception screen as the up-front pick.
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
mode = 'shared';
}
}
@@ -221,13 +221,19 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
}
/**
* Interception screen for the shared-number path: make the self-chat-only
* tradeoff explicit and steer toward alternatives before any install or
* auth happens. Default is bailing back to channel selection.
* Interception screen for the shared-number path: make the account-ban risk
* and self-chat-only tradeoff explicit before any install or auth happens.
* Default is switching to a dedicated number.
*/
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
note(
[
'Connecting your shared or personal number could cause WhatsApp to',
'temporarily suspend or permanently ban that number. You could lose access',
'to the WhatsApp account, chats, and groups you rely on.',
'',
'We strongly recommend using a separate, dedicated number for NanoClaw.',
'',
'On your personal number, the agent lives only in your "You" / self-chat.',
'Messages other people send you are ignored entirely — never read, never',
'answered, never flagged for approval. Nobody else can talk to the agent.',
@@ -238,19 +244,29 @@ async function confirmSharedNumber(): Promise<'continue' | 'back'> {
`${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
`${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
].join('\n'),
'Personal number = self-chat only',
'Risk to your WhatsApp account',
);
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to proceed?',
options: [
{ value: 'back', label: '← Pick a different channel' },
{ value: 'continue', label: 'Continue — self-chat only' },
{
value: 'dedicated',
label: 'Go back and use a dedicated number',
hint: 'recommended',
},
{
value: 'continue',
label: 'I understand the risk — continue with my shared number',
},
],
initialValue: 'back',
initialValue: 'dedicated',
}),
) as 'continue' | 'back';
) as 'continue' | 'dedicated';
setupLog.userInput('whatsapp_shared_confirm', choice);
if (choice === 'continue') {
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
}
return choice;
}
-7
View File
@@ -17,7 +17,6 @@ import {
validateEngageAgainstChannel,
} from '../src/channels/channel-defaults.js';
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
import { backfillContainerConfigs } from '../src/backfill-container-configs.js';
import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
@@ -185,12 +184,6 @@ export async function run(args: string[]): Promise<void> {
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
runMigrations(db);
// Any process that runs migrations must backfill before creating config
// rows: a legacy group without a container_configs row gets its grandfather
// stamp from the backfill, and the ensureContainerConfig below would
// otherwise claim the row first with the lean defaults (host boot does the
// same migrations → backfill sequence in src/index.ts).
backfillContainerConfigs();
// 1. Create or find agent group. Provider-agnostic: provider is a DB
// property set via `ncl groups config update --provider`, not a creation
-6
View File
@@ -65,12 +65,6 @@ export function backfillContainerConfigs(): void {
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
cli_scope: 'group',
// Grandfather, same rule as migration 020: a group reaching this backfill
// has no config row, i.e. it pre-dates the capability feature and ran
// with teams + Workflow on. Migration 020's UPDATE can't cover it (the
// row doesn't exist yet when migrations run), so stamp the legacy state
// here — '{}' would silently flip such a group to the lean defaults.
harness_capabilities: '{"agent-teams":"on","workflow":"on"}',
updated_at: new Date().toISOString(),
};
+1 -1
View File
@@ -222,7 +222,7 @@ function syncSymlink(linkPath: string, target: string): void {
fs.symlinkSync(target, linkPath);
}
export function writeAtomic(filePath: string, content: string): void {
function writeAtomic(filePath: string, content: string): void {
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
+6
View File
@@ -402,6 +402,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,
@@ -414,6 +415,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,
@@ -426,6 +428,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,
@@ -437,6 +440,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,
@@ -448,6 +452,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,
@@ -464,6 +469,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,
+42 -37
View File
@@ -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<string, unknown>) ?? {};
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<string, unknown>) ?? {};
if (!requestId || !command) {
log.warn('cli_request missing requestId or command', { sessionId: session.id });
return;
}
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 ?? '',
};
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 });
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
const response = await dispatch(req, ctx);
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,
});
// 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 });
});
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
},
unguarded('transport envelope — every inner command is guarded at dispatch'),
);
+82 -29
View File
@@ -9,6 +9,7 @@ const approvalState = vi.hoisted(() => ({
| ((args: {
session: unknown;
payload: Record<string, unknown>;
approval: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>),
@@ -18,6 +19,7 @@ const approvalState = vi.hoisted(() => ({
handler: (args: {
session: unknown;
payload: Record<string, unknown>;
approval: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>,
@@ -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.
@@ -316,35 +321,6 @@ describe('CLI scope enforcement', () => {
}
});
it('group: blocks harness_capabilities escalation', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'groups-test', args: { harness_capabilities: 'agent-teams=on' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('harness_capabilities');
}
});
it('group: blocks harness-capabilities escalation (hyphenated)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'groups-test', args: { 'harness-capabilities': 'workflow=on' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: blocks non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
@@ -524,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(),
});
@@ -536,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<string, unknown> };
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 () => {
+41 -50
View File
@@ -3,24 +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 checks 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 { localizeIsoTimestamps } from './format.js';
import { getResource } from './crud.js';
import { listVerbs, renderVerbHelp } from './help-render.js';
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
import { commandGuard, 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,
@@ -55,51 +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 decision, 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.');
}
// Operator-only config fields — block changes from group-scoped agents
// (privilege escalation: cli_scope widens CLI access, harness
// capabilities re-enable harness features the operator turned off). This
// gate runs on RAW args before normalizeArgs, so we normalize each
// incoming key to canonical snake_case and compare — one spelling per
// protected field, no spelling-variant bypass. Add future fields HERE.
const OPERATOR_ONLY_ARGS = new Set(['cli_scope', 'harness_capabilities']);
for (const key of Object.keys(req.args)) {
if (OPERATOR_ONLY_ARGS.has(key.replace(/-/g, '_'))) {
return err(req.id, 'forbidden', `Cannot change ${key.replace(/-/g, '_')} 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<string, unknown> = {
@@ -125,9 +101,19 @@ export async function dispatch(
}
}
const decision = guard(commandGuard(cmd.name), {
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
@@ -136,7 +122,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.');
@@ -224,10 +215,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 localized = localizeIsoTimestamps(response.data);
+86
View File
@@ -0,0 +1,86 @@
/**
* CLI guard adapter the command registry's catalog derivation and
* 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.
*
* 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
* 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<CommandDef, 'name' | 'action'>): 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),
grantActionName: cmd.access === 'approval' ? 'cli_command' : undefined,
// Bind a cli_command grant to the exact command it was approved for.
grantCoversRequest: (grant) => {
try {
const payload = JSON.parse(grant.payload) as { frame?: { command?: string } };
return payload.frame?.command === cmd.name;
} catch {
return false;
}
},
decide: (input) => commandDecide(cmd, input),
};
}
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.');
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');
}
+23
View File
@@ -8,6 +8,8 @@
* registers the help commands, so the registry is populated before the host's
* CLI server accepts connections.
*/
import { defineGuardedAction, type GuardedAction } from '../guard/index.js';
import { commandGuardSpec } from './guard.js';
import type { CallerContext } from './frame.js';
/**
@@ -23,6 +25,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
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.<name>`.
*/
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
@@ -52,12 +61,26 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
};
const registry = new Map<string, CommandDef>();
const commandGuards = new Map<string, GuardedAction>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
if (registry.has(def.name)) {
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, 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 {
-135
View File
@@ -1,135 +0,0 @@
/**
* ncl round-trip for the harness-capabilities config surface: set an
* override, clear it with `default`, reject unknown keys/values, and render
* both the raw overrides and the resolved (default)/(override) view in
* `config get`. Host caller the same code path an approved update takes.
*/
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-groups-config' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-groups-config';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { ensureContainerConfig, getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `groups-*` commands.
import './groups.js';
const GID = 'ag-caps';
const hostCtx = { caller: 'host' as const };
async function configUpdate(caps: string) {
return dispatch(
{ id: 't', command: 'groups-config-update', args: { id: GID, 'harness-capabilities': caps } },
hostCtx,
);
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
runMigrations(initTestDb());
createAgentGroup({
id: GID,
name: 'caps',
folder: 'caps',
agent_provider: null,
created_at: new Date().toISOString(),
});
ensureContainerConfig(GID);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('groups config update --harness-capabilities', () => {
type Resolved = Record<string, { state: string; source: string }>;
it('sets an override and marks it as an override in the resolved view', async () => {
const resp = await configUpdate('agent-teams=on');
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as {
harness_capabilities: Record<string, string>;
harness_capabilities_resolved: Resolved;
};
expect(data.harness_capabilities).toEqual({ 'agent-teams': 'on' });
expect(data.harness_capabilities_resolved['agent-teams']).toEqual({ state: 'on', source: 'override' });
expect(data.harness_capabilities_resolved.workflow).toEqual({ state: 'off', source: 'default' });
}
expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({ 'agent-teams': 'on' });
});
it('`default` clears the override and the resolved view returns to default', async () => {
await configUpdate('agent-teams=on');
const resp = await configUpdate('agent-teams=default');
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { harness_capabilities_resolved: Resolved };
expect(data.harness_capabilities_resolved['agent-teams']).toEqual({ state: 'off', source: 'default' });
}
expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({});
});
it('rejects unknown keys and bad values with usable messages', async () => {
const unknown = await configUpdate('web=off');
expect(unknown.ok).toBe(false);
if (!unknown.ok) expect(unknown.error.message).toContain('unknown harness capability');
const badValue = await configUpdate('workflow=sideways');
expect(badValue.ok).toBe(false);
if (!badValue.ok) expect(badValue.error.message).toContain('on, off, or default');
});
it('a harness-only update passes the nothing-to-update guard', async () => {
const resp = await configUpdate('workflow=on');
expect(resp.ok).toBe(true);
});
it('config get shows raw overrides plus the resolved view', async () => {
await configUpdate('workflow=on');
const resp = await dispatch({ id: 't', command: 'groups-config-get', args: { id: GID } }, hostCtx);
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as {
harness_capabilities: Record<string, string>;
harness_capabilities_resolved: Resolved;
};
expect(data.harness_capabilities).toEqual({ workflow: 'on' });
expect(data.harness_capabilities_resolved).toEqual({
'agent-teams': { state: 'off', source: 'default' },
workflow: { state: 'on', source: 'override' },
});
}
});
it('a stored invalid value reports source=default, not a lying override', async () => {
// Direct DB write bypassing validation (version skew / manual edit).
updateContainerConfigJson(GID, 'harness_capabilities', { workflow: 'sideways' });
const resp = await dispatch({ id: 't', command: 'groups-config-get', args: { id: GID } }, hostCtx);
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { harness_capabilities_resolved: Resolved };
expect(data.harness_capabilities_resolved.workflow).toEqual({ state: 'off', source: 'default' });
}
});
});
+5 -43
View File
@@ -14,19 +14,11 @@ import {
} from '../../db/container-configs.js';
import { initGroupFilesystem } from '../../group-init.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import {
HARNESS_CAPABILITY_KEYS,
parseHarnessCapabilitiesArg,
parseHarnessOverrides,
resolveHarnessCapabilities,
} from '../../harness-capabilities.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
const overrides = parseHarnessOverrides(row.harness_capabilities);
const resolved = resolveHarnessCapabilities(overrides);
return {
agent_group_id: row.agent_group_id,
provider: row.provider,
@@ -41,17 +33,6 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
packages_npm: JSON.parse(row.packages_npm),
additional_mounts: JSON.parse(row.additional_mounts),
cli_scope: row.cli_scope,
harness_capabilities: overrides,
// Structured {state, source} per key — machine-readable and honest: source
// is 'override' only when a VALID override is stored (an invalid stored
// value resolves to the default, so it reports 'default', not a lie).
harness_capabilities_resolved: Object.fromEntries(
Object.entries(resolved).map(([k, state]) => {
const ov = (overrides as Record<string, unknown>)[k];
const source = ov === 'on' || ov === 'off' ? 'override' : 'default';
return [k, { state, source }];
}),
),
updated_at: row.updated_at,
};
}
@@ -271,9 +252,8 @@ registerResource({
'config update': {
access: 'approval',
description:
'Update container config fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, ' +
`--harness-capabilities 'key=on|off|default[,key=...]' (keys: ${HARNESS_CAPABILITY_KEYS.join(', ')}; \`default\` clears the per-group override).`,
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
@@ -301,31 +281,13 @@ registerResource({
updates.cli_scope = scope;
}
// Harness capabilities are a sparse-override JSON column. parse returns
// a key→directive map (last-wins per key); apply each: `default` clears
// the override, on/off set it.
let nextOverrides: Record<string, unknown> | undefined;
if (args.harness_capabilities !== undefined) {
const directives = parseHarnessCapabilitiesArg(String(args.harness_capabilities));
nextOverrides = parseHarnessOverrides(row.harness_capabilities);
for (const [key, directive] of Object.entries(directives)) {
if (directive === 'default') delete nextOverrides[key];
else nextOverrides[key] = directive;
}
}
if (Object.keys(updates).length === 0 && nextOverrides === undefined) {
if (Object.keys(updates).length === 0) {
throw new Error(
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, --harness-capabilities',
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
);
}
// Single transaction: a failure in either write (e.g. a bad scalar
// value) rolls back both, so the command can never half-apply.
getDb().transaction(() => {
if (nextOverrides !== undefined) updateContainerConfigJson(id, 'harness_capabilities', nextOverrides);
if (Object.keys(updates).length > 0) updateContainerConfigScalars(id, updates);
})();
updateContainerConfigScalars(id, updates);
const updated = getContainerConfig(id)!;
return presentConfig(updated);
-49
View File
@@ -1,49 +0,0 @@
/**
* Materialization end-to-end: a stored harness_capabilities override reaches
* groups/<folder>/container.json as the RESOLVED map (defaults overrides),
* which is the only shape the runner ever sees.
*/
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-container-config-test';
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-container-config-test/data',
GROUPS_DIR: '/tmp/nanoclaw-container-config-test/groups',
}));
import { materializeContainerJson } from './container-config.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig, updateContainerConfigJson } from './db/container-configs.js';
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('materializeContainerJson harness capabilities', () => {
it('writes the resolved map — defaults for a fresh row, override applied when stored', () => {
const ag = { id: 'ag-mat', name: 'mat', folder: 'mat', agent_provider: null, created_at: new Date().toISOString() };
createAgentGroup(ag);
ensureContainerConfig(ag.id);
let config = materializeContainerJson(ag.id);
expect(config.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'off' });
updateContainerConfigJson(ag.id, 'harness_capabilities', { workflow: 'on' });
config = materializeContainerJson(ag.id);
expect(config.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'on' });
const onDisk = JSON.parse(fs.readFileSync(path.join(TEST_ROOT, 'groups', 'mat', 'container.json'), 'utf-8'));
expect(onDisk.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'on' });
});
});
-5
View File
@@ -14,8 +14,6 @@ import path from 'path';
import { GROUPS_DIR } from './config.js';
import { getContainerConfig } from './db/container-configs.js';
import { getAgentGroup } from './db/agent-groups.js';
import { resolveHarnessCapabilities } from './harness-capabilities.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup, ContainerConfigRow } from './types.js';
export interface McpServerConfig {
@@ -45,8 +43,6 @@ export interface ContainerConfig {
maxMessagesPerPrompt?: number;
model?: string;
effort?: string;
/** RESOLVED harness-capability map (code defaults ⊕ per-group overrides) — never the raw overrides. */
harnessCapabilities: Record<string, HarnessCapabilityState>;
}
/** Build a `ContainerConfig` from a DB row + agent group identity. */
@@ -67,7 +63,6 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
harnessCapabilities: resolveHarnessCapabilities(row.harness_capabilities),
};
}
-15
View File
@@ -47,21 +47,6 @@ describe('buildContainerArgs ordering invariant (structural)', () => {
});
});
describe('harness-capability spawn seam (structural)', () => {
// The spawn site is the ONLY place resolved capabilities reach the settings
// reconciler — initGroupFilesystem's opt is optional, so dropping it in a
// refactor breaks no type and no other test, while every group's
// settings.json silently stops converging (agent-teams has no runner-side
// backstop). spawnContainer needs a live container runtime to drive, so
// guard the seam structurally.
it('spawnContainer passes containerConfig.harnessCapabilities to initGroupFilesystem', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
expect(src).toMatch(
/initGroupFilesystem\(agentGroup, \{[\s\S]*?harnessCapabilities: containerConfig\.harnessCapabilities,[\s\S]*?\}\)/,
);
});
});
describe('per-container resource limits (structural)', () => {
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
// --cpus / --memory, but only when set. The default is empty string → no flag →
+4 -9
View File
@@ -132,16 +132,11 @@ async function spawnContainer(session: Session): Promise<void> {
const containerConfig = materializeContainerJson(agentGroup.id);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist (plus the
// harness-capability reconcile, which converges settings.json to the
// resolved state), so this call is safe for groups that have spawned
// before. Runs before the provider contribution so a surfaces-providing
// provider finds the group dir ready.
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, {
provider: providerName,
harnessCapabilities: containerConfig.harnessCapabilities,
});
initGroupFilesystem(agentGroup, { provider: providerName });
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
+4 -11
View File
@@ -10,14 +10,7 @@ const SCALAR_COLUMNS = new Set([
'max_messages_per_prompt',
'cli_scope',
]);
const JSON_COLUMNS = new Set([
'skills',
'mcp_servers',
'packages_apt',
'packages_npm',
'additional_mounts',
'harness_capabilities',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
@@ -36,11 +29,11 @@ export function createContainerConfig(config: ContainerConfigRow): void {
`INSERT INTO container_configs (
agent_group_id, provider, model, effort, image_tag, assistant_name,
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
additional_mounts, cli_scope, harness_capabilities, updated_at
additional_mounts, updated_at
) VALUES (
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
@additional_mounts, @cli_scope, @harness_capabilities, @updated_at
@additional_mounts, @updated_at
)`,
)
.run(config);
@@ -89,7 +82,7 @@ export function updateContainerConfigScalars(
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
export function updateContainerConfigJson(
agentGroupId: string,
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts' | 'harness_capabilities',
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
value: unknown,
): void {
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
@@ -1,87 +0,0 @@
/**
* Migration 020 is upgrade-safe: it grandfathers EXISTING groups to their
* pre-feature behavior (agent-teams + Workflow on) so an upgrade changes
* nothing, while groups created after it get the lean column default.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { backfillContainerConfigs } from '../../backfill-container-configs.js';
import { createAgentGroup } from '../agent-groups.js';
import { closeDb, getDb, initTestDb } from '../connection.js';
import { getContainerConfig } from '../container-configs.js';
import { migrations, runMigrations } from './index.js';
const now = () => new Date().toISOString();
function group(id: string): void {
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
}
describe('migration 020 (harness-capabilities) upgrade safety', () => {
beforeEach(() => {
const db = initTestDb();
// Run everything EXCEPT 020 to reach the pre-upgrade schema (no column yet).
runMigrations(
db,
migrations.filter((m) => m.name !== 'harness-capabilities'),
);
});
afterEach(() => closeDb());
it('grandfathers a pre-existing group to teams+workflow on; leaves later rows lean', () => {
const db = getDb();
// A group that existed before the upgrade (raw insert — column absent yet).
group('ag-old');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-old', now());
// The upgrade: run migration 020.
runMigrations(
db,
migrations.filter((m) => m.name === 'harness-capabilities'),
);
// Existing group keeps its prior behavior — non-breaking.
expect(JSON.parse(getContainerConfig('ag-old')!.harness_capabilities)).toEqual({
'agent-teams': 'on',
workflow: 'on',
});
// A group created AFTER the migration gets the lean column default.
group('ag-new');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-new', now());
expect(JSON.parse(getContainerConfig('ag-new')!.harness_capabilities)).toEqual({});
});
it('grandfathers a legacy group whose config row is created by the post-migration backfill', () => {
const db = getDb();
// Startup sequence on a pre-container-configs-era install: the group
// exists but has NO container_configs row when migrations run, so 020's
// grandfather UPDATE has nothing to touch. The row is created moments
// later by backfillContainerConfigs() (index.ts boot order: migrations →
// backfill) — that path must apply the same grandfather rule.
group('ag-backfill');
runMigrations(
db,
migrations.filter((m) => m.name === 'harness-capabilities'),
);
backfillContainerConfigs();
expect(JSON.parse(getContainerConfig('ag-backfill')!.harness_capabilities)).toEqual({
'agent-teams': 'on',
workflow: 'on',
});
});
it('is a no-op on a fresh install (no existing rows) — new groups start lean', () => {
const db = getDb();
runMigrations(
db,
migrations.filter((m) => m.name === 'harness-capabilities'),
);
group('ag-fresh');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-fresh', now());
expect(JSON.parse(getContainerConfig('ag-fresh')!.harness_capabilities)).toEqual({});
});
});
@@ -1,22 +0,0 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration020: Migration = {
version: 20,
name: 'harness-capabilities',
up(db: Database.Database) {
db.prepare("ALTER TABLE container_configs ADD COLUMN harness_capabilities TEXT NOT NULL DEFAULT '{}'").run();
// Upgrade safety (grandfather). Before this feature every group ran with
// agent-teams on (the old default settings.json) and Workflow available.
// Stamp that onto EXISTING rows so an upgrade changes nothing for current
// groups — this makes the feature non-breaking. Only rows inserted AFTER
// this migration (new groups, and every group on a fresh install, which
// has no rows here yet, so this UPDATE is a no-op there) get the lean
// defaults via the column DEFAULT '{}'. Operators opt existing groups into
// the lean defaults per group with
// ncl groups config update --id <g> --harness-capabilities 'agent-teams=off,workflow=off'
db.prepare(`UPDATE container_configs SET harness_capabilities = '{"agent-teams":"on","workflow":"on"}'`).run();
},
};
-2
View File
@@ -18,7 +18,6 @@ import { moduleApprovalsPendingApprovals } from './module-approvals-pending-appr
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-wiring-threads.js';
import { migration020 } from './020-harness-capabilities.js';
export interface Migration {
version: number;
@@ -53,7 +52,6 @@ export const migrations: Migration[] = [
migration015,
migration016,
migration019,
migration020,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
+35 -4
View File
@@ -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(<reason>) 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,8 +36,34 @@ 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',
decide: () => HOLD('t'),
});
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
guardAction,
requestHold: async () => {},
});
// 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,
requestHold: async () => {},
});
expect(getDeliveryAction('test_guarded_overwrite')).toBeDefined();
});
});
+63
View File
@@ -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<string, unknown>, session: Session) => Promise<void>;
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<string, unknown>, session: Session) => boolean | Promise<boolean>;
/** Create the hold (the domain's requestApproval call — card text lives with the domain). */
requestHold: (content: Record<string, unknown>, session: Session) => Promise<void>;
/** Tell the requester about a deny. */
onDeny?: (content: Record<string, unknown>, 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<string, unknown>,
session: Session,
grant: PendingApproval | null,
): Promise<void> {
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);
}
+76 -17
View File
@@ -20,12 +20,14 @@ import {
markDeliveryFailed,
migrateDeliveredTable,
} from './db/session-db.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';
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 +395,19 @@ 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: 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 checks are
* re-run live. Plain actions (the cli_request bridge its inner
* commands are guarded at dispatch) register with an
* explicit `unguarded(<reason>)` 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<string, unknown>,
@@ -408,18 +415,70 @@ export type DeliveryActionHandler = (
inDb: Database.Database,
) => Promise<void>;
const actionHandlers = new Map<string, DeliveryActionHandler>();
type DeliveryEntry =
| { guard: Unguarded; handler: DeliveryActionHandler }
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void {
if (actionHandlers.has(action)) {
log.warn('Delivery action handler overwritten', { action });
}
actionHandlers.set(action, handler);
const deliveryActions = new Map<string, DeliveryEntry>();
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
return isUnguarded(entry.guard);
}
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
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,
guardDecl: DeliveryGuardSpec | Unguarded,
): void {
const existing = deliveryActions.get(action);
if (existing) {
// Replacing a guard-wrapped action with an unguarded handler would
// 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 (isUnguarded(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 });
}
// 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);
}
/**
* 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 checks, 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<string, unknown>; approval: PendingApproval }) => {
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.guard, entry.handler, ctx.payload, ctx.session, ctx.approval);
};
}
/**
* 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);
const entry = deliveryActions.get(action);
if (!entry) return undefined;
if (isUnguardedEntry(entry)) return entry.handler;
return (content, session) => runGuarded(action, entry.guard, entry.handler, content, session, null);
}
/**
@@ -435,7 +494,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;
+26 -143
View File
@@ -1,7 +1,6 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-group-init-settings-test';
@@ -17,35 +16,14 @@ vi.mock('./log.js', () => ({
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { initGroupFilesystem } from './group-init.js';
import { log } from './log.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup } from './types.js';
const TEAMS_ENV_KEY = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS';
const DEFAULT_CAPS: Record<string, HarnessCapabilityState> = { 'agent-teams': 'off', workflow: 'off' };
let seq = 0;
function makeGroup(): AgentGroup {
seq += 1;
const ag = {
id: `ag-settings-${seq}`,
name: `settings-${seq}`,
folder: `settings-${seq}`,
agent_provider: null,
created_at: new Date().toISOString(),
} as AgentGroup;
function makeGroup(id: string): AgentGroup {
const ag = { id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
createAgentGroup(ag);
return ag;
}
function settingsPath(ag: AgentGroup): string {
return path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
}
function readSettings(ag: AgentGroup): Record<string, unknown> & { env?: Record<string, string> } {
return JSON.parse(fs.readFileSync(settingsPath(ag), 'utf-8'));
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
@@ -57,129 +35,34 @@ afterEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('reconcileHarnessSettings via initGroupFilesystem', () => {
it('first spawn with defaults: no teams key, disableWorkflows set, unmanaged keys intact', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
describe('default settings.json for new groups', () => {
it('is lean: no agent-teams env key, unmanaged keys intact', () => {
const ag = makeGroup('ag-lean');
initGroupFilesystem(ag, {});
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBeUndefined();
expect(s.disableWorkflows).toBe(true);
expect(s.env?.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBe('1');
expect(s.env?.CLAUDE_CODE_DISABLE_AUTO_MEMORY).toBe('0');
expect(JSON.stringify(s.hooks)).toContain('compact-instructions');
const file = path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined();
expect(settings.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBe('1');
expect(JSON.stringify(settings.hooks.PreCompact)).toContain('compact-instructions');
});
it('agent-teams=on adds the env key; workflow=on removes disableWorkflows', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: { 'agent-teams': 'on', workflow: 'on' } });
it('never rewrites an existing settings.json — a hand-edited re-enable sticks', () => {
const ag = makeGroup('ag-reenable');
initGroupFilesystem(ag, {});
const file = path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBe('1');
expect('disableWorkflows' in s).toBe(false);
});
// Operator re-enables both features by editing the file (the documented path).
const edited = JSON.parse(fs.readFileSync(file, 'utf-8'));
delete edited.disableWorkflows;
edited.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
fs.writeFileSync(file, JSON.stringify(edited, null, 2) + '\n');
it('converges a legacy settings.json: strips the always-on teams key, preserves hand additions', () => {
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify(
{
env: { [TEAMS_ENV_KEY]: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', OPERATOR_CUSTOM: 'keep-me' },
hooks: { PreCompact: [{ hooks: [{ type: 'command', command: 'bun /app/src/compact-instructions.ts' }] }] },
operatorCustomTopLevel: { nested: true },
},
null,
2,
) + '\n',
);
initGroupFilesystem(ag, {}); // next spawn
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBeUndefined();
expect(s.env?.OPERATOR_CUSTOM).toBe('keep-me');
expect(s.operatorCustomTopLevel).toEqual({ nested: true });
expect(s.disableWorkflows).toBe(true);
});
it('is write-stable: same caps on a second run leave the file byte-identical and unwritten', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const file = settingsPath(ag);
const before = fs.readFileSync(file, 'utf-8');
const mtimeBefore = fs.statSync(file).mtimeMs;
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
expect(fs.readFileSync(file, 'utf-8')).toBe(before);
expect(fs.statSync(file).mtimeMs).toBe(mtimeBefore);
});
it('leaves the file alone and warns instead of throwing on malformed JSON', () => {
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{ not json');
expect(() => initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS })).not.toThrow();
expect(fs.readFileSync(file, 'utf-8')).toBe('{ not json');
expect(vi.mocked(log.warn).mock.calls.some(([msg]) => String(msg).includes('malformed'))).toBe(true);
});
it('warns instead of throwing when settings.json is unreadable (EISDIR), so the spawn proceeds', () => {
// Regression: a non-SyntaxError I/O failure (path replaced by a directory,
// EACCES, EIO) used to propagate out of initGroupFilesystem and wedge the
// group in a wakeContainer retry-fail loop. The dir is mounted read-write
// into the container, so this state is reachable without operator error.
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(file, { recursive: true }); // settings.json IS a directory
expect(() => initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS })).not.toThrow();
expect(vi.mocked(log.warn).mock.calls.some(([msg]) => String(msg).includes('reconcile failed'))).toBe(true);
});
it('never replaces a malformed unmanaged value: a non-object env survives agent-teams=on', () => {
// Regression (external review): {"env":"operator-managed-value"} used to
// become {} when the teams mechanism wrote its key, silently destroying
// the operator's value. Wrong-shaped parents are warn-and-skip; the other
// mechanism (disableWorkflows, a top-level key) still applies.
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ env: 'operator-managed-value' }, null, 2) + '\n');
initGroupFilesystem(ag, { harnessCapabilities: { 'agent-teams': 'on', workflow: 'off' } });
const after = readSettings(ag);
expect(after.env).toBe('operator-managed-value');
expect(after.disableWorkflows).toBe(true);
expect(vi.mocked(log.warn).mock.calls.some(([msg]) => String(msg).includes('not an object'))).toBe(true);
});
it('never replaces a malformed unmanaged value: a non-object hooks survives the PreCompact ensure', () => {
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ hooks: 'custom-hook-config' }, null, 2) + '\n');
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
expect(readSettings(ag).hooks).toBe('custom-hook-config');
});
it('does not touch settings.json when no capabilities are passed (non-spawn callers)', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const file = settingsPath(ag);
const withCaps = fs.readFileSync(file, 'utf-8');
// Simulate a non-spawn caller (create-agent, channel-approval): no opt.
initGroupFilesystem(ag);
expect(fs.readFileSync(file, 'utf-8')).toBe(withCaps);
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.disableWorkflows).toBeUndefined();
expect(after.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
});
});
+47 -139
View File
@@ -1,30 +1,35 @@
import fs from 'fs';
import path from 'path';
import { writeAtomic } from './claude-md-compose.js';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { HARNESS_CAPABILITIES } from './harness-capabilities.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup } from './types.js';
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
// Base settings for a brand-new group. Managed harness keys (the teams env key,
// disableWorkflows) are deliberately NOT here — they enter settings.json
// exclusively through the reconciler from the group's resolved capability state,
// applied on top of this base in the same write.
const DEFAULT_SETTINGS = {
env: {
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
hooks: {
PreCompact: [{ hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }] }],
},
};
const DEFAULT_SETTINGS_JSON =
JSON.stringify(
{
env: {
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'bun /app/src/compact-instructions.ts',
},
],
},
],
},
},
null,
2,
) + '\n';
/**
* Initialize the on-disk filesystem state for an agent group. Idempotent
@@ -43,12 +48,7 @@ const DEFAULT_SETTINGS = {
*/
export function initGroupFilesystem(
group: AgentGroup,
opts?: {
instructions?: string;
provider?: string | null;
/** RESOLVED harness-capability map — when provided (the spawn path), settings.json is reconciled to it. */
harnessCapabilities?: Record<string, HarnessCapabilityState>;
},
opts?: { instructions?: string; provider?: string | null },
): void {
const initialized: string[] = [];
@@ -118,7 +118,12 @@ export function initGroupFilesystem(
}
const settingsFile = path.join(claudeDir, 'settings.json');
applyGroupSettings(settingsFile, opts?.harnessCapabilities, initialized);
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
@@ -139,128 +144,31 @@ export function initGroupFilesystem(
}
}
type SettingsObject = Record<string, unknown>;
function asObject(value: unknown): SettingsObject | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as SettingsObject) : null;
}
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
/**
* Bring a group's settings.json to its desired state in a single read-modify-
* write: ensure the PreCompact hook is present and reconcile the managed
* harness-capability keys to `caps`. A fresh file is composed from
* DEFAULT_SETTINGS + the capability keys in one atomic write; an existing file
* is mutated in memory and rewritten only if it changed. Any content that isn't
* a JSON object (malformed, `null`, a scalar, an array) and any read/write
* I/O failure is a warn-and-skip, never a throw: settings trouble must never
* break group init or block a spawn.
* Patch an existing settings.json to add the PreCompact hook if missing.
* Runs on every group init so pre-existing groups pick up the hook.
*/
function applyGroupSettings(
settingsFile: string,
caps: Record<string, HarnessCapabilityState> | undefined,
initialized: string[],
): void {
// Nothing may escape this function: it runs on the every-spawn path, and a
// thrown EISDIR/EACCES/ENOSPC would wedge the group in a wakeContainer
// retry-fail loop (the pre-capability code swallowed all errors here). A
// group whose settings can't be read or written spawns with whatever state
// is on disk — degraded, but alive.
function ensurePreCompactHook(settingsFile: string, initialized: string[]): void {
try {
if (!fs.existsSync(settingsFile)) {
const settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as SettingsObject;
reconcileHarnessKeys(settings, caps);
writeAtomic(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json');
return;
}
const raw = fs.readFileSync(settingsFile, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
log.warn('settings.json is malformed — leaving it untouched', { settingsFile });
return;
}
const settings = asObject(parsed);
if (!settings) {
log.warn('settings.json is not a JSON object — leaving it untouched', { settingsFile });
return;
}
const settings = JSON.parse(raw);
ensurePreCompactHook(settings);
reconcileHarnessKeys(settings, caps);
// Check if there's already a PreCompact hook with our command.
const existing = settings.hooks?.PreCompact as unknown[] | undefined;
if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
const next = JSON.stringify(settings, null, 2) + '\n';
if (next === raw) return; // no churn on the every-spawn path
writeAtomic(settingsFile, next);
initialized.push('settings.json (updated)');
} catch (err) {
log.warn('settings.json reconcile failed — group spawns with the on-disk state', {
settingsFile,
err: String(err),
// Add the hook, preserving existing hooks.
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
settings.hooks.PreCompact.push({
hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }],
});
}
}
/**
* A managed parent container inside settings.json: absent created; present
* with the wrong shape null. Never replaces an existing value a malformed
* `env`/`hooks` is still operator content, and clobbering it would violate the
* "unmanaged settings are never touched" contract.
*/
function managedParent(settings: SettingsObject, key: string): SettingsObject | null {
if (!(key in settings)) {
const fresh: SettingsObject = {};
settings[key] = fresh;
return fresh;
}
const obj = asObject(settings[key]);
if (!obj) log.warn(`settings.json "${key}" is not an object — leaving it as-is, skipping managed keys under it`);
return obj;
}
/** Ensure the PreCompact archiving hook is present, preserving existing hooks. */
function ensurePreCompactHook(settings: SettingsObject): void {
const hooks = managedParent(settings, 'hooks');
if (!hooks) return;
if ('PreCompact' in hooks && !Array.isArray(hooks.PreCompact)) {
log.warn('settings.json hooks.PreCompact is not an array — leaving it as-is');
return;
}
const existing = Array.isArray(hooks.PreCompact) ? (hooks.PreCompact as unknown[]) : (hooks.PreCompact = []);
if (JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
existing.push({ hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }] });
}
/**
* Reconcile the managed harness-capability keys to `caps`, iterating the
* registry's host mechanisms adding AND removing each key so pre-existing
* groups converge (including removal of the legacy always-on teams key).
* Everything unmanaged in the file is preserved. `caps` undefined (non-spawn
* callers) leaves capability keys untouched.
*/
function reconcileHarnessKeys(
settings: SettingsObject,
caps: Record<string, HarnessCapabilityState> | undefined,
): void {
if (!caps) return;
for (const [key, def] of Object.entries(HARNESS_CAPABILITIES)) {
const state = caps[key] ?? def.default;
const m = def.host;
if (m.kind === 'env') {
// Presence of the env key IS the on-state.
if (state === 'on') {
const env = managedParent(settings, 'env');
if (env) env[m.key] = '1';
} else {
const env = asObject(settings.env);
if (env && m.key in env) delete env[m.key];
}
} else {
// disableFlag: the flag is set true when the capability is OFF.
if (state === 'off') settings[m.key] = true;
else if (m.key in settings) delete settings[m.key];
}
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json (added PreCompact hook)');
} catch {
// Don't break init if settings.json is malformed — it'll use whatever's there.
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 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 the keyed registries require a guard spec or an
* explicit unguarded(<reason>) declaration. What's left to verify is the
* cross-registry pairing the compiler can't see: every holding action has a
* 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';
// Production barrels — side-effect imports populate the real registries.
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 { commandGuard, listCommands } from '../cli/registry.js';
import { getApprovalHandler } from '../modules/approvals/primitive.js';
import { defineGuardedAction, listGuardedActions } from './guard-actions.js';
import { HOLD } from './types.js';
describe('guard conformance', () => {
it('every holding action pairs with a registered approval handler', () => {
const holding = listGuardedActions().filter((spec) => spec.grantActionName);
expect(holding.length).toBeGreaterThan(0);
const dangling = holding.filter((spec) => !getApprovalHandler(spec.grantActionName 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 wrong = mutating.filter((cmd) => commandGuard(cmd.name).grantActionName !== 'cli_command');
expect(wrong.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('defining the same action twice throws — names are the catalog key', () => {
defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') });
expect(() => defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') })).toThrow(
/already defined/,
);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The action catalog the enforcement boundary.
*
* 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 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 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';
export interface GuardedActionSpec {
/** 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
* allow. Runs on every consult, including approved replays (a grant
* satisfies a hold, never a deny).
*/
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 decisions).
*/
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
* grantActionName + live-row checks.
*/
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, decide } object
* does not typecheck at a consult site, and fails the runtime check too.
*/
export type GuardedAction = Readonly<GuardedActionSpec> & { readonly [guardedActionBrand]: true };
const defined = new Map<string, GuardedAction>();
const minted = new WeakSet<object>();
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`);
}
const def = Object.freeze({ ...spec }) as GuardedAction;
minted.add(def);
defined.set(spec.action, def);
return def;
}
/**
* 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(): GuardedAction[] {
return [...defined.values()].sort((a, b) => a.action.localeCompare(b.action));
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 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 decide.
*
* 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 { defineGuardedAction, type GuardedAction } from './guard-actions.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(extra: Partial<GuardInput> = {}): GuardInput {
return { actor: AGENT, payload: {}, ...extra };
}
beforeEach(() => {
mockGetPendingApproval.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
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('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') {
expect(d.reason).toBe('needs approval');
expect(d.approverUserId).toBeUndefined();
}
});
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('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', 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');
});
});
describe('grants', () => {
const grantRow = (action: string) =>
({ approval_id: 'appr-1', action, payload: '{}' }) as unknown as NonNullable<GuardInput['grant']>;
it('a valid live grant satisfies a hold', () => {
const action = defineGuardedAction({
action: 't.g1',
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 checks re-run live', () => {
const action = defineGuardedAction({
action: 't.g2',
grantActionName: 'g2_approved',
decide: () => DENY('revoked since'),
});
const grant = grantRow('g2_approved');
mockGetPendingApproval.mockReturnValue(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', () => {
const action = defineGuardedAction({
action: 't.g3',
grantActionName: 'g3_approved',
decide: () => HOLD('b'),
});
mockGetPendingApproval.mockReturnValue(undefined);
const d = guard(action, input({ grant: grantRow('g3_approved') }));
expect(d.effect).toBe('deny');
});
it("a grant for a different action doesn't transfer", () => {
const action = defineGuardedAction({
action: 't.g4',
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 grantCoversRequest binding can refuse a payload mismatch', () => {
const action = defineGuardedAction({
action: 't.g5',
grantActionName: 'g5_approved',
grantCoversRequest: () => false,
decide: () => HOLD('b'),
});
const grant = grantRow('g5_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('deny');
});
it('a grant on an already-allowed action is a no-op', () => {
const action = defineGuardedAction({
action: 't.g6',
grantActionName: 'g6_approved',
decide: () => ALLOW('ok'),
});
const grant = grantRow('g6_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('allow');
});
});
describe('fail-closed posture', () => {
it('a throwing decide denies', () => {
const action = defineGuardedAction({
action: 't.f1',
decide: () => {
throw new Error('boom');
},
});
expect(guard(action, input()).effect).toBe('deny');
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* guard() the one decision function every privileged action consults.
*
* 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 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,
* plus any domain binding) satisfies a hold the human already decided
* 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 decide denies.
*/
import { getPendingApproval } from '../db/sessions.js';
import { log } from '../log.js';
import { isGuardedAction, type GuardedAction } from './guard-actions.js';
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 decide fn 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 = 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 checks re-run 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 (grantSatisfies(action, input)) {
return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`);
}
return DENY('replay carried an invalid or mismatched grant');
}
function grantSatisfies(action: GuardedAction, input: GuardInput): boolean {
const grant = input.grant;
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.grantActionName) return false;
if (action.grantCoversRequest && !action.grantCoversRequest(grant, input)) return false;
return true;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Guard the privileged-action decision seam.
*
* 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.
*/
export { guard } from './guard.js';
export {
defineGuardedAction,
isGuardedAction,
listGuardedActions,
type GuardedAction,
type GuardedActionSpec,
} from './guard-actions.js';
export {
ALLOW,
DENY,
GuardDenyError,
HOLD,
isUnguarded,
unguarded,
type GuardActor,
type GuardDecision,
type GuardInput,
type Unguarded,
} from './types.js';
+88
View File
@@ -0,0 +1,88 @@
/**
* 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 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.
*/
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 {
actor: GuardActor;
/** Domain resource reference, e.g. { from, to } for a2a.send. */
resource?: Record<string, string>;
/** Action arguments — what the card summarizes and rules may later match on. */
payload: Record<string, unknown>;
/**
* Verified approval row carried by an approved replay. A valid grant
* satisfies a hold (the human already decided) but never a deny the
* structural checks re-run live on every replay.
*/
grant?: PendingApproval | null;
}
const unguardedBrand = Symbol('unguarded');
/**
* 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 };
export function unguarded(reason: string): 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 =
| { 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,
});
/**
* 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';
}
}
-79
View File
@@ -1,79 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
HARNESS_CAPABILITY_DEFAULTS,
parseHarnessCapabilitiesArg,
resolveHarnessCapabilities,
} from './harness-capabilities.js';
describe('resolveHarnessCapabilities', () => {
it('returns code defaults for empty/missing overrides', () => {
for (const input of [undefined, null, '', '{}']) {
expect(resolveHarnessCapabilities(input)).toEqual(HARNESS_CAPABILITY_DEFAULTS);
}
expect(HARNESS_CAPABILITY_DEFAULTS).toEqual({ 'agent-teams': 'off', workflow: 'off' });
});
it('applies stored overrides on top of defaults', () => {
expect(resolveHarnessCapabilities('{"workflow":"on"}')).toEqual({ 'agent-teams': 'off', workflow: 'on' });
expect(resolveHarnessCapabilities('{"agent-teams":"on","workflow":"on"}')).toEqual({
'agent-teams': 'on',
workflow: 'on',
});
});
it('degrades malformed JSON to defaults instead of throwing', () => {
expect(resolveHarnessCapabilities('{nope')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
expect(resolveHarnessCapabilities('[1,2]')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
expect(resolveHarnessCapabilities('"off"')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
});
it('drops unknown keys and garbage values back to defaults (never leaks into the resolved map)', () => {
const resolved = resolveHarnessCapabilities('{"monitor":"off","workflow":"sideways","future":{"nested":1}}');
expect(Object.hasOwn(resolved, 'monitor')).toBe(false); // unknown key dropped
expect(Object.hasOwn(resolved, 'future')).toBe(false); // unknown non-string dropped
expect(resolved.workflow).toBe('off'); // garbage value → default, not 'sideways'
expect(resolved['agent-teams']).toBe('off');
});
it('accepts an already-parsed override map', () => {
expect(resolveHarnessCapabilities({ workflow: 'on' })).toEqual({ 'agent-teams': 'off', workflow: 'on' });
});
it('does not treat inherited Object.prototype names as known keys', () => {
// `key in obj` would be true for 'constructor'/'toString'; Object.hasOwn is not.
const resolved = resolveHarnessCapabilities('{"constructor":"on","toString":"off"}');
expect(resolved).toEqual({ 'agent-teams': 'off', workflow: 'off' });
});
});
describe('parseHarnessCapabilitiesArg', () => {
it('parses on/off/default directives into a key→directive map', () => {
expect(parseHarnessCapabilitiesArg('agent-teams=on')).toEqual({ 'agent-teams': 'on' });
expect(parseHarnessCapabilitiesArg('workflow=default')).toEqual({ workflow: 'default' });
expect(parseHarnessCapabilitiesArg('agent-teams=on, workflow=default')).toEqual({
'agent-teams': 'on',
workflow: 'default',
});
});
it('resolves a repeated key last-wins', () => {
expect(parseHarnessCapabilitiesArg('workflow=on,workflow=default')).toEqual({ workflow: 'default' });
expect(parseHarnessCapabilitiesArg('workflow=default,workflow=on')).toEqual({ workflow: 'on' });
});
it('normalizes key case and underscores', () => {
expect(parseHarnessCapabilitiesArg('AGENT_TEAMS=ON')).toEqual({ 'agent-teams': 'on' });
});
it('rejects unknown keys, including inherited prototype names', () => {
expect(() => parseHarnessCapabilitiesArg('web=off')).toThrow(/unknown harness capability "web".*agent-teams/);
expect(() => parseHarnessCapabilitiesArg('constructor=on')).toThrow(/unknown harness capability "constructor"/);
});
it('rejects bad values and malformed pairs', () => {
expect(() => parseHarnessCapabilitiesArg('workflow=maybe')).toThrow(/must be on, off, or default/);
expect(() => parseHarnessCapabilitiesArg('workflow')).toThrow(/must be key=value/);
expect(() => parseHarnessCapabilitiesArg(' , ')).toThrow(/requires one or more/);
});
});
-156
View File
@@ -1,156 +0,0 @@
/**
* Harness-capability registry: which harness-native features NanoClaw exposes
* as per-group toggles, their code defaults, and how the host applies each one
* to a group's settings.json.
*
* This is the single host-side source of truth. Per-group overrides are a
* sparse JSON map in `container_configs.harness_capabilities`; the RESOLVED map
* (defaults overrides) is materialized into container.json and applied to the
* group's settings.json by the reconciler in group-init.ts, which iterates the
* `host` mechanism of every entry below so adding a capability is one entry
* here plus (if it also needs a runtime tool block) one line in the runner's
* CAPABILITY_DISALLOWS map. Fixed-off capabilities (scheduling, ask-user,
* plan/worktree, DesignSync) are not keys here by design a toggle nobody can
* meaningfully use is surface without value. See docs/harness-capabilities.md.
*/
import { log } from './log.js';
export type HarnessCapabilityState = 'on' | 'off';
/**
* How the settings reconciler expresses a capability in settings.json.
* - `env`: the presence of an env key IS the on-state (the key is written when
* on, deleted when off). Verified on the pinned CLI: settings env strictly
* beats SDK options env, so this file is the only working switch.
* - `disableFlag`: a top-level boolean set `true` when the capability is OFF
* (disable-style flag), deleted when on.
*/
export type HostMechanism = { kind: 'env'; key: string } | { kind: 'disableFlag'; key: string };
export interface CapabilityDef {
default: HarnessCapabilityState;
host: HostMechanism;
}
/**
* Configurable capabilities. Both default OFF because NanoClaw's own systems
* (a2a messaging, host-side orchestration) are the authoritative equivalents.
*/
export const HARNESS_CAPABILITIES: Record<string, CapabilityDef> = {
'agent-teams': { default: 'off', host: { kind: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' } },
workflow: { default: 'off', host: { kind: 'disableFlag', key: 'disableWorkflows' } },
};
export const HARNESS_CAPABILITY_KEYS = Object.keys(HARNESS_CAPABILITIES);
/** Derived key→default map, for callers that only need the defaults. */
export const HARNESS_CAPABILITY_DEFAULTS: Record<string, HarnessCapabilityState> = Object.fromEntries(
Object.entries(HARNESS_CAPABILITIES).map(([key, def]) => [key, def.default]),
);
const VALID_STATES = new Set<string>(['on', 'off']);
function isKnownKey(key: string): boolean {
return Object.hasOwn(HARNESS_CAPABILITIES, key);
}
// Durable bad state (a stale key or garbage value persisted in the DB) would
// otherwise log on every spawn — under scheduled-task wakes, forever. Warn once
// per distinct problem per host process instead, so real warnings aren't buried.
const warnedOnce = new Set<string>();
function warnOnce(signature: string, message: string, data: Record<string, unknown>): void {
if (warnedOnce.has(signature)) return;
warnedOnce.add(signature);
log.warn(message, data);
}
/**
* Parse a group's stored override JSON into the raw sparse map. Malformed or
* non-object JSON degrades to {} with a warning rather than throwing: unlike
* structural config (mcp_servers, mounts), capabilities have a safe fallback,
* and the only sanctioned write path (ncl) validates its input.
*/
export function parseHarnessOverrides(overridesJson: string | null | undefined): Record<string, unknown> {
if (!overridesJson) return {};
try {
const parsed: unknown = JSON.parse(overridesJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
warnOnce(`shape:${overridesJson}`, 'harness_capabilities is not a JSON object — using defaults', {
value: overridesJson,
});
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
warnOnce(`syntax:${overridesJson}`, 'harness_capabilities is malformed JSON — using defaults', {
value: overridesJson,
});
}
return {};
}
/**
* Resolve stored overrides (JSON string, or an already-parsed map) against the
* code defaults. Unknown keys and invalid values are dropped (they fall back to
* defaults) so the resolved map only ever contains known keys with valid
* states the runner therefore never has to reason about unknown keys.
*/
export function resolveHarnessCapabilities(
overrides: string | Record<string, unknown> | null | undefined,
): Record<string, HarnessCapabilityState> {
const raw = typeof overrides === 'string' || overrides == null ? parseHarnessOverrides(overrides) : overrides;
const resolved: Record<string, HarnessCapabilityState> = { ...HARNESS_CAPABILITY_DEFAULTS };
for (const [key, value] of Object.entries(raw)) {
if (!isKnownKey(key)) {
warnOnce(`unknown:${key}`, 'Unknown harness capability key in overrides — dropping', { key });
continue;
}
if (typeof value !== 'string' || !VALID_STATES.has(value)) {
warnOnce(`value:${key}:${String(value)}`, 'Invalid harness capability value — using default', { key, value });
continue;
}
resolved[key] = value as HarnessCapabilityState;
}
return resolved;
}
/**
* Parse the ncl `--harness-capabilities` flag value: comma-separated `k=v`
* pairs where v is `on`, `off`, or `default` (`default` clears the override
* it is never stored). Keys are normalized (trim, lowercase, `_``-`) and
* validated against the registry. Returns a plain keydirective map, so a
* repeated key resolves last-wins by ordinary assignment. Throws with an
* actionable message on the first problem.
*/
export function parseHarnessCapabilitiesArg(input: string): Record<string, HarnessCapabilityState | 'default'> {
const allowed = HARNESS_CAPABILITY_KEYS.join(', ');
const out: Record<string, HarnessCapabilityState | 'default'> = {};
const pairs = input
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (pairs.length === 0) {
throw new Error(`--harness-capabilities requires one or more key=value pairs (keys: ${allowed})`);
}
for (const pair of pairs) {
const eq = pair.indexOf('=');
if (eq === -1) {
throw new Error(`--harness-capabilities entry "${pair}" must be key=value (on|off|default)`);
}
const key = pair.slice(0, eq).trim().toLowerCase().replace(/_/g, '-');
const value = pair
.slice(eq + 1)
.trim()
.toLowerCase();
if (!isKnownKey(key)) {
throw new Error(`unknown harness capability "${key}" — configurable keys: ${allowed}`);
}
if (value === 'default' || VALID_STATES.has(value)) {
out[key] = value as HarnessCapabilityState | 'default';
} else {
throw new Error(`harness capability "${key}" must be on, off, or default — got "${value}"`);
}
}
return out;
}
+53 -42
View File
@@ -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 { GuardDenyError, 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, a2aSend } from './guard.js';
export { isSafeAttachmentName };
export { A2A_MESSAGE_GATE_ACTION } from './guard.js';
export interface ForwardedAttachment {
name: string;
@@ -230,56 +231,64 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session;
}
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
export async function routeAgentMessage(
msg: RoutableAgentMessage,
session: Session,
opts: { grant?: PendingApproval } = {},
): Promise<void> {
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 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
// revoking a destination between hold and approve blocks delivery.
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 },
grant: opts.grant ?? null,
});
if (decision.effect === 'deny') {
throw new GuardDenyError(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 +317,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,
+137 -21
View File
@@ -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` 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
* 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<string, import('../../types.js').PendingApproval>(),
approvalHandlers: new Map<string, (ctx: Record<string, unknown>) => Promise<void>>(),
}));
vi.mock('../approvals/index.js', () => ({
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
notifyAgent: vi.fn(),
registerApprovalHandler: (action: string, handler: (ctx: Record<string, unknown>) => Promise<void>) => {
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<string, unknown>): Promise<void> {
const wrapped = getDeliveryAction('create_agent');
expect(wrapped).toBeDefined();
await wrapped!(content, SESSION, undefined as never);
}
function liveGrant(approvalId: string, payload: Record<string, unknown>): 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 — 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);
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();
});
});
+32 -57
View File
@@ -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` 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
* 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<string, unknown>, session: Session): Promise<void> {
/** Guard precheck: malformed requests are answered without ever creating a hold. */
export function validateCreateAgent(content: Record<string, unknown>, 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<string, unknown>, session: Session): Promise<void> {
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<string, unknown>, 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<string, unknown>, session: Session): Promise<void> {
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(
+88
View File
@@ -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, defineGuardedAction } 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';
export const agentsCreate = defineGuardedAction({
action: 'agents.create',
grantActionName: 'create_agent',
// Bind a create_agent grant to the name that was approved.
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name;
} catch {
return false;
}
},
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') {
// 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');
},
});
export const a2aSend = defineGuardedAction({
action: 'a2a.send',
grantActionName: A2A_MESSAGE_GATE_ACTION,
// Bind an a2a grant to the exact held message target.
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to;
} catch {
return false;
}
},
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 ?? '';
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');
},
});
+19 -12
View File
@@ -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 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
* 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 { 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 { agentsCreate } from './guard.js';
import { applyA2aMessageGate } from './message-gate.js';
registerDeliveryAction('create_agent', handleCreateAgent);
registerApprovalHandler('create_agent', applyCreateAgent);
registerDeliveryAction('create_agent', createAgent, {
guardAction: agentsCreate,
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);
+85 -12
View File
@@ -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<string, unknown>): 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,76 @@ 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 → 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
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 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 };
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 () => {
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
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 ──
+24 -3
View File
@@ -1,9 +1,10 @@
/** 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 { 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 +19,27 @@ 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
// 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,
+6
View File
@@ -59,6 +59,12 @@ const APPROVAL_OPTIONS: RawOption[] = [
export interface ApprovalHandlerContext {
session: Session;
payload: Record<string, unknown>;
/**
* 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. */
+1 -1
View File
@@ -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 });
@@ -68,7 +68,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';
@@ -586,6 +590,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 interceptor
// captures it 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 interceptor 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', () => {
+55
View File
@@ -0,0 +1,55 @@
/**
* 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
* grantActionName: the approve continuation adds the member and replays
* routeInbound, which then passes the gate structurally via membership, no
* grant needed.
*
* 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';
import { hasAdminPrivilege } from './db/user-roles.js';
export const sendersAdmit = defineGuardedAction({
action: 'senders.admit',
decide: (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');
},
});
export const channelsRegister = defineGuardedAction({
action: 'channels.register',
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);
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');
},
});
+45 -32
View File
@@ -33,6 +33,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 } from '../../guard/index.js';
import { channelsRegister, sendersAdmit } from './guard.js';
import { canAccessAgentGroup } from './access.js';
import {
buildAgentSelectionOptions,
@@ -131,43 +133,49 @@ 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 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.
const decision = guard(sendersAdmit, {
actor: userId ? { kind: 'human', userId } : { kind: 'system' },
payload: {
messagingGroupId: mg.id,
agentGroupId,
senderIdentity: userId,
policy: mg.unknown_sender_policy,
},
});
if (decision.effect === 'allow') return; // 'public' — handled before the gate; fall through silently.
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);
return;
}
},
);
recordDroppedMessage(dropRecord);
if (mg.unknown_sender_policy === 'request_approval') {
log.info('MESSAGE DROPPED — unknown sender (approval requested)', {
// 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,
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;
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);
@@ -411,18 +419,23 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
// 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(':')
? payload.userId
: `${payload.channelType}:${payload.userId}`
: null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {
const decision = guard(channelsRegister, {
actor: { kind: 'human', userId: clickerId ?? '' },
payload: { questionId: payload.questionId },
});
if (!clickerId || decision.effect !== 'allow') {
log.warn('Channel registration click rejected — unauthorized clicker', {
messagingGroupId: row.messaging_group_id,
clickerId,
expectedApprover: row.approver_user_id,
reason: decision.reason,
});
return true;
}
+21 -18
View File
@@ -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
* 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.
*
* 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<string, unknown>, session: Session): Promise<void> {
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<string, unknown>, session: Session): Promise<void> {
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 });
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Self-mod guard adapter the module's catalog entries, composed at the
* module edge (imported by ./index.ts).
*
* 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
* registry.)
*/
import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js';
function selfModDecide(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`);
};
}
export const selfModInstallPackages = defineGuardedAction({
action: 'self_mod.install_packages',
grantActionName: 'install_packages',
decide: selfModDecide('install_packages'),
});
export const selfModAddMcpServer = defineGuardedAction({
action: 'self_mod.add_mcp_server',
grantActionName: 'add_mcp_server',
decide: selfModDecide('add_mcp_server'),
});
+36 -14
View File
@@ -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 checks 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 { 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 { selfModAddMcpServer, selfModInstallPackages } from './guard.js';
import {
requestAddMcpServerHold,
requestInstallPackagesHold,
validateAddMcpServer,
validateInstallPackages,
} from './request.js';
registerDeliveryAction('install_packages', handleInstallPackages);
registerDeliveryAction('add_mcp_server', handleAddMcpServer);
registerDeliveryAction('install_packages', applyInstallPackages, {
guardAction: selfModInstallPackages,
precheck: validateInstallPackages,
requestHold: requestInstallPackagesHold,
onDeny: (_content, session, reason) => notifyAgent(session, `install_packages denied: ${reason}`),
});
registerDeliveryAction('add_mcp_server', applyAddMcpServer, {
guardAction: selfModAddMcpServer,
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'));
+32 -16
View File
@@ -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<string, unknown>, session: Session): Promise<void> {
export function validateInstallPackages(content: Record<string, unknown>, 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<string, unknown>, session: Session): Promise<void> {
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<string, unknown>, se
});
}
export async function handleAddMcpServer(content: Record<string, unknown>, session: Session): Promise<void> {
export function validateAddMcpServer(content: Record<string, unknown>, 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<string, unknown>, session: Session): Promise<void> {
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,
+1 -7
View File
@@ -43,13 +43,7 @@ function session(id: string, agentGroupId: string): Session {
}
function containerConfig(): ContainerConfig {
return {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
skills: [],
harnessCapabilities: { 'agent-teams': 'off', workflow: 'off' },
};
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
}
beforeEach(() => {
-1
View File
@@ -25,7 +25,6 @@ export interface ContainerConfigRow {
packages_npm: string; // JSON: string[]
additional_mounts: string; // JSON: AdditionalMountConfig[]
cli_scope: string; // 'disabled' | 'group' | 'global'
harness_capabilities: string; // JSON: sparse Record<key, 'on'|'off'> overrides — see src/harness-capabilities.ts
updated_at: string;
}