Compare commits

..

13 Commits

Author SHA1 Message Date
Moshe Krupper 4f6cba995d feat(add-audit): the approval-lifecycle increment — request + decision events
Extends the /add-audit skill from the ncl surface to every host-routed
approval, the deferred increment the ncl skill named ("rejected dropped until
the approval-lifecycle increment"). Built on the unified base's two approval
lifecycle observer seams, so one adapter covers every gated surface — CLI,
self-mod, a2a create-agent/message-gate, sender admission, OneCLI credential,
channel registration — with zero touch points inside the approval flows.

New adapter (add/src/modules/approvals/approvals.audit.ts):
- registerApprovalRequestedHandler → a `pending` event per hold, naming the
  picked approver as a `user` resource, correlated by the approval id. Skips
  `cli_command`: the CLI adapter already records those holds with the command's
  own dotted action + arg names (the mapping it alone owns) — no double-emit.
- registerApprovalResolvedHandler → an `approvals.decide` event (approved /
  rejected), same correlation id. The deciding admin is the actor with
  origin.transport=channel; OneCLI expiry and startup sweep decide as the
  system actor, outcome rejected + details.reason. details.gated_action names
  what was gated. Value-free — the gated action name and ids, never payload args.

Leaf additions (all additive, schema_version stays 1):
- types.ts / reader.ts: `rejected` outcome.
- vocab.ts: channelOrigin + channelOf (decision transport from the <channel>:<handle>
  approver id) + approvalActionName (hold action → guard-catalog dotted name).
- dispatch.audit.ts: an approved CLI replay now records `success` (ordinary
  execution); the approvals.decide event owns the approved/rejected verdict,
  chained by the same correlation id. So a gated CLI command reads back as
  pending → approvals.decide → success on one --correlation query.

Wiring: one side-effect import in the approvals barrel (SKILL.md step 4);
audit-wiring.test.ts pins it. approvals.audit.test.ts covers request/decision
per surface, the cli_command skip, system-actor expiry/sweep, and correlation.
Verified end-to-end against the real emit→NDJSON→reader pipeline: correct
actors/origins/outcomes, correlation chaining, and no secret ever written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:33:00 +03:00
Moshe Krupper e96fd7d25c merge: unify approval-contract onto the guard-seam + /add-audit base
Brings feat/approval-contract (one approval contract — approver rule, hold
records, sender fold, the requested/resolved lifecycle observer seams) onto
feat/audit-ncl-skill (the guard seam + the /add-audit skill), producing the
single base the audit approval-lifecycle increment needs: both the guard seam
(dispatch instrumentation) and the observer seams (approval instrumentation)
present at once.

Conflict resolutions (feat/guarded-actions, the pre-split donor, as reference):
- self-mod/apply.ts, agent-to-agent/create-agent.ts: keep guard-seam's positional
  delivery bodies — they register via reenterGuardedDeliveryAction, the approval
  handler is the guarded re-entry, not the body itself.
- agent-to-agent/message-gate.ts: blend — guard-seam's `approval` grant field in
  the ApprovalHandler context plus approval-contract's `if (!session)` guard, matching
  the merged (union) ApprovalHandlerContext (session nullable + approval present).
- delivery.ts reenterGuardedDeliveryAction: widen ctx.session to Session | null and
  guard it, so the returned handler satisfies the union ApprovalHandler type.
- permissions/index.ts: anchor on approval-contract (observer wiring + mayResolve +
  sender fold) and restore the `import './guard.js'` side-effect the guard catalog
  needs (senders.admit, channels.register). The guard decision bodies stand as the
  policy-as-data seam; admission/click-auth run inline (observer-wired).
- migrations/index.ts: register both 019-wiring-threads (main) and 020-holds-approver-rule.
- CHANGELOG.md: keep both feature entries.

Host build green, 777/777 host tests pass (guard conformance included). Container
tree untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:12:37 +03:00
Moshe Krupper 0aca34d3bb feat: /add-audit skill — opt-in ncl-surface audit log on the guard seam
Ships the local audit log as an install skill, scoped to the ncl command
surface: one withAudit(dispatch) composition at the definition site covers
both transports and the grant-carrying approved replay; holds are recorded as
pending events correlated by approval id (recovered from the hold row), so
--correlation returns the whole gated chain. src/audit/ installs as a
domain-free leaf — schema v1, NDJSON day-files under data/audit/ (bucketed by
event time), fail-open + loud emit that brackets the dispatcher so a thrown
command still leaves a record, post-write exporter hooks, boot writability
refusal, self-contained retention prune. `ncl audit list` is host +
global-scope only (off the group-scope allowlist, fails closed), validates its
filter flags (a typo is rejected, not silently ignored), reads --since/--until
as UTC, and streams the full window on --format ndjson (no silent 100-row cap).
Core reach-ins: the dispatch composition and the resource-barrel import, both
guarded by a shipped AST/behavior wiring test that also pins guard conformance
clean.

Value-free recording model (hardened per the max-effort review): the event
stores WHO did WHICH action to WHAT target and the outcome — never raw
argument values. `details` carry the flag names that were passed plus a small
allowlist of governance-relevant enum values (role, mode, session_mode,
cli_scope, access, engage_mode, sender_scope, provider, model) echoed
verbatim; every other value is name-only, and a failure keeps the error CODE,
never the free-text message. This replaces the key-pattern redactor outright —
a secret in an error message, a malformed-JSON arg, or an innocent key can no
longer land, because no free-form value is ever written. Target ids surface
structurally in resources.

Other review fixes: emit-on-throw; an approved replay records as `approved`
(rejected dropped until the approval-lifecycle increment); --help probes
record under a neutral cli.help action instead of masquerading as the real
verb succeeding; --limit 0 is honored as zero rows; and an exporter hook whose
module loads after boot still runs its one-time init() — the registration seam
is import-order-insensitive (onEvent/maintain/shutdown already read the live
list; init() now self-heals on late registration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:00:52 +03:00
Moshe Krupper 59b3680212 feat: one approval contract — approver rule, hold records, sender fold, lifecycle observers
Collapse the three separately-implemented approval flows (CLI command
holds, unknown-sender admission, channel registration) into one
hold-record contract on `pending_approvals` with a single
click-authorization rule. This is a behavior-preserving refactor: unifying
the flows changes no click-authorization decision versus main.

- ApproverRule (exclusive | admins-of-scope) + mayResolve() replace the
  three divergent click-auth copies (approvals response handler, sender
  handler, channel handler). a2a named approvers stay exclusive;
  sender/channel keep named-or-admin — the hold row encodes which.
- pending_approvals gains approver_rule / dedup_key (migration 020, with
  backfills: named-approver rows -> exclusive, agent_group_id stamped from
  the session). requestApproval supports sessionless holds, per-card
  options, and dedup keys.
- Sender admission folds onto the primitive (action 'sender_admit'):
  addMember + routeInbound replay on approve, deny via the shared reject
  path; pending_sender_approvals and its card/click code are deleted. Its
  UNIQUE(messaging_group_id, sender_identity) is preserved as a partial
  UNIQUE on dedup_key, and the hold is deleted BEFORE the reroute — so a
  second wired agent group still mints its own card (matching the pre-fold
  table). In-flight sender cards die at upgrade; a new message re-triggers.
- Channel registration + OneCLI keep their flows, adopt the contract:
  channel holds expose a synthesized PendingApproval view, OneCLI rows
  carry the contract fields.
- registerApprovalRequestedHandler / notifyApprovalRequested — the
  creation-side observer sibling of notifyApprovalResolved. Together they
  give observers the full hold lifecycle (outcome approve|reject|expire|
  sweep, session nullable) with zero touch points inside the flows.
- ncl approvals list/get expose approver_user_id / approver_rule /
  dedup_key.

Migration takes slot 020 (main already ships 019). No approver
blast-radius scope and no channel-approver change ride this PR — those
defect fixes (D1/D4) are deliberately out of scope for the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:03:15 +03:00
Moshe Krupper c1445876a6 docs(guard): the name reply is not re-authorized — stop claiming it is
The channelsRegister docstring and the CLAUDE.md guard row still described
the withdrawn reply-time re-check (behavior delta (c), removed in fe235c7):
the docstring claimed the decision is consulted by the name-capture
interceptor "so a privilege revoked mid-flow is re-checked at each step",
and the CLAUDE.md row said message interceptors consult the guard inline.
Neither is true — channels.register is consulted only by the card-click
handler, and the free-text reply deliberately authorizes off the click
(main's behavior). Both docs now state that explicitly, including the
consequence: a privilege revoked between click and reply still completes
the flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:15:44 +03:00
Moshe Krupper 5a114b5427 Merge remote-tracking branch 'origin/main' into feat/guard-seam
# Conflicts:
#	CHANGELOG.md
2026-07-12 00:17:37 +03:00
Moshe Krupper 790a7a6dfd docs: drop internal design-process references from the guard change
The public tree shouldn't point at internal artifacts: phase numbering
("guarded-actions phase 2/3") and the team-hub decisions-doc pointer are
gone from the CHANGELOG bullet, the CLAUDE.md guard row, and the guard
file headers. The facts stay ("policy-as-data is deliberately deferred —
a generalized rules table can arrive later"); only the internal roadmap
vocabulary goes. Scope: lines this branch introduced only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:58:45 +03:00
Moshe Krupper f24edc2c3b 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>
2026-07-11 23:54:23 +03:00
Moshe Krupper fe235c7ca0 refactor(guard): unwrap the broadcast registries — response handlers and interceptors consult inline
registerResponseHandler and registerMessageInterceptor return to main's
single-argument shape: the guard-spec/unguarded declaration machinery
(claims, onDeny, the registry wrappers) is deleted. The declaration
requirement stays where registration is keyed — every ncl command derives
its guard inside register(), and registerDeliveryAction demands a spec or
unguarded(<reason>) — while broadcast hooks gate inline at the point of
privilege, like the a2a route and the unknown-sender gate:

- handleChannelApprovalResponse consults guard(channelsRegister) inline
  where main had the click-auth if — same decision, same timing.
- The free-text name capture returns to main verbatim: the click arms it,
  the reply is not re-authorized (the body's own pending-row re-fetch
  still refuses a vanished registration). Behavior delta (c) is withdrawn
  — the deliberate deltas are back to (a) approve-then-revoke and
  (b) grant refusals.

Also gone with the wrappers: the claims/body predicate duplication (the
same early-exits existed twice and could drift apart) and the repeated
extractAndUpsertUser upsert per captured event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:16:17 +03:00
Moshe Krupper a9e5231039 refactor(guard): one runtime discriminator for Unguarded — isUnguarded()
The unguarded brand becomes a real (module-private) symbol and
isUnguarded() its exported type guard, replacing three hand-rolled
`'reason' in guardDecl` checks (delivery, router, response-registry)
plus the one inside isUnguardedEntry. Only unguarded() can mint the
brand now, so a look-alike { reason } object — or a guard spec that
someday grows a `reason` field — can't pass as an unguarded
declaration at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:23:58 +03:00
Moshe Krupper f87f82c528 refactor(guard): consults carry the defined action value — delete the registry walk
The guard's weak point was the wiring: catalog entries registered by
side-effect import, consult sites naming them by string, a map miss
resolving to ALLOW, and a boot walk that could only see 2 of the 4
registries (response handlers and interceptors erased their specs into
closures). Dropping one unreferenced import silently disarmed
channel-registration click-auth.

Make the broken wiring unconstructible instead of detected:

- defineGuardedAction returns a branded GuardedAction value; guard(action,
  input) takes the value, not a name. A dropped module-edge import or a
  typo'd action is now a compile error; there is no lookup and no
  fail-open branch. A forged value (outside TS) is denied at runtime.
- Every registry (delivery actions, response handlers, interceptors)
  requires a guard spec or an explicit unguarded(<reason>) declaration at
  the registration site — unguarded-by-omission is not representable, and
  the justification travels with the registration (grep "unguarded(" for
  the complete inventory). CLI commands derive their guard inside
  register(), so a command cannot exist without one.
- guardConformanceViolations() and EXEMPT_DELIVERY_ACTIONS are gone. The
  boot check shrinks to the one cross-registry invariant the compiler
  can't see: every holding action has a registered approve continuation
  (grantContinuationGaps), same fail-closed refuse-to-start posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:23:58 +03:00
Moshe Krupper 35ca4e14d2 refactor: rename src/guard/catalog.ts → guard-actions.ts
File + internal map (catalog → guardedActions). The concept stays "the
action catalog" in prose (the term the requirements/decisions docs and
the conformance banner use); exported symbols (registerGuardedAction,
GuardedActionSpec, …) unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:22:29 +03:00
Moshe Krupper 69e3b57d9d feat: guard seam — decision function, registration wrapping, grant-carrying replay, boot conformance
Every privileged action crossing the container or channel boundary now
passes one decision function — guard() in the new src/guard/ leaf —
before it executes: allow | hold | deny (hub:
engineering/requirements/guarded-actions +
engineering/discovery/guarded-actions-decisions, phase 2).

- Registration-derived catalog: registry.register() derives one entry
  per ncl command (dotted action names stamped by registerResource);
  module-edge guard.ts adapters register the domain baselines
  (agents.create, a2a.send incl. the agent_message_policies hold,
  self_mod.*, senders.admit, channels.register). The baseline is the
  decision — policy-as-data (tighten-only rules) is deferred to
  phase 3.
- All four handler registries wrap at registration: dispatch consults
  the guard; guarded delivery actions (create_agent, install_packages,
  add_mcp_server) store only the precheck → guard → handler wrapper
  (the raw handler is never stored; spec-less re-registration throws);
  response handlers + message interceptors take guard specs (channel
  registration's click + free-text name capture).
- Grant-carrying replay: approved continuations re-enter their entry
  point with the verified approval row as the grant
  (ApprovalHandlerContext.approval). A grant satisfies a hold, never a
  deny — live-row + approvalAction + grantMatches checks; the forgeable
  approved:true boolean is deleted.
- Boot conformance: the registry walk (src/guard-conformance.ts) runs
  in CI and at boot — an unmapped privileged registration stops the
  host with a banner (skill-installed code never runs this repo's CI).

Baselines are main's behavior verbatim (host trusted-caller, cli_scope
allowlist, create_agent scope branch, a2a ACL order, unconditional
self-mod hold, unknown_sender_policy, channel click auth incl. the
anchor-group approver). Sender/channel holds stay on their own tables;
guard holds map onto the existing requestApproval options
(approverUserId).

Deliberate outcome changes inherent to the replay semantics (called out
in CHANGELOG too): (a) a2a approve-then-revoke no longer delivers — the
structural baseline re-runs live on replay; (b) forged, already-consumed,
or mismatched grants refuse instead of executing; (c) the channel-name
free-text reply re-checks approver eligibility at reply time. The
D1/D2/D4 click-auth fixes are deliberately NOT here — they belong to the
approval-contract PR (D2's host fallback at dispatch replay is preserved
verbatim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:22:28 +03:00
73 changed files with 5309 additions and 1384 deletions
+58
View File
@@ -0,0 +1,58 @@
# Remove /add-audit
Reverses every change the skill made. Safe to re-run even if some pieces are
already gone. Run from the NanoClaw project root.
## 1. Delete the copied files
```bash
rm -rf src/audit
rm -f src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts
rm -f src/cli/resources/audit.ts
rm -f src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts
rm -f src/audit-wiring.test.ts
```
## 2. Revert the dispatch composition
In `src/cli/dispatch.ts`, delete (not comment out) the three edits the skill
made:
1. The import line: `import { withAudit } from './dispatch.audit.js';`
2. The composition block (comment + `export const dispatch = withAudit(dispatchInner);`)
3. Rename the dispatcher back — change `async function dispatchInner(` to
`export async function dispatch(`
## 3. Unregister the resource
Delete the `import './audit.js';` line from `src/cli/resources/index.ts`.
## 4. Unwire the approval observers
Delete the `import './approvals.audit.js';` line (and its comment) from
`src/modules/approvals/index.ts`.
## 5. Remove the settings
Delete the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines from `.env`.
## 6. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
```
## Day-files
`data/audit/*.ndjson` are the operator's records and are deliberately left in
place. To purge them too:
```bash
rm -rf data/audit
```
No dependency was added, nothing under `container/` was touched, and no DB
schema changed — there is nothing else to undo.
+265
View File
@@ -0,0 +1,265 @@
---
name: add-audit
description: Add an opt-in local audit log for the ncl command surface and every host-routed approval — each command's dispatch outcome on both transports (host socket and container, scope denials included), plus each approval's request and decision (approved/rejected, across CLI, self-mod, a2a, sender admission, OneCLI, channel registration), written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; plug exporters in via registerAuditHook. Off until AUDIT_ENABLED=true.
---
# /add-audit — Local Audit Log (ncl surface + approvals)
Records one canonical audit event for two surfaces:
1. **Every `ncl` command** — human over the host socket or agent over the
container transport, denials included. Both transports converge on the
exported `dispatch`, so one composition covers the whole surface: no second
door.
2. **Every host-routed approval** — its `pending` request and its
`approvals.decide` decision (approved / rejected), whichever stack gated it:
CLI commands, self-mod (`install_packages` / `add_mcp_server`), a2a
create-agent and message-gate, unknown-sender admission, OneCLI credential
holds, and channel registration. Two lifecycle observers cover them all with
zero touch points inside the approval flows.
```
ncl (host socket) ──┐
├─→ dispatch = withAudit(dispatchInner) ─→┐
container cli_request ┘ │ │
approved replay (grant) ───────┘ ├─→ one NDJSON line
│ data/audit/<UTC-day>.ndjson
every hold ─→ requested observer ─→ pending ─────────────────┤
every resolution ─→ resolved observer ─→ approvals.decide ───┘
```
What one event carries: `actor` (`host:<os-user>`, the agent group, a
`<channel>:<handle>` admin for a decision, or `system` for an expiry/sweep),
`origin` (transport, session, channel), dotted `action` from the guard catalog
(e.g. `groups.config.add-mcp-server`, `agents.create`, `approvals.decide`),
touched/attempted `resources` (a gated chain names its `approval` and, on the
`pending` event, the picked approver as a `user`), `outcome` (`success ·
failure · denied · pending · approved · rejected`), `correlation_id` (the
approval id on gated chains — the `pending` request, the `approvals.decide`
decision, and a CLI replay's terminal event all share it, so `--correlation
<id>` returns the whole chain), and `details` (governance-safe names only —
never raw argument values).
Two adapters, one leaf: the CLI adapter (`cli/dispatch.audit.ts`) owns command
events and the CLI hold's `pending`; the approvals adapter
(`modules/approvals/approvals.audit.ts`) owns every non-CLI `pending` and every
decision. Both call the same domain-free `src/audit/` emit/store. Later
surfaces (message traffic, tool calls, container lifecycle) attach the same way
— a new `*.audit.ts` adapter, no schema change.
## Steps
### 0. Pre-flight (idempotency)
The apply is safe to re-run; every step below is guarded. Skip to
**Enable** if all of these already hold:
- `src/audit/` exists
- `src/cli/resources/index.ts` contains `import './audit.js';`
- `src/cli/dispatch.ts` contains `export const dispatch = withAudit(dispatchInner);`
- `src/modules/approvals/index.ts` contains `import './approvals.audit.js';`
Before editing, verify the reach-in targets still exist: `src/cli/dispatch.ts`
must contain `export async function dispatch(` (or the already-applied
composition), `src/cli/resources/index.ts` must be the resource barrel (a list
of `import './<resource>.js';` lines), and `src/modules/approvals/index.ts`
must be the approvals barrel exposing the lifecycle observers
(`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` in
`./primitive.js`). If any has moved, stop and adapt rather than guessing.
### 1. Copy the payload
From the NanoClaw project root:
```bash
cp -R "${CLAUDE_SKILL_DIR}/add/src/." src/
```
What lands (mirrors of the destination paths):
- `src/audit/` — the domain-free leaf: event schema (`types.ts`), env config
(`config.ts`), redactor, NDJSON day-file store, emit seam, post-write hooks,
reader, boot/maintenance wiring, vocabulary — plus its tests.
- `src/cli/dispatch.audit.ts` — the CLI adapter: `withAudit` middleware and
the actor/origin/resource mapping (+ `dispatch.audit.test.ts`).
- `src/modules/approvals/approvals.audit.ts` — the approvals adapter: the two
lifecycle-observer subscriptions that emit `pending` and `approvals.decide`
(+ `approvals.audit.test.ts`).
- `src/cli/resources/audit.ts` — the read-only `ncl audit` resource.
- `src/audit-wiring.test.ts` — goes red if any of the three core edits below is
deleted or drifts.
### 2. Register the resource
Append to `src/cli/resources/index.ts` (skip if the line is already present):
```typescript
import './audit.js';
```
### 3. Compose the dispatch middleware
This is the skill's one functional reach-in, in `src/cli/dispatch.ts`. Three
small edits:
1. Add the import (next to the other `./` imports):
```typescript
import { withAudit } from './dispatch.audit.js';
```
2. Rename the dispatcher declaration — change
```typescript
export async function dispatch(
```
to
```typescript
async function dispatchInner(
```
3. Directly after that function's closing brace (before the
`registerApprovalHandler('cli_command', …)` block), add:
```typescript
// Audit middleware (installed by /add-audit): the exported dispatch is the
// wrapped function, so both transports and the approved replay below all
// pass the one composition.
export const dispatch = withAudit(dispatchInner);
```
The composition must live at the definition site (not at the import sites):
the approved-replay handler in the same file calls `dispatch(...)` too, and
only the wrapped export covers it. `src/audit-wiring.test.ts` asserts exactly
this shape via the TypeScript AST.
Loading `dispatch.audit.ts` also boots the audit log: on an enabled box it
asserts `data/audit/` is writable (refusing to start beats a silent audit
gap), runs the boot retention prune, and arms an unref'd maintenance timer.
(The approvals adapter in the next step boots it too — idempotent — so the
approval surface records even on a build that loads it first.)
### 4. Wire the approval observers
The approval-lifecycle increment's one reach-in, in the approvals barrel
`src/modules/approvals/index.ts`. Append a side-effect import (next to the
other module imports; skip if already present):
```typescript
// Approval-lifecycle audit observers (installed by /add-audit): importing the
// adapter registers its request/decision observers at boot.
import './approvals.audit.js';
```
Importing the adapter is what registers its two observers
(`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` from
`./primitive.js`) — the request/decision events for every non-CLI hold. The
barrel already loads at boot via `src/modules/index.ts`, and
`src/audit-wiring.test.ts` asserts the import is present. No `host-sweep.ts`
edit.
### 5. Enable
Add the two settings to `.env` (idempotent — overwrite or append):
```bash
grep -q '^AUDIT_ENABLED=' .env && sed -i.bak 's/^AUDIT_ENABLED=.*/AUDIT_ENABLED=true/' .env && rm -f .env.bak || echo 'AUDIT_ENABLED=true' >> .env
grep -q '^AUDIT_RETENTION_DAYS=' .env || echo 'AUDIT_RETENTION_DAYS=90' >> .env
```
`AUDIT_ENABLED` is the master switch — off (or absent) means `emitAuditEvent`
is a no-op and `data/audit/` is never created. `AUDIT_RETENTION_DAYS`: day
files strictly older than the horizon are hard-deleted (unlinked) at boot and
once per UTC day; `0` = keep forever; unset = 90.
### 6. Build and test
Run `build` before the tests — it's what catches a missed copy or a drifted
import path across the whole composed tree:
```bash
pnpm run build
pnpm exec vitest run src/audit src/cli/dispatch.audit.test.ts src/modules/approvals/approvals.audit.test.ts src/audit-wiring.test.ts
pnpm test
```
### 7. Restart the service
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
```
### 8. Verify (runtime smoke)
```bash
ncl groups list # any command — this one is now an audit event
cat data/audit/$(date -u +%F).ndjson | tail -1
ncl audit list --limit 5
ncl audit list --outcome pending # what is waiting for approval right now
```
The last line of the day-file is the `groups.list` event you just caused. To
see the approval surface, drive a gated action (e.g. an agent asking to wire an
MCP server) and approve it, then `ncl audit list --correlation <approval-id>`
returns the full chain: `pending` → `approvals.decide` → the terminal event.
## Reading it back
```
ncl audit list [--actor <id>] [--action <name-or-prefix>] [--resource <id-or-type>]
[--outcome success|failure|denied|pending|approved|rejected]
[--since 7d|24h|30m|ISO] [--until …] [--correlation <approval-id>]
[--limit N] [--format ndjson]
```
Newest first, default limit 100. `--action groups.config` matches the whole
dotted subtree. `--format ndjson` streams the stored lines verbatim — pipe to
a file for SIEM import. On a disabled box the command errors with
`audit log is disabled — set AUDIT_ENABLED=true` rather than returning an
empty list that would read as "no actions happened".
The resource is deliberately **not** on the group-scope allowlist: audit
spans agent groups, so group-scoped agents are refused before the handler
(fails closed). Host callers and `cli_scope: global` agents only.
## Recording model, failure posture, exporters
- Events store WHO did WHICH action to WHAT target and the outcome — **never
raw argument values**. `details` carries the flag names that were passed plus
a small allowlist of governance-relevant enum fields (`role`, `mode`,
`session_mode`, `cli_scope`, `access`, `engage_mode`, `sender_scope`,
`provider`, `model`) echoed verbatim; every other value is name-only, and a
failure keeps the error *code*, never the free-text message. There is no value
redactor — a secret can't leak from a value that is never stored. Target
identifiers (ids, users, groups) surface structurally in `resources`.
- Fail-open + loud: a failed append is `log.error`'d and the audited action
proceeds — the emit brackets the dispatcher, so even a command that *throws*
still leaves a record. At boot, an enabled box refuses to start if
`data/audit/` isn't writable.
- In-process exporters register via `registerAuditHook` (from
`src/audit/index.js`) — post-write hooks that fire only after the local
append succeeds, so an external system can never be ahead of the source of
truth. Ship one as its own `/add-*` skill; declare `/add-audit` as its
dependency.
## Troubleshooting
- **Host won't start, banner names the audit directory** — `AUDIT_ENABLED=true`
but `data/audit/` isn't writable. Fix permissions or disable audit.
- **`audit log is disabled — set AUDIT_ENABLED=true`** — the read-back guard;
enable in `.env` and restart.
- **An agent gets "CLI access is scoped to this agent group" for `ncl audit`**
— by design; grant the group `cli_scope: global` only if it should read
cross-group history.
- **`pnpm test` on an enabled box adds a few events to today's day-file** —
expected: the base dispatch tests drive real dispatches. Harmless noise;
the audit test suites themselves write only to temp dirs or capture buffers.
## Removal
See [REMOVE.md](REMOVE.md) — reverses every change; existing day-files are
left in place (they're the operator's records).
@@ -0,0 +1,152 @@
/**
* Wiring tests for the /add-audit skill's two core edits — they go red if
* either edit is deleted or drifts:
*
* 1. src/cli/dispatch.ts — the exported dispatch must be the composed
* `withAudit(dispatchInner)` (AST check: only the definition-site
* composition covers both transports AND the in-module approved replay).
* 2. src/cli/resources/index.ts — the audit resource must register through
* the real barrel (behavior check), stay OFF the group-scope allowlist,
* and leave guard conformance clean.
*
* Plus the emit invariant the module's discipline rests on: emitAuditEvent
* appears only in src/audit/ and *.audit.ts adapter files.
*/
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
// Same production barrels the guard conformance test loads (mirrors
// src/guard/conformance.test.ts).
import './cli/commands/index.js';
import './modules/index.js';
import './cli/delivery-action.js';
import './cli/dispatch.js';
import { commandGuard, GROUP_SCOPE_RESOURCES, lookup } from './cli/registry.js';
import { getApprovalHandler } from './modules/approvals/primitive.js';
import { listGuardedActions } from './guard/index.js';
describe('audit resource registration (barrel wiring)', () => {
it('registers audit-list through the real resource barrel', () => {
const cmd = lookup('audit-list');
expect(cmd).toBeDefined();
expect(cmd?.action).toBe('audit.list');
expect(cmd?.access).toBe('open');
});
it('derives a guard-catalog entry for the audit command', () => {
const guard = commandGuard('audit-list');
expect(guard.action).toBe('audit.list');
});
it('is NOT on the group-scope allowlist — group-scoped agents fail closed', () => {
expect(GROUP_SCOPE_RESOURCES.has('audit')).toBe(false);
});
it('leaves guard conformance clean with the audit resource registered', () => {
// The invariant guard conformance checks: every holding action pairs with a
// registered approve continuation. audit.list is `open` (never holds), so
// registering the resource can't introduce a gap — this pins that.
const dangling = listGuardedActions()
.filter((spec) => spec.grantActionName)
.filter((spec) => !getApprovalHandler(spec.grantActionName as string));
expect(dangling.map((s) => s.action)).toEqual([]);
});
});
describe('dispatch composition (AST wiring)', () => {
const source = fs.readFileSync(path.resolve('src/cli/dispatch.ts'), 'utf8');
const sf = ts.createSourceFile('dispatch.ts', source, ts.ScriptTarget.Latest, true);
const hasExportModifier = (node: ts.HasModifiers): boolean =>
(ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
let importsWithAudit = false;
let innerDeclaredUnexported = false;
let exportsWrappedDispatch = false;
let exportsUnwrappedDispatchFn = false;
sf.forEachChild((node) => {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === './dispatch.audit.js'
) {
const named = node.importClause?.namedBindings;
if (named && ts.isNamedImports(named) && named.elements.some((e) => e.name.text === 'withAudit')) {
importsWithAudit = true;
}
}
if (ts.isFunctionDeclaration(node) && node.name?.text === 'dispatchInner') {
innerDeclaredUnexported = !hasExportModifier(node);
}
if (ts.isFunctionDeclaration(node) && node.name?.text === 'dispatch' && hasExportModifier(node)) {
exportsUnwrappedDispatchFn = true;
}
if (ts.isVariableStatement(node) && hasExportModifier(node)) {
for (const decl of node.declarationList.declarations) {
if (
ts.isIdentifier(decl.name) &&
decl.name.text === 'dispatch' &&
decl.initializer &&
ts.isCallExpression(decl.initializer) &&
ts.isIdentifier(decl.initializer.expression) &&
decl.initializer.expression.text === 'withAudit' &&
decl.initializer.arguments.length === 1 &&
ts.isIdentifier(decl.initializer.arguments[0]) &&
decl.initializer.arguments[0].text === 'dispatchInner'
) {
exportsWrappedDispatch = true;
}
}
}
});
it('imports withAudit from the audit adapter', () => {
expect(importsWithAudit).toBe(true);
});
it('keeps the inner dispatcher unexported (the guarded path is the only path)', () => {
expect(innerDeclaredUnexported).toBe(true);
});
it('exports dispatch as withAudit(dispatchInner)', () => {
expect(exportsWrappedDispatch).toBe(true);
expect(exportsUnwrappedDispatchFn).toBe(false);
});
});
describe('approvals adapter wiring', () => {
it('the approvals barrel imports the approval audit adapter', () => {
// The one reach-in the approval-lifecycle increment adds: importing the
// adapter is what registers its two observers at boot. Goes red if dropped.
const source = fs.readFileSync(path.resolve('src/modules/approvals/index.ts'), 'utf8');
expect(source).toMatch(/import\s+['"]\.\/approvals\.audit\.js['"];/);
});
});
describe('emit invariant', () => {
it('emitAuditEvent appears only in src/audit/ and *.audit.ts files', () => {
const srcRoot = path.resolve('src');
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!entry.name.endsWith('.ts')) continue;
const rel = path.relative(srcRoot, full).split(path.sep).join('/');
if (rel === 'audit-wiring.test.ts') continue; // this file names the symbol
if (rel.startsWith('audit/')) continue;
if (/\.audit(\.test)?\.ts$/.test(rel)) continue;
if (fs.readFileSync(full, 'utf8').includes('emitAuditEvent')) offenders.push(rel);
}
};
walk(srcRoot);
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,30 @@
/**
* Audit env config (installed by /add-audit) — the two audit vars, read the
* same way src/config.ts reads every host setting (readEnvFile from .env,
* with process.env taking precedence). Kept audit-owned so installing the
* skill never edits core config.ts: the feature's whole footprint in core is
* the dispatch composition in dispatch.ts and the resource-barrel import.
*/
import { readEnvFile } from '../env.js';
const envConfig = readEnvFile(['AUDIT_ENABLED', 'AUDIT_RETENTION_DAYS']);
/**
* Master switch. Off by default — nothing is persisted (and data/audit/ is
* never created) until an operator sets AUDIT_ENABLED=true.
*/
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
/**
* Day-file retention horizon in days; consulted only when audit is enabled.
* Unset or unparseable → 90. An explicit 0 (or negative) = keep forever.
*/
function parseRetentionDays(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') return 90;
const n = Number.parseInt(raw, 10);
return Number.isNaN(n) ? 90 : n;
}
export const AUDIT_RETENTION_DAYS = parseRetentionDays(
process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS,
);
@@ -0,0 +1,45 @@
/**
* The single emit seam. Adapters import emitAuditEvent from here directly
* (it is deliberately not re-exported by the barrel): only src/audit/ and
* `*.audit.ts` adapter files may call it — grep holds the invariant.
*
* The opt-in check lives here, so the whole feature switches at one point.
* Fail-open + loud: a failed append (or a throwing input thunk) is
* log.error'd and the audited action proceeds.
*/
import { randomUUID } from 'crypto';
import { log } from '../log.js';
import { AUDIT_ENABLED } from './config.js';
import { notifyAuditHooks } from './hooks.js';
import { appendAuditLine } from './store.js';
import type { AuditEvent, AuditEventInput } from './types.js';
export function emitAuditEvent(input: AuditEventInput | (() => AuditEventInput)): void {
if (!AUDIT_ENABLED) return; // The one opt-in check — the whole feature switches here.
try {
if (typeof input === 'function') input = input();
const event: AuditEvent = {
event_id: randomUUID(),
time: new Date().toISOString(),
schema_version: 1,
actor: { ...input.actor, email: null, user_id: null, group_ids: null },
origin: input.origin,
action: input.action,
resources: input.resources,
outcome: input.outcome,
correlation_id: input.correlationId ?? null,
// Adapters build `details` value-free (key names + an enum allowlist), so
// there is nothing to redact here — a secret can't leak from a value that
// is never stored. Written verbatim.
details: input.details ?? {},
};
const line = JSON.stringify(event);
appendAuditLine(line, event.time.slice(0, 10));
notifyAuditHooks(event, line);
// eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the posture: an audit failure must never take down the audited action
} catch (err) {
const action = typeof input === 'function' ? undefined : input.action;
log.error('Audit append failed — action proceeding (fail-open)', { action, err });
}
}
@@ -0,0 +1,240 @@
/**
* Post-write hook contract: hooks observe the LOG (fire only after a
* successful append, exported ⊆ written), failures are isolated everywhere,
* and the lifecycle (init/maintain/shutdown) behaves.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ enabled: true, appendThrows: false, appended: [] as string[] }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return {
...actual,
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
};
});
vi.mock('./config.js', () => ({
get AUDIT_ENABLED() {
return state.enabled;
},
AUDIT_RETENTION_DAYS: 90,
}));
vi.mock('./store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
if (state.appendThrows) throw new Error('disk full');
state.appended.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let hooks: typeof import('./hooks.js');
let emit: typeof import('./emit.js');
let log: (typeof import('../log.js'))['log'];
beforeEach(async () => {
state.enabled = true;
state.appendThrows = false;
state.appended.length = 0;
vi.resetModules(); // fresh hook registry per test
hooks = await import('./hooks.js');
emit = await import('./emit.js');
log = (await import('../log.js')).log;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
const EVENT_INPUT = {
actor: { type: 'human' as const, id: 'host:test' },
origin: { transport: 'socket' as const },
action: 'groups.list',
resources: [{ type: 'agent_group' }],
outcome: 'success' as const,
details: { limit: 5 },
};
describe('post-write notification', () => {
it('calls a registered hook with the parsed event and the exact stored line', () => {
const seen: Array<{ event: import('./types.js').AuditEvent; line: string }> = [];
hooks.registerAuditHook({ name: 'demo', onEvent: (event, line) => seen.push({ event, line }) });
emit.emitAuditEvent(EVENT_INPUT);
expect(state.appended).toHaveLength(1);
expect(seen).toHaveLength(1);
expect(seen[0].line).toBe(state.appended[0]);
expect(JSON.parse(seen[0].line)).toEqual(seen[0].event);
expect(seen[0].event.action).toBe('groups.list');
});
it('does NOT call hooks when the local append fails — exported ⊆ written', () => {
const onEvent = vi.fn();
hooks.registerAuditHook({ name: 'demo', onEvent });
state.appendThrows = true;
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow(); // action still proceeds
expect(onEvent).not.toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Audit append failed'), expect.anything());
});
it('does NOT call hooks when audit is disabled', () => {
const onEvent = vi.fn();
hooks.registerAuditHook({ name: 'demo', onEvent });
state.enabled = false;
emit.emitAuditEvent(EVENT_INPUT);
expect(state.appended).toHaveLength(0);
expect(onEvent).not.toHaveBeenCalled();
});
it('isolates a throwing hook: the write survives, later hooks still run, the action proceeds', () => {
const second = vi.fn();
hooks.registerAuditHook({
name: 'broken',
onEvent: () => {
throw new Error('exporter exploded');
},
});
hooks.registerAuditHook({ name: 'healthy', onEvent: second });
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
expect(state.appended).toHaveLength(1); // the log has the event regardless
expect(second).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('Audit hook threw'),
expect.objectContaining({ hook: 'broken', action: 'groups.list' }),
);
});
});
describe('lifecycle', () => {
it('initAuditHooks surfaces a failing init as a fatal error naming the hook', () => {
hooks.registerAuditHook({ name: 'ok', onEvent: () => {}, init: vi.fn() });
hooks.registerAuditHook({
name: 'bad-boot',
onEvent: () => {},
init: () => {
throw new Error('no route to collector');
},
});
expect(() => hooks.initAuditHooks()).toThrow(/audit hook "bad-boot" failed to initialize.*no route/);
});
it('defers init() until boot for a hook registered before initAuditHooks', () => {
const init = vi.fn();
hooks.registerAuditHook({ name: 'early', onEvent: () => {}, init });
expect(init).not.toHaveBeenCalled(); // not yet — boot hasn't run
hooks.initAuditHooks();
expect(init).toHaveBeenCalledTimes(1); // exactly once, at boot
});
it('runs init() immediately for a hook registered after boot — import-order-insensitive', () => {
hooks.initAuditHooks(); // boot completes (no hooks yet)
const init = vi.fn();
const onEvent = vi.fn();
hooks.registerAuditHook({ name: 'late', onEvent, init });
// A module that loaded after the CLI adapter still gets its one-time init...
expect(init).toHaveBeenCalledTimes(1);
// ...and still receives events (onEvent already read the live array).
emit.emitAuditEvent(EVENT_INPUT);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it('a hook registered after boot whose init throws is fatal, naming the hook', () => {
hooks.initAuditHooks();
expect(() =>
hooks.registerAuditHook({
name: 'late-bad',
onEvent: () => {},
init: () => {
throw new Error('no route to collector');
},
}),
).toThrow(/audit hook "late-bad" failed to initialize.*no route/);
});
it('maintainAuditHooks calls every maintain and isolates throws', () => {
const m1 = vi.fn(() => {
throw new Error('flush failed');
});
const m2 = vi.fn();
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain: m1 });
hooks.registerAuditHook({ name: 'b', onEvent: () => {}, maintain: m2 });
hooks.registerAuditHook({ name: 'c', onEvent: () => {} }); // no maintain — fine
expect(() => hooks.maintainAuditHooks()).not.toThrow();
expect(m1).toHaveBeenCalledTimes(1);
expect(m2).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('maintenance failed'),
expect.objectContaining({ hook: 'a' }),
);
});
it('shutdownAuditHooks awaits async shutdowns and isolates throws', async () => {
const order: string[] = [];
hooks.registerAuditHook({
name: 'a',
onEvent: () => {},
shutdown: async () => {
await Promise.resolve();
order.push('a');
},
});
hooks.registerAuditHook({
name: 'b',
onEvent: () => {},
shutdown: () => {
throw new Error('handle already closed');
},
});
hooks.registerAuditHook({
name: 'c',
onEvent: () => {},
shutdown: () => {
order.push('c');
},
});
await hooks.shutdownAuditHooks();
expect(order).toEqual(['a', 'c']);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('shutdown failed'),
expect.objectContaining({ hook: 'b' }),
);
});
it('maintainAudit skips hook maintenance when audit is disabled', async () => {
const init = await import('./init.js');
const maintain = vi.fn();
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain });
state.enabled = false;
init.maintainAudit();
expect(maintain).not.toHaveBeenCalled();
state.enabled = true;
init.maintainAudit();
expect(maintain).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,111 @@
/**
* Post-write audit hooks — the in-process extension seam.
*
* A hook observes the audit LOG, not the event stream: `onEvent` fires only
* after an event has been durably appended to the local day-file, so anything
* a hook exports is guaranteed to exist in the source of truth
* (exported ⊆ written). If the local append fails, hooks are not called —
* and a hook that misses events (crash, restart) catches up by reading the
* day-files, which is the at-least-once story.
*
* Registration follows the tree's observer idiom (registerApprovalResolvedHandler,
* registerResponseHandler, …): an in-tree or skill-installed module calls
* `registerAuditHook(...)` at import time — no core edits, and credentials or
* transport for an external system live in that module, never here.
*/
import { log } from '../log.js';
import type { AuditEvent } from './types.js';
export interface AuditHook {
/** Short identifier used in logs and lifecycle errors. */
name: string;
/**
* Called after a successful local append. `line` is the exact stored bytes
* (one NDJSON line, no trailing newline); `event` is the parsed record.
* MUST be fast and non-blocking — this runs on the audited action's call
* path. A real exporter buffers here and does its IO from `maintain`/its own
* timers. Throwing is tolerated: isolated and logged, never propagated.
*/
onEvent(event: AuditEvent, line: string): void;
/**
* One-time setup, called once when audit is enabled — at boot if the hook is
* already registered, else immediately on registration (so it fires exactly
* once regardless of import order). Throw = host refuses to start.
*/
init?(): void;
/** Periodic maintenance — called from the audit maintenance timer (enabled boxes only). */
maintain?(): void;
/** Graceful-shutdown hook (flush buffers, close handles). */
shutdown?(): void | Promise<void>;
}
const hooks: AuditHook[] = [];
let hooksInitialized = false;
export function registerAuditHook(hook: AuditHook): void {
hooks.push(hook);
// onEvent/maintain/shutdown all read the live array, so they pick a hook up
// whenever it registers. init() is the exception — it runs once at boot. If
// boot already ran (a hook whose module loaded after the CLI audit adapter),
// run its init() now; otherwise it would receive events but never its boot
// hook. Same throw-is-fatal posture as boot.
if (hooksInitialized) initOneHook(hook);
}
/** Fan out one written event to every hook, isolating failures per hook. */
export function notifyAuditHooks(event: AuditEvent, line: string): void {
for (const hook of hooks) {
try {
hook.onEvent(event, line);
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad hook must not affect the log, other hooks, or the audited action
} catch (err) {
log.error('Audit hook threw — event is safely in the log', { hook: hook.name, action: event.action, err });
}
}
}
/** Boot lifecycle. A hook that can't start is a silent-export-gap risk — fatal. */
export function initAuditHooks(): void {
for (const hook of hooks) initOneHook(hook);
hooksInitialized = true;
}
/**
* Run one hook's boot init with the fatal-on-throw posture. Shared by the boot
* sweep and by a post-boot registration, so every hook gets exactly one init()
* regardless of the import order its module loaded in.
*/
function initOneHook(hook: AuditHook): void {
try {
hook.init?.();
} catch (err) {
throw new Error(
`audit hook "${hook.name}" failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
/** Periodic lifecycle — maintenance, isolated per hook. */
export function maintainAuditHooks(): void {
for (const hook of hooks) {
try {
hook.maintain?.();
// eslint-disable-next-line no-catch-all/no-catch-all -- one hook's maintenance failure must not stop the others (or the timer)
} catch (err) {
log.error('Audit hook maintenance failed', { hook: hook.name, err });
}
}
}
/** Shutdown lifecycle — awaited by the host's graceful shutdown. */
export async function shutdownAuditHooks(): Promise<void> {
for (const hook of hooks) {
try {
await hook.shutdown?.();
// eslint-disable-next-line no-catch-all/no-catch-all -- shutdown must drain every hook even when one throws
} catch (err) {
log.error('Audit hook shutdown failed', { hook: hook.name, err });
}
}
}
@@ -0,0 +1,17 @@
/**
* Opt-in local audit log — installed by /add-audit.
*
* This directory is a domain-free leaf: the event schema, the emit seam, the
* store, the reader, post-write hooks, and shared vocabulary. What gets
* audited — and how each domain describes itself — lives in the domain-owned
* `*.audit.ts` adapter files next to the code they observe. Business logic
* contains zero audit calls.
*
* emitAuditEvent is deliberately NOT re-exported here: adapters import it
* from './emit.js' directly, and only src/audit/ + `*.audit.ts` may call it.
*/
export * from './types.js';
export { AUDIT_DIR } from './store.js';
export { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
export { initAuditLog, maintainAudit } from './init.js';
export { type AuditHook, registerAuditHook } from './hooks.js';
@@ -0,0 +1,54 @@
/**
* Boot-time audit wiring, self-contained (installed by /add-audit).
*
* initAuditLog() runs at module load of the CLI audit adapter
* (cli/dispatch.audit.ts) — i.e. during the host's barrel phase, before the
* CLI server or any delivery poll accepts work — because dispatch.ts, which
* both transports import, composes withAudit at module scope. When enabled:
* assert data/audit/ is writable (refusing to start beats running with a
* silent audit gap), run the boot prune, start the registered post-write
* hooks, and arm the maintenance timer.
*
* The timer owns the daily cadence in-module (checked hourly; the prune
* itself fires at most once per UTC day) so installing the skill touches
* dispatch.ts and the resource barrel only — no host-sweep edit. It is
* unref'd: it never keeps the process alive.
*/
import { log } from '../log.js';
import { onShutdown } from '../response-registry.js';
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
import { initAuditHooks, maintainAuditHooks, shutdownAuditHooks } from './hooks.js';
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
let initialized = false;
export function initAuditLog(): void {
if (!AUDIT_ENABLED || initialized) return;
initialized = true;
try {
assertAuditWritable();
} catch (err) {
throw new Error(
`AUDIT_ENABLED=true but the audit directory is not writable: ${AUDIT_DIR} (${err instanceof Error ? err.message : String(err)})`,
{ cause: err },
);
}
pruneAuditLog();
markPrunedToday();
initAuditHooks(); // throw → boot fails, same posture as the writability assert
onShutdown(() => shutdownAuditHooks());
setInterval(maintainAudit, MAINTENANCE_INTERVAL_MS).unref();
log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS });
}
/**
* One maintenance tick: retention prune (throttled to once per UTC day
* internally) plus every hook's periodic maintenance. No-op when audit is
* disabled. Exported so a future scheduler can drive it too.
*/
export function maintainAudit(): void {
pruneAuditLogIfDue();
if (AUDIT_ENABLED) maintainAuditHooks();
}
@@ -0,0 +1,180 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ dataDir: '', enabled: true }));
vi.mock('../config.js', () => ({
get DATA_DIR() {
return state.dataDir;
},
}));
vi.mock('./config.js', () => ({
get AUDIT_ENABLED() {
return state.enabled;
},
AUDIT_RETENTION_DAYS: 90,
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let reader: typeof import('./reader.js');
let store: typeof import('./store.js');
beforeEach(async () => {
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-reader-'));
state.enabled = true;
vi.resetModules();
store = await import('./store.js');
reader = await import('./reader.js');
});
afterEach(() => {
fs.rmSync(state.dataDir, { recursive: true, force: true });
});
function dayString(daysAgo: number): string {
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
}
function isoAt(daysAgo: number, tag: number): string {
const base = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
return `${base.toISOString().slice(0, 10)}T10:00:0${tag}.000Z`;
}
let seq = 0;
function seedEvent(daysAgo: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
seq += 1;
const event = {
event_id: `e-${seq}`,
time: isoAt(daysAgo, seq % 10),
schema_version: 1,
actor: { type: 'human', id: 'host:moshe', email: null, user_id: null, group_ids: null },
origin: { transport: 'socket' },
action: 'groups.list',
resources: [{ type: 'agent_group', id: 'ag-1' }],
outcome: 'success',
correlation_id: null,
details: {},
...overrides,
};
const dir = path.join(state.dataDir, 'audit');
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, `${dayString(daysAgo)}.ndjson`), JSON.stringify(event) + '\n');
return event;
}
describe('listAuditEvents', () => {
it('throws the disabled error rather than returning an empty list', () => {
state.enabled = false;
expect(() => reader.listAuditEvents({})).toThrow('audit log is disabled — set AUDIT_ENABLED=true');
});
it('returns flat rows newest-first across day-files, honoring --limit', () => {
seedEvent(2, { event_id: 'old' });
seedEvent(1, { event_id: 'mid-a' });
seedEvent(1, { event_id: 'mid-b' });
seedEvent(0, { event_id: 'new' });
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
expect(rows.map((r) => r.event_id)).toEqual(['new', 'mid-b', 'mid-a', 'old']);
expect(rows[0]).toMatchObject({ actor: 'host:moshe', action: 'groups.list', outcome: 'success' });
expect(rows[0].resources).toBe('agent_group:ag-1');
const limited = reader.listAuditEvents({ limit: 2 }) as Array<Record<string, unknown>>;
expect(limited.map((r) => r.event_id)).toEqual(['new', 'mid-b']);
});
it('filters by actor, outcome, correlation, and resource (id or type)', () => {
seedEvent(0, { event_id: 'a', actor: { type: 'agent', id: 'ag-1' }, outcome: 'denied' });
seedEvent(0, { event_id: 'b', correlation_id: 'appr-9', resources: [{ type: 'approval', id: 'appr-9' }] });
seedEvent(0, { event_id: 'c', resources: [{ type: 'user', id: 'slack:U1' }] });
expect((reader.listAuditEvents({ actor: 'ag-1' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ outcome: 'denied' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ correlation: 'appr-9' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ resource: 'slack:U1' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ resource: 'approval' }) as unknown[]).length).toBe(1);
});
it('matches actions exactly or by dotted prefix', () => {
seedEvent(0, { event_id: 'cfg', action: 'groups.config.add-mcp-server' });
seedEvent(0, { event_id: 'list', action: 'groups.list' });
seedEvent(0, { event_id: 'other', action: 'sessions.list' });
expect((reader.listAuditEvents({ action: 'groups' }) as unknown[]).length).toBe(2);
expect((reader.listAuditEvents({ action: 'groups.config' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ action: 'groups.list' }) as unknown[]).length).toBe(1);
// Prefix means dotted segments, not substrings.
expect((reader.listAuditEvents({ action: 'group' }) as unknown[]).length).toBe(0);
});
it('applies --since/--until with relative and ISO forms', () => {
seedEvent(5, { event_id: 'old' });
seedEvent(0, { event_id: 'recent' });
const relative = reader.listAuditEvents({ since: '2d' }) as Array<Record<string, unknown>>;
expect(relative.map((r) => r.event_id)).toEqual(['recent']);
const iso = reader.listAuditEvents({ until: dayString(2) }) as Array<Record<string, unknown>>;
expect(iso.map((r) => r.event_id)).toEqual(['old']);
expect(() => reader.listAuditEvents({ since: 'yesterdayish' })).toThrow('invalid --since');
});
it('--format ndjson returns the stored lines verbatim', () => {
const seeded = seedEvent(0, { event_id: 'x1' });
const out = reader.listAuditEvents({ format: 'ndjson' });
expect(typeof out).toBe('string');
expect(JSON.parse(out as string)).toEqual(seeded);
expect(() => reader.listAuditEvents({ format: 'csv' })).toThrow('invalid --format');
});
it('--format ndjson exports every event in the window (no silent 100-row cap)', () => {
for (let i = 0; i < 150; i++) seedEvent(0, { event_id: `e-${i}` });
const ndjson = reader.listAuditEvents({ format: 'ndjson' }) as string;
expect(ndjson.split('\n').length).toBe(150);
// The table view still caps at the default for readability.
expect((reader.listAuditEvents({}) as unknown[]).length).toBe(100);
});
it('honors an explicit --limit 0 as zero rows, not the default', () => {
seedEvent(0, { event_id: 'a' });
expect(reader.listAuditEvents({ limit: 0 })).toEqual([]);
});
it('rejects a negative or non-integer --limit', () => {
expect(() => reader.listAuditEvents({ limit: -1 })).toThrow('invalid --limit');
expect(() => reader.listAuditEvents({ limit: 'lots' })).toThrow('invalid --limit');
});
it('parses a timezone-less --since/--until datetime as UTC, not local', () => {
expect(reader.parseTimeFlag('2026-07-12T00:00:00', '--since')).toBe(Date.parse('2026-07-12T00:00:00Z'));
// Date-only already parses UTC; an explicit offset is respected as-is.
expect(reader.parseTimeFlag('2026-07-12', '--since')).toBe(Date.parse('2026-07-12'));
expect(reader.parseTimeFlag('2026-07-12T00:00:00+02:00', '--until')).toBe(
Date.parse('2026-07-12T00:00:00+02:00'),
);
});
it('skips malformed stored lines and still returns the rest', () => {
seedEvent(0, { event_id: 'good' });
fs.appendFileSync(path.join(state.dataDir, 'audit', `${dayString(0)}.ndjson`), 'not-json\n');
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
expect(rows.map((r) => r.event_id)).toEqual(['good']);
});
it('rejects an unknown --outcome value', () => {
expect(() => reader.listAuditEvents({ outcome: 'meh' })).toThrow('invalid --outcome');
});
it('returns empty when nothing has been recorded yet', () => {
expect(reader.listAuditEvents({})).toEqual([]);
});
});
@@ -0,0 +1,178 @@
/**
* Read-back for `ncl audit list` — a newest-first stream-scan over the
* day-files. No index: fine at v1 volume, and adding one later doesn't change
* the store. NDJSON export returns the stored lines verbatim.
*/
import fs from 'fs';
import path from 'path';
import { log } from '../log.js';
import { AUDIT_ENABLED } from './config.js';
import { AUDIT_DIR, utcDay } from './store.js';
import type { AuditEvent, AuditOutcome } from './types.js';
const OUTCOMES: ReadonlySet<string> = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']);
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
const DEFAULT_LIMIT = 100;
export interface AuditQuery {
actor?: string;
/** Exact action or dotted prefix (`groups` matches `groups.config.update`). */
action?: string;
/** Matches any resources[] entry by id or by type. */
resource?: string;
outcome?: AuditOutcome;
sinceMs?: number;
untilMs?: number;
correlation?: string;
limit: number;
}
/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */
export function parseTimeFlag(value: string, flag: string): number {
const rel = /^(\d+)([dhm])$/.exec(value);
if (rel) {
const n = Number(rel[1]);
const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000;
return Date.now() - n * unitMs;
}
// A timezone-less ISO *datetime* parses as LOCAL per spec, but --since/--until
// are documented UTC; append 'Z' so a bare datetime reads as UTC. Date-only
// forms already parse as UTC, and an explicit offset (`+02:00`/`Z`) is left as-is.
const normalized = /^\d{4}-\d{2}-\d{2}T[\d.:]+$/.test(value) ? `${value}Z` : value;
const abs = Date.parse(normalized);
if (!Number.isNaN(abs)) return abs;
throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`);
}
/** Newest first across files and within each file, up to q.limit. */
export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } {
const events: AuditEvent[] = [];
const lines: string[] = [];
let malformed = 0;
for (const { day, file } of dayFilesNewestFirst()) {
if (events.length >= q.limit) break;
// Whole-day skip: a file can't match a window its day lies outside.
if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue;
if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue;
let content: string;
try {
content = fs.readFileSync(file, 'utf8');
// eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query
} catch (err) {
log.warn('Audit reader failed to read day-file', { file, err });
continue;
}
const fileLines = content.split('\n').filter((l) => l.trim() !== '');
// Lines within a file are chronological — walk backwards for newest-first.
for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) {
let event: AuditEvent;
try {
event = JSON.parse(fileLines[i]) as AuditEvent;
// eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below)
} catch {
malformed++;
continue;
}
if (!matches(event, q)) continue;
events.push(event);
lines.push(fileLines[i]);
}
}
if (malformed > 0) {
log.warn('Audit reader skipped malformed lines', { malformed });
}
return { events, lines };
}
function dayFilesNewestFirst(): Array<{ day: string; file: string }> {
let entries: string[];
try {
entries = fs.readdirSync(AUDIT_DIR);
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error
} catch {
return [];
}
return entries
.map((e) => DAY_FILE_RE.exec(e))
.filter((m): m is RegExpExecArray => m !== null)
.map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) }))
.sort((a, b) => (a.day < b.day ? 1 : -1));
}
function matches(event: AuditEvent, q: AuditQuery): boolean {
if (q.actor !== undefined && event.actor?.id !== q.actor) return false;
if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false;
if (q.outcome !== undefined && event.outcome !== q.outcome) return false;
if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false;
if (q.resource !== undefined) {
const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource);
if (!hit) return false;
}
const t = Date.parse(event.time ?? '');
if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false;
if (q.untilMs !== undefined && !(t <= q.untilMs)) return false;
return true;
}
/**
* `ncl audit list` handler. Disabled → an explicit error: an empty list would
* read as "no actions happened", which is a different truth than "not
* recording". `--format ndjson` returns the stored lines verbatim (the human
* formatter passes strings through); default returns flat rows for the table.
*/
export function listAuditEvents(args: Record<string, unknown>): string | Array<Record<string, unknown>> {
if (!AUDIT_ENABLED) {
throw new Error('audit log is disabled — set AUDIT_ENABLED=true');
}
const format = args.format !== undefined ? String(args.format) : '';
if (format && format !== 'ndjson') {
throw new Error(`invalid --format "${format}" — only "ndjson" is supported`);
}
const outcome = args.outcome !== undefined ? String(args.outcome) : undefined;
if (outcome !== undefined && !OUTCOMES.has(outcome)) {
throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`);
}
// An explicit --limit is honored exactly (0 means 0, not "default"). Absent,
// the table view caps at DEFAULT_LIMIT for readability, but ndjson — the SIEM
// export path — is bounded only by --since/--until, never silently truncated.
let limit: number;
if (args.limit !== undefined) {
const n = Number(args.limit);
if (!Number.isInteger(n) || n < 0) {
throw new Error(`invalid --limit "${String(args.limit)}" — use a non-negative integer`);
}
limit = n;
} else {
limit = format === 'ndjson' ? Infinity : DEFAULT_LIMIT;
}
const q: AuditQuery = {
actor: args.actor !== undefined ? String(args.actor) : undefined,
action: args.action !== undefined ? String(args.action) : undefined,
resource: args.resource !== undefined ? String(args.resource) : undefined,
outcome: outcome as AuditOutcome | undefined,
correlation: args.correlation !== undefined ? String(args.correlation) : undefined,
sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined,
untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined,
limit,
};
const { events, lines } = queryAuditEvents(q);
if (format === 'ndjson') return lines.join('\n');
return events.map((e) => ({
time: e.time,
actor: e.actor?.id ?? '',
action: e.action,
resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '),
outcome: e.outcome,
correlation: e.correlation_id ?? '',
event_id: e.event_id,
}));
}
@@ -0,0 +1,197 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Core config (DATA_DIR) and the audit config (toggles) are mocked so
// store/emit resolve a per-test temp DATA_DIR and audit switches. Getters
// keep the values live across vi.resetModules().
const state = vi.hoisted(() => ({ dataDir: '', enabled: true, retention: 90 }));
vi.mock('../config.js', () => ({
get DATA_DIR() {
return state.dataDir;
},
}));
vi.mock('./config.js', () => ({
get AUDIT_ENABLED() {
return state.enabled;
},
get AUDIT_RETENTION_DAYS() {
return state.retention;
},
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let store: typeof import('./store.js');
let emit: typeof import('./emit.js');
let log: (typeof import('../log.js'))['log'];
beforeEach(async () => {
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-store-'));
state.enabled = true;
state.retention = 90;
vi.resetModules();
store = await import('./store.js');
emit = await import('./emit.js');
log = (await import('../log.js')).log;
vi.clearAllMocks();
});
afterEach(() => {
fs.chmodSync(path.join(state.dataDir), 0o700);
const auditDir = path.join(state.dataDir, 'audit');
if (fs.existsSync(auditDir)) fs.chmodSync(auditDir, 0o700);
fs.rmSync(state.dataDir, { recursive: true, force: true });
});
function auditDir(): string {
return path.join(state.dataDir, 'audit');
}
function writeDayFile(day: string, lines = 1): void {
fs.mkdirSync(auditDir(), { recursive: true });
fs.writeFileSync(path.join(auditDir(), `${day}.ndjson`), '{"x":1}\n'.repeat(lines));
}
function dayString(daysAgo: number): string {
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
}
const EVENT_INPUT = {
actor: { type: 'human' as const, id: 'host:test' },
origin: { transport: 'socket' as const },
action: 'groups.list',
resources: [{ type: 'agent_group' }],
outcome: 'success' as const,
};
describe('appendAuditLine', () => {
it("appends one line to today's UTC day-file, creating the directory lazily", () => {
expect(fs.existsSync(auditDir())).toBe(false);
store.appendAuditLine('{"a":1}');
store.appendAuditLine('{"b":2}');
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
expect(fs.readFileSync(file, 'utf8')).toBe('{"a":1}\n{"b":2}\n');
});
it('buckets by the given event day, not wall-clock at append', () => {
store.appendAuditLine('{"c":3}', '2020-01-02');
expect(fs.readFileSync(path.join(auditDir(), '2020-01-02.ndjson'), 'utf8')).toBe('{"c":3}\n');
// Today's file is untouched — the line filed under its own day.
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(false);
});
});
describe('emitAuditEvent', () => {
it('writes a schema_version-1 record with envelope fields and null enrichment', () => {
emit.emitAuditEvent({ ...EVENT_INPUT, details: { limit: 100 } });
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
expect(record).toMatchObject({
schema_version: 1,
actor: { type: 'human', id: 'host:test', email: null, user_id: null, group_ids: null },
origin: { transport: 'socket' },
action: 'groups.list',
outcome: 'success',
correlation_id: null,
details: { limit: 100 },
});
expect(record.event_id).toMatch(/^[0-9a-f-]{36}$/);
expect(new Date(record.time).toISOString()).toBe(record.time);
});
it('stores details verbatim — the emit seam performs no value transformation', () => {
// emit is domain-free: adapters build `details` value-free, so there is
// nothing to redact here. Whatever is passed is written as-is.
emit.emitAuditEvent({ ...EVENT_INPUT, details: { args: ['role'], role: 'admin' } });
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
expect(record.details).toEqual({ args: ['role'], role: 'admin' });
});
it('is a no-op when audit is disabled — the directory is never created', () => {
state.enabled = false;
emit.emitAuditEvent(EVENT_INPUT);
expect(fs.existsSync(auditDir())).toBe(false);
});
it('fails open and loud when the append fails', () => {
fs.mkdirSync(auditDir(), { recursive: true });
fs.chmodSync(auditDir(), 0o500);
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('Audit append failed'),
expect.objectContaining({ action: 'groups.list' }),
);
});
});
describe('assertAuditWritable', () => {
it('creates the directory and probes it with a zero-byte append', () => {
store.assertAuditWritable();
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(true);
});
it('throws when the directory is not writable', () => {
fs.mkdirSync(auditDir(), { recursive: true });
fs.chmodSync(auditDir(), 0o500);
expect(() => store.assertAuditWritable()).toThrow();
});
});
describe('pruneAuditLog', () => {
it('unlinks only day-files strictly older than the horizon', () => {
writeDayFile(dayString(100));
writeDayFile(dayString(91));
writeDayFile(dayString(90));
writeDayFile(dayString(1));
fs.writeFileSync(path.join(auditDir(), 'not-a-day-file.txt'), 'keep');
fs.writeFileSync(path.join(auditDir(), '2026-01-01.ndjson.bak'), 'keep');
store.pruneAuditLog(90);
const left = fs.readdirSync(auditDir()).sort();
expect(left).toEqual(
[`${dayString(90)}.ndjson`, `${dayString(1)}.ndjson`, '2026-01-01.ndjson.bak', 'not-a-day-file.txt'].sort(),
);
});
it('keeps forever when retention is 0', () => {
writeDayFile(dayString(400));
store.pruneAuditLog(0);
expect(fs.existsSync(path.join(auditDir(), `${dayString(400)}.ndjson`))).toBe(true);
});
it('is a no-op when the audit directory does not exist', () => {
expect(() => store.pruneAuditLog(90)).not.toThrow();
});
});
describe('pruneAuditLogIfDue', () => {
it('prunes at most once per UTC day', () => {
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(false);
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
it('does nothing when audit is disabled', () => {
state.enabled = false;
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
it('does nothing after markPrunedToday until the day rolls over', () => {
store.markPrunedToday();
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
});
@@ -0,0 +1,87 @@
/**
* Day-file store — daily NDJSON files under data/audit/, host process is the
* single writer. Append-only is structural: nothing here can update a line,
* and retention is unlinking whole files (a literal hard delete). Both
* transports converge host-side, so single-writer holds by construction.
*
* This module throws on fs failure; the fail-open posture (log.error, action
* proceeds) lives in emit.ts, and the boot-time strictness (refuse to start)
* lives in init.ts.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
export const AUDIT_DIR = path.join(DATA_DIR, 'audit');
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
const DAY_MS = 24 * 60 * 60 * 1000;
export function utcDay(d: Date = new Date()): string {
return d.toISOString().slice(0, 10);
}
export function dayFilePath(day: string): string {
return path.join(AUDIT_DIR, `${day}.ndjson`);
}
/**
* The single append point. `day` buckets the line by the EVENT's own UTC day
* (emit passes it from event.time), not wall-clock at append — so a line whose
* emit crossed a UTC-midnight tick still files under its timestamp's day, and a
* bounded reader query can't whole-day-skip it. Throws on fs failure —
* emitAuditEvent catches.
*/
export function appendAuditLine(line: string, day: string = utcDay()): void {
fs.mkdirSync(AUDIT_DIR, { recursive: true });
fs.appendFileSync(dayFilePath(day), line + '\n');
}
/** Boot-time writability assert — a zero-byte append is a true write probe. */
export function assertAuditWritable(): void {
fs.mkdirSync(AUDIT_DIR, { recursive: true });
fs.appendFileSync(dayFilePath(utcDay()), '');
}
/** Unlink day-files strictly older than (today UTC retentionDays). 0 or negative = keep forever. */
export function pruneAuditLog(retentionDays: number = AUDIT_RETENTION_DAYS): void {
if (retentionDays <= 0) return;
let entries: string[];
try {
entries = fs.readdirSync(AUDIT_DIR);
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means nothing to prune, not an error
} catch {
return;
}
const horizon = utcDay(new Date(Date.now() - retentionDays * DAY_MS));
let pruned = 0;
for (const entry of entries) {
const m = DAY_FILE_RE.exec(entry);
if (!m || m[1] >= horizon) continue;
try {
fs.unlinkSync(path.join(AUDIT_DIR, entry));
pruned++;
// eslint-disable-next-line no-catch-all/no-catch-all -- one stuck file must not stop the prune of the others
} catch (err) {
log.error('Audit prune failed to unlink day-file', { file: entry, err });
}
}
if (pruned > 0) log.info('Audit retention pruned day-files', { pruned, retentionDays });
}
let lastPruneDay: string | null = null;
/** Retention prune, throttled to once per UTC day — safe to call every tick. */
export function pruneAuditLogIfDue(): void {
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
const today = utcDay();
if (lastPruneDay === today) return;
lastPruneDay = today;
pruneAuditLog();
}
export function markPrunedToday(): void {
lastPruneDay = utcDay();
}
@@ -0,0 +1,57 @@
/**
* Canonical audit event — one flat, SIEM-shaped record per action.
*
* Fields are chosen so they project losslessly onto OCSF and Elastic ECS
* (the two schemas SIEMs converge on); the store keeps the neutral shape and
* a future forwarder pays the translation once, at the edge. `schema_version`
* covers evolution — additive changes only within a version.
*/
export type AuditActorType = 'human' | 'agent' | 'system';
export interface AuditActor {
type: AuditActorType;
/** `host:<os-user>` | `<channel>:<handle>` | agent group id | `host` (system). */
id: string;
}
export interface AuditOrigin {
transport: 'socket' | 'container' | 'channel';
session_id?: string;
messaging_group_id?: string;
channel?: string;
}
export interface AuditResource {
type: string;
/** Omitted when only the attempted type is known (e.g. a denied list). */
id?: string;
}
export type AuditOutcome = 'success' | 'failure' | 'denied' | 'pending' | 'approved' | 'rejected';
/** What emit sites provide. Envelope fields are stamped by emitAuditEvent. */
export interface AuditEventInput {
actor: AuditActor;
origin: AuditOrigin;
/** Dotted namespaced verb, e.g. `groups.config.add-mcp-server`. */
action: string;
resources: AuditResource[];
outcome: AuditOutcome;
/** The approval id on gated chains; null/omitted otherwise. */
correlationId?: string | null;
details?: Record<string, unknown>;
}
export interface AuditEvent {
event_id: string;
time: string;
schema_version: 1;
actor: AuditActor & { email: null; user_id: null; group_ids: null };
origin: AuditOrigin;
action: string;
resources: AuditResource[];
outcome: AuditOutcome;
correlation_id: string | null;
details: Record<string, unknown>;
}
@@ -0,0 +1,73 @@
/**
* Domain-free event vocabulary — actor and origin constructors shared by the
* domain-owned `*.audit.ts` adapters (the CLI adapter today; approval and
* channel adapters attach here in later increments). Pure derivation; nothing
* here writes the log.
*
* Leaf rule: this module (like the rest of src/audit/) may depend on node,
* config/log, shared types, and the db read layer — never on src/cli/* or
* src/modules/*. Domain-specific mapping (CLI resources, approval payloads)
* lives in the adapter file of the domain that owns it.
*/
import os from 'os';
import { getMessagingGroup } from '../db/messaging-groups.js';
import type { AuditOrigin } from './types.js';
/**
* Host callers stamp `host:<install user>` daemon-side: the ncl socket is
* 0600 and owned by the install user, so the identity is accurate by
* construction without peer credentials.
*/
export function hostUser(): string {
try {
return os.userInfo().username;
// eslint-disable-next-line no-catch-all/no-catch-all -- os.userInfo throws on exotic hosts; a fallback actor id beats no audit event
} catch {
return process.env.USER || 'unknown';
}
}
export function containerOrigin(sessionId: string, messagingGroupId: string | null): AuditOrigin {
const origin: AuditOrigin = { transport: 'container', session_id: sessionId };
if (messagingGroupId) {
origin.messaging_group_id = messagingGroupId;
const channel = getMessagingGroup(messagingGroupId)?.channel_type;
if (channel) origin.channel = channel;
}
return origin;
}
/**
* An approval decision answered on a chat platform (the approver clicked a
* card). `channelType` is the platform the click came from.
*/
export function channelOrigin(channelType: string | null): AuditOrigin {
return channelType ? { transport: 'channel', channel: channelType } : { transport: 'channel' };
}
/** Channel of a namespaced `<channel>:<handle>` id, or null if unprefixed. */
export function channelOf(namespacedUserId: string): string | null {
const i = namespacedUserId.indexOf(':');
return i > 0 ? namespacedUserId.slice(0, i) : null;
}
/**
* Dotted governance name for a hold's action, matching the guard catalog
* (agents.create, self_mod.*, a2a.send, senders.admit, channels.register).
* An unmapped action falls back to its raw name so a new gated surface still
* records — uncatalogued, never dropped.
*/
const APPROVAL_ACTION_DOTTED: Record<string, string> = {
create_agent: 'agents.create',
install_packages: 'self_mod.install_packages',
add_mcp_server: 'self_mod.add_mcp_server',
a2a_message_gate: 'a2a.send',
sender_admit: 'senders.admit',
channel_registration: 'channels.register',
onecli_credential: 'onecli.credential.use',
};
export function approvalActionName(action: string): string {
return APPROVAL_ACTION_DOTTED[action] ?? action;
}
@@ -0,0 +1,363 @@
/**
* Audit middleware behavior of the exported dispatch — what gets recorded,
* for whom, and how gated chains correlate. Drives the real wrapped dispatch
* (real registry, real guard); audit is force-enabled and the store's append
* is captured. DB reads and approval delivery are mocked.
*
* Recording model under test: the log stores arg KEY NAMES plus a small
* allowlist of safe enum values — never raw argument values — so a
* secret-bearing arg leaves only its key behind.
*/
import os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PendingApproval } from '../types.js';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
const pendingRows = vi.hoisted(() => ({ rows: [] as unknown[] }));
vi.mock('../audit/config.js', () => ({
AUDIT_ENABLED: true,
AUDIT_RETENTION_DAYS: 90,
}));
// Neutralize the adapter's module-scope boot (writability assert, prune,
// maintenance timer) — the middleware is the unit under test here.
vi.mock('../audit/init.js', () => ({
initAuditLog: vi.fn(),
maintainAudit: vi.fn(),
}));
vi.mock('../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
const mockGetContainerConfig = vi.fn();
vi.mock('../db/container-configs.js', () => ({
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
}));
vi.mock('../db/agent-groups.js', () => ({
getAgentGroup: vi.fn(() => ({ id: 'g1', name: 'Group One' })),
}));
const mockGetPendingApproval = vi.fn();
vi.mock('../db/sessions.js', () => ({
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
getPendingApprovalsByAction: () => pendingRows.rows,
}));
vi.mock('../db/messaging-groups.js', () => ({
getMessagingGroup: vi.fn(() => ({ channel_type: 'slack' })),
}));
const mockGetResource = vi.fn();
vi.mock('./crud.js', () => ({
getResource: (...args: unknown[]) => mockGetResource(...args),
}));
const mockRequestApproval = vi.fn();
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: (...args: unknown[]) => mockRequestApproval(...args),
}));
import { register } from './registry.js';
register({
name: 'groups-test',
description: 'echo command on the groups resource',
action: 'groups.test',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'groups-get',
description: 'echo command for dash-joined id resolution',
action: 'groups.get',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'not on the group-scope allowlist',
action: 'wirings.list',
resource: 'wirings',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => [],
});
register({
name: 'groups-fail',
description: 'handler that throws',
action: 'groups.fail',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => {
throw new Error('boom');
},
});
register({
name: 'groups-gated',
description: 'approval-gated command',
action: 'groups.gated',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => 'ran',
});
register({
name: 'roles-grant',
description: 'command carrying an allowlisted --role value',
action: 'roles.grant',
resource: 'roles',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => ({ granted: true }),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
const AGENT_CTX: CallerContext = { caller: 'agent', sessionId: 's1', agentGroupId: 'g1', messagingGroupId: 'mg1' };
function grantRow(frameId: string, command: string): PendingApproval {
return {
approval_id: 'appr-123-abc',
session_id: 's1',
request_id: 'appr-123-abc',
action: 'cli_command',
payload: JSON.stringify({ frame: { id: frameId, command, args: {} }, callerContext: AGENT_CTX }),
created_at: new Date().toISOString(),
agent_group_id: 'g1',
channel_type: null,
platform_id: null,
platform_message_id: null,
expires_at: null,
status: 'pending',
title: 'CLI: groups-gated',
options_json: '[]',
approver_user_id: null,
approver_rule: 'admins-of-scope',
dedup_key: null,
};
}
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
vi.clearAllMocks();
appended.lines.length = 0;
pendingRows.rows = [];
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockImplementation((plural: string) => (plural === 'groups' ? { scopeField: 'id' } : undefined));
mockRequestApproval.mockResolvedValue(undefined);
});
describe('withAudit(dispatch)', () => {
it('records a success event for a host caller with socket origin and host actor', async () => {
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, { caller: 'host' });
expect(resp.ok).toBe(true);
const [event] = events();
expect(event).toMatchObject({
schema_version: 1,
actor: { type: 'human', id: `host:${os.userInfo().username}`, email: null },
origin: { transport: 'socket' },
action: 'groups.test',
outcome: 'success',
correlation_id: null,
details: { args: ['foo'] },
});
// The value 'bar' is never stored — only the key name.
expect(event.details.foo).toBeUndefined();
});
it('records an agent command value-free, with container origin and channel', async () => {
await dispatch({ id: '1', command: 'groups-test', args: {} }, AGENT_CTX);
const [event] = events();
expect(event.actor).toMatchObject({ type: 'agent', id: 'g1' });
expect(event.origin).toEqual({
transport: 'container',
session_id: 's1',
messaging_group_id: 'mg1',
channel: 'slack',
});
expect(event.details).toEqual({ args: [] });
expect(event.resources).toContainEqual({ type: 'agent_group' });
});
it('records the passed flag names and echoes allowlisted safe values verbatim', async () => {
await dispatch(
{ id: '1', command: 'roles-grant', args: { role: 'admin', user: 'slack:U1' } },
{ caller: 'host' },
);
const [event] = events();
expect(event.action).toBe('roles.grant');
// Both flag names recorded; only the allowlisted `role` keeps its value.
expect(event.details.args).toEqual(['role', 'user']);
expect(event.details.role).toBe('admin');
expect(event.details.user).toBeUndefined();
// The user id still surfaces structurally, in resources.
expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U1' });
});
it('records a denied event for a scope denial, naming the attempted resource type, with no free-text reason', async () => {
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, AGENT_CTX);
expect(resp.ok).toBe(false);
const [event] = events();
expect(event).toMatchObject({
action: 'wirings.list',
outcome: 'denied',
resources: [{ type: 'wirings' }],
details: { args: [], error: 'forbidden' },
});
// The denial message (which can echo caller input) is never stored.
expect(event.details.reason).toBeUndefined();
});
it('records a failure event with the error code only when the handler throws', async () => {
await dispatch({ id: '1', command: 'groups-fail', args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({ action: 'groups.fail', outcome: 'failure', details: { error: 'handler-error' } });
expect(event.details.reason).toBeUndefined();
});
it('records a failure event and re-throws when the dispatcher itself throws', async () => {
mockRequestApproval.mockRejectedValueOnce(new Error('pending_approvals insert failed'));
await expect(dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX)).rejects.toThrow(
'pending_approvals insert failed',
);
const [event] = events();
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'failure', details: { error: 'exception' } });
});
it('records a hold as a pending event correlated to the approval row it created', async () => {
pendingRows.rows = [grantRow('1', 'groups-gated')];
const resp = await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.code).toBe('approval-pending');
const [event] = events();
expect(event).toMatchObject({
action: 'groups.gated',
outcome: 'pending',
correlation_id: 'appr-123-abc',
});
expect(event.resources).toContainEqual({ type: 'approval', id: 'appr-123-abc' });
expect(event.details.error).toBeUndefined();
});
it('records an uncorrelated pending event when no approval row was created (no approver)', async () => {
pendingRows.rows = [];
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
const [event] = events();
expect(event).toMatchObject({ outcome: 'pending', correlation_id: null });
});
it('records an approved replay as a `success` event carrying the grant approval id', async () => {
// The approved/rejected verdict is the approvals.decide event's (approvals.audit.ts);
// the replayed command's terminal event is an ordinary success, chained by correlation_id.
const grant = grantRow('9', 'groups-gated');
mockGetPendingApproval.mockReturnValue(grant);
const resp = await dispatch({ id: '9', command: 'groups-gated', args: {} }, AGENT_CTX, { grant });
expect(resp.ok).toBe(true);
const [event] = events();
expect(event).toMatchObject({
action: 'groups.gated',
outcome: 'success',
correlation_id: 'appr-123-abc',
});
expect(event.resources).toContainEqual({ type: 'approval', id: 'appr-123-abc' });
});
it('records a --help probe under a neutral cli.help action, never the real verb', async () => {
const resp = await dispatch({ id: '1', command: 'groups-gated', args: { help: true } }, AGENT_CTX);
expect(resp.ok).toBe(true);
const [event] = events();
expect(event.action).toBe('cli.help');
expect(event.outcome).toBe('success');
expect(event.resources).toEqual([]);
});
it('records unknown commands as cli.unknown-command with the raw name in details', async () => {
await dispatch({ id: '1', command: 'nope-nothing', args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({
action: 'cli.unknown-command',
outcome: 'failure',
resources: [],
details: { args: [], command: 'nope-nothing', error: 'unknown-command' },
});
});
it('records the resolved command and target id for dash-joined positional ids', async () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({ action: 'groups.get', outcome: 'success' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: uuid });
// The id surfaces in resources; details keeps only the flag name.
expect(event.details.args).toContain('id');
expect(event.details.id).toBeUndefined();
});
it('normalizes hyphenated arg key names in details', async () => {
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
const [event] = events();
expect(event.details.args).toContain('dry_run');
expect(event.details.dry_run).toBeUndefined();
});
it('never stores arg values — a secret-bearing arg leaves only its key', async () => {
await dispatch(
{ id: '1', command: 'groups-test', args: { env: '{"NOTION_TOKEN":"tok-123","SAFE":"ok"}' } },
{ caller: 'host' },
);
const [event] = events();
expect(event.details.args).toContain('env');
expect(event.details.env).toBeUndefined();
// The secret never reaches the stored bytes.
expect(appended.lines[0]).not.toContain('NOTION_TOKEN');
expect(appended.lines[0]).not.toContain('tok-123');
});
});
@@ -0,0 +1,264 @@
/**
* CLI audit adapter (installed by /add-audit) — owns how the dispatcher
* describes itself to the audit log: the dispatch middleware plus the
* CLI-specific actor/origin/resource mapping. Composed in dispatch.ts as
* `export const dispatch = withAudit(dispatchInner)`; business logic there
* contains zero audit calls.
*
* Recording model: the log stores WHO did WHICH action to WHAT target and the
* outcome — never raw argument VALUES. `details` carries the arg key names
* that were passed plus a small allowlist of governance-relevant enum fields
* (role, mode, …) echoed verbatim; everything else is name-only. There is no
* value redactor because no free-form value is ever written — a secret can't
* leak from a field that was never stored. Target identifiers (ids, users,
* groups) are structured and surface separately in `resources`.
*
* Loading this module also boots the audit log (writability assert, boot
* prune, hook lifecycle, maintenance timer): dispatch.ts is imported by both
* transports during the host's barrel phase, so initAuditLog() runs before
* any command is accepted — and an enabled box with an unwritable
* data/audit/ refuses to start.
*/
import { emitAuditEvent } from '../audit/emit.js';
import { initAuditLog } from '../audit/init.js';
import {
type AuditActor,
type AuditEventInput,
type AuditOrigin,
type AuditOutcome,
type AuditResource,
} from '../audit/types.js';
import { containerOrigin, hostUser } from '../audit/vocab.js';
import { getPendingApprovalsByAction } from '../db/sessions.js';
import type { PendingApproval } from '../types.js';
import { getResource } from './crud.js';
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
import { commandGuardAction } from './guard.js';
import { type CommandDef, lookup } from './registry.js';
initAuditLog();
// ── CLI mapping ──
/**
* Host callers stamp `host:<install user>` daemon-side (the ncl socket is
* 0600 and owned by the install user); container callers are their agent group.
*/
export function actorForCaller(ctx: CallerContext): AuditActor {
return ctx.caller === 'host' ? { type: 'human', id: `host:${hostUser()}` } : { type: 'agent', id: ctx.agentGroupId };
}
export function originForCaller(ctx: CallerContext): AuditOrigin {
if (ctx.caller === 'host') return { transport: 'socket' };
return containerOrigin(ctx.sessionId, ctx.messagingGroupId || null);
}
/**
* Frame-level args use `--hyphen-keys`; recorded key names use the same
* underscore form the parsed handlers see. Mirrors crud's normalizeArgs
* (kept local so audit doesn't depend on a module tests commonly mock).
*/
export function normalizeArgKeys(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw)) {
out[k.replace(/-/g, '_')] = v;
}
return out;
}
/**
* Arg values safe to record verbatim: governance-relevant enums that are never
* secrets and whose value IS the audit-worthy detail (which role was granted,
* which scope/mode was set). Every other value is dropped — only the presence
* of the flag (its key) is kept — so no free-form or secret-bearing value ever
* reaches disk.
*/
const SAFE_VALUE_FIELDS: ReadonlySet<string> = new Set([
'role',
'mode',
'session_mode',
'cli_scope',
'access',
'engage_mode',
'sender_scope',
'provider',
'model',
]);
/** CLI resource plural → audit resource type, where the singular isn't it. */
const RESOURCE_TYPE_OVERRIDES: Record<string, string> = {
groups: 'agent_group',
'messaging-groups': 'messaging_group',
'dropped-messages': 'dropped_message',
'user-dms': 'user_dm',
};
/**
* Derive touched/attempted resources from a command's args. Generic by design:
* `id` → the command's own resource, group/user args → their types, and a bare
* `{type}` entry when nothing else is known (a denied `users list` still names
* what was attempted). Ids are structured identifiers, not secrets.
*/
export function resourcesForCli(cmd: CommandDef, args: Record<string, unknown>): AuditResource[] {
if (!cmd.resource) return [];
const type = RESOURCE_TYPE_OVERRIDES[cmd.resource] ?? getResource(cmd.resource)?.name ?? cmd.resource;
const out: AuditResource[] = [];
const push = (t: string, id: unknown): void => {
if (typeof id !== 'string' || !id) return;
if (!out.some((r) => r.type === t && r.id === id)) out.push({ type: t, id });
};
push(type, args.id);
push('agent_group', args.agent_group_id ?? args.group);
push('user', args.user);
if (out.length === 0) out.push({ type });
return out;
}
// ── Command resolution, mirrored for the record ──
// Dispatch resolves the command on a local copy of the frame that never leaves
// it, so the middleware mirrors the one documented mechanic below. The mirror
// is mechanical, and drift only ever degrades a record's detail (a fallback
// action name) — never dispatch behavior, and never an outcome.
/**
* Mirror of dispatch's command resolution: exact lookup, then the longest
* registered dash-prefix with the remainder recorded as --id.
*/
function resolveForRecord(req: RequestFrame): { cmd?: CommandDef; args: Record<string, unknown> } {
const direct = lookup(req.command);
if (direct) return { cmd: direct, args: req.args };
let shortened = req.command;
let idx: number;
while ((idx = shortened.lastIndexOf('-')) > 0) {
shortened = shortened.slice(0, idx);
const fallback = lookup(shortened);
if (fallback) {
const tail = req.command.slice(shortened.length + 1);
return { cmd: fallback, args: { ...req.args, id: req.args.id ?? tail } };
}
}
return { args: req.args };
}
/**
* The approval row a hold just created for this frame — it gives the pending
* event the same correlation_id the approved replay will carry as its guard
* grant. requestApproval keeps the minted id internal, so the row is
* recovered by the frame id it stored in its payload; no row (e.g. no
* configured approver) → the hold is still recorded, uncorrelated.
*/
function holdApprovalIdFor(frameId: string): string | null {
const rows = getPendingApprovalsByAction('cli_command');
for (let i = rows.length - 1; i >= 0; i--) {
try {
const payload = JSON.parse(rows[i].payload) as { frame?: { id?: string } };
if (payload.frame?.id === frameId) return rows[i].approval_id;
// eslint-disable-next-line no-catch-all/no-catch-all -- a row with an unparseable payload is simply not this frame's hold
} catch {
continue;
}
}
return null;
}
// ── The dispatch middleware ──
type DispatchInner = (
req: RequestFrame,
ctx: CallerContext,
opts?: { grant?: PendingApproval },
) => Promise<ResponseFrame>;
/**
* Build the audit record for one dispatch. `res` is the response frame, or
* null when `inner` threw (`err` set) — a crash still leaves a record.
*
* Outcome: ok → success (an approved replay included — the approvals.decide
* event owns the approved/rejected verdict), forbidden → denied (captures
* pre-handler scope denials), approval-pending → pending (the record of a
* hold), a thrown/other error → failure. A `--help`
* probe is introspection, not the verb, so it records under a neutral
* `cli.help` action with no target — never as the real command succeeding.
* Correlation is the approval id: a replay carries the row as its grant, and a
* fresh hold recovers the row it just created.
*/
function buildEvent(
req: RequestFrame,
ctx: CallerContext,
opts: { grant?: PendingApproval },
res: ResponseFrame | null,
err: unknown,
): AuditEventInput {
const resolved = resolveForRecord(req);
const cmd = resolved.cmd;
const normArgs = normalizeArgKeys(resolved.args);
const isHelp = req.args.help === true && !!res && res.ok;
const pending = !!res && !res.ok && res.error.code === 'approval-pending';
// An approved replay records as ordinary `success`; the approvals.decide
// event (approvals.audit.ts) owns the approved/rejected verdict, chained to
// this terminal event by the same correlation_id (the grant's approval id).
const outcome: AuditOutcome = !res
? 'failure' // inner threw
: res.ok
? 'success'
: res.error.code === 'forbidden'
? 'denied'
: pending
? 'pending'
: 'failure';
// details: arg key names + allowlisted safe values only. No free-form value
// is ever stored, so nothing needs redacting and nothing can leak.
const details: Record<string, unknown> = { args: Object.keys(normArgs).sort() };
for (const f of SAFE_VALUE_FIELDS) {
if (normArgs[f] !== undefined) details[f] = normArgs[f];
}
if (err) {
details.error = 'exception'; // the throw's message is never stored
} else if (res && !res.ok && !pending) {
details.error = res.error.code; // the error CODE (a safe enum), never the free-text message
}
if (!cmd) details.command = req.command;
const correlationId = opts.grant?.approval_id ?? (pending ? holdApprovalIdFor(req.id) : null);
const action = isHelp ? 'cli.help' : cmd ? commandGuardAction(cmd) : 'cli.unknown-command';
const resources: AuditResource[] = isHelp ? [] : cmd ? resourcesForCli(cmd, normArgs) : [];
if (correlationId) resources.push({ type: 'approval', id: correlationId });
return {
actor: actorForCaller(ctx),
origin: originForCaller(ctx),
action,
resources,
outcome,
correlationId,
details,
};
}
/**
* Dispatch middleware — the exported `dispatch` is the wrapped function, so
* the socket server, the container delivery-action, and the in-module
* approved replay are all covered by the one composition.
*
* The emit brackets `inner` so a thrown dispatcher still leaves a record: on
* throw we emit a `failure` event and re-raise unchanged, so an approval-gated
* command whose hold crashes on the DB write is not a silent governance gap.
*/
export function withAudit(inner: DispatchInner): DispatchInner {
return async (req, ctx, opts = {}) => {
let res: ResponseFrame;
try {
res = await inner(req, ctx, opts);
} catch (err) {
emitAuditEvent(() => buildEvent(req, ctx, opts, null, err));
throw err;
}
emitAuditEvent(() => buildEvent(req, ctx, opts, res, null));
return res;
};
}
@@ -0,0 +1,69 @@
import { listAuditEvents } from '../../audit/reader.js';
import { registerResource } from '../crud.js';
/**
* Read-only audit resource (installed by /add-audit). The "table" is the
* NDJSON day-file store — no generic CRUD verbs are declared, so it is never
* queried as SQL; `list` is a custom operation backed by the audit reader.
*
* The filters are declared as the op's `args` (not `columns`), so crud
* validates them: a typo'd flag (`--actro`) is rejected instead of silently
* ignored, and `ncl audit list --help` lists every filter.
*
* Deliberately NOT on the group-scope allowlist (GROUP_SCOPE_RESOURCES):
* audit spans agent groups, so group-scoped agents are refused before the
* handler — the resource is host + `cli_scope: global` only, by omission
* (fails closed).
*/
registerResource({
name: 'audit_event',
plural: 'audit',
table: '(data/audit/*.ndjson)',
description: 'Local audit log — one event per ncl command. Newest first. Requires AUDIT_ENABLED=true.',
idColumn: 'event_id',
columns: [],
operations: {},
customOperations: {
list: {
access: 'open',
description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.',
args: [
{
name: 'actor',
type: 'string',
description: 'Filter: exact actor id (host:<user>, <channel>:<handle>, agent group id)',
},
{ name: 'action', type: 'string', description: 'Filter: exact action or dotted prefix (e.g. groups.config)' },
{ name: 'resource', type: 'string', description: 'Filter: matches any event resource by id or type' },
{
name: 'outcome',
type: 'string',
description: 'Filter: event outcome',
enum: ['success', 'failure', 'denied', 'pending', 'approved'],
},
{ name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date/datetime (UTC)' },
{ name: 'until', type: 'string', description: 'Window end: same formats as --since' },
{ name: 'correlation', type: 'string', description: 'Filter: approval id tying a gated chain together' },
{
name: 'limit',
type: 'number',
description: 'Max events (default 100 for the table; ndjson exports every event in the window)',
},
{ name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] },
],
examples: [
'ncl audit list --outcome denied --since 7d',
'ncl audit list --correlation appr-1751970000000-x1y2z3',
'ncl audit list --action groups.config --format ndjson',
],
handler: async (args) => listAuditEvents(args),
formatHuman: (data) => {
// NDJSON export prints the stored lines verbatim; for the table view
// this throws so dispatch falls back to the row objects, which every
// client already renders.
if (typeof data === 'string') return data;
throw new Error('render rows client-side');
},
},
},
});
@@ -0,0 +1,181 @@
/**
* Approvals audit adapter — what the two lifecycle observers record. Audit is
* force-enabled and the store's append is captured; the observer registration
* is captured through a mock of the approvals primitive, then invoked directly.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PendingApproval, Session } from '../../types.js';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
const handlers = vi.hoisted(
() =>
({ requested: undefined, resolved: undefined }) as {
requested?: (e: unknown) => unknown;
resolved?: (e: unknown) => unknown;
},
);
vi.mock('../../audit/config.js', () => ({ AUDIT_ENABLED: true, AUDIT_RETENTION_DAYS: 90 }));
vi.mock('../../audit/init.js', () => ({ initAuditLog: vi.fn(), maintainAudit: vi.fn() }));
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
// containerOrigin (via vocab) looks up the messaging group's channel.
vi.mock('../../db/messaging-groups.js', () => ({ getMessagingGroup: () => ({ channel_type: 'slack' }) }));
// Capture the observer handlers the adapter registers at module load.
vi.mock('./primitive.js', () => ({
registerApprovalRequestedHandler: (h: (e: unknown) => unknown) => {
handlers.requested = h;
},
registerApprovalResolvedHandler: (h: (e: unknown) => unknown) => {
handlers.resolved = h;
},
}));
// Loads the adapter (registers into `handlers`) with the mocks in place.
import './approvals.audit.js';
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
function last(): Record<string, any> {
return events()[appended.lines.length - 1];
}
const AGENT_SESSION: Session = {
id: 's1',
agent_group_id: 'g1',
messaging_group_id: 'mg1',
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'running',
last_active: null,
created_at: '2026-01-01T00:00:00.000Z',
};
function hold(action: string, over: Partial<PendingApproval> = {}): PendingApproval {
return {
approval_id: 'appr-1',
session_id: 's1',
request_id: 'appr-1',
action,
payload: '{}',
created_at: '2026-01-01T00:00:00.000Z',
agent_group_id: 'g1',
channel_type: null,
platform_id: null,
platform_message_id: null,
expires_at: null,
status: 'pending',
title: '',
options_json: '[]',
approver_user_id: null,
approver_rule: 'admins-of-scope',
dedup_key: null,
...over,
};
}
beforeEach(() => {
appended.lines = [];
});
describe('approvals audit — request (pending)', () => {
it('records a non-CLI hold as pending: dotted action, picked approver, container origin', async () => {
await handlers.requested!({ approval: hold('add_mcp_server'), session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
const e = last();
expect(e.action).toBe('self_mod.add_mcp_server');
expect(e.outcome).toBe('pending');
expect(e.actor).toEqual({ type: 'agent', id: 'g1', email: null, user_id: null, group_ids: null });
expect(e.origin).toEqual({ transport: 'container', session_id: 's1', messaging_group_id: 'mg1', channel: 'slack' });
expect(e.correlation_id).toBe('appr-1');
expect(e.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
expect(e.resources).toContainEqual({ type: 'approval', id: 'appr-1' });
expect(e.resources).toContainEqual({ type: 'user', id: 'slack:UADMIN' });
});
it('skips cli_command — the dispatch adapter owns those holds', async () => {
await handlers.requested!({ approval: hold('cli_command'), session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
expect(appended.lines).toHaveLength(0);
});
it('a sessionless sender hold records as the system actor on the host', async () => {
await handlers.requested!({
approval: hold('sender_admit', { agent_group_id: null, session_id: null }),
session: null,
deliveredTo: 'slack:UADMIN',
});
const e = last();
expect(e.action).toBe('senders.admit');
expect(e.actor.type).toBe('system');
expect(e.origin).toEqual({ transport: 'socket' });
// no agent_group resource when host-raised
expect(e.resources.some((r: any) => r.type === 'agent_group')).toBe(false);
});
});
describe('approvals audit — decision (approvals.decide)', () => {
it('an approve records approved; actor is the deciding admin on their channel', async () => {
await handlers.resolved!({ approval: hold('add_mcp_server'), session: AGENT_SESSION, outcome: 'approve', userId: 'slack:UADMIN' });
const e = last();
expect(e.action).toBe('approvals.decide');
expect(e.outcome).toBe('approved');
expect(e.actor).toEqual({ type: 'human', id: 'slack:UADMIN', email: null, user_id: null, group_ids: null });
expect(e.origin).toEqual({ transport: 'channel', channel: 'slack' });
expect(e.correlation_id).toBe('appr-1');
expect(e.details.gated_action).toBe('self_mod.add_mcp_server');
expect(e.details.requested_by).toBe('g1');
});
it('a reject records rejected, naming the gated action', async () => {
await handlers.resolved!({ approval: hold('a2a_message_gate'), session: AGENT_SESSION, outcome: 'reject', userId: 'discord:UMOD' });
const e = last();
expect(e.action).toBe('approvals.decide');
expect(e.outcome).toBe('rejected');
expect(e.origin.channel).toBe('discord');
expect(e.details.gated_action).toBe('a2a.send');
});
it('expiry decides as the system actor: rejected + reason expired', async () => {
await handlers.resolved!({ approval: hold('onecli_credential'), session: null, outcome: 'expire', userId: '' });
const e = last();
expect(e.actor).toEqual({ type: 'system', id: 'host', email: null, user_id: null, group_ids: null });
expect(e.origin).toEqual({ transport: 'socket' });
expect(e.outcome).toBe('rejected');
expect(e.details.reason).toBe('expired');
expect(e.details.gated_action).toBe('onecli.credential.use');
});
it('startup sweep decides as the system actor with reason swept', async () => {
await handlers.resolved!({ approval: hold('onecli_credential'), session: null, outcome: 'sweep', userId: '' });
const e = last();
expect(e.actor.type).toBe('system');
expect(e.outcome).toBe('rejected');
expect(e.details.reason).toBe('swept');
});
});
describe('approvals audit — correlation chain', () => {
it('the request and the decision share the approval id', async () => {
const approval = hold('create_agent', { approval_id: 'appr-xyz', request_id: 'appr-xyz' });
await handlers.requested!({ approval, session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
await handlers.resolved!({ approval, session: AGENT_SESSION, outcome: 'approve', userId: 'slack:UADMIN' });
const [pending, decide] = events();
expect(pending.action).toBe('agents.create');
expect(pending.outcome).toBe('pending');
expect(pending.correlation_id).toBe('appr-xyz');
expect(decide.action).toBe('approvals.decide');
expect(decide.correlation_id).toBe('appr-xyz');
});
});
@@ -0,0 +1,109 @@
/**
* Approvals audit adapter (installed by /add-audit) — records the approval
* lifecycle for every host-routed hold, whichever stack created it: CLI
* commands, self-mod (install_packages / add_mcp_server), a2a create-agent and
* message-gate, unknown-sender admission, OneCLI credential holds, and channel
* registration. It subscribes to the two approval lifecycle observers, so the
* approval flows themselves contain zero audit calls.
*
* - request → a `pending` event naming the picked approver (a `user`
* resource), correlated by the approval id. `cli_command` is skipped: the
* CLI adapter (dispatch.audit.ts) already records those holds with the
* command's own dotted action + arg names — the mapping it alone owns.
* - resolve → an `approvals.decide` event (approved / rejected; expiry and
* sweep decide as the system actor), same correlation id. The gated action
* it decided rides `details.gated_action`.
*
* The approved/rejected verdict lives here; a CLI command's replayed execution
* records its own terminal success/failure through the dispatch adapter, on the
* same correlation id. Non-CLI holds have no separate execution event in this
* increment — the request and decision are the durable governance record, which
* is what survives the pending_approvals row being deleted on resolution.
*
* Recording model matches the rest of the log: WHO decided WHAT, never raw
* payload values — the gated action name and the approval id, not its args.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import { initAuditLog } from '../../audit/init.js';
import type { AuditActor, AuditOrigin, AuditOutcome, AuditResource } from '../../audit/types.js';
import { approvalActionName, channelOf, channelOrigin, containerOrigin } from '../../audit/vocab.js';
import type { PendingApproval, Session } from '../../types.js';
import {
type ApprovalRequestedEvent,
type ApprovalResolvedEvent,
registerApprovalRequestedHandler,
registerApprovalResolvedHandler,
} from './primitive.js';
// Loading this adapter also boots the audit log when enabled (idempotent — the
// dispatch adapter may have run it already), so the approval surface records
// even on a build that composed it before the CLI middleware.
initAuditLog();
/** The agent group whose action raised the hold, or null for host-raised holds. */
function requesterOf(approval: PendingApproval, session: Session | null): string | null {
return approval.agent_group_id ?? session?.agent_group_id ?? null;
}
/** The requester as an actor: the agent group, else the host (system-raised). */
function requesterActor(agentGroupId: string | null): AuditActor {
return agentGroupId ? { type: 'agent', id: agentGroupId } : { type: 'system', id: 'host' };
}
/** Origin of the requesting side: the container session if any, else the host socket. */
function requesterOrigin(session: Session | null): AuditOrigin {
return session ? containerOrigin(session.id, session.messaging_group_id) : { transport: 'socket' };
}
// request → pending. cli_command holds belong to the dispatch adapter (correct
// dotted action + args); every other gated surface is recorded here.
registerApprovalRequestedHandler((event: ApprovalRequestedEvent) => {
if (event.approval.action === 'cli_command') return;
emitAuditEvent(() => {
const agentGroupId = requesterOf(event.approval, event.session);
const resources: AuditResource[] = [];
if (agentGroupId) resources.push({ type: 'agent_group', id: agentGroupId });
resources.push({ type: 'approval', id: event.approval.approval_id });
if (event.deliveredTo) resources.push({ type: 'user', id: event.deliveredTo });
return {
actor: requesterActor(agentGroupId),
origin: requesterOrigin(event.session),
action: approvalActionName(event.approval.action),
resources,
outcome: 'pending' as AuditOutcome,
correlationId: event.approval.approval_id,
};
});
});
const RESOLVED_OUTCOME: Record<ApprovalResolvedEvent['outcome'], AuditOutcome> = {
approve: 'approved',
reject: 'rejected',
expire: 'rejected',
sweep: 'rejected',
};
// resolve → approvals.decide. A human clicker is the actor (answered on a chat
// platform); expiry and startup sweep decide as the system actor.
registerApprovalResolvedHandler((event: ApprovalResolvedEvent) => {
emitAuditEvent(() => {
const agentGroupId = requesterOf(event.approval, event.session);
const bySystem = !event.userId || event.outcome === 'expire' || event.outcome === 'sweep';
const resources: AuditResource[] = [];
if (agentGroupId) resources.push({ type: 'agent_group', id: agentGroupId });
resources.push({ type: 'approval', id: event.approval.approval_id });
const details: Record<string, unknown> = { gated_action: approvalActionName(event.approval.action) };
if (agentGroupId) details.requested_by = agentGroupId;
if (event.outcome === 'expire') details.reason = 'expired';
if (event.outcome === 'sweep') details.reason = 'swept';
return {
actor: bySystem ? { type: 'system', id: 'host' } : { type: 'human', id: event.userId },
origin: bySystem ? { transport: 'socket' } : channelOrigin(channelOf(event.userId)),
action: 'approvals.decide',
resources,
outcome: RESOLVED_OUTCOME[event.outcome],
correlationId: event.approval.approval_id,
details,
};
});
});
+4
View File
@@ -4,9 +4,13 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **New skill: `/add-audit` — opt-in local audit log for the `ncl` surface.** Installs an append-only, SIEM-shaped audit log: every command through the dispatcher (both transports — host socket and container — including scope denials, approval holds, and approved replays) becomes one canonical NDJSON event under `data/audit/<UTC-day>.ndjson`. Gated chains share a `correlation_id` (the approval id: the hold's `pending` event and the replay's terminal event carry the same one); `details` record only the flag names passed plus an allowlist of safe enum values (`role`, `mode`, …), so raw argument values — where secrets hide — are never written. `AUDIT_RETENTION_DAYS` (default 90; `0` = keep forever) hard-deletes day-files past the horizon at boot and once per UTC day. Read back with `ncl audit list [--actor --action --resource --outcome --since --until --correlation --limit] [--format ndjson]` — host + global-scope callers only (the resource stays off the group-scope allowlist, so group-scoped agents fail closed). In-process exporters plug in via `registerAuditHook` — post-write hooks that fire only after the local append succeeds. Off by default: nothing is persisted (and `data/audit/` is never created) until `AUDIT_ENABLED=true`, an enabled box refuses to boot if the directory isn't writable, and a disabled box answers `ncl audit list` with a clear error instead of an empty list. The skill's whole core footprint is the dispatch composition (`export const dispatch = withAudit(dispatchInner)`) plus the resource-barrel import, both guarded by a shipped wiring test. Approval-lifecycle events (request/decision as their own events), OneCLI credential holds, and channel/sender events are later increments on the same schema.
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<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 deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.)
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **One approval contract across every hold.** All approval holds now share one hold-record shape on `pending_approvals` (approver rule, dedup key — migration 020, with backfills) and ONE click-authorization rule (`mayResolve` in `src/modules/approvals/approver-rule.ts`), replacing three divergent click-auth copies. Unknown-sender admission folds onto the approvals primitive (sessionless hold, action `sender_admit`) — the `pending_sender_approvals` table is dropped by migration, its `UNIQUE(messaging_group_id, sender_identity)` preserved as a partial UNIQUE on the hold's dedup key; an in-flight sender card does not survive the upgrade (a new message from the same sender re-triggers one). Channel registration and OneCLI keep their flows and adopt the shared rule. Observers now see the full hold lifecycle with zero touch points inside the flows: `registerApprovalRequestedHandler` (new, the creation-side sibling of `registerApprovalResolvedHandler`) fires once whenever a hold record comes into existence, whichever stack created it — `requestApproval`, the OneCLI credential bridge, channel registration (as a synthesized hold view) — and every resolution announces through the approval-resolved observer (outcome `approve` | `reject` | `expire` | `sweep`, session nullable). This is a behavior-preserving refactor: unifying the three flows changes no click-authorization decision versus today.
- **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.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
+5 -2
View File
@@ -62,12 +62,15 @@ 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 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 stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` |
| `src/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/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry, hold-lifecycle observers (`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` — every stack announces creation + resolution through these) |
| `src/modules/approvals/approver-rule.ts` | `mayResolve` — the one click-authorization rule (approver rule `exclusive` \| `admins-of-scope`) for every hold |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
@@ -109,7 +112,7 @@ ncl help
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get, approve, reject, reject-with-reason | Pending approval requests; `approve`/`reject`/`reject-with-reason` resolve one from the host CLI (operator-only, `--as-user <approver>`) via the same auth + resolution as a channel button |
| approvals | list, get | Pending approval requests (read-only) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
+6
View File
@@ -402,6 +402,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.list) {
register({
name: `${def.plural}-list`,
action: `${def.plural}.list`,
description: `List all ${def.plural}.`,
access: def.operations.list,
resource: def.plural,
@@ -414,6 +415,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.get) {
register({
name: `${def.plural}-get`,
action: `${def.plural}.get`,
description: `Get a ${def.name} by ID.`,
access: def.operations.get,
resource: def.plural,
@@ -426,6 +428,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.create) {
register({
name: `${def.plural}-create`,
action: `${def.plural}.create`,
description: `Create a new ${def.name}.`,
access: def.operations.create,
resource: def.plural,
@@ -437,6 +440,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.update) {
register({
name: `${def.plural}-update`,
action: `${def.plural}.update`,
description: `Update a ${def.name}.`,
access: def.operations.update,
resource: def.plural,
@@ -448,6 +452,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.delete) {
register({
name: `${def.plural}-delete`,
action: `${def.plural}.delete`,
description: `Delete a ${def.name}.`,
access: def.operations.delete,
resource: def.plural,
@@ -464,6 +469,7 @@ export function registerResource(def: ResourceDef): void {
const declared = op.args;
register({
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
action: `${def.plural}.${verb.replace(/ /g, '.')}`,
description: op.description,
access: op.access,
resource: def.plural,
+42 -37
View File
@@ -8,52 +8,57 @@
import type Database from 'better-sqlite3';
import { registerDeliveryAction } from '../delivery.js';
import { unguarded } from '../guard/index.js';
import { insertMessage } from '../db/session-db.js';
import { log } from '../log.js';
import { dispatch } from './dispatch.js';
import type { RequestFrame } from './frame.js';
import type { Session } from '../types.js';
registerDeliveryAction('cli_request', async (content, session, inDb) => {
const requestId = content.requestId as string;
const command = content.command as string;
const args = (content.args as Record<string, unknown>) ?? {};
registerDeliveryAction(
'cli_request',
async (content, session, inDb) => {
const requestId = content.requestId as string;
const command = content.command as string;
const args = (content.args as Record<string, unknown>) ?? {};
if (!requestId || !command) {
log.warn('cli_request missing requestId or command', { sessionId: session.id });
return;
}
if (!requestId || !command) {
log.warn('cli_request missing requestId or command', { sessionId: session.id });
return;
}
const req: RequestFrame = { id: requestId, command, args };
const ctx = {
caller: 'agent' as const,
sessionId: session.id,
agentGroupId: session.agent_group_id,
messagingGroupId: session.messaging_group_id ?? '',
};
const req: RequestFrame = { id: requestId, command, args };
const ctx = {
caller: 'agent' as const,
sessionId: session.id,
agentGroupId: session.agent_group_id,
messagingGroupId: session.messaging_group_id ?? '',
};
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
const response = await dispatch(req, ctx);
const response = await dispatch(req, ctx);
// Write response to inbound.db so the container can read it.
// trigger=0: don't wake the agent — this is an inline response to a tool call.
insertMessage(inDb, {
id: `cli-resp-${requestId}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({
type: 'cli_response',
requestId,
frame: response,
}),
processAfter: null,
recurrence: null,
trigger: 0,
});
// Write response to inbound.db so the container can read it.
// trigger=0: don't wake the agent — this is an inline response to a tool call.
insertMessage(inDb, {
id: `cli-resp-${requestId}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({
type: 'cli_response',
requestId,
frame: response,
}),
processAfter: null,
recurrence: null,
trigger: 0,
});
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
});
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
},
unguarded('transport envelope — every inner command is guarded at dispatch'),
);
+109
View File
@@ -9,6 +9,7 @@ const approvalState = vi.hoisted(() => ({
| ((args: {
session: unknown;
payload: Record<string, unknown>;
approval: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>),
@@ -18,6 +19,7 @@ const approvalState = vi.hoisted(() => ({
handler: (args: {
session: unknown;
payload: Record<string, unknown>;
approval: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>,
@@ -43,8 +45,11 @@ vi.mock('../db/agent-groups.js', () => ({
}));
const mockGetSession = vi.fn();
// The guard's grant check re-fetches the approval row to prove it's live.
const mockGetPendingApproval = vi.fn();
vi.mock('../db/sessions.js', () => ({
getSession: (...args: unknown[]) => mockGetSession(...args),
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
}));
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
@@ -145,6 +150,33 @@ register({
},
});
register({
name: 'roles-grant',
description: 'approval command on a non-scoped resource (global blast radius)',
resource: 'roles',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
register({
name: 'members-add-gated',
description: 'approval command on a scoped resource',
resource: 'members',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
register({
name: 'groups-update',
description: 'approval command on the groups resource (id = agent group)',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -495,10 +527,20 @@ describe('CLI scope enforcement', () => {
callerContext: ctx,
});
// The approve path hands the handler the live approval row — the grant
// the replay carries back into dispatch.
const grantRow = {
approval_id: 'appr-t1',
action: 'cli_command',
payload: JSON.stringify(approval.payload),
};
mockGetPendingApproval.mockReturnValue(grantRow);
expect(approvalState.approvalHandler).toBeTypeOf('function');
await approvalState.approvalHandler!({
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
payload: approval.payload,
approval: grantRow,
userId: 'telegram:admin',
notify: vi.fn(),
});
@@ -507,6 +549,73 @@ describe('CLI scope enforcement', () => {
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
});
// --- Grant-carrying replay (the `approved: true` boolean no longer exists) ---
it('replay with a dead grant (row deleted) refuses instead of re-holding', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const ctx = agentCtx();
await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
mockGetPendingApproval.mockReturnValue(undefined); // resolution already deleted the row
const notify = vi.fn();
await approvalState.approvalHandler!({
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
payload: approval.payload,
approval: { approval_id: 'appr-dead', action: 'cli_command', payload: JSON.stringify(approval.payload) },
userId: 'telegram:admin',
notify,
});
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1); // no second card
expect(notify.mock.calls[0][0]).toContain('failed');
});
it("a grant approved for one command doesn't transfer to another", async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
// A live cli_command row, but held for a DIFFERENT command.
const grantRow = {
approval_id: 'appr-other',
action: 'cli_command',
payload: JSON.stringify({ frame: { command: 'members-add' } }),
};
mockGetPendingApproval.mockReturnValue(grantRow);
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
grant: grantRow as never,
});
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('grant');
}
expect(approvalState.observedContexts).toHaveLength(0);
});
it('a fabricated grant object without a live row is refused', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetPendingApproval.mockReturnValue(undefined);
const forged = {
approval_id: 'appr-forged',
action: 'cli_command',
payload: JSON.stringify({ frame: { command: 'approval-context-command' } }),
};
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
grant: forged as never,
});
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
expect(approvalState.requestApproval).not.toHaveBeenCalled(); // refusal, not a fresh hold
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
+41 -42
View File
@@ -3,24 +3,38 @@
* the per-session DB poller (container caller) call dispatch() with the
* same frame and a transport-supplied CallerContext.
*
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `open` commands run inline.
* Every command passes the guard before its handler runs the decision
* (allow / hold / deny) comes from the command's catalog entry, derived at
* registration (see cli/guard.ts). Dispatch keeps the mechanics: arg
* auto-fill, the sessions-get existence oracle, `--help` interception,
* parseArgs, and post-handler row filtering. An approved replay re-enters
* here carrying the verified approval row as its grant the guard re-checks
* the structural checks live, and the `approved: true` boolean no longer
* exists.
*/
import { getContainerConfig } from '../db/container-configs.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { guard, type GuardActor } from '../guard/index.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { PendingApproval } from '../types.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { localizeIsoTimestamps } from './format.js';
import { getResource } from './crud.js';
import { listVerbs, renderVerbHelp } from './help-render.js';
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
import { commandGuard, listCommands, lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
/** Verified approval row when a command is replayed after approval. */
grant?: PendingApproval;
};
function actorFor(ctx: CallerContext): GuardActor {
return ctx.caller === 'host'
? { kind: 'host' }
: { kind: 'agent', agentGroupId: ctx.agentGroupId, sessionId: ctx.sessionId };
}
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
@@ -55,43 +69,13 @@ export async function dispatch(
return err(req.id, 'unknown-command', unknownCommandMessage(req.command));
}
// CLI scope enforcement for agent callers
// Group-scope mechanics for agent callers (visibility, not policy — the
// allow/hold/deny decisions live in the guard decision, cli/guard.ts).
if (ctx.caller === 'agent') {
const configRow = getContainerConfig(ctx.agentGroupId);
const cliScope = configRow?.cli_scope ?? 'group';
if (cliScope === 'disabled') {
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
}
if (cliScope === 'group') {
// Only allow whitelisted resources and general commands (no resource, like help)
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
}
// Enforce group scope on all agent-group-related args.
// Different resources use different arg names for the agent group ID.
// Only check --id for resources where it IS the agent group ID.
const groupArgs = ['agent_group_id', 'group'] as const;
for (const key of groupArgs) {
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
}
if (
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
req.args.id &&
req.args.id !== ctx.agentGroupId
) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
// Block cli_scope changes from group-scoped agents (privilege escalation)
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
}
// Auto-fill agent-group-related args so the agent doesn't need
// to pass its own group ID explicitly.
const fill: Record<string, unknown> = {
@@ -117,9 +101,19 @@ export async function dispatch(
}
}
const decision = guard(commandGuard(cmd.name), {
actor: actorFor(ctx),
payload: req.args,
grant: opts.grant ?? null,
});
if (decision.effect === 'deny') {
return err(req.id, 'forbidden', decision.reason);
}
// `--help` interception: answer with the command's generated help instead of
// executing. Placed after scope enforcement (a group-scoped agent can't probe
// forbidden resources) and BEFORE approval gating — asking for help on an
// executing. Placed after the guard's deny (a group-scoped agent can't probe
// forbidden resources) and BEFORE hold execution — asking for help on an
// approval-gated verb must never mint an approval card.
if (req.args.help === true) {
// Carry the help text in `human` too, so both clients print it verbatim
@@ -128,7 +122,12 @@ export async function dispatch(
return { id: req.id, ok: true, data: helpText, human: helpText };
}
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
if (decision.effect === 'hold') {
if (ctx.caller !== 'agent') {
// Holds only arise for agent callers; anything else is a guard bug —
// fail closed rather than card a ghost.
return err(req.id, 'forbidden', decision.reason);
}
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
@@ -216,10 +215,10 @@ export async function dispatch(
}
}
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
registerApprovalHandler('cli_command', async ({ payload, approval, notify }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
const response = await dispatch(frame, callerContext, { grant: approval });
if (response.ok) {
const localized = localizeIsoTimestamps(response.data);
+86
View File
@@ -0,0 +1,86 @@
/**
* CLI guard adapter the command registry's catalog derivation and
* structural decision, moved verbatim out of dispatch.ts.
* Declaration is registration: registry.register() derives one
* catalog entry per command from the CommandDef itself; no second file is
* edited when a command is added.
*
* The decide fn carries today's decisions exactly:
* host caller allow (the 0600 socket is the auth story in code,
* unremovable by data);
* cli_scope 'disabled' deny; 'group' resource allowlist, cross-group
* arg denial, cli_scope-change denial;
* access 'approval' for agent callers hold for the group's admin chain.
*
* Arg auto-fill, the sessions-get existence oracle, and post-handler row
* filtering stay in dispatch.ts mechanics, not policy.
*/
import { getContainerConfig } from '../db/container-configs.js';
import { ALLOW, DENY, HOLD, type GuardedActionSpec, type GuardInput } from '../guard/index.js';
import { GROUP_SCOPE_RESOURCES, type CommandDef } from './registry.js';
/** Dotted catalog action name for a command. */
export function commandGuardAction(cmd: Pick<CommandDef, 'name' | 'action'>): string {
return cmd.action ?? `cli.${cmd.name}`;
}
/** Catalog entry derived from a CommandDef at registration time. */
export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec {
return {
action: commandGuardAction(cmd),
grantActionName: cmd.access === 'approval' ? 'cli_command' : undefined,
// Bind a cli_command grant to the exact command it was approved for.
grantCoversRequest: (grant) => {
try {
const payload = JSON.parse(grant.payload) as { frame?: { command?: string } };
return payload.frame?.command === cmd.name;
} catch {
return false;
}
},
decide: (input) => commandDecide(cmd, input),
};
}
function commandDecide(cmd: CommandDef, input: GuardInput) {
const { actor } = input;
if (actor.kind === 'host') return ALLOW('host caller (trusted socket)');
if (actor.kind !== 'agent') return DENY('CLI commands accept host or agent callers only.');
const args = input.payload;
const cliScope = getContainerConfig(actor.agentGroupId)?.cli_scope ?? 'group';
if (cliScope === 'disabled') {
return DENY('CLI access is disabled for this agent group.');
}
if (cliScope === 'group') {
// Only allow whitelisted resources and general commands (no resource, like help)
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
return DENY(`CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
}
// Enforce group scope on all agent-group-related args.
// Different resources use different arg names for the agent group ID.
// Only check --id for resources where it IS the agent group ID.
for (const key of ['agent_group_id', 'group'] as const) {
if (args[key] && args[key] !== actor.agentGroupId) {
return DENY('CLI access is scoped to this agent group.');
}
}
if ((cmd.resource === 'groups' || cmd.resource === 'destinations') && args.id && args.id !== actor.agentGroupId) {
return DENY('CLI access is scoped to this agent group.');
}
// Block cli_scope changes from group-scoped agents (privilege escalation)
if (args.cli_scope !== undefined || args['cli-scope'] !== undefined) {
return DENY('Cannot change cli_scope from a group-scoped agent.');
}
}
if (cmd.access === 'approval') {
return HOLD(`agent-initiated "${cmd.name}" requires admin approval`);
}
return ALLOW('open command');
}
+23
View File
@@ -8,6 +8,8 @@
* registers the help commands, so the registry is populated before the host's
* CLI server accepts connections.
*/
import { defineGuardedAction, type GuardedAction } from '../guard/index.js';
import { commandGuardSpec } from './guard.js';
import type { CallerContext } from './frame.js';
/**
@@ -23,6 +25,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
description: string;
access: Access;
/**
* Dotted guard-catalog action name (e.g. `roles.grant`,
* `groups.config.add-mcp-server`). Set by registerResource from the
* resource + verb; commands registered directly (help) fall back to
* `cli.<name>`.
*/
action?: string;
/**
* The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher
* only lets an agent run commands whose `resource` is on the whitelist
@@ -52,12 +61,26 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
};
const registry = new Map<string, CommandDef>();
const commandGuards = new Map<string, GuardedAction>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
if (registry.has(def.name)) {
throw new Error(`CLI command "${def.name}" already registered`);
}
registry.set(def.name, def as CommandDef);
// Declaration is registration: every command gets a guard-catalog entry
// derived from its own definition, in the same call that registers it — a
// command cannot exist without a guard, and dispatch consults it by value.
commandGuards.set(def.name, defineGuardedAction(commandGuardSpec(def as CommandDef)));
}
/** The guard defined for a registered command — total for anything register() accepted. */
export function commandGuard(name: string): GuardedAction {
const g = commandGuards.get(name);
if (!g) {
throw new Error(`CLI command "${name}" has no guard — was it registered through register()?`);
}
return g;
}
export function lookup(name: string): CommandDef | undefined {
-163
View File
@@ -1,163 +0,0 @@
/**
* Operator approval-resolution verbs (`ncl approvals approve|reject|
* reject-with-reason`). Drives `resolveApprovalFromCli` directly with a
* fabricated caller context + seeded DB state, asserting the host-only guard,
* the authorization check, and that each decision routes to the real
* resolution (handler run on approve; row consumed; reason relayed).
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAgentGroup } from '../../db/agent-groups.js';
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createPendingApproval, createSession, getPendingApproval } from '../../db/sessions.js';
import { registerApprovalHandler } from '../../modules/approvals/primitive.js';
import { grantRole } from '../../modules/permissions/db/user-roles.js';
import { upsertUser } from '../../modules/permissions/db/users.js';
import { initSessionFolder } from '../../session-manager.js';
import type { CallerContext } from '../frame.js';
import { resolveApprovalFromCli } from './approvals-resolve.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approvals-resolve' };
});
const TEST_DIR = '/tmp/nanoclaw-test-approvals-resolve';
const HOST: CallerContext = { caller: 'host' };
const AGENT: CallerContext = { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' };
const now = () => new Date().toISOString();
function seedApproval(approvalId: string, action: string): void {
createPendingApproval({
approval_id: approvalId,
session_id: 'sess-1',
request_id: approvalId,
action,
payload: JSON.stringify({}),
created_at: now(),
title: 'Test approval',
options_json: JSON.stringify([]),
});
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
initSessionFolder('ag-1', 'sess-1');
upsertUser({ id: 'cli:owner', kind: 'cli', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'cli:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
upsertUser({ id: 'cli:stranger', kind: 'cli', display_name: 'Stranger', created_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('resolveApprovalFromCli — operator approval resolution', () => {
it('rejects an agent caller (self-approval guard) and leaves the row', async () => {
seedApproval('appr-1', 'cli_command');
await expect(resolveApprovalFromCli({ id: 'appr-1', as_user: 'cli:owner' }, AGENT, 'approve')).rejects.toThrow(
/operator \(host\)/,
);
expect(getPendingApproval('appr-1')).toBeTruthy();
});
it('errors on a missing approval', async () => {
await expect(resolveApprovalFromCli({ id: 'nope', as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(
/no pending approval/,
);
});
it('rejects an unauthorized approver and leaves the row', async () => {
seedApproval('appr-2', 'cli_command');
await expect(resolveApprovalFromCli({ id: 'appr-2', as_user: 'cli:stranger' }, HOST, 'approve')).rejects.toThrow(
/not authorized/,
);
expect(getPendingApproval('appr-2')).toBeTruthy();
});
it('requires --id and --as-user', async () => {
await expect(resolveApprovalFromCli({ as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(/--id is required/);
await expect(resolveApprovalFromCli({ id: 'x' }, HOST, 'approve')).rejects.toThrow(/--as-user is required/);
});
it('approve runs the registered action handler and consumes the row', async () => {
const calls: string[] = [];
registerApprovalHandler('cli_test_approve', async () => {
calls.push('ran');
});
seedApproval('appr-3', 'cli_test_approve');
const res = await resolveApprovalFromCli({ id: 'appr-3', as_user: 'cli:owner' }, HOST, 'approve');
expect(calls).toEqual(['ran']);
expect(res).toMatchObject({ approval_id: 'appr-3', action: 'cli_test_approve', resolved: 'approve' });
expect(getPendingApproval('appr-3')).toBeUndefined();
});
it('reject consumes the row without running the action handler', async () => {
const calls: string[] = [];
registerApprovalHandler('cli_test_reject', async () => {
calls.push('ran');
});
seedApproval('appr-4', 'cli_test_reject');
const res = await resolveApprovalFromCli({ id: 'appr-4', as_user: 'cli:owner' }, HOST, 'reject');
expect(calls).toEqual([]);
expect(res.resolved).toBe('reject');
expect(getPendingApproval('appr-4')).toBeUndefined();
});
it('reject-with-reason consumes the row and carries the trimmed reason', async () => {
seedApproval('appr-5', 'cli_command');
const res = await resolveApprovalFromCli(
{ id: 'appr-5', as_user: 'cli:owner', reason: ' not this quarter ' },
HOST,
'reject-with-reason',
);
expect(res).toMatchObject({ approval_id: 'appr-5', resolved: 'reject', reason: 'not this quarter' });
expect(getPendingApproval('appr-5')).toBeUndefined();
});
it('reject-with-reason requires a reason and leaves the row on omission', async () => {
seedApproval('appr-6', 'cli_command');
await expect(
resolveApprovalFromCli({ id: 'appr-6', as_user: 'cli:owner' }, HOST, 'reject-with-reason'),
).rejects.toThrow(/--reason is required/);
expect(getPendingApproval('appr-6')).toBeTruthy();
});
it('namespaces a bare --as-user against the cli channel', async () => {
seedApproval('appr-7', 'cli_command');
// 'owner' → 'cli:owner', the seeded owner
const res = await resolveApprovalFromCli({ id: 'appr-7', as_user: 'owner' }, HOST, 'reject');
expect(res.resolved).toBe('reject');
expect(getPendingApproval('appr-7')).toBeUndefined();
});
});
-88
View File
@@ -1,88 +0,0 @@
/**
* Operator-side approval resolution the host-CLI equivalent of a channel
* button click.
*
* `ncl approvals approve|reject|reject-with-reason` routes through the SAME
* authorization (`isAuthorizedApprovalClick`) and resolution the click path
* uses `handleApprovalsResponse` for approve/plain-reject, `finalizeReject`
* with an inline reason for reject-with-reason (the operator supplies the reason
* directly, so the DM prompt-and-capture flow is skipped). Only the ingress
* differs from a rendered card button.
*
* Host-only: an agent can never resolve an approval that would let it
* self-approve its own held action. `--as-user` is the approver identity the
* operator resolves as, checked against the pending row exactly as a click's
* user id would be. `--as-user` is asserted, not authenticated which is sound
* because the ncl socket is owner-only and a host caller already bypasses the
* approval gate outright.
*/
import { getPendingApproval, getSession } from '../../db/sessions.js';
import { finalizeReject } from '../../modules/approvals/finalize.js';
import { handleApprovalsResponse, isAuthorizedApprovalClick } from '../../modules/approvals/response-handler.js';
import type { ResponsePayload } from '../../response-registry.js';
import type { CallerContext } from '../frame.js';
export type ResolveDecision = 'approve' | 'reject' | 'reject-with-reason';
export interface ResolveResult {
approval_id: string;
action: string;
resolved: 'approve' | 'reject';
reason?: string;
}
/** Matches reason-capture's cap so an operator reason and a chat reason relay identically. */
const MAX_REASON_LEN = 280;
function clampReason(raw: string): string {
const trimmed = raw.trim();
return trimmed.length <= MAX_REASON_LEN ? trimmed : trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
}
export async function resolveApprovalFromCli(
args: Record<string, unknown>,
ctx: CallerContext,
decision: ResolveDecision,
): Promise<ResolveResult> {
if (ctx.caller !== 'host') {
throw new Error('approvals can only be resolved by an operator (host), not an agent');
}
const id = String(args.id ?? '').trim();
const asUserRaw = String(args.as_user ?? '').trim();
if (!id) throw new Error('--id is required');
if (!asUserRaw) throw new Error('--as-user is required (the approver identity, e.g. cli:local)');
const approver = asUserRaw.includes(':') ? asUserRaw : `cli:${asUserRaw}`;
const approval = getPendingApproval(id);
if (!approval) throw new Error(`no pending approval: ${id}`);
const payload: ResponsePayload = {
questionId: id,
value: decision === 'approve' ? 'approve' : 'reject',
userId: approver,
channelType: 'cli',
platformId: '',
threadId: null,
};
if (!isAuthorizedApprovalClick(approval, payload)) {
throw new Error(
`${approver} is not authorized to resolve approval ${id} — must be its named approver, or an admin/owner of the requesting agent group`,
);
}
if (decision === 'reject-with-reason') {
const reason = clampReason(String(args.reason ?? ''));
if (!reason) throw new Error('--reason is required for reject-with-reason');
if (!approval.session_id) throw new Error(`approval ${id} has no session to notify`);
const session = getSession(approval.session_id);
if (!session) throw new Error(`approval ${id} session not found`);
await finalizeReject(approval, session, approver, reason);
return { approval_id: id, action: approval.action, resolved: 'reject', reason };
}
// approve / plain reject → the same path a card click drives (handler +
// grant-carrying replay on approve; finalizeReject on plain reject).
await handleApprovalsResponse(payload);
return { approval_id: id, action: approval.action, resolved: decision === 'approve' ? 'approve' : 'reject' };
}
+12 -71
View File
@@ -1,5 +1,4 @@
import { registerResource } from '../crud.js';
import { resolveApprovalFromCli } from './approvals-resolve.js';
registerResource({
name: 'approval',
@@ -49,76 +48,18 @@ registerResource({
},
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
{
name: 'approver_user_id',
type: 'string',
description: 'Named approver (exclusive) or the admin the card was delivered to (admins-of-scope).',
},
{
name: 'approver_rule',
type: 'string',
description: 'Who may resolve: only the named approver, or the admin chain of the anchoring group.',
enum: ['exclusive', 'admins-of-scope'],
},
{ name: 'dedup_key', type: 'string', description: 'In-flight dedup key (e.g. sender admission per chat+sender).' },
],
operations: { list: 'open', get: 'open' },
// Operator resolution verbs — the host-CLI equivalent of a channel button
// click. Host-only (enforced in resolveApprovalFromCli); an agent can never
// resolve an approval. Same auth + resolution as a real click.
customOperations: {
approve: {
access: 'open',
description:
'Approve a pending approval and run its action (operator only). Runs the same authorization and resolution as a channel button click.',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description:
'Approver identity to resolve as — a namespaced user id (e.g. cli:local). Must be an authorized approver of the row.',
required: true,
},
],
examples: ['ncl approvals approve --id appr-… --as-user cli:local'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'approve'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Approved ${r.action} (${r.approval_id}) and ran its action.`;
},
},
reject: {
access: 'open',
description: 'Reject a pending approval (operator only).',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
required: true,
},
],
examples: ['ncl approvals reject --id appr-… --as-user cli:local'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Rejected ${r.action} (${r.approval_id}).`;
},
},
'reject-with-reason': {
access: 'open',
description: 'Reject a pending approval and relay a one-line reason to the requesting agent (operator only).',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
required: true,
},
{
name: 'reason',
type: 'string',
description: 'One-line reason relayed to the agent (trimmed to 280 chars).',
required: true,
},
],
examples: ['ncl approvals reject-with-reason --id appr-… --as-user cli:local --reason "not this quarter"'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject-with-reason'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Rejected ${r.action} (${r.approval_id}) with reason.`;
},
},
},
});
+7 -6
View File
@@ -109,10 +109,13 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
VALUES (?, ?, 'req-1', 'cli_command', '{}', ?, ?, 'pending', '', '[]')`,
).run('pa-1', SID, now(), GID);
// Sessionless sender-admission hold anchored to the group (the folded
// pending_sender_approvals shape) — covered by the agent_group_id leg of
// the pending_approvals cascade.
db.prepare(
`INSERT INTO pending_sender_approvals (id, messaging_group_id, agent_group_id, sender_identity, sender_name, original_message, approver_user_id, created_at)
VALUES ('psa-1', ?, ?, 'tg:99', 'them', '{}', ?, ?)`,
).run(MGID, GID, UID, now());
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, status, title, options_json, dedup_key)
VALUES (?, NULL, 'req-2', 'sender_admit', '{}', ?, ?, 'pending', '', '[]', 'sender_admit:mg:tg:99')`,
).run('pa-2', now(), GID);
db.prepare(
`INSERT INTO pending_channel_approvals (messaging_group_id, agent_group_id, original_message, approver_user_id, created_at)
@@ -148,10 +151,9 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
expect(data.removed).toMatchObject({
sessions: 1,
pending_questions: 1,
pending_approvals: 1,
pending_approvals: 2,
agent_destinations_owned: 1,
agent_destinations_pointing: 0,
pending_sender_approvals: 1,
pending_channel_approvals: 1,
messaging_group_agents: 1,
agent_group_members: 1,
@@ -167,7 +169,6 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
count('SELECT COUNT(*) AS c FROM pending_approvals WHERE agent_group_id = ? OR session_id = ?', GID, SID),
).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM pending_sender_approvals WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM pending_channel_approvals WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM agent_group_members WHERE agent_group_id = ?', GID)).toBe(0);
-4
View File
@@ -134,7 +134,6 @@ registerResource({
pending_approvals: 0,
agent_destinations_owned: 0,
agent_destinations_pointing: 0,
pending_sender_approvals: 0,
pending_channel_approvals: 0,
messaging_group_agents: 0,
agent_group_members: 0,
@@ -163,9 +162,6 @@ registerResource({
.run(groupId, groupId).changes;
}
counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
counts.pending_sender_approvals = db
.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
.run(groupId).changes;
counts.pending_channel_approvals = db
.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
.run(groupId).changes;
@@ -0,0 +1,99 @@
/**
* Upgrade-path test for migration 020 (holds-approver-rule): in-flight
* pending_approvals rows created by the pre-contract code must come out with
* the approver rule the old click-auth gave them, and the sender table must be
* gone.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDb, initTestDb, runMigrations } from './index.js';
import { migrations } from './migrations/index.js';
import type Database from 'better-sqlite3';
let db: Database.Database;
beforeEach(() => {
db = initTestDb();
// Everything up to — but not including — the holds-approver-rule migration.
runMigrations(
db,
migrations.filter((m) => m.name !== 'holds-approver-rule'),
);
});
afterEach(() => {
closeDb();
});
function hasTable(name: string): boolean {
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined;
}
describe('migration 020 — holds-approver-rule', () => {
it('backfills approver_rule and agent_group_id on in-flight rows and drops the sender table', () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO agent_groups (id, name, folder, created_at) VALUES ('ag-1', 'One', 'one', ?)").run(now);
db.prepare(
"INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, created_at) VALUES ('sess-1', 'ag-1', NULL, NULL, ?)",
).run(now);
// Legacy a2a hold: named approver ⇒ was exclusive.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, approver_user_id, title, options_json)
VALUES ('appr-a2a', 'sess-1', 'appr-a2a', 'a2a_message_gate', '{}', ?, 'tg:dana', '', '[]')`,
).run(now);
// Legacy cli hold: no approver, no agent_group_id — click-auth fell back
// to the session's group; the backfill makes that anchoring explicit.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, title, options_json)
VALUES ('appr-cli', 'sess-1', 'appr-cli', 'cli_command', '{}', ?, '', '[]')`,
).run(now);
// Legacy OneCLI hold: sessionless, agent_group_id already stamped.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, title, options_json)
VALUES ('oa-1', NULL, 'req-uuid', 'onecli_credential', '{}', ?, 'ag-1', '', '[]')`,
).run(now);
expect(hasTable('pending_sender_approvals')).toBe(true);
runMigrations(db); // applies only holds-approver-rule
const rows = db.prepare('SELECT approval_id, approver_rule, agent_group_id FROM pending_approvals').all() as Array<{
approval_id: string;
approver_rule: string;
agent_group_id: string;
}>;
const byId = Object.fromEntries(rows.map((r) => [r.approval_id, r]));
expect(byId['appr-a2a']).toMatchObject({
approver_rule: 'exclusive',
agent_group_id: 'ag-1',
});
expect(byId['appr-cli']).toMatchObject({
approver_rule: 'admins-of-scope',
agent_group_id: 'ag-1',
});
expect(byId['oa-1']).toMatchObject({ approver_rule: 'admins-of-scope', agent_group_id: 'ag-1' });
expect(hasTable('pending_sender_approvals')).toBe(false);
});
it('the dedup_key partial UNIQUE keeps at most one in-flight hold per key (sender fold parity)', () => {
runMigrations(db);
const now = new Date().toISOString();
const insert = (id: string, key: string | null) =>
db
.prepare(
`INSERT OR IGNORE INTO pending_approvals
(approval_id, request_id, action, payload, created_at, title, options_json, dedup_key)
VALUES (?, ?, 'sender_admit', '{}', ?, '', '[]', ?)`,
)
.run(id, id, now, key).changes;
expect(insert('a', 'sender_admit:mg:tg:stranger')).toBe(1);
expect(insert('b', 'sender_admit:mg:tg:stranger')).toBe(0); // same key → dropped by the partial UNIQUE
// NULL keys are exempt (the partial index), so ordinary holds still coexist.
expect(insert('c', null)).toBe(1);
expect(insert('d', null)).toBe(1);
});
});
@@ -0,0 +1,48 @@
import type { Migration } from './index.js';
/**
* The hold-record contract lands on `pending_approvals` (guarded-actions
* phase 1 see the guarded-actions decisions doc, decision 5):
*
* - `approver_rule` who may resolve the hold: 'exclusive' (only
* `approver_user_id`, e.g. an a2a policy's named approver) or
* 'admins-of-scope' (the admin chain of `agent_group_id`, plus the
* specific user the card was delivered to when `approver_user_id` is
* stamped the sender/channel "named-or-admin" semantic).
* - `dedup_key` in-flight dedup: while a pending row carries a key, a
* second request with the same key is dropped. The partial UNIQUE index
* enforces it at the DB level under concurrency, preserving the sender
* table's old UNIQUE(messaging_group_id, sender_identity) guarantee.
*
* Backfills: rows with a named approver were exclusive before this column
* existed; `agent_group_id` is stamped from the requesting session so
* click-auth no longer needs the session fallback.
*
* `pending_sender_approvals` is dropped: sender admission now holds through
* the approvals primitive (action 'sender_admit'). In-flight sender cards at
* upgrade time die with the table they are transient courtesy cards, and a
* new message from the same sender re-triggers one.
*/
export const migration020: Migration = {
version: 20,
name: 'holds-approver-rule',
up(db) {
db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_rule TEXT NOT NULL DEFAULT 'admins-of-scope';`);
db.exec(`ALTER TABLE pending_approvals ADD COLUMN dedup_key TEXT;`);
// Partial UNIQUE preserves the dropped sender table's
// UNIQUE(messaging_group_id, sender_identity): at most one live hold per
// dedup key, so a concurrent duplicate request can't mint a second card.
db.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_approvals_dedup
ON pending_approvals(dedup_key) WHERE dedup_key IS NOT NULL;`,
);
db.exec(`UPDATE pending_approvals SET approver_rule = 'exclusive' WHERE approver_user_id IS NOT NULL;`);
db.exec(
`UPDATE pending_approvals
SET agent_group_id = (SELECT s.agent_group_id FROM sessions s WHERE s.id = pending_approvals.session_id)
WHERE agent_group_id IS NULL AND session_id IS NOT NULL;`,
);
db.exec(`DROP INDEX IF EXISTS idx_pending_sender_approvals_mg;`);
db.exec(`DROP TABLE IF EXISTS pending_sender_approvals;`);
},
};
+2
View File
@@ -18,6 +18,7 @@ import { moduleApprovalsPendingApprovals } from './module-approvals-pending-appr
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-wiring-threads.js';
import { migration020 } from './020-holds-approver-rule.js';
export interface Migration {
version: number;
@@ -52,6 +53,7 @@ export const migrations: Migration[] = [
migration015,
migration016,
migration019,
migration020,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
-15
View File
@@ -136,21 +136,6 @@ CREATE TABLE pending_questions (
created_at TEXT NOT NULL
);
-- Pending approvals for unknown senders (unknown_sender_policy='request_approval').
-- In-flight dedup via UNIQUE(messaging_group_id, sender_identity): a second
-- message from the same unknown sender while a card is pending is silently
-- dropped instead of spamming the admin.
CREATE TABLE pending_sender_approvals (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle)
sender_name TEXT,
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, sender_identity)
);
`;
/**
+14 -11
View File
@@ -202,11 +202,11 @@ export function createPendingApproval(
`INSERT OR IGNORE INTO pending_approvals
(approval_id, session_id, request_id, action, payload, created_at,
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
title, options_json, approver_user_id)
title, options_json, approver_user_id, approver_rule, dedup_key)
VALUES
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status,
@title, @options_json, @approver_user_id)`,
@title, @options_json, @approver_user_id, @approver_rule, @dedup_key)`,
)
.run({
session_id: null,
@@ -217,11 +217,20 @@ export function createPendingApproval(
expires_at: null,
status: 'pending',
approver_user_id: null,
approver_rule: 'admins-of-scope',
dedup_key: null,
...pa,
});
return result.changes > 0;
}
/** In-flight lookup for `requestApproval`'s dedup: any live row with this key blocks a repeat request. */
export function getPendingApprovalByDedupKey(dedupKey: string): PendingApproval | undefined {
return getDb().prepare('SELECT * FROM pending_approvals WHERE dedup_key = ? LIMIT 1').get(dedupKey) as
| PendingApproval
| undefined;
}
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
return getDb().prepare('SELECT * FROM pending_approvals WHERE approval_id = ?').get(approvalId) as
| PendingApproval
@@ -277,8 +286,9 @@ export function getAskQuestionRender(
| undefined;
if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) };
// Channel-registration + unknown-sender approvals persist title/options_json
// the same way pending_approvals does — just SELECT and return.
// Channel-registration approvals persist title/options_json the same way
// pending_approvals does — just SELECT and return. (Unknown-sender approvals
// are pending_approvals rows since the sender fold.)
if (hasTable(getDb(), 'pending_channel_approvals')) {
const c = getDb()
.prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?')
@@ -286,12 +296,5 @@ export function getAskQuestionRender(
if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) };
}
if (hasTable(getDb(), 'pending_sender_approvals')) {
const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as
| { title: string; options_json: string }
| undefined;
if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) };
}
return undefined;
}
+35 -4
View File
@@ -4,7 +4,9 @@
* `registerDeliveryAction` is the hook modules use to handle system-kind
* outbound messages; `getDeliveryAction` is the read side that makes those
* registrations behavior-testable. Goes red if either half of the registry
* is removed or the two stop sharing the same map.
* is removed or the two stop sharing the same map. Every registration now
* carries a guard spec or an explicit unguarded(<reason>) declaration
* omission is a type error.
*/
import { describe, it, expect, vi } from 'vitest';
@@ -16,11 +18,14 @@ vi.mock('./container-runner.js', () => ({
}));
import { registerDeliveryAction, getDeliveryAction, type DeliveryActionHandler } from './delivery.js';
import { defineGuardedAction, HOLD, unguarded } from './guard/index.js';
const testUnguarded = unguarded('test — registry mechanics only');
describe('delivery action registry', () => {
it('getDeliveryAction returns the handler registerDeliveryAction registered', () => {
const handler: DeliveryActionHandler = async () => {};
registerDeliveryAction('test_registry_action', handler);
registerDeliveryAction('test_registry_action', handler, testUnguarded);
expect(getDeliveryAction('test_registry_action')).toBe(handler);
});
@@ -31,8 +36,34 @@ describe('delivery action registry', () => {
it('re-registering an action overwrites the previous handler', () => {
const first: DeliveryActionHandler = async () => {};
const second: DeliveryActionHandler = async () => {};
registerDeliveryAction('test_overwrite_action', first);
registerDeliveryAction('test_overwrite_action', second);
registerDeliveryAction('test_overwrite_action', first, testUnguarded);
registerDeliveryAction('test_overwrite_action', second, testUnguarded);
expect(getDeliveryAction('test_overwrite_action')).toBe(second);
});
it('refuses to replace a guard-wrapped action with an unguarded handler', () => {
const guardAction = defineGuardedAction({
action: 'test.guarded-overwrite',
decide: () => HOLD('t'),
});
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
guardAction,
requestHold: async () => {},
});
// Disarming the guard by re-registering unguarded must throw — otherwise
// the action's catalog entry would still exist while the live path runs
// unguarded.
expect(() => registerDeliveryAction('test_guarded_overwrite', async () => {}, testUnguarded)).toThrow(
/disarm the guard/,
);
// Re-registering WITH a spec stays allowed (a legitimate replacement
// keeps the action guarded).
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
guardAction,
requestHold: async () => {},
});
expect(getDeliveryAction('test_guarded_overwrite')).toBeDefined();
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* The guard-consult path for privileged delivery actions.
*
* The registry itself registration, lookup, approved-replay re-entry
* stays in delivery.ts, close to main's shape. This file holds the new
* guard logic: the spec a privileged registration carries, and runGuarded,
* the precheck guard deny/hold/allow pipeline every consult runs.
*/
import { guard, type GuardedAction } from './guard/index.js';
import { log } from './log.js';
import type { PendingApproval, Session } from './types.js';
/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */
export type GuardedDeliveryHandler = (content: Record<string, unknown>, session: Session) => Promise<void>;
export interface DeliveryGuardSpec {
/** Guard action consulted before the handler runs — the defined value, not a name. */
guardAction: GuardedAction;
/**
* Domain validation that runs before the guard malformed requests are
* answered (notify) without ever creating a hold. Return false to stop.
*/
precheck?: (content: Record<string, unknown>, session: Session) => boolean | Promise<boolean>;
/** Create the hold (the domain's requestApproval call — card text lives with the domain). */
requestHold: (content: Record<string, unknown>, session: Session) => Promise<void>;
/** Tell the requester about a deny. */
onDeny?: (content: Record<string, unknown>, session: Session, reason: string) => void;
}
/**
* Run a guarded delivery action: precheck, consult the guard, then route the
* decision deny onDeny, hold requestHold, allow handler. A fresh
* dispatch passes grant=null; an approved replay passes the approval row,
* which satisfies a hold but never a deny (the structural checks re-run
* live, so approve-then-revoke does not execute).
*/
export async function runGuarded(
action: string,
spec: DeliveryGuardSpec,
handler: GuardedDeliveryHandler,
content: Record<string, unknown>,
session: Session,
grant: PendingApproval | null,
): Promise<void> {
if (spec.precheck && !(await spec.precheck(content, session))) return;
const decision = guard(spec.guardAction, {
actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id },
payload: content,
grant,
});
if (decision.effect === 'deny') {
log.warn('Delivery action denied by guard', { action, reason: decision.reason });
spec.onDeny?.(content, session, decision.reason);
return;
}
if (decision.effect === 'hold') {
await spec.requestHold(content, session);
return;
}
await handler(content, session);
}
+80 -17
View File
@@ -20,12 +20,14 @@ import {
markDeliveryFailed,
migrateDeliveredTable,
} from './db/session-db.js';
import { runGuarded, type DeliveryGuardSpec, type GuardedDeliveryHandler } from './delivery-guard.js';
import { isUnguarded, type Unguarded } from './guard/index.js';
import { log } from './log.js';
import { normalizeOptions } from './channels/ask-question.js';
import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js';
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
import type { OutboundFile } from './channels/adapter.js';
import type { Session } from './types.js';
import type { PendingApproval, Session } from './types.js';
const ACTIVE_POLL_MS = 1000;
const SWEEP_POLL_MS = 60_000;
@@ -393,14 +395,19 @@ async function deliverMessage(
* Delivery action registry.
*
* Modules register handlers for system-kind outbound message actions via
* `registerDeliveryAction`. Core checks the registry first in
* `handleSystemAction` and falls through to the inline switch when no
* handler is registered. The switch will shrink as modules are extracted
* (scheduling, approvals, agent-to-agent) and eventually only its default
* branch remains.
* `registerDeliveryAction`. Unknown actions log "Unknown system action".
*
* Default when no handler registered and the switch doesn't match: log
* "Unknown system action" and return.
* Privileged delivery actions (create_agent, install_packages,
* add_mcp_server) register with a guard spec: every path to the handler body
* dispatch, approved replay, test lookup goes through the guard consult
* (allow / hold / deny), so there is no unguarded route to it. On approve,
* the continuation re-enters the same entry carrying the approval row as its
* grant (`reenterGuardedDeliveryAction`), so the structural checks are
* re-run live. Plain actions (the cli_request bridge its inner
* commands are guarded at dispatch) register with an
* explicit `unguarded(<reason>)` declaration instead of a spec omission is
* not representable, so the decision to run unguarded is visible, and
* justified, at the registration site.
*/
export type DeliveryActionHandler = (
content: Record<string, unknown>,
@@ -408,18 +415,74 @@ export type DeliveryActionHandler = (
inDb: Database.Database,
) => Promise<void>;
const actionHandlers = new Map<string, DeliveryActionHandler>();
type DeliveryEntry =
| { guard: Unguarded; handler: DeliveryActionHandler }
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void {
if (actionHandlers.has(action)) {
log.warn('Delivery action handler overwritten', { action });
}
actionHandlers.set(action, handler);
const deliveryActions = new Map<string, DeliveryEntry>();
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
return isUnguarded(entry.guard);
}
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void;
export function registerDeliveryAction(action: string, handler: GuardedDeliveryHandler, spec: DeliveryGuardSpec): void;
export function registerDeliveryAction(
action: string,
handler: DeliveryActionHandler | GuardedDeliveryHandler,
guardDecl: DeliveryGuardSpec | Unguarded,
): void {
const existing = deliveryActions.get(action);
if (existing) {
// Replacing a guard-wrapped action with an unguarded handler would
// disarm the guard while its catalog entry still exists — refuse. A
// skill that wants to extend a guarded action must compose at the
// module's exported functions instead, or re-register with a guard spec
// of its own.
if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) {
throw new Error(
`delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`,
);
}
log.warn('Delivery action handler overwritten', { action });
}
// The overloads pair each handler shape with its declaration; the merged
// implementation signature erases that pairing, hence the one cast.
deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry);
}
/**
* Approve continuation for a guard-wrapped delivery action: re-enter the
* entry with the approval row as the grant. The guard treats the grant as
* hold-satisfied but re-runs the structural checks, so approve-then-revoke
* does not execute. Domains register this as their approval handler in the
* same line that registers the action.
*/
export function reenterGuardedDeliveryAction(action: string) {
return async (ctx: { session: Session | null; payload: Record<string, unknown>; approval: PendingApproval }) => {
if (!ctx.session) {
log.warn('Approved replay arrived without a session — dropping', { action });
return;
}
const entry = deliveryActions.get(action);
if (!entry || isUnguardedEntry(entry)) {
log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action });
return;
}
await runGuarded(action, entry.guard, entry.handler, ctx.payload, ctx.session, ctx.approval);
};
}
/**
* The invocable for a registered action the raw handler for unguarded
* entries, the guard-consulting path for guarded ones. Dispatch and tests
* both come through here; there is no route around the guard.
*/
export function getDeliveryAction(action: string): DeliveryActionHandler | undefined {
return actionHandlers.get(action);
const entry = deliveryActions.get(action);
if (!entry) return undefined;
if (isUnguardedEntry(entry)) return entry.handler;
return (content, session) => runGuarded(action, entry.guard, entry.handler, content, session, null);
}
/**
@@ -435,7 +498,7 @@ async function handleSystemAction(
const action = content.action as string;
log.info('System action from agent', { sessionId: session.id, action });
const registered = actionHandlers.get(action);
const registered = getDeliveryAction(action);
if (registered) {
await registered(content, session, inDb);
return;
+64
View File
@@ -0,0 +1,64 @@
/**
* Guard conformance checked with the real registries.
*
* The old registry walk is gone: an unmapped consult or an undeclared
* unguarded registration is now unconstructible guard() takes the defined
* GuardedAction value (a dropped module-edge import or typo'd name is a
* compile error), and the keyed registries require a guard spec or an
* explicit unguarded(<reason>) declaration. What's left to verify is the
* cross-registry pairing the compiler can't see: every holding action has a
* registered approve continuation. (At runtime a missing continuation is
* handled loudly at click time the requester is told no handler is
* installed; this test keeps the tree from shipping that state.)
*/
import { describe, expect, it } from 'vitest';
// Production barrels — side-effect imports populate the real registries.
import '../cli/commands/index.js';
import '../modules/index.js';
import '../cli/delivery-action.js';
import '../cli/dispatch.js'; // registers the cli_command approval handler
import { commandGuard, listCommands } from '../cli/registry.js';
import { getApprovalHandler } from '../modules/approvals/primitive.js';
import { defineGuardedAction, listGuardedActions } from './guard-actions.js';
import { HOLD } from './types.js';
describe('guard conformance', () => {
it('every holding action pairs with a registered approval handler', () => {
const holding = listGuardedActions().filter((spec) => spec.grantActionName);
expect(holding.length).toBeGreaterThan(0);
const dangling = holding.filter((spec) => !getApprovalHandler(spec.grantActionName as string));
expect(dangling.map((s) => s.action)).toEqual([]);
});
it('every mutating ncl command derives a guard that holds via cli_command', () => {
const mutating = listCommands().filter((cmd) => cmd.access === 'approval');
expect(mutating.length).toBeGreaterThan(0);
const wrong = mutating.filter((cmd) => commandGuard(cmd.name).grantActionName !== 'cli_command');
expect(wrong.map((c) => c.name)).toEqual([]);
});
it('the domain catalog entries are defined once the module barrels load', () => {
const actions = new Set(listGuardedActions().map((s) => s.action));
for (const expected of [
'agents.create',
'a2a.send',
'self_mod.install_packages',
'self_mod.add_mcp_server',
'senders.admit',
'channels.register',
]) {
expect(actions.has(expected), `catalog is missing "${expected}"`).toBe(true);
}
});
it('defining the same action twice throws — names are the catalog key', () => {
defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') });
expect(() => defineGuardedAction({ action: 'test.dup-define', decide: () => HOLD('x') })).toThrow(
/already defined/,
);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The action catalog the enforcement boundary.
*
* An action either is defined here (and every consult passes its decision)
* or cannot be consulted at all: guard() takes the GuardedAction VALUE
* returned by defineGuardedAction, so the wiring between a consult site and
* its decide fn is a symbol reference the compiler checks. A dropped
* module-edge import or a typo'd action name is a build error, not a
* runtime fail-open there is no lookup that can miss.
*
* Definitions are still recorded by name so the catalog can be enumerated:
* the conformance test pairs every holding action with its registered
* approval handler, and duplicate names are refused at definition time
* (grants match on the name).
*/
import type { GuardDecision, GuardInput } from './types.js';
import type { PendingApproval } from '../types.js';
export interface GuardedActionSpec {
/** Dotted action name, e.g. 'roles.grant', 'agents.create', 'a2a.send'. */
action: string;
/**
* Today's structural checks for this action, verbatim the only source of
* allow. Runs on every consult, including approved replays (a grant
* satisfies a hold, never a deny).
*/
decide: (input: GuardInput) => GuardDecision;
/**
* The pending_approvals.action its holds resolve through a grant is only
* accepted when its row carries this action. Omit for actions that can
* never be held (deny/allow-only decisions).
*/
grantActionName?: string;
/**
* Extra domain binding between a grant and the replayed input (e.g. the
* a2a target must match the held message). Runs in addition to the
* grantActionName + live-row checks.
*/
grantCoversRequest?: (grant: PendingApproval, input: GuardInput) => boolean;
}
declare const guardedActionBrand: unique symbol;
/**
* A defined guarded action only defineGuardedAction can mint one. The
* brand makes the type nominal: a hand-rolled { action, decide } object
* does not typecheck at a consult site, and fails the runtime check too.
*/
export type GuardedAction = Readonly<GuardedActionSpec> & { readonly [guardedActionBrand]: true };
const defined = new Map<string, GuardedAction>();
const minted = new WeakSet<object>();
export function defineGuardedAction(spec: GuardedActionSpec): GuardedAction {
if (defined.has(spec.action)) {
throw new Error(`guarded action "${spec.action}" is already defined — action names are the catalog key`);
}
const def = Object.freeze({ ...spec }) as GuardedAction;
minted.add(def);
defined.set(spec.action, def);
return def;
}
/**
* Runtime backstop for callers outside the type system (plain JS, casts):
* only values minted by defineGuardedAction pass guard() denies the rest.
*/
export function isGuardedAction(value: unknown): value is GuardedAction {
return typeof value === 'object' && value !== null && minted.has(value);
}
export function listGuardedActions(): GuardedAction[] {
return [...defined.values()].sort((a, b) => a.action.localeCompare(b.action));
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Guard decision-function unit tests: decide is the decision (allow /
* hold / deny returned as-is), grant semantics (satisfies holds, never
* denies; invalid refuse), the runtime backstop against forged action
* values, and the fail-closed posture on a throwing decide.
*
* Uses synthetic actions defined per test the catalog is per-worker module
* state with no reset, so action names are unique.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { guard } from './guard.js';
import { defineGuardedAction, type GuardedAction } from './guard-actions.js';
import { ALLOW, DENY, HOLD, type GuardInput } from './types.js';
const mockGetPendingApproval = vi.fn();
vi.mock('../db/sessions.js', () => ({
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
}));
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const AGENT = { kind: 'agent', agentGroupId: 'ag-1', sessionId: 'sess-1' } as const;
function input(extra: Partial<GuardInput> = {}): GuardInput {
return { actor: AGENT, payload: {}, ...extra };
}
beforeEach(() => {
mockGetPendingApproval.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('decide is the decision', () => {
it('decide allow → allow', () => {
const action = defineGuardedAction({ action: 't.allow1', decide: () => ALLOW('ok') });
expect(guard(action, input()).effect).toBe('allow');
});
it('decide hold → hold, default approver chain', () => {
const action = defineGuardedAction({ action: 't.hold1', decide: () => HOLD('needs approval') });
const d = guard(action, input());
expect(d.effect).toBe('hold');
if (d.effect === 'hold') {
expect(d.reason).toBe('needs approval');
expect(d.approverUserId).toBeUndefined();
}
});
it('decide hold → hold, carrying a named approver', () => {
const action = defineGuardedAction({ action: 't.hold2', decide: () => HOLD('policy row', 'telegram:dana') });
const d = guard(action, input());
expect(d.effect).toBe('hold');
if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana');
});
it('decide deny → deny, carrying the reason', () => {
const action = defineGuardedAction({ action: 't.deny1', decide: () => DENY('structurally unauthorized') });
const d = guard(action, input());
expect(d.effect).toBe('deny');
if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized');
});
it('a forged action value (not from defineGuardedAction) is denied', () => {
const forged = { action: 't.forged', decide: () => ALLOW('never vetted') } as unknown as GuardedAction;
const d = guard(forged, input());
expect(d.effect).toBe('deny');
if (d.effect === 'deny') expect(d.reason).toContain('undefined action');
});
});
describe('grants', () => {
const grantRow = (action: string) =>
({ approval_id: 'appr-1', action, payload: '{}' }) as unknown as NonNullable<GuardInput['grant']>;
it('a valid live grant satisfies a hold', () => {
const action = defineGuardedAction({
action: 't.g1',
grantActionName: 'g1_approved',
decide: () => HOLD('b'),
});
const grant = grantRow('g1_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('allow');
});
it('a grant never satisfies a deny — the checks re-run live', () => {
const action = defineGuardedAction({
action: 't.g2',
grantActionName: 'g2_approved',
decide: () => DENY('revoked since'),
});
const grant = grantRow('g2_approved');
mockGetPendingApproval.mockReturnValue(grant);
const d = guard(action, input({ grant }));
expect(d.effect).toBe('deny');
if (d.effect === 'deny') expect(d.reason).toBe('revoked since');
});
it('a dead grant (row deleted) refuses instead of re-holding', () => {
const action = defineGuardedAction({
action: 't.g3',
grantActionName: 'g3_approved',
decide: () => HOLD('b'),
});
mockGetPendingApproval.mockReturnValue(undefined);
const d = guard(action, input({ grant: grantRow('g3_approved') }));
expect(d.effect).toBe('deny');
});
it("a grant for a different action doesn't transfer", () => {
const action = defineGuardedAction({
action: 't.g4',
grantActionName: 'g4_approved',
decide: () => HOLD('b'),
});
const grant = grantRow('other_action');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('deny');
});
it('a domain grantCoversRequest binding can refuse a payload mismatch', () => {
const action = defineGuardedAction({
action: 't.g5',
grantActionName: 'g5_approved',
grantCoversRequest: () => false,
decide: () => HOLD('b'),
});
const grant = grantRow('g5_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('deny');
});
it('a grant on an already-allowed action is a no-op', () => {
const action = defineGuardedAction({
action: 't.g6',
grantActionName: 'g6_approved',
decide: () => ALLOW('ok'),
});
const grant = grantRow('g6_approved');
mockGetPendingApproval.mockReturnValue(grant);
expect(guard(action, input({ grant })).effect).toBe('allow');
});
});
describe('fail-closed posture', () => {
it('a throwing decide denies', () => {
const action = defineGuardedAction({
action: 't.f1',
decide: () => {
throw new Error('boom');
},
});
expect(guard(action, input()).effect).toBe('deny');
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* guard() the one decision function every privileged action consults.
*
* The decision is the action's decide fn — today's code checks,
* defined per action at the module edges. The consult site holds the
* GuardedAction value itself (defineGuardedAction), so there is no name
* lookup and no fail-open path for an unknown action: an unwired consult is
* a compile error, and a value that didn't come from defineGuardedAction is
* denied at runtime. Policy-as-data (tighten-only rule sources composing
* with the decision) is deliberately deferred a generalized rules table
* can arrive later, with its first operator-visible consumer; until then
* the one policy table (agent_message_policies) is consulted inside
* a2a.send's decide.
*
* Grants: an approved replay carries the verified approval row. A valid
* grant (live pending row whose action matches the entry's approval action,
* plus any domain binding) satisfies a hold the human already decided
* but NEVER a deny: the checks re-run live, so approve-then-revoke
* no longer executes. A grant that is present but invalid fails closed to
* deny (no second card).
*
* The guard itself fails closed: a throwing decide denies.
*/
import { getPendingApproval } from '../db/sessions.js';
import { log } from '../log.js';
import { isGuardedAction, type GuardedAction } from './guard-actions.js';
import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js';
export function guard(action: GuardedAction, input: GuardInput): GuardDecision {
if (!isGuardedAction(action)) {
// JS-level backstop — the branded type already forbids this. A
// hand-rolled object must not carry a decide fn never vetted at
// definition time.
log.error('Guard consulted with an undefined action — failing closed', {
action: (action as { action?: unknown } | null)?.action,
});
return DENY('guard consulted with an undefined action (failing closed)');
}
let decision: GuardDecision;
try {
decision = action.decide(input);
} catch (err) {
log.error('Guard evaluation threw — failing closed', { action: action.action, err });
return DENY('guard failure (failing closed)');
}
if (!input.grant || decision.effect !== 'hold') {
// A grant never loosens a deny (the checks re-run live), and a
// grant on an already-allowed action is a no-op.
return decision;
}
// An invalid grant on a replay is a refusal, not a fresh hold — approved
// replays must execute exactly once.
if (grantSatisfies(action, input)) {
return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`);
}
return DENY('replay carried an invalid or mismatched grant');
}
function grantSatisfies(action: GuardedAction, input: GuardInput): boolean {
const grant = input.grant;
if (!grant || !action.grantActionName) return false;
if (grant.action !== action.grantActionName) return false;
// The row must still be live — resolution deletes it, so a grant can only
// execute once and a fabricated row object doesn't pass.
const live = getPendingApproval(grant.approval_id);
if (!live || live.action !== action.grantActionName) return false;
if (action.grantCoversRequest && !action.grantCoversRequest(grant, input)) return false;
return true;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Guard the privileged-action decision seam.
*
* One decision function (guard.ts) and a definition-derived action
* catalog (guard-actions.ts). Consults carry the GuardedAction value returned by
* defineGuardedAction never a name to look up so mis-wiring is a build
* error, not a runtime fail-open.
* Domain-free leaf: domain decisions are defined at the domain modules' edges.
*/
export { guard } from './guard.js';
export {
defineGuardedAction,
isGuardedAction,
listGuardedActions,
type GuardedAction,
type GuardedActionSpec,
} from './guard-actions.js';
export {
ALLOW,
DENY,
HOLD,
isUnguarded,
unguarded,
type GuardActor,
type GuardDecision,
type GuardInput,
type Unguarded,
} from './types.js';
+75
View File
@@ -0,0 +1,75 @@
/**
* Guard vocabulary the decision seam every privileged action passes.
*
* The guard is a domain-free leaf: this module may import the DB read layer,
* config, log, and shared types never src/cli/* or src/modules/*. Domain
* knowledge (what an action's decide fn checks) arrives via
* definition: domain modules call defineGuardedAction (guard-actions.ts) at
* their module edges and pass the returned value to every consult and
* registration site the wiring is a symbol reference the compiler checks.
*/
import type { PendingApproval } from '../types.js';
/** Who is attempting the action. Mirrors the CLI CallerContext + click identities. */
export type GuardActor =
| { kind: 'host' }
| { kind: 'agent'; agentGroupId: string; sessionId?: string }
| { kind: 'human'; userId: string }
| { kind: 'system' };
export interface GuardInput {
actor: GuardActor;
/** Domain resource reference, e.g. { from, to } for a2a.send. */
resource?: Record<string, string>;
/** Action arguments — what the card summarizes and rules may later match on. */
payload: Record<string, unknown>;
/**
* Verified approval row carried by an approved replay. A valid grant
* satisfies a hold (the human already decided) but never a deny the
* structural checks re-run live on every replay.
*/
grant?: PendingApproval | null;
}
const unguardedBrand = Symbol('unguarded');
/**
* A registration that deliberately carries no guard. Where a registry takes
* a declaration (delivery actions), omission is not representable
* registration requires either a guard spec or this marker, so the decision
* to run unguarded is visible, and justified, in the diff that registers
* the handler. The reason travels with the registration;
* `grep "unguarded("` is the complete inventory.
*/
export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true };
export function unguarded(reason: string): Unguarded {
return Object.freeze({ reason, [unguardedBrand]: true as const });
}
/**
* The one runtime discriminator for guard declarations. The brand symbol is
* module-private, so `unguarded()` is the only mint a look-alike
* `{ reason }` object (or a guard spec that someday grows a `reason` field)
* doesn't pass.
*/
export function isUnguarded(decl: object): decl is Unguarded {
return unguardedBrand in decl;
}
export type GuardDecision =
| { effect: 'allow'; reason: string }
| { effect: 'hold'; reason: string; approverUserId?: string }
| { effect: 'deny'; reason: string };
export const ALLOW = (reason: string): GuardDecision => ({ effect: 'allow', reason });
export const DENY = (reason: string): GuardDecision => ({ effect: 'deny', reason });
/**
* approverUserId names an exclusive approver for the hold (the a2a policy
* row's named approver). Absent, the hold goes to the approvals primitive's
* default chain (scoped admins global admins owners).
*/
export const HOLD = (reason: string, approverUserId?: string): GuardDecision => ({
effect: 'hold',
reason,
approverUserId,
});
+53 -42
View File
@@ -27,14 +27,15 @@ import { getAgentGroup } from '../../db/agent-groups.js';
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { guard } from '../../guard/index.js';
import { log } from '../../log.js';
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
import type { PendingApproval, Session } from '../../types.js';
import { requestApproval } from '../approvals/index.js';
import { hasDestination } from './db/agent-destinations.js';
import { getMessagePolicy } from './db/agent-message-policies.js';
import { A2A_MESSAGE_GATE_ACTION, a2aSend } from './guard.js';
export { isSafeAttachmentName };
export { A2A_MESSAGE_GATE_ACTION } from './guard.js';
export interface ForwardedAttachment {
name: string;
@@ -230,56 +231,64 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session;
}
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
export async function routeAgentMessage(
msg: RoutableAgentMessage,
session: Session,
opts: { grant?: PendingApproval } = {},
): Promise<void> {
const sourceAgentGroupId = session.agent_group_id;
const targetAgentGroupId = msg.platform_id;
if (!targetAgentGroupId) {
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
}
const isSelf = targetAgentGroupId === sourceAgentGroupId;
if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) {
throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`);
}
if (!getAgentGroup(targetAgentGroupId)) {
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
// The a2a.send decision (guard.ts) carries the checks verbatim in their
// original order: destination ACL deny, target-exists deny, self-send
// allow, agent_message_policies hold. An approved replay carries the
// grant — the hold is satisfied but the structure is re-checked live, so
// revoking a destination between hold and approve blocks delivery.
const decision = guard(a2aSend, {
actor: { kind: 'agent', agentGroupId: sourceAgentGroupId, sessionId: session.id },
resource: { from: sourceAgentGroupId, to: targetAgentGroupId },
payload: { id: msg.id, platform_id: targetAgentGroupId, content: msg.content, in_reply_to: msg.in_reply_to },
grant: opts.grant ?? null,
});
if (decision.effect === 'deny') {
throw new Error(decision.reason);
}
// Gated edge: hold the message and return (not throw) so the delivery loop
// consumes the outbound row; `applyA2aMessageGate` re-routes it on approve.
if (!isSelf) {
const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId);
if (policy) {
const { approver } = policy;
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
await requestApproval({
session,
agentName: sourceName,
action: A2A_MESSAGE_GATE_ACTION,
approverUserId: approver,
title: 'Message approval',
question: buildGateQuestion(sourceName, targetName, msg.content),
payload: {
id: msg.id,
platform_id: targetAgentGroupId,
content: msg.content,
in_reply_to: msg.in_reply_to,
},
});
log.info('Agent message held for approval', {
from: sourceAgentGroupId,
to: targetAgentGroupId,
msgId: msg.id,
});
return;
}
// consumes the outbound row; `applyA2aMessageGate` re-enters here with the
// grant on approve.
if (decision.effect === 'hold') {
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
await requestApproval({
session,
agentName: sourceName,
action: A2A_MESSAGE_GATE_ACTION,
approverUserId: decision.approverUserId,
title: 'Message approval',
question: buildGateQuestion(sourceName, targetName, msg.content),
payload: {
id: msg.id,
platform_id: targetAgentGroupId,
content: msg.content,
in_reply_to: msg.in_reply_to,
},
});
log.info('Agent message held for approval', {
from: sourceAgentGroupId,
to: targetAgentGroupId,
msgId: msg.id,
});
return;
}
await performAgentRoute(msg, session, targetAgentGroupId);
}
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
const GATE_CARD_BODY_MAX = 1500;
function parseMessageContent(contentStr: string): { text: string; files: string[] } {
@@ -308,9 +317,11 @@ function buildGateQuestion(sourceName: string, targetName: string, contentStr: s
/**
* Cross-session route: pick the target session, forward files, write to its
* inbound DB, wake it. Authorization is the caller's responsibility.
* inbound DB, wake it. Module-private the only door is routeAgentMessage's
* guard decision (the approve continuation re-enters with a grant rather
* than calling this directly).
*/
export async function performAgentRoute(
async function performAgentRoute(
msg: RoutableAgentMessage,
session: Session,
targetAgentGroupId: string,
+137 -21
View File
@@ -2,26 +2,48 @@
* Tests for create_agent host-side authorization.
*
* Regression guard for the audit finding: `create_agent` is a privileged
* central-DB write with no host-side authz. The fix authorizes by CLI scope
* trusted owner agent groups ('global') create directly; confined groups
* ('group', the default and the prompt-injection victim) must get admin
* approval. These tests pin that branch decision.
* central-DB write with no host-side authz. Authorization is the guard's
* `agents.create` decision trusted owner agent groups ('global') create
* directly; confined groups ('group', the default and the prompt-injection
* victim) hold for admin approval. These tests drive the REAL wrapped
* delivery action (the only reachable path) and the approve continuation's
* grant-carrying re-entry.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '../../types.js';
import type { PendingApproval, Session } from '../../types.js';
// Mocks for the collaborators the branch decides between / depends on.
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
const mockGetContainerConfig = vi.fn();
const mockCreateAgentGroup = vi.fn();
const mockInitGroupFilesystem = vi.fn();
const mockUpdateScalars = vi.fn();
const mockWriteDestinations = vi.fn();
const mockNotifyWrite = vi.fn();
// vi.hoisted: the module barrel import below runs before this file's const
// initializers, and the mock factories close over this state.
const {
mockRequestApproval,
mockGetContainerConfig,
mockCreateAgentGroup,
mockInitGroupFilesystem,
mockUpdateScalars,
mockWriteDestinations,
mockNotifyWrite,
liveApprovals,
approvalHandlers,
} = vi.hoisted(() => ({
mockRequestApproval: vi.fn().mockResolvedValue(undefined),
mockGetContainerConfig: vi.fn(),
mockCreateAgentGroup: vi.fn(),
mockInitGroupFilesystem: vi.fn(),
mockUpdateScalars: vi.fn(),
mockWriteDestinations: vi.fn(),
mockNotifyWrite: vi.fn(),
liveApprovals: new Map<string, import('../../types.js').PendingApproval>(),
approvalHandlers: new Map<string, (ctx: Record<string, unknown>) => Promise<void>>(),
}));
vi.mock('../approvals/index.js', () => ({
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
notifyAgent: vi.fn(),
registerApprovalHandler: (action: string, handler: (ctx: Record<string, unknown>) => Promise<void>) => {
approvalHandlers.set(action, handler);
},
}));
vi.mock('../../db/container-configs.js', () => ({
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
@@ -42,36 +64,81 @@ vi.mock('./write-destinations.js', () => ({
vi.mock('./db/agent-destinations.js', () => ({
getDestinationByName: () => undefined,
createDestination: vi.fn(),
hasDestination: () => true,
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
}));
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
// delivery.ts and agent-route.ts pull more session-manager exports at import time.
vi.mock('../../session-manager.js', () => ({
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
openInboundDb: vi.fn(),
openOutboundDb: vi.fn(),
clearOutbox: vi.fn(),
readOutboxFiles: vi.fn().mockReturnValue([]),
resolveSession: vi.fn(),
sessionDir: vi.fn().mockReturnValue('/tmp/nowhere'),
inboundDbPath: vi.fn().mockReturnValue('/tmp/nowhere/inbound.db'),
}));
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../db/sessions.js', () => ({
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
getPendingApproval: (id: string) => liveApprovals.get(id),
getRunningSessions: () => [],
getActiveSessions: () => [],
createPendingQuestion: vi.fn(),
}));
import { handleCreateAgent } from './create-agent.js';
// The a2a module barrel registers ./guard.js (catalog entries) and the
// guard-wrapped create_agent delivery action — the path under test.
import './index.js';
import { getDeliveryAction } from '../../delivery.js';
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
async function runCreateAgent(content: Record<string, unknown>): Promise<void> {
const wrapped = getDeliveryAction('create_agent');
expect(wrapped).toBeDefined();
await wrapped!(content, SESSION, undefined as never);
}
function liveGrant(approvalId: string, payload: Record<string, unknown>): PendingApproval {
const row = {
approval_id: approvalId,
session_id: SESSION.id,
request_id: approvalId,
action: 'create_agent',
payload: JSON.stringify(payload),
created_at: new Date().toISOString(),
agent_group_id: 'ag-1',
channel_type: null,
platform_id: null,
platform_message_id: null,
expires_at: null,
status: 'pending',
title: '',
options_json: '[]',
approver_user_id: null,
} as PendingApproval;
liveApprovals.set(approvalId, row);
return row;
}
beforeEach(() => {
vi.clearAllMocks();
liveApprovals.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleCreateAgent — scope-based authorization', () => {
describe('create_agent — guard-based authorization (wrapped delivery action)', () => {
it('global scope: creates directly, no approval requested', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
await runCreateAgent({ name: 'Scout', instructions: 'help' });
expect(mockRequestApproval).not.toHaveBeenCalled();
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
@@ -84,7 +151,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
// dropping the inheritance leaves the child provider-less (→ claude).
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
await runCreateAgent({ name: 'Scout', instructions: 'help' });
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
expect.anything(),
@@ -96,7 +163,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('claude creator leaves the child provider unset (built-in default)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
await runCreateAgent({ name: 'Scout', instructions: 'help' });
expect(mockUpdateScalars).not.toHaveBeenCalled();
});
@@ -104,7 +171,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('group scope (default): requires approval, does NOT create directly', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
await runCreateAgent({ name: 'Scout', instructions: 'help' });
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
@@ -115,7 +182,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('missing config: fails closed to approval (no direct create)', async () => {
mockGetContainerConfig.mockReturnValue(undefined);
await handleCreateAgent({ name: 'Scout' }, SESSION);
await runCreateAgent({ name: 'Scout' });
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
@@ -124,7 +191,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('disabled/other scope: requires approval', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
await handleCreateAgent({ name: 'Scout' }, SESSION);
await runCreateAgent({ name: 'Scout' });
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
@@ -133,9 +200,58 @@ describe('handleCreateAgent — scope-based authorization', () => {
it('empty name: neither creates nor requests approval', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
await handleCreateAgent({ name: '' }, SESSION);
await runCreateAgent({ name: '' });
expect(mockRequestApproval).not.toHaveBeenCalled();
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
});
});
describe('create_agent — approved replay (grant-carrying re-entry)', () => {
it('valid grant executes exactly once — decide hold is satisfied, create runs', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const payload = { name: 'Scout', instructions: 'help' };
const approval = liveGrant('appr-ca-1', payload);
const continuation = approvalHandlers.get('create_agent');
expect(continuation).toBeDefined();
await continuation!({ session: SESSION, payload, approval, userId: 'telegram:admin', notify: vi.fn() });
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
expect(mockRequestApproval).not.toHaveBeenCalled(); // no second card
});
it('dead grant (row already resolved) refuses the replay', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const payload = { name: 'Scout', instructions: 'help' };
const approval = liveGrant('appr-ca-2', payload);
liveApprovals.delete('appr-ca-2'); // resolution consumed the row
await approvalHandlers.get('create_agent')!({
session: SESSION,
payload,
approval,
userId: 'telegram:admin',
notify: vi.fn(),
});
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
expect(mockRequestApproval).not.toHaveBeenCalled(); // refused, not re-held
});
it('mismatched grant (approved for a different name) refuses the replay', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const approval = liveGrant('appr-ca-3', { name: 'OtherAgent' });
await approvalHandlers.get('create_agent')!({
session: SESSION,
payload: { name: 'Scout' },
approval,
userId: 'telegram:admin',
notify: vi.fn(),
});
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
expect(mockRequestApproval).not.toHaveBeenCalled();
});
});
+32 -57
View File
@@ -1,16 +1,18 @@
/**
* `create_agent` delivery-action handler.
* `create_agent` delivery-action bodies.
*
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
* container_configs, agent_destinations) and scaffolds host filesystem state
* a privileged operation a confined container is otherwise architecturally
* barred from. The container's MCP tool gate is inside the (untrusted)
* container and is trivially bypassed by writing the outbound system row
* directly, so authorization MUST be enforced host-side. Trusted owner agent
* groups (CLI scope 'global') create directly; every other (confined) group
* requires admin approval via `requestApproval` matching `ncl groups create`
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
* creation on approve; `performCreateAgent` is the shared body.
* directly, so authorization MUST be enforced host-side: the delivery
* registry wraps this action with the guard, whose `agents.create` decision
* (./guard.ts) is the old cli_scope branch verbatim trusted global-scope
* groups allow, everything else (including unknown config, fail-closed)
* holds for admin approval. On approve the continuation re-enters the
* wrapped action with the approval row as its grant and `createAgent` runs.
* `performCreateAgent` is the module-private body.
*/
import path from 'path';
@@ -23,7 +25,7 @@ import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { AgentGroup, Session } from '../../types.js';
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
import { requestApproval } from '../approvals/index.js';
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
import { writeDestinations } from './write-destinations.js';
@@ -43,41 +45,27 @@ function notifyAgent(session: Session, text: string): void {
}
}
/**
* Delivery-action entry.
*
* Authorization depends on the calling group's CLI scope:
* - `global` (set by init-first-agent for trusted owner agent groups):
* create immediately. create_agent is the intended primitive for these
* privileged agents, and an approval tap on every sub-agent spawn would be
* needless friction.
* - anything else (the default `group` scope the realistic
* prompt-injection victim): require an admin to approve before any
* central-DB write. `applyCreateAgent` runs on approve.
* Unknown/missing config fails closed to the approval path.
*/
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
/** Guard precheck: malformed requests are answered without ever creating a hold. */
export function validateCreateAgent(content: Record<string, unknown>, session: Session): boolean {
const name = typeof content.name === 'string' ? content.name : '';
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
if (!name) {
notifyAgent(session, 'create_agent failed: name is required.');
return;
return false;
}
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup) {
if (!getAgentGroup(session.agent_group_id)) {
notifyAgent(session, 'create_agent failed: source agent group not found.');
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
return;
return false;
}
return true;
}
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
if (cliScope === 'global') {
// Trusted owner agent group — create directly, then notify (+wake) it.
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
return;
}
/** Guard hold: card the requesting group's admin chain. */
export async function requestCreateAgentHold(content: Record<string, unknown>, session: Session): Promise<void> {
const name = typeof content.name === 'string' ? content.name : '';
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup) return;
await requestApproval({
session,
@@ -89,35 +77,22 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
});
}
/**
* Approval handler: performs the creation once an admin approves a request from
* a confined (non-global) agent group. `session` is the requesting parent.
*/
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
const name = typeof payload.name === 'string' ? payload.name : '';
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
if (!name) {
notify('create_agent approved but the request had no name.');
return;
}
/** Guard allow body: performs the creation (fresh global-scope call or approved replay). */
export async function createAgent(content: Record<string, unknown>, session: Session): Promise<void> {
const name = typeof content.name === 'string' ? content.name : '';
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup) {
notify('create_agent approved but the source agent group no longer exists.');
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
return;
}
if (!name || !sourceGroup) return; // precheck already answered the requester
await performCreateAgent(name, instructions, session, sourceGroup, notify);
};
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
}
/**
* Core creation: writes the new agent group + bidirectional destinations and
* scaffolds its filesystem, then reports via `notify`. Authorization is the
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
* admin approval via applyCreateAgent) never call this from an unauthorized
* path, as it performs privileged central-DB writes a confined container is
* CALLER's responsibility (the guard's agents.create decision) never call
* this from an unauthorized path, as it performs privileged central-DB
* writes a confined container is
* otherwise barred from.
*/
async function performCreateAgent(
+88
View File
@@ -0,0 +1,88 @@
/**
* Agent-to-agent guard adapter the module's catalog entries, composed at
* the module edge (imported by ./index.ts).
*
* agents.create the cli_scope branch moved verbatim out of
* create-agent.ts: `global` scope creates directly (create_agent is the
* intended primitive for trusted owner agent groups); anything else the
* default `group` scope, and unknown/missing config, fail-closed holds for
* the requesting group's admin chain.
*
* a2a.send the decision moved verbatim out of routeAgentMessage, in its
* original check order: a missing destination row denies; a missing target
* group denies; self-sends allow without a destination row; an
* agent_message_policies row for the (from, to) pair holds for the row's
* named approver. The ghost-policy edge (policy row with no destination row)
* denies the destination check precedes the policy check, exactly today's
* outcome. Policy rows can only tighten (hold), never allow: absence of a
* row falls through to the structural checks.
*/
import { getAgentGroup } from '../../db/agent-groups.js';
import { getContainerConfig } from '../../db/container-configs.js';
import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js';
import { hasDestination } from './db/agent-destinations.js';
import { getMessagePolicy } from './db/agent-message-policies.js';
/**
* pending_approvals action string for held a2a messages. Lives here (not in
* agent-route.ts) so agent-route can import this adapter loading the
* consult site guarantees its catalog entry is registered without a cycle.
*/
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
export const agentsCreate = defineGuardedAction({
action: 'agents.create',
grantActionName: 'create_agent',
// Bind a create_agent grant to the name that was approved.
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name;
} catch {
return false;
}
},
decide: (input) => {
if (input.actor.kind !== 'agent') return DENY('create_agent is a container-originated action.');
const cliScope = getContainerConfig(input.actor.agentGroupId)?.cli_scope ?? 'group';
if (cliScope === 'global') {
// Trusted owner agent group — an approval tap on every sub-agent spawn
// would be needless friction.
return ALLOW('trusted global-scope agent group');
}
// The realistic prompt-injection victim (default `group` scope) — and any
// unknown config value, fail-closed — requires an admin before any
// central-DB write.
return HOLD('agent-initiated create_agent requires admin approval');
},
});
export const a2aSend = defineGuardedAction({
action: 'a2a.send',
grantActionName: A2A_MESSAGE_GATE_ACTION,
// Bind an a2a grant to the exact held message target.
grantCoversRequest: (grant, input) => {
try {
return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to;
} catch {
return false;
}
},
decide: (input) => {
if (input.actor.kind !== 'agent') return DENY('agent-to-agent send requires an agent actor');
const from = input.actor.agentGroupId;
const to = input.resource?.to ?? '';
const isSelf = to === from;
if (!isSelf && !hasDestination(from, 'agent', to)) {
return DENY(`unauthorized agent-to-agent: ${from} has no destination for ${to}`);
}
if (!getAgentGroup(to)) {
return DENY(`target agent group ${to} not found for message ${String(input.payload.id)}`);
}
if (isSelf) return ALLOW('self-send');
const policy = getMessagePolicy(from, to);
if (policy) {
return HOLD(`a2a message policy ${from}${to} holds for ${policy.approver}`, policy.approver);
}
return ALLOW('destination grant exists');
},
});
+19 -12
View File
@@ -1,13 +1,14 @@
/**
* Agent-to-agent module inter-agent messaging and on-demand agent creation.
*
* Registers one delivery action (`create_agent`) plus its matching approval
* handler `create_agent` writes central-DB state, so confined (non-global)
* groups require admin approval (the delivery action queues the request;
* `applyCreateAgent` runs on approve); trusted global-scope groups create
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
* action core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
* import when it sees `msg.channel_type === 'agent'`.
* Registers its guard-catalog entries (./guard.js) and one guard-wrapped
* delivery action (`create_agent`) `create_agent` writes central-DB state,
* so the guard's agents.create decision holds confined (non-global) groups
* for admin approval while trusted global-scope groups create directly; the
* approval handler re-enters the wrapped action carrying the approval row as
* its grant. The sibling `channel_type === 'agent'` routing path is NOT a
* system action core `delivery.ts` dispatches into `./agent-route.js` via
* a dynamic import when it sees `msg.channel_type === 'agent'`.
*
* Host integration points:
* - `src/container-runner.ts::spawnContainer` dynamically imports
@@ -20,13 +21,19 @@
* system action logs "Unknown system action", `channel_type='agent'` messages
* throw because the module isn't installed.
*/
import { registerDeliveryAction } from '../../delivery.js';
import { registerApprovalHandler } from '../approvals/index.js';
import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js';
import { notifyAgent, registerApprovalHandler } from '../approvals/index.js';
import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js';
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
import { createAgent, requestCreateAgentHold, validateCreateAgent } from './create-agent.js';
import { agentsCreate } from './guard.js';
import { applyA2aMessageGate } from './message-gate.js';
registerDeliveryAction('create_agent', handleCreateAgent);
registerApprovalHandler('create_agent', applyCreateAgent);
registerDeliveryAction('create_agent', createAgent, {
guardAction: agentsCreate,
precheck: validateCreateAgent,
requestHold: requestCreateAgentHold,
onDeny: (_content, session, reason) => notifyAgent(session, `create_agent denied: ${reason}`),
});
registerApprovalHandler('create_agent', reenterGuardedDeliveryAction('create_agent'));
registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate);
+80 -12
View File
@@ -2,16 +2,17 @@ import Database from 'better-sqlite3';
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import './guard.js'; // register the a2a.send catalog entry (incl. the policy hold)
import { routeAgentMessage } from './agent-route.js';
import { createDestination, deleteDestination, deleteAllDestinationsTouching } from './db/agent-destinations.js';
import { getMessagePolicy, removeMessagePolicy, setMessagePolicy } from './db/agent-message-policies.js';
import { applyA2aMessageGate } from './message-gate.js';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { getDb } from '../../db/connection.js';
import { createSession } from '../../db/sessions.js';
import { createPendingApproval, createSession, deletePendingApproval, getPendingApproval } from '../../db/sessions.js';
import { requestApproval } from '../approvals/index.js';
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
import type { Session } from '../../types.js';
import type { PendingApproval, Session } from '../../types.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
@@ -67,6 +68,23 @@ function makeSession(id: string, agentGroupId: string): Session {
};
}
/** Seed a live a2a hold row (what requestApproval writes) and return it as the grant. */
function seedA2aHold(approvalId: string, payload: Record<string, unknown>): PendingApproval {
createPendingApproval({
approval_id: approvalId,
session_id: 'sess-A',
request_id: approvalId,
action: 'a2a_message_gate',
payload: JSON.stringify(payload),
created_at: now(),
agent_group_id: A,
title: 'Message approval',
options_json: '[]',
approver_user_id: 'telegram:dana',
});
return getPendingApproval(approvalId)!;
}
describe('agent message policies', () => {
let SA: Session;
let SB: Session;
@@ -129,7 +147,7 @@ describe('agent message policies', () => {
expect(requestApproval).not.toHaveBeenCalled();
});
it('policy present → holds the message and requests approval from the policy approver scoped to the target', async () => {
it('policy present → holds the message and requests approval from the policy approver', async () => {
setMessagePolicy(A, B, 'telegram:dana', now());
await routeAgentMessage(
@@ -139,7 +157,7 @@ describe('agent message policies', () => {
// Held: nothing routed to B.
expect(readInbound(B, SB.id)).toHaveLength(0);
// One approval requested, to the policy's approver, scoped to the target group.
// One approval requested, to the policy's approver.
expect(requestApproval).toHaveBeenCalledTimes(1);
const opts = vi.mocked(requestApproval).mock.calls[0][0];
expect(opts.action).toBe('a2a_message_gate');
@@ -158,21 +176,71 @@ describe('agent message policies', () => {
expect(readInbound(A, SA.id)).toHaveLength(1);
});
// ── approve handler re-routes the held message ──
it('ghost policy (policy row, no destination row) still denies — deny beats the policy hold', async () => {
deleteDestination(A, 'b'); // removes A→B — the destination ACL now denies
setMessagePolicy(A, B, 'telegram:dana', now()); // ...but a stale policy row remains
await expect(
routeAgentMessage({ id: 'ghost', platform_id: B, content: JSON.stringify({ text: 'x' }), in_reply_to: null }, SA),
).rejects.toThrow(/unauthorized agent-to-agent/);
expect(requestApproval).not.toHaveBeenCalled();
expect(readInbound(B, SB.id)).toHaveLength(0);
});
// ── approve handler re-enters the guarded route with the grant ──
it('applyA2aMessageGate delivers the held message to the target (valid grant)', async () => {
setMessagePolicy(A, B, 'telegram:dana', now());
const payload = { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null };
const approval = seedA2aHold('appr-a2a-1', payload);
it('applyA2aMessageGate delivers the held message to the target', async () => {
const notify = vi.fn();
await applyA2aMessageGate({
session: SA,
userId: 'slack:dana',
notify,
payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null },
});
await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval });
const bRows = readInbound(B, SB.id);
expect(bRows).toHaveLength(1);
expect(JSON.parse(bRows[0].content).text).toBe('approved!');
expect(notify).not.toHaveBeenCalled();
// The hold is satisfied by the grant — no second card.
expect(requestApproval).not.toHaveBeenCalled();
});
it('destination revoked between hold and approve → the approved replay is blocked', async () => {
setMessagePolicy(A, B, 'telegram:dana', now());
const payload = { id: 'held-2', platform_id: B, content: JSON.stringify({ text: 'stale' }), in_reply_to: null };
const approval = seedA2aHold('appr-a2a-2', payload);
deleteDestination(A, 'b'); // revoke A→B while the card is pending
await expect(
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
).rejects.toThrow(/unauthorized agent-to-agent/);
expect(readInbound(B, SB.id)).toHaveLength(0);
});
it('mismatched grant (held for another target) refuses the replay', async () => {
setMessagePolicy(A, B, 'telegram:dana', now());
// Grant was approved for a message to A (different target than the replay).
const approval = seedA2aHold('appr-a2a-3', { id: 'other', platform_id: A, content: '{}', in_reply_to: null });
const payload = { id: 'held-3', platform_id: B, content: JSON.stringify({ text: 'swap' }), in_reply_to: null };
await expect(
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
).rejects.toThrow(/invalid or mismatched grant/);
expect(readInbound(B, SB.id)).toHaveLength(0);
});
it('a grant only works while its row is live (executes once)', async () => {
setMessagePolicy(A, B, 'telegram:dana', now());
const payload = { id: 'held-4', platform_id: B, content: JSON.stringify({ text: 'once' }), in_reply_to: null };
const approval = seedA2aHold('appr-a2a-4', payload);
deletePendingApproval(approval.approval_id); // resolution already consumed the row
await expect(
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
).rejects.toThrow(/invalid or mismatched grant/);
expect(readInbound(B, SB.id)).toHaveLength(0);
});
// ── ghost-gate cleanup ──
+12 -3
View File
@@ -1,9 +1,13 @@
/** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */
import { log } from '../../log.js';
import type { ApprovalHandler } from '../approvals/index.js';
import { performAgentRoute, type RoutableAgentMessage } from './agent-route.js';
import { routeAgentMessage, type RoutableAgentMessage } from './agent-route.js';
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, notify }) => {
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, approval, notify }) => {
if (!session) {
log.warn('a2a_message_gate approval resolved without a session — dropping');
return;
}
const { id, platform_id, content, in_reply_to } = payload;
if (typeof platform_id !== 'string' || !platform_id) {
notify('Message approved but the target agent group was missing from the request.');
@@ -18,7 +22,12 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, n
in_reply_to: typeof in_reply_to === 'string' ? in_reply_to : null,
};
await performAgentRoute(msg, session, platform_id);
// One replay semantics: re-enter the guarded route carrying the approval
// row as the grant. The policy hold is satisfied, but the structural
// checks run live — 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 });
log.info('Held agent message delivered after approval', {
from: session.agent_group_id,
to: platform_id,
@@ -13,12 +13,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createSession, createPendingApproval } from '../../db/sessions.js';
import { getDb } from '../../db/connection.js';
import { createMessagingGroup } from '../../db/messaging-groups.js';
import { createSession, createPendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import { upsertUser } from '../permissions/db/users.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { initSessionFolder } from '../../session-manager.js';
import { handleApprovalsResponse } from './response-handler.js';
import { registerApprovalHandler, registerApprovalResolvedHandler, type ApprovalResolvedEvent } from './primitive.js';
import {
registerApprovalHandler,
registerApprovalRequestedHandler,
registerApprovalResolvedHandler,
requestApproval,
type ApprovalRequestedEvent,
type ApprovalResolvedEvent,
} from './primitive.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
@@ -100,7 +109,7 @@ describe('approval-resolved callbacks', () => {
expect(events[0].outcome).toBe('reject');
expect(events[0].approval.approval_id).toBe('appr-reject-1');
expect(events[0].approval.action).toBe('test_reject_action');
expect(events[0].session.id).toBe('sess-1');
expect(events[0].session?.id).toBe('sess-1');
expect(events[0].userId).toBe('slack:admin-1');
});
@@ -150,3 +159,43 @@ describe('approval-resolved callbacks', () => {
expect(events).toEqual(['boom', 'after']);
});
});
describe('approval-requested callbacks', () => {
it('fires when requestApproval creates a hold, with the row and the delivered approver', async () => {
// The approver needs a reachable DM for the delivery walk.
createMessagingGroup({
id: 'mg-dm-admin',
channel_type: 'slack',
platform_id: 'dm-admin',
name: 'Admin DM',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
getDb()
.prepare(`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at) VALUES (?, ?, ?, ?)`)
.run('slack:admin-1', 'slack', 'mg-dm-admin', now());
const events: ApprovalRequestedEvent[] = [];
registerApprovalRequestedHandler((event) => {
if (event.approval.action === 'test_requested_action') events.push(event);
});
await requestApproval({
session: getSession('sess-1')!,
agentName: 'Agent',
action: 'test_requested_action',
payload: { thing: 1 },
title: 'Test hold',
question: 'Approve the thing?',
});
expect(events).toHaveLength(1);
expect(events[0].deliveredTo).toBe('slack:admin-1');
expect(events[0].session?.id).toBe('sess-1');
expect(events[0].approval.agent_group_id).toBe('ag-1');
expect(events[0].approval.approver_rule).toBe('admins-of-scope');
// The event carries the live row.
expect(getPendingApproval(events[0].approval.approval_id)).toBeDefined();
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* mayResolve matrix the one click-authorization rule for every hold.
*
* These cases pin the folded rule to the behavior of the three pre-fold
* click-auth copies it replaces (approvals response handler, sender handler,
* channel handler), so unifying them changes no click decision:
* - exclusive named approvers (a2a policy semantics: nobody else, not even
* an owner, may resolve)
* - admins-of-scope with and without a delivered approver (the
* sender/channel "named-or-admin" semantic)
* - the null-anchor variant (owners + global admins only)
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { upsertUser } from '../permissions/db/users.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { approverRuleOf, mayResolve } from './approver-rule.js';
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approver-rule' };
});
const TEST_DIR = '/tmp/nanoclaw-test-approver-rule';
function now() {
return new Date().toISOString();
}
const OWNER = 'slack:owner';
const GLOBAL_ADMIN = 'slack:global-admin';
const SCOPED_ADMIN = 'slack:scoped-admin'; // admin @ ag-1
const OTHER_ADMIN = 'slack:other-admin'; // admin @ ag-2
const DELIVEREE = 'slack:deliveree'; // no role — the user a card was delivered to
const RANDO = 'slack:rando'; // no role
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'One', folder: 'one', agent_provider: null, created_at: now() });
createAgentGroup({ id: 'ag-2', name: 'Two', folder: 'two', agent_provider: null, created_at: now() });
for (const id of [OWNER, GLOBAL_ADMIN, SCOPED_ADMIN, OTHER_ADMIN, DELIVEREE, RANDO]) {
upsertUser({ id, kind: 'slack', display_name: id, created_at: now() });
}
grantRole({ user_id: OWNER, role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: GLOBAL_ADMIN, role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: SCOPED_ADMIN, role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() });
grantRole({ user_id: OTHER_ADMIN, role: 'admin', agent_group_id: 'ag-2', granted_by: null, granted_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('mayResolve matrix', () => {
it('exclusive: only the named user, regardless of rank', () => {
const e = { kind: 'exclusive', approverUserId: DELIVEREE } as const;
expect(mayResolve(e, DELIVEREE)).toBe(true);
expect(mayResolve(e, OWNER)).toBe(false);
expect(mayResolve(e, GLOBAL_ADMIN)).toBe(false);
expect(mayResolve(e, SCOPED_ADMIN)).toBe(false);
expect(mayResolve(e, RANDO)).toBe(false);
expect(mayResolve(e, null)).toBe(false);
});
it('admins-of-scope(group) with a delivered approver: named-or-admin', () => {
const e = { kind: 'admins-of-scope', agentGroupId: 'ag-1', deliveredTo: DELIVEREE } as const;
expect(mayResolve(e, DELIVEREE)).toBe(true); // delivered-to shortcut
expect(mayResolve(e, SCOPED_ADMIN)).toBe(true);
expect(mayResolve(e, GLOBAL_ADMIN)).toBe(true);
expect(mayResolve(e, OWNER)).toBe(true);
expect(mayResolve(e, OTHER_ADMIN)).toBe(false); // admin of another group
expect(mayResolve(e, RANDO)).toBe(false);
expect(mayResolve(e, null)).toBe(false);
});
it('admins-of-scope(group) without a delivered approver: pure admin chain', () => {
const e = { kind: 'admins-of-scope', agentGroupId: 'ag-1', deliveredTo: null } as const;
expect(mayResolve(e, SCOPED_ADMIN)).toBe(true);
expect(mayResolve(e, GLOBAL_ADMIN)).toBe(true);
expect(mayResolve(e, OWNER)).toBe(true);
expect(mayResolve(e, DELIVEREE)).toBe(false);
expect(mayResolve(e, OTHER_ADMIN)).toBe(false);
});
it('admins-of-scope(null): owners and global admins only', () => {
const e = { kind: 'admins-of-scope', agentGroupId: null, deliveredTo: null } as const;
expect(mayResolve(e, OWNER)).toBe(true);
expect(mayResolve(e, GLOBAL_ADMIN)).toBe(true);
expect(mayResolve(e, SCOPED_ADMIN)).toBe(false);
expect(mayResolve(e, RANDO)).toBe(false);
});
it('admins-of-scope(null) with a delivered approver keeps the delivered-to shortcut (channel semantics)', () => {
const e = { kind: 'admins-of-scope', agentGroupId: null, deliveredTo: DELIVEREE } as const;
expect(mayResolve(e, DELIVEREE)).toBe(true);
expect(mayResolve(e, SCOPED_ADMIN)).toBe(false);
});
it('approverRuleOf maps row columns onto the rule', () => {
const base = { agent_group_id: 'ag-1' };
expect(approverRuleOf({ ...base, approver_rule: 'exclusive', approver_user_id: DELIVEREE })).toEqual({
kind: 'exclusive',
approverUserId: DELIVEREE,
});
expect(approverRuleOf({ ...base, approver_rule: 'admins-of-scope', approver_user_id: DELIVEREE })).toEqual({
kind: 'admins-of-scope',
agentGroupId: 'ag-1',
deliveredTo: DELIVEREE,
});
expect(approverRuleOf({ ...base, approver_rule: 'admins-of-scope', approver_user_id: null })).toEqual({
kind: 'admins-of-scope',
agentGroupId: 'ag-1',
deliveredTo: null,
});
// Malformed exclusive (no named user) falls back to the admin chain
// instead of bricking the hold.
expect(approverRuleOf({ ...base, approver_rule: 'exclusive', approver_user_id: null })).toEqual({
kind: 'admins-of-scope',
agentGroupId: 'ag-1',
deliveredTo: null,
});
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Approver rules the one click-authorization rule for every hold.
*
* The hold-record contract (guarded-actions phase 1) is carried on the
* existing tables: a hold has an id, an action, a payload, an approver
* rule (who may resolve it), a restart policy, and an optional expiry. On
* `pending_approvals` these map to `approval_id` / `action` / `payload` /
* (`approver_rule` + `approver_user_id` + `agent_group_id`) / `expires_at`;
* the restart policy is derived from the action (`onecli_credential` rows are
* swept-and-denied on boot, everything else is durable and keeps waiting).
* `pending_channel_approvals` maps through a synthesized view
* (channel-approval.ts) the channel flow keeps its own table.
*
* Two approver-rule kinds:
* - `exclusive` only the named user may resolve (an a2a message policy's
* approver). Nobody else, including owners.
* - `admins-of-scope` the admin chain of the anchoring agent group
* (scoped admin / global admin / owner), or owners + global admins when
* the anchor is null. When the hold records the user the card was
* delivered to, that user may also resolve the sender/channel
* "named-or-admin" semantic, preserved verbatim from the pre-fold tables.
*
* `mayResolve` replaces the three divergent click-auth copies (approvals
* response handler, sender handler, channel handler) with one function.
*/
import type { ApproverRule, PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
export type { ApproverRule } from '../../types.js';
/** May `clickerUserId` (namespaced `<channel>:<handle>`) resolve a hold with this approver rule? */
export function mayResolve(e: ApproverRule, clickerUserId: string | null): boolean {
if (!clickerUserId) return false;
if (e.kind === 'exclusive') {
return clickerUserId === e.approverUserId;
}
return (
(e.deliveredTo !== null && clickerUserId === e.deliveredTo) ||
(e.agentGroupId
? hasAdminPrivilege(clickerUserId, e.agentGroupId)
: isOwner(clickerUserId) || isGlobalAdmin(clickerUserId))
);
}
/** The approver rule a `pending_approvals` row encodes. */
export function approverRuleOf(
approval: Pick<PendingApproval, 'approver_rule' | 'approver_user_id' | 'agent_group_id'>,
): ApproverRule {
if (approval.approver_rule === 'exclusive' && approval.approver_user_id) {
return { kind: 'exclusive', approverUserId: approval.approver_user_id };
}
return {
kind: 'admins-of-scope',
agentGroupId: approval.agent_group_id,
deliveredTo: approval.approver_rule === 'exclusive' ? null : approval.approver_user_id,
};
}
+19 -14
View File
@@ -25,26 +25,31 @@ import { notifyApprovalResolved } from './primitive.js';
* attribution the why, not the who (the rejecting admin may belong to a
* different owner than the requesting agent). Callers are responsible for
* clamping the reason length before passing it in.
*
* `session` is null for sessionless holds (e.g. sender admission) there is
* no agent to notify or wake, so only the row delete + resolved callbacks run.
*/
export async function finalizeReject(
approval: PendingApproval,
session: Session,
session: Session | null,
userId: string,
reason?: string,
): Promise<void> {
const text = reason
? `Your ${approval.action} request was rejected by admin: "${reason}"`
: `Your ${approval.action} request was rejected by admin.`;
if (session) {
const text = reason
? `Your ${approval.action} request was rejected by admin: "${reason}"`
: `Your ${approval.action} request was rejected by admin.`;
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
}
log.info('Approval rejected', {
approvalId: approval.approval_id,
@@ -55,5 +60,5 @@ export async function finalizeReject(
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
await wakeContainer(session);
if (session) await wakeContainer(session);
}
+25 -3
View File
@@ -16,15 +16,21 @@
*
* Startup sweep edits any leftover cards from a previous process to
* "Expired (host restarted)" and drops the rows.
*
* Hold creation and every resolution (click / expiry / sweep) announce
* through the shared approval observers (notifyApprovalRequested /
* notifyApprovalResolved) observers see the full OneCLI lifecycle without
* touching this file.
*/
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
import { notifyApprovalRequested, notifyApprovalResolved, pickApprovalDelivery, pickApprover } from './primitive.js';
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import {
createPendingApproval,
deletePendingApproval,
getPendingApproval,
getPendingApprovalsByAction,
updatePendingApprovalStatus,
} from '../../db/sessions.js';
@@ -64,20 +70,29 @@ function shortApprovalId(): string {
return `oa-${Math.random().toString(36).slice(2, 10)}`;
}
/** Called from the approvals response handler when a card button is clicked. */
export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean {
/** Called from the approvals response handler when a card button is clicked. `resolvedBy` = namespaced clicker id. */
export function resolveOneCLIApproval(approvalId: string, selectedOption: string, resolvedBy = ''): boolean {
const state = pending.get(approvalId);
if (!state) return false;
pending.delete(approvalId);
clearTimeout(state.timer);
const decision: Decision = selectedOption === 'approve' ? 'approve' : 'deny';
const row = getPendingApproval(approvalId);
updatePendingApprovalStatus(approvalId, decision === 'approve' ? 'approved' : 'rejected');
// Card is auto-edited to "✅ <option>" by chat-sdk-bridge's onAction handler,
// so we don't need to deliver an edit here.
deletePendingApproval(approvalId);
state.resolve(decision);
if (row) {
void notifyApprovalResolved({
approval: row,
session: null,
outcome: decision === 'approve' ? 'approve' : 'reject',
userId: resolvedBy,
});
}
log.info('OneCLI approval resolved', { approvalId, decision });
return true;
}
@@ -195,6 +210,11 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
options_json: JSON.stringify(onecliOptions),
});
const created = getPendingApproval(approvalId);
if (created) {
await notifyApprovalRequested({ approval: created, session: null, deliveredTo: target.userId });
}
// Expiry timer fires just before the gateway's own TTL so our decision lands
// in time to be recorded, even though the HTTP side will already be closing.
const expiresAtMs = new Date(request.expiresAt).getTime();
@@ -222,6 +242,7 @@ async function expireApproval(approvalId: string, reason: string): Promise<void>
updatePendingApprovalStatus(approvalId, 'expired');
await editCardExpired(row, reason);
deletePendingApproval(approvalId);
await notifyApprovalResolved({ approval: row, session: null, outcome: 'expire', userId: '' });
log.info('OneCLI approval expired', { approvalId, reason });
}
@@ -251,6 +272,7 @@ async function sweepStaleApprovals(): Promise<void> {
for (const row of rows) {
await editCardExpired(row, 'host restarted');
deletePendingApproval(row.approval_id);
await notifyApprovalResolved({ approval: row, session: null, outcome: 'sweep', userId: '' });
}
}
+140 -25
View File
@@ -23,7 +23,12 @@
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { createPendingApproval, getSession } from '../../db/sessions.js';
import {
createPendingApproval,
getPendingApproval,
getPendingApprovalByDedupKey,
getSession,
} from '../../db/sessions.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
@@ -57,11 +62,18 @@ const APPROVAL_OPTIONS: RawOption[] = [
// their `requestApproval()` calls.
export interface ApprovalHandlerContext {
session: Session;
/** Requesting agent's session. Null for sessionless holds (e.g. sender admission). */
session: Session | null;
payload: Record<string, unknown>;
/**
* The verified approval row the grant an approved continuation carries
* when it re-enters its guarded entry point. Still live here; resolution
* deletes it after the handler returns, so a grant executes exactly once.
*/
approval: PendingApproval;
/** User ID of the admin who approved. Empty string if unknown. */
userId: string;
/** Send a system chat message to the requesting agent's session. */
/** Send a system chat message to the requesting agent's session. No-op when sessionless. */
notify: (text: string) => void;
}
@@ -88,14 +100,20 @@ export function getApprovalHandler(action: string): ApprovalHandler | undefined
// out. Callback errors are logged and isolated; they never block resolution.
//
// Only authorized clicks resolve an approval (the response handler's
// isAuthorizedApprovalClick gate runs first), so callbacks never fire for
// unauthorized responses.
// mayResolve gate runs first), so callbacks never fire for unauthorized
// responses. Non-click resolutions (OneCLI expiry timers, the boot sweep)
// announce here too, with outcome 'expire' / 'sweep'.
export interface ApprovalResolvedEvent {
/**
* The resolved hold. For holds that live outside pending_approvals
* (channel registration) this is a synthesized view of the same shape.
*/
approval: PendingApproval;
session: Session;
outcome: 'approve' | 'reject';
/** Namespaced user ID (`<channel>:<handle>`) of the resolving admin. Empty string if unknown. */
/** Requesting agent's session; null for sessionless holds (sender admission, OneCLI, channel registration). */
session: Session | null;
outcome: 'approve' | 'reject' | 'expire' | 'sweep';
/** Namespaced user ID (`<channel>:<handle>`) of the resolving admin. Empty string if unknown (expiry/sweep). */
userId: string;
}
@@ -124,6 +142,51 @@ export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Prom
}
}
// ── Approval-requested callbacks ──
// The creation-side sibling of the resolved observer: fires once whenever a
// hold record comes into existence, whichever stack created it —
// requestApproval (cli_command, create_agent, self-mod, a2a, sender
// admission), the OneCLI credential bridge (its own rows, ids and card), and
// channel registration (as a synthesized hold view). Together with
// notifyApprovalResolved this gives observers the full hold lifecycle with
// zero touch points inside the flows.
export interface ApprovalRequestedEvent {
/**
* The created hold. For holds that live outside pending_approvals
* (channel registration) this is a synthesized view of the same shape.
*/
approval: PendingApproval;
/** Requesting agent's session; null for sessionless holds (sender admission, OneCLI, channel registration). */
session: Session | null;
/** Namespaced user ID (`<channel>:<handle>`) of the approver the card was delivered to. */
deliveredTo: string;
}
export type ApprovalRequestedHandler = (event: ApprovalRequestedEvent) => Promise<void> | void;
const approvalRequestedHandlers: ApprovalRequestedHandler[] = [];
export function registerApprovalRequestedHandler(handler: ApprovalRequestedHandler): void {
approvalRequestedHandlers.push(handler);
}
/** Fire every registered approval-requested callback. Called wherever a hold record is created. */
export async function notifyApprovalRequested(event: ApprovalRequestedEvent): Promise<void> {
for (const handler of approvalRequestedHandlers) {
try {
await handler(event);
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad callback must not block the hold or other callbacks
} catch (err) {
log.error('Approval-requested handler threw', {
approvalId: event.approval.approval_id,
action: event.approval.action,
err,
});
}
}
}
// ── Approver picking ──
/**
@@ -200,7 +263,14 @@ export function notifyAgent(session: Session, text: string): void {
}
export interface RequestApprovalOptions {
session: Session;
/**
* Requesting agent's session. Omit for sessionless holds (e.g. sender
* admission) failure notices are then logged instead of chat-relayed,
* and the hold anchors to `agentGroupId`.
*/
session?: Session;
/** Approver-rule anchor when there is no session. Ignored when `session` is set (its agent group wins). */
agentGroupId?: string;
agentName: string;
/** Free-form action identifier. Must match the key the consumer registered via registerApprovalHandler. */
action: string;
@@ -210,8 +280,27 @@ export interface RequestApprovalOptions {
title: string;
/** Card body shown to the admin. */
question: string;
/** Deliver the card to this specific user instead of all of the session group's admins. */
/**
* Deliver the card to this specific user AND make the hold exclusively
* theirs to resolve (approver rule 'exclusive' an a2a policy's approver).
*/
approverUserId?: string;
/** Card buttons. Default: Approve / Reject / Reject with reason…. */
options?: RawOption[];
/**
* In-flight dedup: while a pending row carries this key, a repeat request
* with the same key is dropped without a second card.
*/
dedupKey?: string;
/**
* Record the user the card is delivered to on the hold, letting them
* resolve it alongside the scope's admins even if their role changes
* mid-flight (the sender/channel "named-or-admin" semantic). Off by
* default module holds authorize purely by the admin chain.
*/
recordDeliveredApprover?: boolean;
/** Channel preference for the approver-DM walk when there is no session to derive it from. */
originChannelType?: string;
}
/**
@@ -221,37 +310,63 @@ export interface RequestApprovalOptions {
* approval handler for this action via the response dispatcher.
*/
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
const { session, action, payload, title, question, agentName, approverUserId } = opts;
const { session, action, payload, title, question, agentName, approverUserId, dedupKey } = opts;
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
const agentGroupId = session?.agent_group_id ?? opts.agentGroupId ?? null;
const fail = (text: string): void => {
if (session) notifyAgent(session, `${action} failed: ${text}`);
else log.warn('Approval request failed', { action, agentGroupId, reason: text });
};
if (dedupKey && getPendingApprovalByDedupKey(dedupKey)) {
log.debug('Approval request already in flight — dropping duplicate', { action, dedupKey });
return;
}
const originChannelType = session.messaging_group_id
? (getMessagingGroup(session.messaging_group_id)?.channel_type ?? '')
: '';
const approvers = approverUserId ? [approverUserId] : pickApprover(agentGroupId);
if (approvers.length === 0) {
fail('no owner or admin configured to approve.');
return;
}
const originChannelType =
opts.originChannelType ??
(session?.messaging_group_id ? (getMessagingGroup(session.messaging_group_id)?.channel_type ?? '') : '');
const target = await pickApprovalDelivery(approvers, originChannelType);
if (!target) {
notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`);
fail('no DM channel found for any eligible approver.');
return;
}
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS);
createPendingApproval({
const cardOptions = opts.options ?? APPROVAL_OPTIONS;
const inserted = createPendingApproval({
approval_id: approvalId,
session_id: session.id,
session_id: session?.id ?? null,
request_id: approvalId,
action,
payload: JSON.stringify(payload),
created_at: new Date().toISOString(),
agent_group_id: agentGroupId,
title,
options_json: JSON.stringify(normalizedOptions),
approver_user_id: approverUserId ?? null,
options_json: JSON.stringify(normalizeOptions(cardOptions)),
approver_user_id: approverUserId ?? (opts.recordDeliveredApprover ? target.userId : null),
approver_rule: approverUserId ? 'exclusive' : 'admins-of-scope',
dedup_key: dedupKey ?? null,
});
if (!inserted) {
// A concurrent request with the same dedup key won the partial UNIQUE —
// this insert was dropped, so don't announce or deliver a second card.
log.debug('Approval request already in flight — dropping duplicate', { action, dedupKey });
return;
}
const created = getPendingApproval(approvalId);
if (created) {
await notifyApprovalRequested({ approval: created, session: session ?? null, deliveredTo: target.userId });
}
const adapter = getDeliveryAdapter();
if (adapter) {
@@ -266,12 +381,12 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<voi
questionId: approvalId,
title,
question,
options: APPROVAL_OPTIONS,
options: cardOptions,
}),
);
} catch (err) {
log.error('Failed to deliver approval card', { action, approvalId, err });
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
fail(`could not deliver approval request to ${target.userId}.`);
return;
}
}
+33 -45
View File
@@ -12,6 +12,9 @@
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
* via an in-memory Promise see onecli-approvals.ts.
*
* Click authorization is `mayResolve` over the hold's approver rule
* (approver-rule.ts) the one shared rule for every hold.
*
* The response handler is registered via core's `registerResponseHandler`;
* core iterates handlers and the first one to return `true` claims the response.
*/
@@ -20,8 +23,8 @@ import { deletePendingApproval, getPendingApproval, getSession } from '../../db/
import type { ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
import type { PendingApproval, Session } from '../../types.js';
import { approverRuleOf, mayResolve } from './approver-rule.js';
import { finalizeReject } from './finalize.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
@@ -31,7 +34,8 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
const approval = getPendingApproval(payload.questionId);
if (!approval) return false;
if (!isAuthorizedApprovalClick(approval, payload)) {
const clickerId = namespacedUserId(payload);
if (!mayResolve(approverRuleOf(approval), clickerId)) {
log.warn('Ignoring unauthorized approval response', {
approvalId: approval.approval_id,
action: approval.action,
@@ -42,7 +46,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
}
if (approval.action === ONECLI_ACTION) {
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
if (resolveOneCLIApproval(payload.questionId, payload.value, clickerId ?? '')) {
return true;
}
// Row exists but the in-memory resolver is gone (timer fired or the process
@@ -51,7 +55,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
return true;
}
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
await handleRegisteredApproval(approval, payload.value, clickerId ?? '');
return true;
}
@@ -60,12 +64,11 @@ async function handleRegisteredApproval(
selectedOption: string,
userId: string,
): Promise<void> {
if (!approval.session_id) {
deletePendingApproval(approval.approval_id);
return;
}
const session = getSession(approval.session_id);
if (!session) {
// Sessionless holds (sender admission) carry session_id null and resolve
// without an agent to notify; a session-BOUND hold whose session vanished
// is stale — drop it.
const session: Session | null = approval.session_id ? (getSession(approval.session_id) ?? null) : null;
if (approval.session_id && !session) {
deletePendingApproval(approval.approval_id);
return;
}
@@ -73,8 +76,10 @@ async function handleRegisteredApproval(
// "Reject with reason…" — hold the row and capture the admin's next DM
// instead of finalizing now. The agent is notified exactly once: after the
// reason arrives, or after the sweep's timeout if the admin ghosts.
// Sessionless holds have nobody to relay a reason to — plain reject.
if (selectedOption === REJECT_WITH_REASON_VALUE) {
await armReasonCapture(approval, session, userId);
if (session) await armReasonCapture(approval, session, userId);
else await finalizeReject(approval, null, userId);
return;
}
@@ -85,17 +90,19 @@ async function handleRegisteredApproval(
}
// Approved — dispatch to the module that registered for this action.
const notify = (text: string): void => {
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
};
const notify = session
? (text: string): void => {
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
}
: (): void => {};
const handler = getApprovalHandler(approval.action);
if (!handler) {
@@ -106,13 +113,13 @@ async function handleRegisteredApproval(
notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`);
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
await wakeContainer(session);
if (session) await wakeContainer(session);
return;
}
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, notify });
await handler({ session, payload, approval, userId, notify });
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
} catch (err) {
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
@@ -123,29 +130,10 @@ async function handleRegisteredApproval(
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
await wakeContainer(session);
if (session) await wakeContainer(session);
}
function namespacedUserId(payload: ResponsePayload): string | null {
if (!payload.userId) return null;
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
}
export function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
const userId = namespacedUserId(payload);
if (!userId) return false;
// An approval may name a specific approver; only that exact user may resolve it.
if (approval.approver_user_id) {
return userId === approval.approver_user_id;
}
const agentGroupId =
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
if (!agentGroupId) {
return isOwner(userId) || isGlobalAdmin(userId);
}
return hasAdminPrivilege(userId, agentGroupId);
}
+19 -231
View File
@@ -19,23 +19,9 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { registerChannelAdapter } from '../../channels/channel-registry.js';
import type { ChannelDefaults } from '../../channels/adapter.js';
import { upsertUser } from './db/users.js';
import { grantRole } from './db/user-roles.js';
// Registration-tier declaration for the fixture channel — a threaded platform
// whose declared defaults match the historical card-flow behavior
// (mention-sticky groups, pattern '.' DMs). Without it, the no-live-adapter
// fallback resolves threads=false and coerces sticky → mention.
// Registry maps are module-global; keep channel names unique per test file.
const telegramDefaults: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('telegram', { factory: () => null, defaults: telegramDefaults });
// Mock container runner — prevent actual docker spawn.
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
@@ -127,14 +113,13 @@ function groupMention(platformId: string, text = '@bot hello') {
return {
channelType: 'telegram',
platformId,
threadId: 'thread-1',
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
timestamp: now(),
isMention: true,
isGroup: true, // group context comes from the adapter flag, never threadId
},
};
}
@@ -168,8 +153,6 @@ describe('unknown-channel registration flow', () => {
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
// Card tells the approver the resolved engage rule.
expect(payload.question).toContain('will respond to @-mentions in this group');
// Single-agent card offers a direct "Connect to <name>" button.
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
expect(connectOption).toBeDefined();
@@ -188,13 +171,28 @@ describe('unknown-channel registration flow', () => {
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const payload = JSON.parse(deliverMock.mock.calls[0][4] as string) as { question: string };
expect(payload.question).toContain('will respond to all messages');
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
});
it('announces the hold through the shared approval-requested observer', async () => {
const { registerApprovalRequestedHandler } = await import('../approvals/primitive.js');
const { routeInbound } = await import('../../router.js');
const events: Array<{ approval: { approval_id: string; action: string }; deliveredTo: string }> = [];
registerApprovalRequestedHandler((event) => {
if (event.approval.action === 'channel_registration') events.push(event);
});
await routeInbound(groupMention('chat-observed'));
await new Promise((r) => setTimeout(r, 10));
expect(events).toHaveLength(1);
expect(events[0].deliveredTo).toBe('telegram:owner');
expect(events[0].approval.approval_id).toMatch(/^mg-/); // the hold view is keyed by the messaging group
});
it('dedups a second mention while the card is pending', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-busy'));
@@ -247,7 +245,7 @@ describe('unknown-channel registration flow', () => {
agent_group_id: string;
};
expect(mga).toBeDefined();
expect(mga.engage_mode).toBe('mention-sticky'); // declared group default (threads:true keeps sticky)
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
expect(mga.engage_pattern).toBeNull();
expect(mga.sender_scope).toBe('known');
expect(mga.ignored_message_policy).toBe('accumulate');
@@ -298,135 +296,6 @@ describe('unknown-channel registration flow', () => {
expect(mga.engage_pattern).toBe('.');
});
// WhatsApp-like platform: groups exist but thread ids don't (threadId is
// always null), and the adapter is undeclared (stale skill-installed copy)
// so resolution goes through the behavior-faithful fallback. This is the
// one deliberate behavior change of the defaults work: the old
// `threadId !== null` heuristic misread these groups as DMs and wired
// pattern '.'.
function waGroupMention(platformId: string) {
return {
channelType: 'wamock',
platformId,
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text: '@bot hi' }),
timestamp: now(),
isMention: true,
isGroup: true,
},
};
}
async function approvePending(agentGroupId = 'ag-1') {
const { getResponseHandlers } = await import('../../response-registry.js');
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
messaging_group_id: string;
};
expect(pending).toBeDefined();
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: `connect:${agentGroupId}`,
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
return pending.messaging_group_id;
}
it('non-threaded group (isGroup flag, null threadId) wires the GROUP default, sticky coerced to mention', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(waGroupMention('wa-group-1'));
await new Promise((r) => setTimeout(r, 10));
const mgId = await approvePending();
const { getDb } = await import('../../db/connection.js');
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
// Faithful fallback group default is mention-sticky, but with no live
// adapter threads resolve false → coerced to plain mention. NOT the old
// pattern '.' DM misclassification.
expect(mga.engage_mode).toBe('mention');
expect(mga.engage_pattern).toBeNull();
});
it('DM on an undeclared channel stays pattern "." through the faithful fallback', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound({
...waGroupMention('wa-dm-1'),
message: { ...waGroupMention('wa-dm-1').message, isGroup: false },
});
await new Promise((r) => setTimeout(r, 10));
const mgId = await approvePending();
const { getDb } = await import('../../db/connection.js');
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
expect(mga.engage_mode).toBe('pattern');
expect(mga.engage_pattern).toBe('.');
});
it('connect-existing and new-agent approve paths produce identical wirings', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { getDb } = await import('../../db/connection.js');
// Path 1: connect to existing agent.
await routeInbound(groupMention('chat-path-connect'));
await new Promise((r) => setTimeout(r, 10));
const mgIdConnect = await approvePending();
// Path 2: new agent via free-text name reply.
await routeInbound(groupMention('chat-path-newagent'));
await new Promise((r) => setTimeout(r, 10));
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
messaging_group_id: string;
};
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'new_agent',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// Owner replies with the agent name in their DM — interceptor wires.
await routeInbound({
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'owner', text: 'Bravo' }),
timestamp: now(),
},
});
await new Promise((r) => setTimeout(r, 10));
const select =
'SELECT engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority ' +
'FROM messaging_group_agents WHERE messaging_group_id = ?';
const viaConnect = getDb().prepare(select).get(mgIdConnect);
const viaNewAgent = getDb().prepare(select).get(pending.messaging_group_id);
expect(viaNewAgent).toBeDefined();
expect(viaNewAgent).toEqual(viaConnect);
});
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
@@ -505,87 +374,6 @@ describe('unknown-channel registration flow', () => {
.c;
expect(stillPending).toBe(1);
});
it('does not let a scoped admin connect an unknown channel to another agent group', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { getDb } = await import('../../db/connection.js');
createAgentGroup({ id: 'ag-2', name: 'Betty', folder: 'betty', agent_provider: null, created_at: now() });
upsertUser({ id: 'telegram:scoped-admin', kind: 'telegram', display_name: 'Scoped Admin', created_at: now() });
grantRole({
user_id: 'telegram:scoped-admin',
role: 'admin',
agent_group_id: 'ag-1',
granted_by: 'telegram:owner',
granted_at: now(),
});
createMessagingGroup({
id: 'mg-dm-scoped-admin',
channel_type: 'telegram',
platform_id: 'dm-scoped-admin',
name: 'Scoped Admin DM',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (?, ?, ?, ?)`,
)
.run('telegram:scoped-admin', 'telegram', 'mg-dm-scoped-admin', now());
await routeInbound(groupMention('chat-scoped-cross-group'));
await new Promise((r) => setTimeout(r, 10));
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
messaging_group_id: string;
};
expect(pending).toBeDefined();
expect(deliverMock).toHaveBeenCalledTimes(1);
expect(deliverMock.mock.calls[0][1]).toBe('dm-scoped-admin');
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'choose_existing',
userId: 'scoped-admin',
channelType: 'telegram',
platformId: 'dm-scoped-admin',
threadId: null,
});
if (claimed) break;
}
const followupPayload = JSON.parse(deliverMock.mock.calls[1][4] as string) as {
options: Array<{ label: string; value: string }>;
};
expect(followupPayload.options.map((option) => option.value)).toContain('connect:ag-1');
expect(followupPayload.options.map((option) => option.value)).not.toContain('connect:ag-2');
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'connect:ag-2',
userId: 'scoped-admin',
channelType: 'telegram',
platformId: 'dm-scoped-admin',
threadId: null,
});
if (claimed) break;
}
const mgaCount = (
getDb()
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { c: number }
).c;
expect(mgaCount).toBe(0);
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
.c;
expect(stillPending).toBe(1);
});
});
describe('no-owner / no-agent failure modes', () => {
+43 -5
View File
@@ -53,9 +53,13 @@ import { getDeliveryAdapter } from '../../delivery.js';
import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js';
import type { AgentGroup } from '../../types.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
import type { AgentGroup, PendingApproval } from '../../types.js';
import { notifyApprovalRequested, pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import {
createPendingChannelApproval,
hasInFlightChannelApproval,
type PendingChannelApproval,
} from './db/pending-channel-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
// ── Value constants (response handler in index.ts parses these) ──
@@ -179,6 +183,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
});
return;
}
// Use first agent group for approver resolution — owners and global admins
// are returned regardless of which group we pass.
const referenceGroup = agentGroups[0];
@@ -245,7 +250,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType, ruleNote);
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
createPendingChannelApproval({
const row: PendingChannelApproval = {
messaging_group_id: messagingGroupId,
agent_group_id: referenceGroup.id,
original_message: JSON.stringify(event),
@@ -253,7 +258,9 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(options),
});
};
createPendingChannelApproval(row);
await notifyApprovalRequested({ approval: channelHoldView(row), session: null, deliveredTo: delivery.userId });
const adapter = getDeliveryAdapter();
if (!adapter) {
@@ -308,6 +315,37 @@ export function buildAgentSelectionOptions(
return normalizeOptions(options);
}
/**
* The channel-registration hold as a hold-record view (the shape
* pending_approvals rows have), so its terminal resolutions can announce
* through the shared approval-resolved observer. The flow itself keeps its
* own table and multi-step conversation.
*/
export function channelHoldView(
row: PendingChannelApproval,
payloadExtra: Record<string, unknown> = {},
): PendingApproval {
return {
approval_id: row.messaging_group_id,
session_id: null,
request_id: row.messaging_group_id,
action: 'channel_registration',
payload: JSON.stringify({ messagingGroupId: row.messaging_group_id, ...payloadExtra }),
created_at: row.created_at,
agent_group_id: null,
channel_type: null,
platform_id: null,
platform_message_id: null,
expires_at: null,
status: 'pending',
title: row.title,
options_json: row.options_json,
approver_user_id: row.approver_user_id,
approver_rule: 'admins-of-scope',
dedup_key: null,
};
}
/**
* Create a new agent group and initialize its filesystem. Handles
* folder-name collisions with numeric suffixes.
@@ -1,60 +0,0 @@
/**
* CRUD for pending_sender_approvals the in-flight state for the
* request_approval unknown-sender flow. Rows are created when an unknown
* sender writes into a wired messaging group with that policy, and are
* deleted on admin approve (after adding the user as a member) or deny.
*
* UNIQUE(messaging_group_id, sender_identity) enforces in-flight dedup:
* a retry / second message from the same unknown sender while a card is
* still pending is silently dropped instead of spamming the admin.
*/
import { getDb } from '../../../db/connection.js';
export interface PendingSenderApproval {
id: string;
messaging_group_id: string;
agent_group_id: string;
sender_identity: string;
sender_name: string | null;
original_message: string;
approver_user_id: string;
created_at: string;
/** Card title shown at creation and re-used by getAskQuestionRender on click. */
title: string;
/** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */
options_json: string;
}
export function createPendingSenderApproval(row: PendingSenderApproval): void {
getDb()
.prepare(
`INSERT INTO pending_sender_approvals (
id, messaging_group_id, agent_group_id, sender_identity,
sender_name, original_message, approver_user_id, created_at,
title, options_json
)
VALUES (
@id, @messaging_group_id, @agent_group_id, @sender_identity,
@sender_name, @original_message, @approver_user_id, @created_at,
@title, @options_json
)`,
)
.run(row);
}
export function getPendingSenderApproval(id: string): PendingSenderApproval | undefined {
return getDb().prepare('SELECT * FROM pending_sender_approvals WHERE id = ?').get(id) as
| PendingSenderApproval
| undefined;
}
export function hasInFlightSenderApproval(messagingGroupId: string, senderIdentity: string): boolean {
const row = getDb()
.prepare('SELECT 1 AS x FROM pending_sender_approvals WHERE messaging_group_id = ? AND sender_identity = ?')
.get(messagingGroupId, senderIdentity) as { x: number } | undefined;
return row !== undefined;
}
export function deletePendingSenderApproval(id: string): void {
getDb().prepare('DELETE FROM pending_sender_approvals WHERE id = ?').run(id);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Permissions guard adapter the module's catalog entries, composed at the
* module edge (imported by ./index.ts).
*
* senders.admit the `unknown_sender_policy` switch moved verbatim out of
* handleUnknownSender: `public` allows (short-circuited before the gate
* anyway), `request_approval` holds, `strict` denies. The hold is executed by
* the caller through the module's own pending_sender_approvals flow (card,
* in-flight dedup) not the approvals primitive so this entry has no
* grantActionName: the approve continuation adds the member and replays
* routeInbound, which then passes the gate structurally via membership, no
* grant needed.
*
* channels.register click authorization for the channel-registration flow,
* verbatim from today's response handler: the delivered approver, or an
* admin of the pending row's anchor agent group. Consulted inline by the
* card-click response handler only. The free-text name reply is deliberately
* NOT re-authorized (main's behavior): the click arms the capture and stands
* as the auth a privilege revoked between the click and the reply still
* completes the flow.
*/
import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js';
import { getPendingChannelApproval } from './db/pending-channel-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
export const sendersAdmit = defineGuardedAction({
action: 'senders.admit',
decide: (input) => {
const policy = input.payload.policy;
if (policy === 'public') return ALLOW('public messaging group');
if (policy === 'request_approval') {
return HOLD(
`unknown sender requires admin approval on messaging group ${String(input.payload.messagingGroupId)}`,
);
}
return DENY('unknown sender on a strict messaging group');
},
});
export const channelsRegister = defineGuardedAction({
action: 'channels.register',
decide: (input) => {
if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies');
const questionId = typeof input.payload.questionId === 'string' ? input.payload.questionId : '';
const row = getPendingChannelApproval(questionId);
if (!row) return DENY(`no pending channel registration for ${questionId || '(missing questionId)'}`);
if (
input.actor.userId &&
(input.actor.userId === row.approver_user_id || hasAdminPrivilege(input.actor.userId, row.agent_group_id))
) {
return ALLOW('delivered approver or anchor-group admin');
}
return DENY('not an eligible channel-registration approver');
},
});
+192 -185
View File
@@ -17,8 +17,8 @@
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, getMessagingGroup, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { resolveWiringDefaults } from '../../channels/channel-defaults.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { deletePendingApproval, getPendingApprovalByDedupKey } from '../../db/sessions.js';
import {
routeInbound,
setAccessGate,
@@ -33,9 +33,18 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { mayResolve } from '../approvals/approver-rule.js';
import { notifyApprovalResolved, registerApprovalHandler } from '../approvals/primitive.js';
import { canAccessAgentGroup } from './access.js';
// Registers the permissions guard catalog entries (senders.admit, channels.register)
// via defineGuardedAction at module load — keeps the guard catalog complete. Their
// decision bodies stand as the policy-as-data seam; unknown-sender admission and
// channel-registration click-auth are currently enforced inline below (unknown_sender_policy
// switch + mayResolve), which also carries the approval-lifecycle observer wiring.
import './guard.js';
import {
buildAgentSelectionOptions,
channelHoldView,
CHOOSE_EXISTING_VALUE,
CONNECT_PREFIX,
createNewAgentGroup,
@@ -48,12 +57,10 @@ import {
deletePendingChannelApproval,
getPendingChannelApproval,
updatePendingChannelApprovalCard,
type PendingChannelApproval,
} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.js';
import { requestSenderApproval, senderAdmitDedupKey, SENDER_ADMIT_ACTION } from './sender-approval.js';
import { ensureUserDm } from './user-dm.js';
// ── Free-text name input state ──
@@ -211,83 +218,47 @@ setSenderScopeGate(
);
/**
* Response handler for the unknown-sender approval card.
* Approve continuation for the unknown-sender hold (a sessionless
* pending_approvals row created by sender-approval.ts): add the sender to
* agent_group_members and re-invoke routeInbound with the stored event the
* second routing attempt clears the gate because the user is now a member.
*
* Claim rule: questionId matches a row in pending_sender_approvals. If no
* such row, return false so the next handler (approvals module, OneCLI,
* interactive) gets a shot.
*
* Approve: add the sender to agent_group_members + re-invoke routeInbound
* with the stored event. The second routing attempt clears the gate because
* the user is now a member.
*
* Deny: delete the row (no "deny list" a future message re-triggers a
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
* Click authorization and the deny path are the approvals module's shared
* response handler (mayResolve + finalizeReject). Deny just drops the hold
* no "deny list"; a future message re-triggers a fresh card.
*/
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingSenderApproval(payload.questionId);
if (!row) return false;
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
// with the channel type so it matches users(id) format. Some platforms
// (e.g. Teams "29:xxx") already include a colon — mirror resolveOrCreateUser
// logic and only prefix when the raw id has no colon.
const clickerId = payload.userId
? payload.userId.includes(':')
? payload.userId
: `${payload.channelType}:${payload.userId}`
: null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {
log.warn('Unknown-sender approval click rejected — unauthorized clicker', {
approvalId: row.id,
clickerId,
expectedApprover: row.approver_user_id,
});
return true; // claim the response so it's not unclaimed-logged, but do nothing
}
const approverId = clickerId;
const approved = payload.value === 'approve';
if (approved) {
addMember({
user_id: row.sender_identity,
agent_group_id: row.agent_group_id,
added_by: approverId,
added_at: new Date().toISOString(),
});
log.info('Unknown sender approved — member added', {
approvalId: row.id,
senderIdentity: row.sender_identity,
agentGroupId: row.agent_group_id,
approverId,
});
// Clear the pending row BEFORE re-routing so the gate check on the
// second attempt doesn't see the in-flight row and short-circuit.
deletePendingSenderApproval(row.id);
try {
const event = JSON.parse(row.original_message) as InboundEvent;
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after sender approval', { approvalId: row.id, err });
}
return true;
registerApprovalHandler(SENDER_ADMIT_ACTION, async ({ payload, userId }) => {
const senderIdentity = typeof payload.senderIdentity === 'string' ? payload.senderIdentity : '';
const agentGroupId = typeof payload.agentGroupId === 'string' ? payload.agentGroupId : '';
const messagingGroupId = typeof payload.messagingGroupId === 'string' ? payload.messagingGroupId : '';
if (!senderIdentity || !agentGroupId) {
log.warn('sender_admit approved but the hold payload was malformed', { senderIdentity, agentGroupId });
return;
}
log.info('Unknown sender denied', {
approvalId: row.id,
senderIdentity: row.sender_identity,
agentGroupId: row.agent_group_id,
approverId,
addMember({
user_id: senderIdentity,
agent_group_id: agentGroupId,
added_by: userId,
added_at: new Date().toISOString(),
});
deletePendingSenderApproval(row.id);
return true;
}
log.info('Unknown sender approved — member added', { senderIdentity, agentGroupId, approverId: userId });
registerResponseHandler(handleSenderApprovalResponse);
// Delete the hold BEFORE re-routing (the pre-fold table did the same). The
// replay re-fans-out the original event, and any OTHER agent group on this
// messaging group must be free to mint its own sender card — the shared
// dedup key would suppress it while this now-resolved row still exists.
if (messagingGroupId) {
const hold = getPendingApprovalByDedupKey(senderAdmitDedupKey(messagingGroupId, senderIdentity));
if (hold) deletePendingApproval(hold.approval_id);
}
try {
await routeInbound(payload.event as InboundEvent);
} catch (err) {
log.error('Failed to replay message after sender approval', { senderIdentity, agentGroupId, err });
}
});
// ── Unknown-channel registration flow ──
@@ -295,104 +266,6 @@ setChannelRequestGate(async (mg, event) => {
await requestChannelApproval({ messagingGroupId: mg.id, event });
});
/**
* Wire an approved channel to an agent group and replay the stored event.
* Shared by both approve paths (connect-existing button, free-text new-agent
* name reply) so they produce identical wirings. Returns true when the wiring
* was created callers must not confirm success to the approver otherwise.
*
* Engage defaults come from the channel's declared defaults (DM vs group
* context). isGroup uses the adapter's own flag with the persisted row as
* fallback never `threadId !== null` (DM sub-threads exist on Slack/Discord;
* non-threaded group platforms like WhatsApp have null threadIds in groups).
*/
async function wireApprovedChannel(
row: PendingChannelApproval,
agentGroupId: string,
approverId: string,
): Promise<boolean> {
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return false;
}
const mg = getMessagingGroup(row.messaging_group_id);
const isGroup = event.message.isGroup ?? mg?.is_group === 1;
const agentGroupName = getAgentGroup(agentGroupId)?.name ?? '';
let engage: { engage_mode: MessagingGroupAgent['engage_mode']; engage_pattern: string | null };
try {
engage = resolveWiringDefaults(
mg?.instance ?? mg?.channel_type ?? event.channelType,
isGroup,
agentGroupName,
mg?.channel_type ?? event.channelType,
);
} catch (err) {
// Mis-declared adapter (pattern mode without a pattern). Drop the pending
// row so a future mention can retry once the declaration is fixed.
log.error('Channel registration: channel defaults unresolvable', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return false;
}
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: agentGroupId,
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
// Deliberate card-flow choices, not channel defaults: the triggering
// sender is auto-admitted below, so 'known' keeps other strangers gated;
// 'accumulate' / 'shared' / priority 0 are the flow's fixed semantics.
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId,
mgaId,
engageMode: engage.engage_mode,
approverId,
});
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: agentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
/**
* Response handler for the unknown-channel registration card.
*
@@ -411,14 +284,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
// with the channel type so it matches users(id) format. Some platforms
// (e.g. Teams "29:xxx") already include a colon — mirror resolveOrCreateUser
// logic and only prefix when the raw id has no colon.
const clickerId = payload.userId
? payload.userId.includes(':')
? payload.userId
: `${payload.channelType}:${payload.userId}`
: null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
if (!isAuthorized) {
// The delivered-to approver, or an admin of the hold's anchoring agent group.
if (
!mayResolve(
{ kind: 'admins-of-scope', agentGroupId: row.agent_group_id, deliveredTo: row.approver_user_id },
clickerId,
)
) {
log.warn('Channel registration click rejected — unauthorized clicker', {
messagingGroupId: row.messaging_group_id,
clickerId,
@@ -426,12 +307,18 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
});
return true;
}
const approverId = clickerId;
const approverId = clickerId as string;
// ── Reject / Cancel ──
if (payload.value === REJECT_VALUE) {
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id);
await notifyApprovalResolved({
approval: channelHoldView(row),
session: null,
outcome: 'reject',
userId: approverId,
});
log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id,
approverId,
@@ -555,7 +442,69 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}
// ── Wire + replay (shared path for connect and create) ──
await wireApprovedChannel(row, targetAgentGroupId, approverId);
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: targetAgentGroupId,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: targetAgentGroupId,
mgaId,
engageMode,
approverId,
});
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: targetAgentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
await notifyApprovalResolved({
approval: channelHoldView(row, { targetAgentGroupId }),
session: null,
outcome: 'approve',
userId: approverId,
});
try {
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
@@ -599,7 +548,69 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
folder: ag.folder,
});
const wired = await wireApprovedChannel(row, ag.id, userId);
let originalEvent: InboundEvent;
try {
originalEvent = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
const isGroup = originalEvent.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: ag.id,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: ag.id,
mgaId,
engageMode,
approverId: userId,
});
const senderUserId = extractAndUpsertUser(originalEvent);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: ag.id,
added_by: userId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
await notifyApprovalResolved({
approval: channelHoldView(row, { targetAgentGroupId: ag.id, createdAgentGroup: true }),
session: null,
outcome: 'approve',
userId,
});
try {
await routeInbound(originalEvent);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
const adapter = getDeliveryAdapter();
if (adapter) {
@@ -611,11 +622,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
dm.platform_id,
null,
'chat-sdk',
JSON.stringify({
text: wired
? `✅ Agent "${ag.name}" created and connected.`
: `⚠️ Agent "${ag.name}" was created but the channel couldn't be connected — check the host logs.`,
}),
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
)
.catch(() => {});
}
+67 -36
View File
@@ -1,6 +1,8 @@
/**
* Integration tests for the unknown-sender request_approval flow
* (ACTION-ITEMS item 5).
* (ACTION-ITEMS item 5), folded onto the approvals primitive: the hold is a
* sessionless pending_approvals row (action 'sender_admit') resolved by the
* approvals module's shared response handler.
*
* Covers:
* - request_approval policy fires `requestSenderApproval` on first unknown
@@ -9,7 +11,9 @@
* silently dropped (no second card, no second row)
* - Approve path: member added, original message replayed via routeInbound,
* container woken
* - Deny path: pending row deleted, no member added
* - Deny path: pending hold deleted, no member added
* - Click authorization: named-or-admin (delivered approver or any admin of
* the group's chain); strangers can't self-admit
*/
import fs from 'fs';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
@@ -28,12 +32,14 @@ vi.mock('../../container-runner.js', () => ({
killContainer: vi.fn(),
}));
// Mock delivery adapter — record card deliveries for assertions.
// Mock delivery adapter — record card deliveries for assertions. The approvals
// barrel also pulls onDeliveryAdapterReady from this module at import time.
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => ({
deliver: deliverMock,
}),
onDeliveryAdapterReady: vi.fn(),
}));
// Mock ensureUserDm to return the approver's existing messaging group
@@ -69,10 +75,12 @@ beforeEach(async () => {
const db = initTestDb();
runMigrations(db);
// Side-effect imports: register hooks (permissions module) AFTER the
// mocks are in place so the access gate / response handler pick up the
// mocked delivery + user-dm helpers.
// Side-effect imports: register hooks AFTER the mocks are in place so the
// access gate / response handler pick up the mocked delivery + user-dm
// helpers. The approvals barrel registers the shared response handler that
// resolves sender_admit holds.
await import('./index.js');
await import('../approvals/index.js');
// Fixtures: agent group, messaging group with request_approval, wiring,
// owner + DM messaging group for approver delivery.
@@ -152,6 +160,20 @@ function stranger(text: string) {
};
}
async function pendingSenderHold(): Promise<{ approval_id: string } | undefined> {
const { getDb } = await import('../../db/connection.js');
return getDb().prepare("SELECT approval_id FROM pending_approvals WHERE action = 'sender_admit'").get() as
| { approval_id: string }
| undefined;
}
async function senderHoldCount(): Promise<number> {
const { getDb } = await import('../../db/connection.js');
return (
getDb().prepare("SELECT COUNT(*) AS c FROM pending_approvals WHERE action = 'sender_admit'").get() as { c: number }
).c;
}
describe('unknown-sender request_approval flow', () => {
it('delivers an approval card on first unknown message', async () => {
const { routeInbound } = await import('../../router.js');
@@ -168,11 +190,26 @@ describe('unknown-sender request_approval flow', () => {
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
expect(payload.questionId).toMatch(/^nsa-/);
expect(payload.questionId).toMatch(/^appr-/);
expect(payload.title).toBe('👤 New sender');
expect(payload.options.map((o: { value: string }) => o.value)).toEqual(['approve', 'reject']);
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_sender_approvals').all();
const rows = getDb().prepare("SELECT * FROM pending_approvals WHERE action = 'sender_admit'").all() as Array<{
session_id: string | null;
agent_group_id: string;
approver_rule: string;
approver_user_id: string;
dedup_key: string;
}>;
expect(rows).toHaveLength(1);
// Hold-record contract: sessionless, anchored to the agent group,
// named-or-admin approver rule with the delivered approver recorded.
expect(rows[0].session_id).toBeNull();
expect(rows[0].agent_group_id).toBe('ag-1');
expect(rows[0].approver_rule).toBe('admins-of-scope');
expect(rows[0].approver_user_id).toBe('telegram:owner');
expect(rows[0].dedup_key).toBe('sender_admit:mg-chat:tg:stranger');
});
it('dedups a second message from the same stranger while pending', async () => {
@@ -183,9 +220,7 @@ describe('unknown-sender request_approval flow', () => {
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }).c;
expect(count).toBe(1);
expect(await senderHoldCount()).toBe(1);
});
it('approve → adds member and replays the original message', async () => {
@@ -197,17 +232,16 @@ describe('unknown-sender request_approval flow', () => {
await routeInbound(stranger('please let me in'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
const pending = await pendingSenderHold();
expect(pending).toBeDefined();
// Fire the approve click through the response-handler chain.
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
questionId: pending!.approval_id,
value: 'approve',
// Chat SDK's onAction surfaces the raw platform userId (e.g. Telegram
// chat id). The permissions handler namespaces it with channelType to
// chat id). The response handler namespaces it with channelType to
// match users(id).
userId: 'owner',
channelType: 'telegram',
@@ -218,33 +252,32 @@ describe('unknown-sender request_approval flow', () => {
}
// Member row added for the stranger against the wired agent group.
const { getDb } = await import('../../db/connection.js');
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
expect(member).toBeDefined();
// Pending row cleared.
const stillPending = getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number };
expect(stillPending.c).toBe(0);
// Pending hold cleared.
expect(await senderHoldCount()).toBe(0);
// Message replayed + container woken.
expect(wakeContainer).toHaveBeenCalled();
});
it('deny → deletes the pending row without adding a member', async () => {
it('deny → deletes the pending hold without adding a member', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(stranger('hello'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
const pending = await pendingSenderHold();
expect(pending).toBeDefined();
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
questionId: pending!.approval_id,
value: 'reject',
userId: 'owner', // raw platform id — handler namespaces with channelType
channelType: 'telegram',
@@ -254,8 +287,8 @@ describe('unknown-sender request_approval flow', () => {
if (claimed) break;
}
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }).c;
expect(count).toBe(0);
expect(await senderHoldCount()).toBe(0);
const { getDb } = await import('../../db/connection.js');
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
@@ -270,8 +303,7 @@ describe('unknown-sender request_approval flow', () => {
await routeInbound(stranger('can I play'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
const pending = await pendingSenderHold();
expect(pending).toBeDefined();
// A random user (not the stranger, not the owner, not an admin) tries to
@@ -279,7 +311,7 @@ describe('unknown-sender request_approval flow', () => {
// rejected without admitting them.
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
questionId: pending!.approval_id,
value: 'approve',
userId: 'random-bystander', // not owner, not admin
channelType: 'telegram',
@@ -290,18 +322,17 @@ describe('unknown-sender request_approval flow', () => {
}
// No member added for the stranger.
const { getDb } = await import('../../db/connection.js');
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
expect(member).toBeUndefined();
// Pending row is still there — a legitimate approver can still act on it.
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number })
.c;
expect(stillPending).toBe(1);
// Pending hold is still there — a legitimate approver can still act on it.
expect(await senderHoldCount()).toBe(1);
});
it('accepts a click from a global admin even if they are not the designated approver', async () => {
it('accepts a click from a global admin even if they are not the delivered approver', async () => {
// Pre-seed a separate admin user so we can click as them.
upsertUser({ id: 'telegram:admin-bob', kind: 'telegram', display_name: 'Bob', created_at: now() });
grantRole({
@@ -318,14 +349,13 @@ describe('unknown-sender request_approval flow', () => {
await routeInbound(stranger('knock knock'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
const pending = await pendingSenderHold();
expect(pending).toBeDefined();
// Admin clicks approve (not the designated approver, which was owner).
// Admin clicks approve (not the delivered approver, which was the owner).
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.id,
questionId: pending!.approval_id,
value: 'approve',
userId: 'admin-bob',
channelType: 'telegram',
@@ -336,6 +366,7 @@ describe('unknown-sender request_approval flow', () => {
}
// Stranger admitted thanks to the admin's authority.
const { getDb } = await import('../../db/connection.js');
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('tg:stranger', 'ag-1');
+33 -121
View File
@@ -3,44 +3,38 @@
*
* When `messaging_groups.unknown_sender_policy = 'request_approval'` and a
* non-member writes into a wired chat, the access gate drops the routing
* attempt and calls `requestSenderApproval` to:
* attempt and calls `requestSenderApproval`, which holds through the
* approvals primitive (action 'sender_admit'):
*
* 1. Pick an eligible approver (owner / admin of the agent group).
* 2. Open / reuse a DM to that approver on a reachable channel.
* 3. Deliver an Approve / Deny card.
* 4. Record a pending_sender_approvals row that holds the original message
* so it can be re-routed on approve.
* - approver rule: the agent group's admin chain, plus the specific
* admin the card was delivered to (named-or-admin);
* - in-flight dedup via the hold's dedup key a retry / rapid second
* message from the same unknown sender is silently dropped (no duplicate
* card), replacing the old sender table's UNIQUE(mg, sender);
* - the hold is sessionless: there is no agent session to notify, so
* failure modes (no approver, no reachable DM, no adapter) log and leave
* no row, letting a future attempt retry.
*
* On approve: the handler in index.ts adds an agent_group_members row for
* the sender and re-invokes routeInbound with the stored event the second
* routing attempt passes the gate because the user is now a member.
*
* Failure modes (logged + row NOT created, so the dedup gate lets a future
* attempt try again):
* - No eligible approver in user_roles fresh install, no owner yet.
* - Approver has no reachable DM (no user_dms row + channel can't
* openDM) e.g. owner hasn't registered on any channel we're wired to.
* - Delivery adapter missing.
*
* Dedup: `pending_sender_approvals` has UNIQUE(messaging_group_id,
* sender_identity). A retry / rapid second message from the same unknown
* sender is silently dropped (no duplicate card sent).
* On approve: the 'sender_admit' handler in index.ts adds an
* agent_group_members row for the sender and re-invokes routeInbound with the
* stored event the second routing attempt passes the gate because the user
* is now a member. On deny: the shared reject path just drops the hold (no
* denial persistence a future message re-triggers a fresh card).
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import type { RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js';
import { requestApproval } from '../approvals/primitive.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve', style: 'primary' },
{ label: 'Deny', selectedLabel: '❌ Denied', value: 'reject', style: 'danger' },
];
function generateId(): string {
return `nsa-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const SENDER_ADMIT_ACTION = 'sender_admit';
export function senderAdmitDedupKey(messagingGroupId: string, senderIdentity: string): string {
return `${SENDER_ADMIT_ACTION}:${messagingGroupId}:${senderIdentity}`;
}
export interface RequestSenderApprovalInput {
@@ -54,102 +48,20 @@ export interface RequestSenderApprovalInput {
export async function requestSenderApproval(input: RequestSenderApprovalInput): Promise<void> {
const { messagingGroupId, agentGroupId, senderIdentity, senderName, event } = input;
// In-flight dedup: don't spam the admin if the same unknown sender
// retries while a card is already pending.
if (hasInFlightSenderApproval(messagingGroupId, senderIdentity)) {
log.debug('Unknown-sender approval already in flight — dropping retry', {
messagingGroupId,
senderIdentity,
});
return;
}
const approvers = pickApprover(agentGroupId);
if (approvers.length === 0) {
log.warn('Unknown-sender approval skipped — no owner or admin configured', {
messagingGroupId,
agentGroupId,
senderIdentity,
});
return;
}
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
const target = await pickApprovalDelivery(approvers, originChannelType);
if (!target) {
log.warn('Unknown-sender approval skipped — no DM channel for any approver', {
messagingGroupId,
agentGroupId,
senderIdentity,
});
return;
}
const approvalId = generateId();
const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity;
const originName = originMg?.name ?? `a ${originChannelType} channel`;
const originName = originMg?.name ?? `a ${originMg?.channel_type ?? ''} channel`;
const title = '👤 New sender';
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
const options = normalizeOptions(APPROVAL_OPTIONS);
createPendingSenderApproval({
id: approvalId,
messaging_group_id: messagingGroupId,
agent_group_id: agentGroupId,
sender_identity: senderIdentity,
sender_name: senderName,
original_message: JSON.stringify(event),
approver_user_id: target.userId,
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(options),
await requestApproval({
agentGroupId,
agentName: senderDisplay,
action: SENDER_ADMIT_ACTION,
payload: { messagingGroupId, agentGroupId, senderIdentity, senderName, event },
title: '👤 New sender',
question: `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`,
options: APPROVAL_OPTIONS,
dedupKey: senderAdmitDedupKey(messagingGroupId, senderIdentity),
recordDeliveredApprover: true,
originChannelType: originMg?.channel_type ?? '',
});
const adapter = getDeliveryAdapter();
if (!adapter) {
// Without a delivery adapter, the card can't be sent. Log + leave the
// row in place so the admin can see it via DB or manual tooling; the
// dedup gate will suppress further cards until it's cleared.
log.error('Unknown-sender approval row created but no delivery adapter is wired', {
approvalId,
});
return;
}
try {
await adapter.deliver(
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: approvalId,
title,
question,
options,
}),
);
log.info('Unknown-sender approval card delivered', {
approvalId,
senderIdentity,
approver: target.userId,
messagingGroupId,
agentGroupId,
});
} catch (err) {
log.error('Unknown-sender approval card delivery failed', {
approvalId,
err,
});
}
}
/**
* Option value the admin clicked that means "allow" shared with the
* response handler so the two sides can't drift.
*/
export const APPROVE_VALUE = 'approve';
export const REJECT_VALUE = 'reject';
+21 -18
View File
@@ -1,11 +1,12 @@
/**
* Approval handlers for self-modification actions.
* Guarded handler bodies for self-modification actions.
*
* The approvals module calls these when an admin clicks Approve on a
* pending_approvals row whose action matches. Each handler mutates the
* container config in the DB, rebuilds/kills the container as needed,
* and writes an on_wake message so the fresh container picks up where
* the old one left off.
* The delivery registry's guard wrapper runs these only on `allow` which,
* for self-mod, means an approved replay carrying a valid grant (the
* decision holds unconditionally from the container path; see ./guard.ts).
* Each body mutates the container config in the DB, rebuilds/kills the
* container as needed, and writes an on_wake message so the fresh container
* picks up where the old one left off.
*
* install_packages: update DB + rebuild image + kill container + on_wake.
* add_mcp_server: update DB + kill container + on_wake.
@@ -17,18 +18,19 @@ import { getSession } from '../../db/sessions.js';
import type { McpServerConfig } from '../../container-config.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { ApprovalHandler } from '../approvals/index.js';
import type { Session } from '../../types.js';
import { notifyAgent } from '../approvals/index.js';
export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => {
export async function applyInstallPackages(payload: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('install_packages approved but agent group missing.');
notifyAgent(session, 'install_packages approved but agent group missing.');
return;
}
const configRow = getContainerConfig(agentGroup.id);
if (!configRow) {
notify('install_packages approved but container config missing.');
notifyAgent(session, 'install_packages approved but container config missing.');
return;
}
@@ -52,7 +54,7 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload,
...((payload.apt as string[] | undefined) || []),
...((payload.npm as string[] | undefined) || []),
].join(', ');
log.info('Package install approved', { agentGroupId: session.agent_group_id, userId });
log.info('Package install approved', { agentGroupId: session.agent_group_id });
try {
await buildAgentGroupImage(session.agent_group_id);
writeSessionMessage(session.agent_group_id, session.id, {
@@ -75,23 +77,24 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload,
});
log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id });
} catch (e) {
notify(
notifyAgent(
session,
`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Tell the user — an admin will need to retry the install_packages request or inspect the build logs.`,
);
log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e });
}
};
}
export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => {
export async function applyAddMcpServer(payload: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('add_mcp_server approved but agent group missing.');
notifyAgent(session, 'add_mcp_server approved but agent group missing.');
return;
}
const configRow = getContainerConfig(agentGroup.id);
if (!configRow) {
notify('add_mcp_server approved but container config missing.');
notifyAgent(session, 'add_mcp_server approved but container config missing.');
return;
}
@@ -122,5 +125,5 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use
const s = getSession(session.id);
if (s) wakeContainer(s);
});
log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId });
};
log.info('MCP server add approved', { agentGroupId: session.agent_group_id });
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Self-mod guard adapter the module's catalog entries, composed at the
* module edge (imported by ./index.ts).
*
* The decision is today's behavior verbatim: from the container
* path, self-modification is held unconditionally for the agent group's
* admin chain. (The equivalent host-side mutations `ncl groups config
* add-package` etc. — are separate catalog actions derived from the command
* registry.)
*/
import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js';
function selfModDecide(label: string) {
return (input: GuardInput) => {
if (input.actor.kind !== 'agent') {
return DENY(`${label} is a container-originated action.`);
}
return HOLD(`${label} always requires admin approval from the container path`);
};
}
export const selfModInstallPackages = defineGuardedAction({
action: 'self_mod.install_packages',
grantActionName: 'install_packages',
decide: selfModDecide('install_packages'),
});
export const selfModAddMcpServer = defineGuardedAction({
action: 'self_mod.add_mcp_server',
grantActionName: 'add_mcp_server',
decide: selfModDecide('add_mcp_server'),
});
+36 -14
View File
@@ -2,29 +2,51 @@
* Self-modification module admin-approved container mutations.
*
* Optional tier. Depends on the approvals default module for the request/
* handler plumbing. On install the module registers:
* - Two delivery actions (install_packages, add_mcp_server) that validate
* input and queue an approval via requestApproval().
* - Two matching approval handlers that run on approve and perform the
* complete follow-up:
* install_packages update container.json, rebuild image, kill
* handler plumbing and on the guard for the decision. On install the module
* registers:
* - Its guard-catalog entries (./guard.ts): unconditional hold from the
* container path.
* - Two guard-wrapped delivery actions (install_packages, add_mcp_server):
* validation runs as the wrapper's precheck, the hold builders card the
* admin, and the handler bodies (./apply.ts) run only on allow i.e. on
* an approved replay:
* install_packages update container_configs, rebuild image, kill
* container (next wake respawns on the new image), schedule a
* verify-and-report follow-up prompt.
* add_mcp_server update container.json, kill container. No image
* add_mcp_server update container_configs, kill container. No image
* rebuild bun runs TS directly, so the new MCP server is wired
* by the next container start.
* - Two approval handlers that re-enter the wrapped actions with the
* approval row as the grant (one replay semantics the guard re-checks
* the structural checks live).
*
* Without this module: the MCP tools in the container still write outbound
* system messages with these actions, but delivery logs "Unknown system
* action" and drops them. Admin never sees a card; nothing changes.
*/
import { registerDeliveryAction } from '../../delivery.js';
import { registerApprovalHandler } from '../approvals/index.js';
import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js';
import { notifyAgent, registerApprovalHandler } from '../approvals/index.js';
import { applyAddMcpServer, applyInstallPackages } from './apply.js';
import { handleAddMcpServer, handleInstallPackages } from './request.js';
import { selfModAddMcpServer, selfModInstallPackages } from './guard.js';
import {
requestAddMcpServerHold,
requestInstallPackagesHold,
validateAddMcpServer,
validateInstallPackages,
} from './request.js';
registerDeliveryAction('install_packages', handleInstallPackages);
registerDeliveryAction('add_mcp_server', handleAddMcpServer);
registerDeliveryAction('install_packages', applyInstallPackages, {
guardAction: selfModInstallPackages,
precheck: validateInstallPackages,
requestHold: requestInstallPackagesHold,
onDeny: (_content, session, reason) => notifyAgent(session, `install_packages denied: ${reason}`),
});
registerDeliveryAction('add_mcp_server', applyAddMcpServer, {
guardAction: selfModAddMcpServer,
precheck: validateAddMcpServer,
requestHold: requestAddMcpServerHold,
onDeny: (_content, session, reason) => notifyAgent(session, `add_mcp_server denied: ${reason}`),
});
registerApprovalHandler('install_packages', applyInstallPackages);
registerApprovalHandler('add_mcp_server', applyAddMcpServer);
registerApprovalHandler('install_packages', reenterGuardedDeliveryAction('install_packages'));
registerApprovalHandler('add_mcp_server', reenterGuardedDeliveryAction('add_mcp_server'));
+32 -16
View File
@@ -1,12 +1,12 @@
/**
* Delivery-action handlers for agent-initiated self-modification requests.
* Validation + hold-request builders for agent-initiated self-modification.
*
* Two actions the container can write into messages_out (via the self-mod
* MCP tools): install_packages, add_mcp_server. Each one validates input
* and queues an approval request. The admin's approval triggers the
* matching approval handler in ./apply.ts, which also performs the
* required follow-up (rebuild+restart for install_packages, restart-only
* for add_mcp_server).
* MCP tools): install_packages, add_mcp_server. The delivery registry wraps
* each one with the guard (see ./guard.ts unconditional hold from the
* container path): validation here runs as the wrapper's precheck, and the
* hold builders create the approval card when the guard holds. On approve,
* the continuation re-enters the wrapped action and ./apply.ts runs.
*
* Host-side sanitization for install_packages is defense-in-depth the MCP
* tool validates first. Both layers matter: the DB row carries the payload
@@ -17,40 +17,48 @@ import { log } from '../../log.js';
import type { Session } from '../../types.js';
import { notifyAgent, requestApproval } from '../approvals/index.js';
export async function handleInstallPackages(content: Record<string, unknown>, session: Session): Promise<void> {
export function validateInstallPackages(content: Record<string, unknown>, session: Session): boolean {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'install_packages failed: agent group not found.');
return;
return false;
}
const apt = (content.apt as string[]) || [];
const npm = (content.npm as string[]) || [];
const reason = (content.reason as string) || '';
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const MAX_PACKAGES = 20;
if (apt.length + npm.length === 0) {
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
return;
return false;
}
if (apt.length + npm.length > MAX_PACKAGES) {
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
return;
return false;
}
const invalidApt = apt.find((p) => !APT_RE.test(p));
if (invalidApt) {
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
return;
return false;
}
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
if (invalidNpm) {
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
return;
return false;
}
return true;
}
export async function requestInstallPackagesHold(content: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) return;
const apt = (content.apt as string[]) || [];
const npm = (content.npm as string[]) || [];
const reason = (content.reason as string) || '';
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
await requestApproval({
@@ -63,18 +71,26 @@ export async function handleInstallPackages(content: Record<string, unknown>, se
});
}
export async function handleAddMcpServer(content: Record<string, unknown>, session: Session): Promise<void> {
export function validateAddMcpServer(content: Record<string, unknown>, session: Session): boolean {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
return;
return false;
}
const serverName = content.name as string;
const command = content.command as string;
if (!serverName || !command) {
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
return;
return false;
}
return true;
}
export async function requestAddMcpServerHold(content: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) return;
const serverName = content.name as string;
const command = content.command as string;
await requestApproval({
session,
agentName: agentGroup.name,
+20 -1
View File
@@ -198,6 +198,17 @@ export interface PendingQuestion {
// ── Pending approvals (central DB) ──
/**
* Who may resolve a hold. `exclusive`: only the named user (an a2a policy's
* approver). `admins-of-scope`: the admin chain of the anchoring agent group
* (owners + global admins when the anchor is null), plus the user the card
* was delivered to when recorded. Evaluation lives in
* src/modules/approvals/approver-rule.ts (`mayResolve`).
*/
export type ApproverRule =
| { kind: 'exclusive'; approverUserId: string }
| { kind: 'admins-of-scope'; agentGroupId: string | null; deliveredTo: string | null };
export interface PendingApproval {
approval_id: string;
session_id: string | null;
@@ -218,8 +229,16 @@ export interface PendingApproval {
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'awaiting_reason';
title: string;
options_json: string;
/** When set, only this exact user may resolve the approval. */
/**
* Named approver. Under `approver_rule: 'exclusive'` only this exact user
* may resolve the approval; under 'admins-of-scope' it records the user the
* card was delivered to, who may resolve alongside the scope's admins.
*/
approver_user_id: string | null;
/** Who may resolve this hold — see modules/approvals/approver-rule.ts. */
approver_rule: 'exclusive' | 'admins-of-scope';
/** In-flight dedup key: while a row carries this key, a repeat request with the same key is dropped. */
dedup_key: string | null;
}
// ── Agent destinations (central DB) ──