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>
This commit is contained in:
Moshe Krupper
2026-07-11 23:54:23 +03:00
parent fe235c7ca0
commit f24edc2c3b
26 changed files with 174 additions and 251 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<reason>)` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) when a guarded action that can hold has no registered approve continuation — the one cross-registry invariant the type system can't see, since catalog entries and approval handlers register from different modules. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the check (`src/guard-conformance.ts`) runs in `main()` before the host accepts a message, and a mis-composition crashes at skill-install time instead of at the first approved card. The old registry walk is deleted: everything it detected is now unconstructible at the registration API. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded.
- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<reason>)` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.)
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(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).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
+2 -1
View File
@@ -62,11 +62,12 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) |
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` |
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded(<reason>)`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` |
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded(<reason>)`); broadcast hooks (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` |
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
+2 -2
View File
@@ -9,7 +9,7 @@
* auto-fill, the sessions-get existence oracle, `--help` interception,
* parseArgs, and post-handler row filtering. An approved replay re-enters
* here carrying the verified approval row as its grant — the guard re-checks
* the structural baseline live, and the `approved: true` boolean no longer
* the structural checks live, and the `approved: true` boolean no longer
* exists.
*/
import { getContainerConfig } from '../db/container-configs.js';
@@ -69,7 +69,7 @@ export async function dispatch(
}
// Group-scope mechanics for agent callers (visibility, not policy — the
// allow/hold/deny decisions live in the guard baseline, cli/guard.ts).
// allow/hold/deny decisions live in the guard decision, cli/guard.ts).
if (ctx.caller === 'agent') {
const configRow = getContainerConfig(ctx.agentGroupId);
const cliScope = configRow?.cli_scope ?? 'group';
+6 -6
View File
@@ -1,11 +1,11 @@
/**
* CLI guard adapter — the command registry's catalog derivation and
* structural baseline, moved verbatim out of dispatch.ts (guarded-actions
* structural decision, moved verbatim out of dispatch.ts (guarded-actions
* phase 2). Declaration is registration: registry.register() derives one
* catalog entry per command from the CommandDef itself; no second file is
* edited when a command is added.
*
* The baseline carries today's decisions exactly:
* The decide fn carries today's decisions exactly:
* host caller → allow (the 0600 socket is the auth story — in code,
* unremovable by data);
* cli_scope 'disabled' → deny; 'group' → resource allowlist, cross-group
@@ -28,9 +28,9 @@ export function commandGuardAction(cmd: Pick<CommandDef, 'name' | 'action'>): st
export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec {
return {
action: commandGuardAction(cmd),
approvalAction: cmd.access === 'approval' ? 'cli_command' : undefined,
grantActionName: cmd.access === 'approval' ? 'cli_command' : undefined,
// Bind a cli_command grant to the exact command it was approved for.
grantMatches: (grant) => {
grantCoversRequest: (grant) => {
try {
const payload = JSON.parse(grant.payload) as { frame?: { command?: string } };
return payload.frame?.command === cmd.name;
@@ -38,11 +38,11 @@ export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec {
return false;
}
},
baseline: (input) => commandBaseline(cmd, input),
decide: (input) => commandDecide(cmd, input),
};
}
function commandBaseline(cmd: CommandDef, input: GuardInput) {
function commandDecide(cmd: CommandDef, input: GuardInput) {
const { actor } = input;
if (actor.kind === 'host') return ALLOW('host caller (trusted socket)');
if (actor.kind !== 'agent') return DENY('CLI commands accept host or agent callers only.');
+1 -1
View File
@@ -44,7 +44,7 @@ describe('delivery action registry', () => {
it('refuses to replace a guard-wrapped action with an unguarded handler', () => {
const guardAction = defineGuardedAction({
action: 'test.guarded-overwrite',
baseline: () => HOLD('t'),
decide: () => HOLD('t'),
});
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
guardAction,
+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);
}
+7 -51
View File
@@ -20,7 +20,8 @@ import {
markDeliveryFailed,
migrateDeliveredTable,
} from './db/session-db.js';
import { guard, isUnguarded, type GuardedAction, type Unguarded } from './guard/index.js';
import { runGuarded, type DeliveryGuardSpec, type GuardedDeliveryHandler } from './delivery-guard.js';
import { isUnguarded, type Unguarded } from './guard/index.js';
import { log } from './log.js';
import { normalizeOptions } from './channels/ask-question.js';
import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js';
@@ -401,8 +402,8 @@ async function deliverMessage(
* dispatch, approved replay, test lookup goes through the guard consult
* (allow / hold / deny), so there is no unguarded route to it. On approve,
* the continuation re-enters the same entry carrying the approval row as its
* grant (`reenterGuardedDeliveryAction`), so the structural baseline is
* re-checked live. Plain actions (the cli_request bridge its inner
* grant (`reenterGuardedDeliveryAction`), so the structural checks are
* re-run live. Plain actions (the cli_request bridge its inner
* commands are guarded at dispatch) register with an
* explicit `unguarded(<reason>)` declaration instead of a spec omission is
* not representable, so the decision to run unguarded is visible, and
@@ -414,23 +415,6 @@ export type DeliveryActionHandler = (
inDb: Database.Database,
) => Promise<void>;
/** 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;
}
type DeliveryEntry =
| { guard: Unguarded; handler: DeliveryActionHandler }
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
@@ -467,38 +451,10 @@ export function registerDeliveryAction(
deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry);
}
async function runGuarded(
action: string,
entry: Extract<DeliveryEntry, { guard: DeliveryGuardSpec }>,
content: Record<string, unknown>,
session: Session,
grant: PendingApproval | null,
): Promise<void> {
const spec = entry.guard;
if (spec.precheck && !(await spec.precheck(content, session))) return;
const decision = guard(spec.guardAction, {
actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id },
payload: content,
grant,
});
if (decision.effect === 'deny') {
log.warn('Delivery action denied by guard', { action, reason: decision.reason });
spec.onDeny?.(content, session, decision.reason);
return;
}
if (decision.effect === 'hold') {
await spec.requestHold(content, session);
return;
}
await entry.handler(content, session);
}
/**
* Approve continuation for a guard-wrapped delivery action: re-enter the
* entry with the approval row as the grant. The guard treats the grant as
* hold-satisfied but re-runs the structural baseline, so approve-then-revoke
* hold-satisfied but re-runs the structural checks, so approve-then-revoke
* does not execute. Domains register this as their approval handler in the
* same line that registers the action.
*/
@@ -509,7 +465,7 @@ export function reenterGuardedDeliveryAction(action: string) {
log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action });
return;
}
await runGuarded(action, entry, ctx.payload, ctx.session, ctx.approval);
await runGuarded(action, entry.guard, entry.handler, ctx.payload, ctx.session, ctx.approval);
};
}
@@ -522,7 +478,7 @@ export function getDeliveryAction(action: string): DeliveryActionHandler | undef
const entry = deliveryActions.get(action);
if (!entry) return undefined;
if (isUnguardedEntry(entry)) return entry.handler;
return (content, session) => runGuarded(action, entry, content, session, null);
return (content, session) => runGuarded(action, entry.guard, entry.handler, content, session, null);
}
/**
-70
View File
@@ -1,70 +0,0 @@
/**
* Boot-time guard sanity the one cross-registry invariant left to check
* after all import-time registrations have run.
*
* The old registry walk is gone: everything it detected is now
* unconstructible at the API level.
* - A consult site cannot name a missing catalog entry guard() takes the
* GuardedAction VALUE returned by defineGuardedAction, so a dropped
* module-edge import or a typo'd action is a compile error (and a forged
* value is denied at runtime), not a silent allow.
* - A privileged handler cannot register unguarded by omission the
* delivery-action registry requires a guard spec or an explicit
* unguarded(<reason>) declaration, and every ncl command derives its
* guard inside register().
*
* What remains is completeness ACROSS registries: a guarded action that
* holds via `approvalAction` needs a registered approval handler, or an
* approved card resolves into nothing the hold has no continuation. That
* pairing only exists once every module has loaded (catalog entries and
* approval handlers register from different modules), so it stays a boot
* check with the fail-closed posture: the host refuses to start, surfacing
* the mis-composition at skill-install time instead of at the first
* approved card.
*/
import { listGuardedActions } from './guard/index.js';
import { log } from './log.js';
import { getApprovalHandler } from './modules/approvals/primitive.js';
/** Holding actions with no approve continuation. Empty = conformant. */
export function grantContinuationGaps(): string[] {
return listGuardedActions()
.filter((spec) => spec.approvalAction && !getApprovalHandler(spec.approvalAction))
.map(
(spec) =>
`guarded action "${spec.action}" holds via approval action "${spec.approvalAction}" ` +
'but no approval handler is registered — an approved hold would have no continuation',
);
}
/**
* Boot check: refuse to start when a holding action has no continuation.
* Call after all import-time registrations (any point in main()).
*/
export function enforceGuardConformance(): void {
const gaps = grantContinuationGaps();
if (gaps.length === 0) return;
console.error(
[
'',
'='.repeat(64),
'NanoClaw stopped: guard conformance failure',
'='.repeat(64),
'A guarded action can hold for approval, but no approval handler is',
'registered for its approval action — an admin could click Approve',
'and nothing would execute. This usually means a module (or skill)',
'defined a holding baseline without registering its continuation.',
'',
...gaps.map((g) => ` - ${g}`),
'',
'Register the approval handler (registerApprovalHandler) in the same',
'module that defines the guarded action, or drop approvalAction from',
'the definition if the action can never hold.',
'='.repeat(64),
'',
].join('\n'),
);
log.error('Guard conformance failure — refusing to start', { gaps });
process.exit(1);
}
+11 -31
View File
@@ -1,15 +1,15 @@
/**
* Guard conformance the boot invariant, checked with the real registries.
* Guard conformance checked with the real registries.
*
* The old registry walk is gone: an unmapped consult or an undeclared
* unguarded registration is now unconstructible guard() takes the defined
* GuardedAction value (a dropped module-edge import or typo'd name is a
* compile error), and every registry requires a guard spec or an explicit
* unguarded(<reason>) declaration. What's left to verify structurally is the
* 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. The check runs here in CI and at every
* boot (enforceGuardConformance refuses to start) CI can't see
* skill-installed registrations, the boot check can.
* registered approve continuation. (At runtime a missing continuation is
* handled loudly at click time the requester is told no handler is
* installed; this test keeps the tree from shipping that state.)
*/
import { describe, expect, it } from 'vitest';
@@ -20,21 +20,16 @@ import '../cli/delivery-action.js';
import '../cli/dispatch.js'; // registers the cli_command approval handler
import { commandGuard, listCommands } from '../cli/registry.js';
import { grantContinuationGaps } from '../guard-conformance.js';
import { getApprovalHandler } from '../modules/approvals/primitive.js';
import { defineGuardedAction, listGuardedActions } from './guard-actions.js';
import { HOLD } from './types.js';
describe('guard conformance', () => {
it('the grant-continuation check (shared with the boot check) reports zero gaps', () => {
expect(grantContinuationGaps()).toEqual([]);
});
it('every holding action pairs with a registered approval handler', () => {
const holding = listGuardedActions().filter((spec) => spec.approvalAction);
const holding = listGuardedActions().filter((spec) => spec.grantActionName);
expect(holding.length).toBeGreaterThan(0);
const dangling = holding.filter((spec) => !getApprovalHandler(spec.approvalAction as string));
const dangling = holding.filter((spec) => !getApprovalHandler(spec.grantActionName as string));
expect(dangling.map((s) => s.action)).toEqual([]);
});
@@ -42,7 +37,7 @@ describe('guard conformance', () => {
const mutating = listCommands().filter((cmd) => cmd.access === 'approval');
expect(mutating.length).toBeGreaterThan(0);
const wrong = mutating.filter((cmd) => commandGuard(cmd.name).approvalAction !== 'cli_command');
const wrong = mutating.filter((cmd) => commandGuard(cmd.name).grantActionName !== 'cli_command');
expect(wrong.map((c) => c.name)).toEqual([]);
});
@@ -61,24 +56,9 @@ describe('guard conformance', () => {
});
it('defining the same action twice throws — names are the catalog key', () => {
defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') });
expect(() => defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') })).toThrow(
defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') });
expect(() => defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') })).toThrow(
/already defined/,
);
});
// KEEP LAST: defines a holding action with no continuation into the shared
// per-worker catalog, so every gap check after this point sees it.
it('the check names a holding action with no approve continuation (what boot refuses on)', () => {
defineGuardedAction({
action: 'test.dangling-hold',
approvalAction: 'test_dangling_hold_approved',
baseline: () => HOLD('always'),
});
const gaps = grantContinuationGaps();
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('test.dangling-hold');
expect(gaps[0]).toContain('no approval handler');
});
});
+11 -11
View File
@@ -4,14 +4,14 @@
* An action either is defined here (and every consult passes its decision)
* or cannot be consulted at all: guard() takes the GuardedAction VALUE
* returned by defineGuardedAction, so the wiring between a consult site and
* its baseline is a symbol reference the compiler checks. A dropped
* its decide fn is a symbol reference the compiler checks. A dropped
* module-edge import or a typo'd action name is a build error, not a
* runtime fail-open there is no lookup that can miss.
*
* Definitions are still recorded by name so boot can enumerate them: the
* grant-continuation check (src/guard-conformance.ts) pairs every holding
* action with its registered approval handler, and duplicate names are
* refused at definition time (grants match on the name).
* Definitions are still recorded by name so the catalog can be enumerated:
* the conformance test pairs every holding action with its registered
* approval handler, and duplicate names are refused at definition time
* (grants match on the name).
*/
import type { GuardDecision, GuardInput } from './types.js';
import type { PendingApproval } from '../types.js';
@@ -24,25 +24,25 @@ export interface GuardedActionSpec {
* allow. Runs on every consult, including approved replays (a grant
* satisfies a hold, never a deny).
*/
baseline: (input: GuardInput) => GuardDecision;
decide: (input: GuardInput) => GuardDecision;
/**
* The pending_approvals.action its holds resolve through a grant is only
* accepted when its row carries this action. Omit for actions that can
* never be held (deny/allow-only baselines).
* never be held (deny/allow-only decisions).
*/
approvalAction?: string;
grantActionName?: string;
/**
* Extra domain binding between a grant and the replayed input (e.g. the
* a2a target must match the held message). Runs in addition to the
* approvalAction + live-row checks.
* grantActionName + live-row checks.
*/
grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean;
grantCoversRequest?: (grant: PendingApproval, input: GuardInput) => boolean;
}
declare const guardedActionBrand: unique symbol;
/**
* A defined guarded action only defineGuardedAction can mint one. The
* brand makes the type nominal: a hand-rolled { action, baseline } object
* brand makes the type nominal: a hand-rolled { action, decide } object
* does not typecheck at a consult site, and fails the runtime check too.
*/
export type GuardedAction = Readonly<GuardedActionSpec> & { readonly [guardedActionBrand]: true };
+29 -29
View File
@@ -1,8 +1,8 @@
/**
* Guard decision-function unit tests: the baseline is the decision (allow /
* Guard decision-function unit tests: decide is the decision (allow /
* hold / deny returned as-is), grant semantics (satisfies holds, never
* denies; invalid refuse), the runtime backstop against forged action
* values, and the fail-closed posture on a throwing baseline.
* values, and the fail-closed posture on a throwing decide.
*
* Uses synthetic actions defined per test the catalog is per-worker module
* state with no reset, so action names are unique.
@@ -35,14 +35,14 @@ afterEach(() => {
vi.clearAllMocks();
});
describe('the baseline is the decision', () => {
it('baseline allow → allow', () => {
const action = defineGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') });
describe('decide is the decision', () => {
it('decide allow → allow', () => {
const action = defineGuardedAction({ action: 't.allow1', decide: () => ALLOW('ok') });
expect(guard(action, input()).effect).toBe('allow');
});
it('baseline hold → hold, default approver chain', () => {
const action = defineGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') });
it('decide hold → hold, default approver chain', () => {
const action = defineGuardedAction({ action: 't.hold1', decide: () => HOLD('needs approval') });
const d = guard(action, input());
expect(d.effect).toBe('hold');
if (d.effect === 'hold') {
@@ -51,22 +51,22 @@ describe('the baseline is the decision', () => {
}
});
it('baseline hold → hold, carrying a named approver', () => {
const action = defineGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') });
it('decide hold → hold, carrying a named approver', () => {
const action = defineGuardedAction({ action: 't.hold2', decide: () => HOLD('policy row', 'telegram:dana') });
const d = guard(action, input());
expect(d.effect).toBe('hold');
if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana');
});
it('baseline deny → deny, carrying the reason', () => {
const action = defineGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') });
it('decide deny → deny, carrying the reason', () => {
const action = defineGuardedAction({ action: 't.deny1', decide: () => DENY('structurally unauthorized') });
const d = guard(action, input());
expect(d.effect).toBe('deny');
if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized');
});
it('a forged action value (not from defineGuardedAction) is denied', () => {
const forged = { action: 't.forged', baseline: () => ALLOW('never vetted') } as unknown as GuardedAction;
const forged = { action: 't.forged', decide: () => ALLOW('never vetted') } as unknown as GuardedAction;
const d = guard(forged, input());
expect(d.effect).toBe('deny');
if (d.effect === 'deny') expect(d.reason).toContain('undefined action');
@@ -80,19 +80,19 @@ describe('grants', () => {
it('a valid live grant satisfies a hold', () => {
const action = defineGuardedAction({
action: 't.g1',
approvalAction: 'g1_approved',
baseline: () => HOLD('b'),
grantActionName: 'g1_approved',
decide: () => HOLD('b'),
});
const grant = grantRow('g1_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('allow');
});
it('a grant never satisfies a deny — the baseline is re-checked live', () => {
it('a grant never satisfies a deny — the checks re-run live', () => {
const action = defineGuardedAction({
action: 't.g2',
approvalAction: 'g2_approved',
baseline: () => DENY('revoked since'),
grantActionName: 'g2_approved',
decide: () => DENY('revoked since'),
});
const grant = grantRow('g2_approved');
mockGetPendingApproval.mockReturnValue(grant);
@@ -104,8 +104,8 @@ describe('grants', () => {
it('a dead grant (row deleted) refuses instead of re-holding', () => {
const action = defineGuardedAction({
action: 't.g3',
approvalAction: 'g3_approved',
baseline: () => HOLD('b'),
grantActionName: 'g3_approved',
decide: () => HOLD('b'),
});
mockGetPendingApproval.mockReturnValue(undefined);
const d = guard(action, input({ grant: grantRow('g3_approved') }));
@@ -115,20 +115,20 @@ describe('grants', () => {
it("a grant for a different action doesn't transfer", () => {
const action = defineGuardedAction({
action: 't.g4',
approvalAction: 'g4_approved',
baseline: () => HOLD('b'),
grantActionName: 'g4_approved',
decide: () => HOLD('b'),
});
const grant = grantRow('other_action');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('deny');
});
it('a domain grantMatches binding can refuse a payload mismatch', () => {
it('a domain grantCoversRequest binding can refuse a payload mismatch', () => {
const action = defineGuardedAction({
action: 't.g5',
approvalAction: 'g5_approved',
grantMatches: () => false,
baseline: () => HOLD('b'),
grantActionName: 'g5_approved',
grantCoversRequest: () => false,
decide: () => HOLD('b'),
});
const grant = grantRow('g5_approved');
mockGetPendingApproval.mockReturnValue(grant);
@@ -138,8 +138,8 @@ describe('grants', () => {
it('a grant on an already-allowed action is a no-op', () => {
const action = defineGuardedAction({
action: 't.g6',
approvalAction: 'g6_approved',
baseline: () => ALLOW('ok'),
grantActionName: 'g6_approved',
decide: () => ALLOW('ok'),
});
const grant = grantRow('g6_approved');
mockGetPendingApproval.mockReturnValue(grant);
@@ -148,10 +148,10 @@ describe('grants', () => {
});
describe('fail-closed posture', () => {
it('a throwing baseline denies', () => {
it('a throwing decide denies', () => {
const action = defineGuardedAction({
action: 't.f1',
baseline: () => {
decide: () => {
throw new Error('boom');
},
});
+12 -12
View File
@@ -1,25 +1,25 @@
/**
* guard() the one decision function every privileged action consults.
*
* The decision is the action's structural baseline — today's code checks,
* The decision is the action's decide fn — today's code checks,
* defined per action at the module edges. The consult site holds the
* GuardedAction value itself (defineGuardedAction), so there is no name
* lookup and no fail-open path for an unknown action: an unwired consult is
* a compile error, and a value that didn't come from defineGuardedAction is
* denied at runtime. Policy-as-data (tighten-only rule sources composing
* with the baseline) is deliberately deferred to phase 3 of the
* with the decision) is deliberately deferred to phase 3 of the
* guarded-actions design, where the generalized rules table arrives with its
* first operator-visible consumer; until then the one policy table
* (agent_message_policies) is consulted inside the a2a.send baseline.
* (agent_message_policies) is consulted inside a2a.send's decide.
*
* Grants: an approved replay carries the verified approval row. A valid
* grant (live pending row whose action matches the entry's approval action,
* plus any domain binding) satisfies a hold the human already decided
* but NEVER a deny: the baseline is re-checked live, so approve-then-revoke
* but NEVER a deny: the checks re-run live, so approve-then-revoke
* no longer executes. A grant that is present but invalid fails closed to
* deny (no second card).
*
* The guard itself fails closed: a throwing baseline denies.
* The guard itself fails closed: a throwing decide denies.
*/
import { getPendingApproval } from '../db/sessions.js';
import { log } from '../log.js';
@@ -29,7 +29,7 @@ import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js';
export function guard(action: GuardedAction, input: GuardInput): GuardDecision {
if (!isGuardedAction(action)) {
// JS-level backstop — the branded type already forbids this. A
// hand-rolled object must not carry a baseline never vetted at
// hand-rolled object must not carry a decide fn never vetted at
// definition time.
log.error('Guard consulted with an undefined action — failing closed', {
action: (action as { action?: unknown } | null)?.action,
@@ -39,14 +39,14 @@ export function guard(action: GuardedAction, input: GuardInput): GuardDecision {
let decision: GuardDecision;
try {
decision = action.baseline(input);
decision = action.decide(input);
} catch (err) {
log.error('Guard evaluation threw — failing closed', { action: action.action, err });
return DENY('guard failure (failing closed)');
}
if (!input.grant || decision.effect !== 'hold') {
// A grant never loosens a deny (the baseline re-check is live), and a
// A grant never loosens a deny (the checks re-run live), and a
// grant on an already-allowed action is a no-op.
return decision;
}
@@ -61,12 +61,12 @@ export function guard(action: GuardedAction, input: GuardInput): GuardDecision {
function grantSatisfies(action: GuardedAction, input: GuardInput): boolean {
const grant = input.grant;
if (!grant || !action.approvalAction) return false;
if (grant.action !== action.approvalAction) return false;
if (!grant || !action.grantActionName) return false;
if (grant.action !== action.grantActionName) return false;
// The row must still be live — resolution deletes it, so a grant can only
// execute once and a fabricated row object doesn't pass.
const live = getPendingApproval(grant.approval_id);
if (!live || live.action !== action.approvalAction) return false;
if (action.grantMatches && !action.grantMatches(grant, input)) return false;
if (!live || live.action !== action.grantActionName) return false;
if (action.grantCoversRequest && !action.grantCoversRequest(grant, input)) return false;
return true;
}
+1 -1
View File
@@ -6,7 +6,7 @@
* (guard-actions.ts). Consults carry the GuardedAction value returned by
* defineGuardedAction never a name to look up so mis-wiring is a build
* error, not a runtime fail-open.
* Domain-free leaf: domain baselines are defined at the domain modules' edges.
* Domain-free leaf: domain decisions are defined at the domain modules' edges.
*/
export { guard } from './guard.js';
export {
+2 -2
View File
@@ -3,7 +3,7 @@
*
* The guard is a domain-free leaf: this module may import the DB read layer,
* config, log, and shared types never src/cli/* or src/modules/*. Domain
* knowledge (what an action's structural baseline checks) arrives via
* knowledge (what an action's decide fn checks) arrives via
* definition: domain modules call defineGuardedAction (guard-actions.ts) at
* their module edges and pass the returned value to every consult and
* registration site the wiring is a symbol reference the compiler checks.
@@ -26,7 +26,7 @@ export interface GuardInput {
/**
* Verified approval row carried by an approved replay. A valid grant
* satisfies a hold (the human already decided) but never a deny the
* structural baseline is re-checked live on every replay.
* structural checks re-run live on every replay.
*/
grant?: PendingApproval | null;
}
-7
View File
@@ -14,7 +14,6 @@ import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
import { enforceGuardConformance } from './guard-conformance.js';
import { startHostSweep, stopHostSweep } from './host-sweep.js';
import { routeInbound } from './router.js';
import { log } from './log.js';
@@ -70,12 +69,6 @@ async function main(): Promise<void> {
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
enforceUpgradeTripwire();
// 0.6 Guard conformance — every import-time registration has already run;
// refuse to start if any privileged command / delivery action is unmapped
// (CI can't see skill-installed code — this makes the invariant hold at
// the boundary where third-party registrations enter).
enforceGuardConformance();
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
+1 -1
View File
@@ -242,7 +242,7 @@ export async function routeAgentMessage(
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
}
// The a2a.send baseline (guard.ts) carries the checks verbatim in their
// The a2a.send decision (guard.ts) carries the checks verbatim in their
// original order: destination ACL deny, target-exists deny, self-send
// allow, agent_message_policies hold. An approved replay carries the
// grant — the hold is satisfied but the structure is re-checked live, so
@@ -3,7 +3,7 @@
*
* Regression guard for the audit finding: `create_agent` is a privileged
* central-DB write with no host-side authz. Authorization is the guard's
* `agents.create` baseline trusted owner agent groups ('global') create
* `agents.create` decision trusted owner agent groups ('global') create
* directly; confined groups ('group', the default and the prompt-injection
* victim) hold for admin approval. These tests drive the REAL wrapped
* delivery action (the only reachable path) and the approve continuation's
@@ -208,7 +208,7 @@ describe('create_agent — guard-based authorization (wrapped delivery action)',
});
describe('create_agent — approved replay (grant-carrying re-entry)', () => {
it('valid grant executes exactly once — baseline hold is satisfied, create runs', async () => {
it('valid grant executes exactly once — decide hold is satisfied, create runs', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const payload = { name: 'Scout', instructions: 'help' };
const approval = liveGrant('appr-ca-1', payload);
+1 -1
View File
@@ -7,7 +7,7 @@
* barred from. The container's MCP tool gate is inside the (untrusted)
* container and is trivially bypassed by writing the outbound system row
* directly, so authorization MUST be enforced host-side: the delivery
* registry wraps this action with the guard, whose `agents.create` baseline
* registry wraps this action with the guard, whose `agents.create` decision
* (./guard.ts) is the old cli_scope branch verbatim trusted global-scope
* groups allow, everything else (including unknown config, fail-closed)
* holds for admin approval. On approve the continuation re-enters the
+6 -6
View File
@@ -32,16 +32,16 @@ export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
export const agentsCreate = defineGuardedAction({
action: 'agents.create',
approvalAction: 'create_agent',
grantActionName: 'create_agent',
// Bind a create_agent grant to the name that was approved.
grantMatches: (grant, input) => {
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name;
} catch {
return false;
}
},
baseline: (input) => {
decide: (input) => {
if (input.actor.kind !== 'agent') return DENY('create_agent is a container-originated action.');
const cliScope = getContainerConfig(input.actor.agentGroupId)?.cli_scope ?? 'group';
if (cliScope === 'global') {
@@ -58,16 +58,16 @@ export const agentsCreate = defineGuardedAction({
export const a2aSend = defineGuardedAction({
action: 'a2a.send',
approvalAction: A2A_MESSAGE_GATE_ACTION,
grantActionName: A2A_MESSAGE_GATE_ACTION,
// Bind an a2a grant to the exact held message target.
grantMatches: (grant, input) => {
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to;
} catch {
return false;
}
},
baseline: (input) => {
decide: (input) => {
if (input.actor.kind !== 'agent') return DENY('agent-to-agent send requires an agent actor');
const from = input.actor.agentGroupId;
const to = input.resource?.to ?? '';
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Registers its guard-catalog entries (./guard.js) and one guard-wrapped
* delivery action (`create_agent`) `create_agent` writes central-DB state,
* so the guard's agents.create baseline holds confined (non-global) groups
* so the guard's agents.create decision holds confined (non-global) groups
* for admin approval while trusted global-scope groups create directly; the
* approval handler re-enters the wrapped action carrying the approval row as
* its grant. The sibling `channel_type === 'agent'` routing path is NOT a
+1 -1
View File
@@ -20,7 +20,7 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, a
// One replay semantics: re-enter the guarded route carrying the approval
// row as the grant. The policy hold is satisfied, but the structural
// baseline runs live — un-wiring the pair between hold and approve now
// checks run live — un-wiring the pair between hold and approve now
// blocks delivery (the throw surfaces via the response handler's
// "approved, but applying it failed" notify).
await routeAgentMessage(msg, session, { grant: approval });
+3 -3
View File
@@ -7,7 +7,7 @@
* anyway), `request_approval` holds, `strict` denies. The hold is executed by
* the caller through the module's own pending_sender_approvals flow (card,
* in-flight dedup) not the approvals primitive so this entry has no
* approvalAction: the approve continuation adds the member and replays
* grantActionName: the approve continuation adds the member and replays
* routeInbound, which then passes the gate structurally via membership, no
* grant needed.
*
@@ -24,7 +24,7 @@ import { hasAdminPrivilege } from './db/user-roles.js';
export const sendersAdmit = defineGuardedAction({
action: 'senders.admit',
baseline: (input) => {
decide: (input) => {
const policy = input.payload.policy;
if (policy === 'public') return ALLOW('public messaging group');
if (policy === 'request_approval') {
@@ -38,7 +38,7 @@ export const sendersAdmit = defineGuardedAction({
export const channelsRegister = defineGuardedAction({
action: 'channels.register',
baseline: (input) => {
decide: (input) => {
if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies');
const questionId = typeof input.payload.questionId === 'string' ? input.payload.questionId : '';
const row = getPendingChannelApproval(questionId);
+2 -2
View File
@@ -131,7 +131,7 @@ function handleUnknownSender(
agent_group_id: agentGroupId,
};
// The admission decision is the guard's senders.admit baseline (./guard.ts)
// The admission decision is the guard's senders.admit decision (./guard.ts)
// — unknown_sender_policy verbatim: strict → deny, request_approval → hold,
// public → allow (short-circuited before the gate). Drop-recording and the
// hold creation stay here.
@@ -319,7 +319,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
// Click authorization is the guard's channels.register baseline (./guard.ts):
// Click authorization is the guard's channels.register decision (./guard.ts):
// the delivered approver, or an admin of the pending row's anchor agent group.
const clickerId = payload.userId
? payload.userId.includes(':')
+1 -1
View File
@@ -3,7 +3,7 @@
*
* The delivery registry's guard wrapper runs these only on `allow` which,
* for self-mod, means an approved replay carrying a valid grant (the
* baseline holds unconditionally from the container path; see ./guard.ts).
* decision holds unconditionally from the container path; see ./guard.ts).
* Each body mutates the container config in the DB, rebuilds/kills the
* container as needed, and writes an on_wake message so the fresh container
* picks up where the old one left off.
+6 -6
View File
@@ -2,7 +2,7 @@
* Self-mod guard adapter the module's catalog entries, composed at the
* module edge (imported by ./index.ts).
*
* The structural baseline is today's behavior verbatim: from the container
* The decision is today's behavior verbatim: from the container
* path, self-modification is held unconditionally for the agent group's
* admin chain. (The equivalent host-side mutations `ncl groups config
* add-package` etc. — are separate catalog actions derived from the command
@@ -10,7 +10,7 @@
*/
import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js';
function selfModBaseline(label: string) {
function selfModDecide(label: string) {
return (input: GuardInput) => {
if (input.actor.kind !== 'agent') {
return DENY(`${label} is a container-originated action.`);
@@ -21,12 +21,12 @@ function selfModBaseline(label: string) {
export const selfModInstallPackages = defineGuardedAction({
action: 'self_mod.install_packages',
approvalAction: 'install_packages',
baseline: selfModBaseline('install_packages'),
grantActionName: 'install_packages',
decide: selfModDecide('install_packages'),
});
export const selfModAddMcpServer = defineGuardedAction({
action: 'self_mod.add_mcp_server',
approvalAction: 'add_mcp_server',
baseline: selfModBaseline('add_mcp_server'),
grantActionName: 'add_mcp_server',
decide: selfModDecide('add_mcp_server'),
});
+1 -1
View File
@@ -18,7 +18,7 @@
* by the next container start.
* - Two approval handlers that re-enter the wrapped actions with the
* approval row as the grant (one replay semantics the guard re-checks
* the structural baseline live).
* the structural checks live).
*
* Without this module: the MCP tools in the container still write outbound
* system messages with these actions, but delivery logs "Unknown system