mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
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:
+2
-2
@@ -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^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(':')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user