mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56f9c5c58 | |||
| 5a114b5427 | |||
| 790a7a6dfd | |||
| f24edc2c3b | |||
| fe235c7ca0 | |||
| a9e5231039 | |||
| f87f82c528 | |||
| 35ca4e14d2 | |||
| 69e3b57d9d |
@@ -0,0 +1,52 @@
|
||||
# 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/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. Remove the settings
|
||||
|
||||
Delete the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines from `.env`.
|
||||
|
||||
## 5. 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.
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: add-audit
|
||||
description: Add an opt-in local audit log for the ncl command surface — every dispatch outcome on both transports (host socket and container), including scope denials, approval holds, and approved replays, 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)
|
||||
|
||||
Records one canonical audit event for every command that reaches the `ncl`
|
||||
dispatcher — human over the host socket or agent over the container transport
|
||||
— including denials. Both transports converge on the exported `dispatch`, so
|
||||
one composition covers the whole surface: there is no second door.
|
||||
|
||||
```
|
||||
ncl (host socket) ──┐
|
||||
├─→ dispatch = withAudit(dispatchInner) ─→ one NDJSON line
|
||||
container cli_request ┘ │ data/audit/<UTC-day>.ndjson
|
||||
approved replay (grant) ───────┘
|
||||
```
|
||||
|
||||
What one event carries: `actor` (`host:<os-user>` or the agent group),
|
||||
`origin` (transport, session, channel), dotted `action` from the guard
|
||||
catalog (e.g. `groups.config.add-mcp-server`), touched/attempted `resources`,
|
||||
`outcome` (`success · failure · denied · pending`), `correlation_id` (the
|
||||
approval id on gated chains — the pending event and the approved replay share
|
||||
it, so `--correlation <id>` returns the whole chain), and redacted `details`.
|
||||
|
||||
Scope of this increment: the ncl dispatch surface only. Approval-lifecycle
|
||||
events (request/decision as their own events), OneCLI credential holds, and
|
||||
channel/sender events are later increments — they attach to `src/audit/` as
|
||||
pure adds (new `*.audit.ts` adapters and observer subscriptions).
|
||||
|
||||
## 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);`
|
||||
|
||||
Before editing, verify the reach-in targets still exist: `src/cli/dispatch.ts`
|
||||
must contain `export async function dispatch(` (or the already-applied
|
||||
composition), and `src/cli/resources/index.ts` must be the resource barrel (a
|
||||
list of `import './<resource>.js';` lines). If either 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/cli/resources/audit.ts` — the read-only `ncl audit` resource.
|
||||
- `src/audit-wiring.test.ts` — goes red if either core edit 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.
|
||||
Nothing else in core changes — no `index.ts` or `host-sweep.ts` edits.
|
||||
|
||||
### 4. 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.
|
||||
|
||||
### 5. 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/audit-wiring.test.ts
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 6. 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
|
||||
```
|
||||
|
||||
### 7. 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
|
||||
```
|
||||
|
||||
The last line of the day-file is the `groups.list` event you just caused.
|
||||
|
||||
## 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.
|
||||
|
||||
## Redaction, failure posture, exporters
|
||||
|
||||
- Every event's `details` passes a recursive key-pattern redactor
|
||||
(`/(token|secret|key|password|credential|auth|bearer)/i` → `[REDACTED]`,
|
||||
~2 KB per-value cap) at the single emit seam.
|
||||
- Fail-open + loud: a failed append is `log.error`'d and the audited action
|
||||
proceeds. 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,143 @@
|
||||
/**
|
||||
* 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('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,43 @@
|
||||
/**
|
||||
* 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 { redactDetails } from './redact.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,
|
||||
details: redactDetails(input.details ?? {}),
|
||||
};
|
||||
const line = JSON.stringify(event);
|
||||
appendAuditLine(line);
|
||||
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,203 @@
|
||||
/**
|
||||
* 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('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,92 @@
|
||||
/**
|
||||
* 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;
|
||||
/** Boot hook, called only when audit is enabled. 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[] = [];
|
||||
|
||||
export function registerAuditHook(hook: AuditHook): void {
|
||||
hooks.push(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) {
|
||||
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,18 @@
|
||||
/**
|
||||
* 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 { redactDetails } from './redact.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,153 @@
|
||||
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('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,160 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
const abs = Date.parse(value);
|
||||
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(', ')}`);
|
||||
}
|
||||
|
||||
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: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_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,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { redactDetails } from './redact.js';
|
||||
|
||||
describe('redactDetails', () => {
|
||||
it('masks sensitive keys at any depth, case-insensitively', () => {
|
||||
const out = redactDetails({
|
||||
name: 'notion',
|
||||
env: { NOTION_TOKEN: 'secret-value', SAFE_VALUE: 'ok' },
|
||||
nested: { Authorization: 'Bearer abc', list: [{ 'api-key': 'k' }, { plain: 'p' }] },
|
||||
password: 'hunter2',
|
||||
});
|
||||
expect(out).toEqual({
|
||||
name: 'notion',
|
||||
env: { NOTION_TOKEN: '[REDACTED]', SAFE_VALUE: 'ok' },
|
||||
nested: { Authorization: '[REDACTED]', list: [{ 'api-key': '[REDACTED]' }, { plain: 'p' }] },
|
||||
password: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the documented key pattern (token|secret|key|password|credential|auth|bearer)', () => {
|
||||
const out = redactDetails({
|
||||
access_token: 'x',
|
||||
clientSecret: 'x',
|
||||
ssh_key: 'x',
|
||||
credentials: 'x',
|
||||
oauth_flow: 'x',
|
||||
bearerValue: 'x',
|
||||
username: 'moshe',
|
||||
});
|
||||
expect(Object.entries(out).filter(([, v]) => v === '[REDACTED]')).toHaveLength(6);
|
||||
expect(out.username).toBe('moshe');
|
||||
});
|
||||
|
||||
it('never recurses into a masked key — the whole value is replaced', () => {
|
||||
const out = redactDetails({ auth: { inner: 'visible?' } });
|
||||
expect(out.auth).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('truncates strings over 2 KB post-redaction', () => {
|
||||
const long = 'a'.repeat(5000);
|
||||
const out = redactDetails({ blob: long, short: 'b' });
|
||||
expect(out.blob).toBe('a'.repeat(2048) + '…[truncated]');
|
||||
expect(out.short).toBe('b');
|
||||
});
|
||||
|
||||
it('passes non-string scalars through untouched', () => {
|
||||
const out = redactDetails({ n: 42, b: true, z: null, u: undefined });
|
||||
expect(out).toEqual({ n: 42, b: true, z: null, u: undefined });
|
||||
});
|
||||
|
||||
it('caps depth (cycle guard) without throwing', () => {
|
||||
const cyclic: Record<string, unknown> = { a: 1 };
|
||||
cyclic.self = cyclic;
|
||||
const out = redactDetails(cyclic);
|
||||
let cursor: unknown = out;
|
||||
for (let i = 0; i < 20 && typeof cursor === 'object' && cursor !== null; i++) {
|
||||
cursor = (cursor as Record<string, unknown>).self;
|
||||
}
|
||||
expect(cursor).toBe('[MAX_DEPTH]');
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = { token: 'x', nested: { list: ['a'.repeat(3000)] } };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
redactDetails(input);
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The one redactor, applied to every event's `details` at the emit seam —
|
||||
* guarding current and future surfaces by default. Key-pattern mask plus a
|
||||
* per-value size cap; message bodies are never passed in (emit sites record
|
||||
* shape only — the audit log is a governance record, not a chat archive).
|
||||
*/
|
||||
|
||||
const SENSITIVE_KEY = /(token|secret|key|password|credential|auth|bearer)/i;
|
||||
const MAX_VALUE_CHARS = 2048;
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
export function redactDetails(details: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactObject(details, 0);
|
||||
}
|
||||
|
||||
function redactObject(obj: Record<string, unknown>, depth: number): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) ? '[REDACTED]' : redactValue(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactValue(value: unknown, depth: number): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.length > MAX_VALUE_CHARS ? `${value.slice(0, MAX_VALUE_CHARS)}…[truncated]` : value;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (depth > MAX_DEPTH) return '[MAX_DEPTH]';
|
||||
if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
|
||||
return redactObject(value as Record<string, unknown>, depth);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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('redacts details at the emit seam', () => {
|
||||
emit.emitAuditEvent({ ...EVENT_INPUT, details: { env: { API_TOKEN: 'x' } } });
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
expect(fs.readFileSync(file, 'utf8')).toContain('"API_TOKEN":"[REDACTED]"');
|
||||
});
|
||||
|
||||
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,81 @@
|
||||
/**
|
||||
* 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. Throws on fs failure — emitAuditEvent catches. */
|
||||
export function appendAuditLine(line: string): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(utcDay()), 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,60 @@
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
/** Actor for timer/sweep-driven outcomes (expiries, startup sweeps). */
|
||||
export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' };
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
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: { foo: 'bar' },
|
||||
});
|
||||
});
|
||||
|
||||
it('records effective args after group auto-fill, 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).toMatchObject({ id: 'g1', agent_group_id: 'g1', group: 'g1' });
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
|
||||
});
|
||||
|
||||
it('records a denied event for a scope denial, naming the attempted resource type', 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: { error: 'forbidden' },
|
||||
});
|
||||
expect(event.details.reason).toContain('scoped');
|
||||
});
|
||||
|
||||
it('records a failure event 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).toContain('boom');
|
||||
});
|
||||
|
||||
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 success with the grant approval id as correlation_id', async () => {
|
||||
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 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: { command: 'nope-nothing', error: 'unknown-command' },
|
||||
});
|
||||
});
|
||||
|
||||
it('records the resolved command and 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 });
|
||||
expect(event.details.id).toBe(uuid);
|
||||
});
|
||||
|
||||
it('normalizes hyphenated arg keys in details', async () => {
|
||||
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
|
||||
|
||||
const [event] = events();
|
||||
expect(event.details).toMatchObject({ dry_run: 'true' });
|
||||
});
|
||||
|
||||
it('parses JSON string args so the redactor reaches their inner keys', async () => {
|
||||
await dispatch(
|
||||
{ id: '1', command: 'groups-test', args: { env: '{"NOTION_TOKEN":"tok-123","SAFE":"ok"}', note: '{not json' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
const [event] = events();
|
||||
expect(event.details.env).toEqual({ NOTION_TOKEN: '[REDACTED]', SAFE: 'ok' });
|
||||
expect(event.details.note).toBe('{not json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 AuditOrigin, type AuditOutcome, type AuditResource } from '../audit/types.js';
|
||||
import { containerOrigin, hostUser } from '../audit/vocab.js';
|
||||
import { getContainerConfig } from '../db/container-configs.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 { 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 details 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;
|
||||
}
|
||||
|
||||
/** 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 effective 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).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Dispatch mechanics, mirrored for the record ──
|
||||
// Dispatch resolves the command and auto-fills args on a local copy of the
|
||||
// frame that never leaves it, so the middleware mirrors the two documented
|
||||
// mechanics below. The copies are mechanical, and drift only ever degrades a
|
||||
// record's detail (a fallback action name, a missing auto-filled arg) — 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of dispatch's group-scope arg auto-fill, so the record shows the
|
||||
* effective args the handler saw (e.g. which agent group a bare
|
||||
* `sessions list` actually listed).
|
||||
*/
|
||||
function effectiveArgs(
|
||||
cmd: CommandDef | undefined,
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
): Record<string, unknown> {
|
||||
if (ctx.caller !== 'agent') return args;
|
||||
if ((getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group') !== 'group') return args;
|
||||
const fill: Record<string, unknown> = {
|
||||
agent_group_id: args.agent_group_id ?? ctx.agentGroupId,
|
||||
group: args.group ?? ctx.agentGroupId,
|
||||
};
|
||||
if (cmd?.resource === 'groups' || cmd?.resource === 'destinations') {
|
||||
fill.id = args.id ?? ctx.agentGroupId;
|
||||
}
|
||||
return { ...args, ...fill };
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI args arrive as strings, so a JSON-object/array value (e.g.
|
||||
* `--env '{"NOTION_TOKEN":"…"}'`) would reach the redactor as one opaque
|
||||
* string under an innocent key and its inner secret keys would survive.
|
||||
* Recording the parsed form lets the redactor walk inside — the audit log's
|
||||
* "secrets never land" property depends on it for exactly this flow.
|
||||
*/
|
||||
function parseJsonishValues(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (typeof v === 'string' && (v.startsWith('{') || v.startsWith('['))) {
|
||||
try {
|
||||
out[k] = JSON.parse(v);
|
||||
continue;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- not JSON after all: record the string as-is
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Outcome derives from the response frame: ok → success, forbidden → denied
|
||||
* (captures pre-handler scope denials), approval-pending → pending (the
|
||||
* record of a hold), anything else → failure. Correlation is the approval id:
|
||||
* an approved replay carries the approval row as its guard grant
|
||||
* (opts.grant), and a fresh hold is correlated by recovering the row it just
|
||||
* created — so `--correlation <approval-id>` returns the whole gated chain.
|
||||
*/
|
||||
export function withAudit(inner: DispatchInner): DispatchInner {
|
||||
return async (req, ctx, opts = {}) => {
|
||||
const res = await inner(req, ctx, opts);
|
||||
emitAuditEvent(() => {
|
||||
const resolved = resolveForRecord(req);
|
||||
const cmd = resolved.cmd;
|
||||
const pending = !res.ok && res.error.code === 'approval-pending';
|
||||
const outcome: AuditOutcome = res.ok
|
||||
? 'success'
|
||||
: res.error.code === 'forbidden'
|
||||
? 'denied'
|
||||
: pending
|
||||
? 'pending'
|
||||
: 'failure';
|
||||
// A denial records the attempt as asked (raw args): nothing ran, so the
|
||||
// auto-fill never conceptually happened — and a cross-group attempt
|
||||
// must show the foreign id the caller passed, not a filled-in own-group.
|
||||
const args = normalizeArgKeys(outcome === 'denied' ? resolved.args : effectiveArgs(cmd, resolved.args, ctx));
|
||||
const correlationId = opts.grant?.approval_id ?? (pending ? holdApprovalIdFor(req.id) : null);
|
||||
const details: Record<string, unknown> = parseJsonishValues(args);
|
||||
if (!res.ok && !pending) {
|
||||
details.error = res.error.code;
|
||||
details.reason = res.error.message;
|
||||
}
|
||||
if (!cmd) details.command = req.command;
|
||||
const resources = cmd ? resourcesForCli(cmd, args) : [];
|
||||
if (correlationId) resources.push({ type: 'approval', id: correlationId });
|
||||
return {
|
||||
actor: actorForCaller(ctx),
|
||||
origin: originForCaller(ctx),
|
||||
action: cmd ? (cmd.action ?? `cli.${cmd.name}`) : 'cli.unknown-command',
|
||||
resources,
|
||||
outcome,
|
||||
correlationId,
|
||||
details,
|
||||
};
|
||||
});
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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.
|
||||
*
|
||||
* 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: [
|
||||
{ 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', 'rejected'],
|
||||
},
|
||||
{ name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date' },
|
||||
{ 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, newest first)' },
|
||||
{ name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.',
|
||||
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');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -7,34 +7,6 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
|
||||
## Number safety check (required)
|
||||
|
||||
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
|
||||
|
||||
Otherwise, use `AskUserQuestion`:
|
||||
|
||||
**Which WhatsApp number will NanoClaw use?**
|
||||
|
||||
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
|
||||
- **Shared / personal number** — the user's existing everyday WhatsApp number
|
||||
|
||||
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
|
||||
|
||||
If `NUMBER_MODE=shared`, show this warning exactly:
|
||||
|
||||
> ⚠️ **Risk to your WhatsApp account**
|
||||
>
|
||||
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
|
||||
>
|
||||
> We strongly recommend using a separate, dedicated number for NanoClaw.
|
||||
|
||||
Then use `AskUserQuestion`:
|
||||
|
||||
- **Go back and use a dedicated number (Recommended)**
|
||||
- **I understand the risk — continue with my shared number**
|
||||
|
||||
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
@@ -120,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -237,7 +209,9 @@ The adapter behaves fundamentally differently depending on whether the linked nu
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
@@ -250,7 +224,7 @@ ASSISTANT_HAS_OWN_NUMBER=false
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ 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` pass a recursive secret-key redactor (~2 KB value cap). `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^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
|
||||
@@ -62,10 +62,12 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
|
||||
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
|
||||
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) |
|
||||
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the 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 (response handlers, message interceptors) consult the guard inline where privileged. Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (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/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
brightSelect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
userInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bright-select.js', () => ({
|
||||
brightSelect: mocks.brightSelect,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/theme.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../lib/theme.js')>()),
|
||||
note: mocks.note,
|
||||
}));
|
||||
|
||||
vi.mock('../logs.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../logs.js')>()),
|
||||
userInput: mocks.userInput,
|
||||
}));
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
|
||||
import { runWhatsAppChannel } from './whatsapp.js';
|
||||
|
||||
describe('WhatsApp shared-number risk gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining('temporarily suspend or permanently ban that number'),
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
|
||||
});
|
||||
|
||||
it('does not show the warning for a dedicated number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
|
||||
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledOnce();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
});
|
||||
+13
-29
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* 1. Ask whether the agent gets a dedicated number or shares the
|
||||
* operator's personal one. Personal ⇒ interception screen that spells
|
||||
* out the account-ban risk and self-chat-only mode (default is switching
|
||||
* to a dedicated number)
|
||||
* out self-chat-only mode and steers toward alternatives (default is
|
||||
* back to channel selection)
|
||||
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 3. If pairing-code: collect the phone number
|
||||
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
@@ -68,7 +68,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
let mode: 'dedicated' | 'shared' = ownership;
|
||||
if (mode === 'shared') {
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') mode = 'dedicated';
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
const method = await askAuthMethod();
|
||||
@@ -121,7 +121,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
// Chatting from the bot's own number IS the shared-number setup —
|
||||
// route through the same interception screen as the up-front pick.
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
mode = 'shared';
|
||||
}
|
||||
}
|
||||
@@ -221,19 +221,13 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interception screen for the shared-number path: make the account-ban risk
|
||||
* and self-chat-only tradeoff explicit before any install or auth happens.
|
||||
* Default is switching to a dedicated number.
|
||||
* Interception screen for the shared-number path: make the self-chat-only
|
||||
* tradeoff explicit and steer toward alternatives before any install or
|
||||
* auth happens. Default is bailing back to channel selection.
|
||||
*/
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'Connecting your shared or personal number could cause WhatsApp to',
|
||||
'temporarily suspend or permanently ban that number. You could lose access',
|
||||
'to the WhatsApp account, chats, and groups you rely on.',
|
||||
'',
|
||||
'We strongly recommend using a separate, dedicated number for NanoClaw.',
|
||||
'',
|
||||
'On your personal number, the agent lives only in your "You" / self-chat.',
|
||||
'Messages other people send you are ignored entirely — never read, never',
|
||||
'answered, never flagged for approval. Nobody else can talk to the agent.',
|
||||
@@ -244,29 +238,19 @@ async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
` • ${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
|
||||
` • ${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
|
||||
].join('\n'),
|
||||
'Risk to your WhatsApp account',
|
||||
'Personal number = self-chat only',
|
||||
);
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to proceed?',
|
||||
options: [
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'Go back and use a dedicated number',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'I understand the risk — continue with my shared number',
|
||||
},
|
||||
{ value: 'back', label: '← Pick a different channel' },
|
||||
{ value: 'continue', label: 'Continue — self-chat only' },
|
||||
],
|
||||
initialValue: 'dedicated',
|
||||
initialValue: 'back',
|
||||
}),
|
||||
) as 'continue' | 'dedicated';
|
||||
) as 'continue' | 'back';
|
||||
setupLog.userInput('whatsapp_shared_confirm', choice);
|
||||
if (choice === 'continue') {
|
||||
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -495,10 +500,20 @@ describe('CLI scope enforcement', () => {
|
||||
callerContext: ctx,
|
||||
});
|
||||
|
||||
// The approve path hands the handler the live approval row — the grant
|
||||
// the replay carries back into dispatch.
|
||||
const grantRow = {
|
||||
approval_id: 'appr-t1',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify(approval.payload),
|
||||
};
|
||||
mockGetPendingApproval.mockReturnValue(grantRow);
|
||||
|
||||
expect(approvalState.approvalHandler).toBeTypeOf('function');
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
approval: grantRow,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
@@ -507,6 +522,73 @@ describe('CLI scope enforcement', () => {
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Grant-carrying replay (the `approved: true` boolean no longer exists) ---
|
||||
|
||||
it('replay with a dead grant (row deleted) refuses instead of re-holding', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const ctx = agentCtx();
|
||||
await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
|
||||
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
|
||||
|
||||
mockGetPendingApproval.mockReturnValue(undefined); // resolution already deleted the row
|
||||
const notify = vi.fn();
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
approval: { approval_id: 'appr-dead', action: 'cli_command', payload: JSON.stringify(approval.payload) },
|
||||
userId: 'telegram:admin',
|
||||
notify,
|
||||
});
|
||||
|
||||
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1); // no second card
|
||||
expect(notify.mock.calls[0][0]).toContain('failed');
|
||||
});
|
||||
|
||||
it("a grant approved for one command doesn't transfer to another", async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
// A live cli_command row, but held for a DIFFERENT command.
|
||||
const grantRow = {
|
||||
approval_id: 'appr-other',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify({ frame: { command: 'members-add' } }),
|
||||
};
|
||||
mockGetPendingApproval.mockReturnValue(grantRow);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
|
||||
grant: grantRow as never,
|
||||
});
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('grant');
|
||||
}
|
||||
expect(approvalState.observedContexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a fabricated grant object without a live row is refused', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetPendingApproval.mockReturnValue(undefined);
|
||||
|
||||
const forged = {
|
||||
approval_id: 'appr-forged',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify({ frame: { command: 'approval-context-command' } }),
|
||||
};
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
|
||||
grant: forged as never,
|
||||
});
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
|
||||
expect(approvalState.requestApproval).not.toHaveBeenCalled(); // refusal, not a fresh hold
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
|
||||
+41
-42
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The guard-consult path for privileged delivery actions.
|
||||
*
|
||||
* The registry itself — registration, lookup, approved-replay re-entry —
|
||||
* stays in delivery.ts, close to main's shape. This file holds the new
|
||||
* guard logic: the spec a privileged registration carries, and runGuarded,
|
||||
* the precheck → guard → deny/hold/allow pipeline every consult runs.
|
||||
*/
|
||||
import { guard, type GuardedAction } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
import type { PendingApproval, Session } from './types.js';
|
||||
|
||||
/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */
|
||||
export type GuardedDeliveryHandler = (content: Record<string, unknown>, session: Session) => Promise<void>;
|
||||
|
||||
export interface DeliveryGuardSpec {
|
||||
/** Guard action consulted before the handler runs — the defined value, not a name. */
|
||||
guardAction: GuardedAction;
|
||||
/**
|
||||
* Domain validation that runs before the guard — malformed requests are
|
||||
* answered (notify) without ever creating a hold. Return false to stop.
|
||||
*/
|
||||
precheck?: (content: Record<string, unknown>, session: Session) => boolean | Promise<boolean>;
|
||||
/** Create the hold (the domain's requestApproval call — card text lives with the domain). */
|
||||
requestHold: (content: Record<string, unknown>, session: Session) => Promise<void>;
|
||||
/** Tell the requester about a deny. */
|
||||
onDeny?: (content: Record<string, unknown>, session: Session, reason: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a guarded delivery action: precheck, consult the guard, then route the
|
||||
* decision — deny → onDeny, hold → requestHold, allow → handler. A fresh
|
||||
* dispatch passes grant=null; an approved replay passes the approval row,
|
||||
* which satisfies a hold but never a deny (the structural checks re-run
|
||||
* live, so approve-then-revoke does not execute).
|
||||
*/
|
||||
export async function runGuarded(
|
||||
action: string,
|
||||
spec: DeliveryGuardSpec,
|
||||
handler: GuardedDeliveryHandler,
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
grant: PendingApproval | null,
|
||||
): Promise<void> {
|
||||
if (spec.precheck && !(await spec.precheck(content, session))) return;
|
||||
|
||||
const decision = guard(spec.guardAction, {
|
||||
actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id },
|
||||
payload: content,
|
||||
grant,
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
log.warn('Delivery action denied by guard', { action, reason: decision.reason });
|
||||
spec.onDeny?.(content, session, decision.reason);
|
||||
return;
|
||||
}
|
||||
if (decision.effect === 'hold') {
|
||||
await spec.requestHold(content, session);
|
||||
return;
|
||||
}
|
||||
await handler(content, session);
|
||||
}
|
||||
+76
-17
@@ -20,12 +20,14 @@ import {
|
||||
markDeliveryFailed,
|
||||
migrateDeliveredTable,
|
||||
} from './db/session-db.js';
|
||||
import { runGuarded, type DeliveryGuardSpec, type GuardedDeliveryHandler } from './delivery-guard.js';
|
||||
import { isUnguarded, type Unguarded } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
import { normalizeOptions } from './channels/ask-question.js';
|
||||
import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js';
|
||||
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import type { Session } from './types.js';
|
||||
import type { PendingApproval, Session } from './types.js';
|
||||
|
||||
const ACTIVE_POLL_MS = 1000;
|
||||
const SWEEP_POLL_MS = 60_000;
|
||||
@@ -393,14 +395,19 @@ async function deliverMessage(
|
||||
* Delivery action registry.
|
||||
*
|
||||
* Modules register handlers for system-kind outbound message actions via
|
||||
* `registerDeliveryAction`. Core checks the registry first in
|
||||
* `handleSystemAction` and falls through to the inline switch when no
|
||||
* handler is registered. The switch will shrink as modules are extracted
|
||||
* (scheduling, approvals, agent-to-agent) and eventually only its default
|
||||
* branch remains.
|
||||
* `registerDeliveryAction`. Unknown actions log "Unknown system action".
|
||||
*
|
||||
* Default when no handler registered and the switch doesn't match: log
|
||||
* "Unknown system action" and return.
|
||||
* Privileged delivery actions (create_agent, install_packages,
|
||||
* add_mcp_server) register with a guard spec: every path to the handler body
|
||||
* — dispatch, approved replay, test lookup — goes through the guard consult
|
||||
* (allow / hold / deny), so there is no unguarded route to it. On approve,
|
||||
* the continuation re-enters the same entry carrying the approval row as its
|
||||
* grant (`reenterGuardedDeliveryAction`), so the structural checks are
|
||||
* re-run live. Plain actions (the cli_request bridge — its inner
|
||||
* commands are guarded at dispatch) register with an
|
||||
* explicit `unguarded(<reason>)` declaration instead of a spec — omission is
|
||||
* not representable, so the decision to run unguarded is visible, and
|
||||
* justified, at the registration site.
|
||||
*/
|
||||
export type DeliveryActionHandler = (
|
||||
content: Record<string, unknown>,
|
||||
@@ -408,18 +415,70 @@ export type DeliveryActionHandler = (
|
||||
inDb: Database.Database,
|
||||
) => Promise<void>;
|
||||
|
||||
const actionHandlers = new Map<string, DeliveryActionHandler>();
|
||||
type DeliveryEntry =
|
||||
| { guard: Unguarded; handler: DeliveryActionHandler }
|
||||
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
|
||||
|
||||
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void {
|
||||
if (actionHandlers.has(action)) {
|
||||
log.warn('Delivery action handler overwritten', { action });
|
||||
}
|
||||
actionHandlers.set(action, handler);
|
||||
const deliveryActions = new Map<string, DeliveryEntry>();
|
||||
|
||||
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
|
||||
return isUnguarded(entry.guard);
|
||||
}
|
||||
|
||||
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
|
||||
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void;
|
||||
export function registerDeliveryAction(action: string, handler: GuardedDeliveryHandler, spec: DeliveryGuardSpec): void;
|
||||
export function registerDeliveryAction(
|
||||
action: string,
|
||||
handler: DeliveryActionHandler | GuardedDeliveryHandler,
|
||||
guardDecl: DeliveryGuardSpec | Unguarded,
|
||||
): void {
|
||||
const existing = deliveryActions.get(action);
|
||||
if (existing) {
|
||||
// Replacing a guard-wrapped action with an unguarded handler would
|
||||
// disarm the guard while its catalog entry still exists — refuse. A
|
||||
// skill that wants to extend a guarded action must compose at the
|
||||
// module's exported functions instead, or re-register with a guard spec
|
||||
// of its own.
|
||||
if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) {
|
||||
throw new Error(
|
||||
`delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`,
|
||||
);
|
||||
}
|
||||
log.warn('Delivery action handler overwritten', { action });
|
||||
}
|
||||
// The overloads pair each handler shape with its declaration; the merged
|
||||
// implementation signature erases that pairing, hence the one cast.
|
||||
deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve continuation for a guard-wrapped delivery action: re-enter the
|
||||
* entry with the approval row as the grant. The guard treats the grant as
|
||||
* hold-satisfied but re-runs the structural checks, so approve-then-revoke
|
||||
* does not execute. Domains register this as their approval handler in the
|
||||
* same line that registers the action.
|
||||
*/
|
||||
export function reenterGuardedDeliveryAction(action: string) {
|
||||
return async (ctx: { session: Session; payload: Record<string, unknown>; approval: PendingApproval }) => {
|
||||
const entry = deliveryActions.get(action);
|
||||
if (!entry || isUnguardedEntry(entry)) {
|
||||
log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action });
|
||||
return;
|
||||
}
|
||||
await runGuarded(action, entry.guard, entry.handler, ctx.payload, ctx.session, ctx.approval);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The invocable for a registered action — the raw handler for unguarded
|
||||
* entries, the guard-consulting path for guarded ones. Dispatch and tests
|
||||
* both come through here; there is no route around the guard.
|
||||
*/
|
||||
export function getDeliveryAction(action: string): DeliveryActionHandler | undefined {
|
||||
return actionHandlers.get(action);
|
||||
const entry = deliveryActions.get(action);
|
||||
if (!entry) return undefined;
|
||||
if (isUnguardedEntry(entry)) return entry.handler;
|
||||
return (content, session) => runGuarded(action, entry.guard, entry.handler, content, session, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,7 +494,7 @@ async function handleSystemAction(
|
||||
const action = content.action as string;
|
||||
log.info('System action from agent', { sessionId: session.id, action });
|
||||
|
||||
const registered = actionHandlers.get(action);
|
||||
const registered = getDeliveryAction(action);
|
||||
if (registered) {
|
||||
await registered(content, session, inDb);
|
||||
return;
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** 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 }) => {
|
||||
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 +18,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,
|
||||
|
||||
@@ -59,6 +59,12 @@ const APPROVAL_OPTIONS: RawOption[] = [
|
||||
export interface ApprovalHandlerContext {
|
||||
session: Session;
|
||||
payload: Record<string, unknown>;
|
||||
/**
|
||||
* The verified approval row — the grant an approved continuation carries
|
||||
* when it re-enters its guarded entry point. Still live here; resolution
|
||||
* deletes it after the handler returns, so a grant executes exactly once.
|
||||
*/
|
||||
approval: PendingApproval;
|
||||
/** User ID of the admin who approved. Empty string if unknown. */
|
||||
userId: string;
|
||||
/** Send a system chat message to the requesting agent's session. */
|
||||
|
||||
@@ -112,7 +112,7 @@ async function handleRegisteredApproval(
|
||||
|
||||
const payload = JSON.parse(approval.payload);
|
||||
try {
|
||||
await handler({ session, payload, userId, notify });
|
||||
await handler({ session, payload, approval, userId, notify });
|
||||
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
} catch (err) {
|
||||
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
|
||||
|
||||
@@ -68,7 +68,11 @@ vi.mock('./user-dm.js', () => ({
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' };
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-channel-approval',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-channel-approval/groups',
|
||||
};
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-channel-approval';
|
||||
@@ -586,6 +590,108 @@ describe('unknown-channel registration flow', () => {
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
|
||||
it('create new agent: the free-text name reply creates the group and wires the channel', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
await routeInbound(groupMention('chat-create-new'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
|
||||
// Owner clicks "Connect new agent" → name prompt lands in their DM.
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'new_agent',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// Owner replies with the agent name in the same DM — the interceptor
|
||||
// captures it and creates.
|
||||
await routeInbound({
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'name-reply-1',
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Newbie' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const created = getDb().prepare("SELECT id FROM agent_groups WHERE name = 'Newbie'").get() as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
expect(created).toBeDefined();
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?')
|
||||
.get(pending.messaging_group_id, created!.id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(1);
|
||||
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
|
||||
.c;
|
||||
expect(stillPending).toBe(0);
|
||||
});
|
||||
|
||||
it('a name reply after the registration vanished is consumed without creating anything', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
await routeInbound(groupMention('chat-vanished'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'new_agent',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// The registration disappears between the click and the reply (rejected
|
||||
// from another card, group delete cascade, …) — the interceptor no
|
||||
// longer finds a pending registration, so the reply must not create.
|
||||
getDb()
|
||||
.prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?')
|
||||
.run(pending.messaging_group_id);
|
||||
|
||||
const agentGroupsBefore = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c;
|
||||
await routeInbound({
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'name-reply-2',
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Ghost' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const agentGroupsAfter = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c;
|
||||
expect(agentGroupsAfter).toBe(agentGroupsBefore);
|
||||
const mgaCount = (getDb().prepare('SELECT COUNT(*) AS c FROM messaging_group_agents').get() as { c: number }).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-owner / no-agent failure modes', () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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/reply 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 by the wrapped
|
||||
* response handler (card clicks) and the wrapped name-capture interceptor
|
||||
* (free-text replies), so a privilege revoked mid-flow is re-checked at each
|
||||
* step.
|
||||
*/
|
||||
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');
|
||||
},
|
||||
});
|
||||
@@ -33,6 +33,8 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { guard } from '../../guard/index.js';
|
||||
import { channelsRegister, sendersAdmit } from './guard.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import {
|
||||
buildAgentSelectionOptions,
|
||||
@@ -131,43 +133,49 @@ function handleUnknownSender(
|
||||
agent_group_id: agentGroupId,
|
||||
};
|
||||
|
||||
if (mg.unknown_sender_policy === 'strict') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
|
||||
// The admission decision is the guard's senders.admit decision (./guard.ts)
|
||||
// — unknown_sender_policy verbatim: strict → deny, request_approval → hold,
|
||||
// public → allow (short-circuited before the gate). Drop-recording and the
|
||||
// hold creation stay here.
|
||||
const decision = guard(sendersAdmit, {
|
||||
actor: userId ? { kind: 'human', userId } : { kind: 'system' },
|
||||
payload: {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
senderIdentity: userId,
|
||||
policy: mg.unknown_sender_policy,
|
||||
},
|
||||
});
|
||||
|
||||
if (decision.effect === 'allow') return; // 'public' — handled before the gate; fall through silently.
|
||||
|
||||
log.info(
|
||||
decision.effect === 'hold'
|
||||
? 'MESSAGE DROPPED — unknown sender (approval requested)'
|
||||
: 'MESSAGE DROPPED — unknown sender (strict policy)',
|
||||
{
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
recordDroppedMessage(dropRecord);
|
||||
|
||||
if (mg.unknown_sender_policy === 'request_approval') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval requested)', {
|
||||
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
|
||||
// If it fails it logs internally — the user's message still stays dropped
|
||||
// either way. Requires a resolved userId (senderResolver populates users
|
||||
// row before the gate fires); if we got here without one, there's nothing
|
||||
// to identify for approval and we just drop silently.
|
||||
if (decision.effect === 'hold' && userId) {
|
||||
requestSenderApproval({
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
|
||||
// If it fails it logs internally — the user's message still stays dropped
|
||||
// either way. Requires a resolved userId (senderResolver populates users
|
||||
// row before the gate fires); if we got here without one, there's nothing
|
||||
// to identify for approval and we just stay in the "silent strict" branch.
|
||||
if (userId) {
|
||||
requestSenderApproval({
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
senderIdentity: userId,
|
||||
senderName,
|
||||
event,
|
||||
}).catch((err) => log.error('Sender-approval flow threw', { err }));
|
||||
}
|
||||
return;
|
||||
senderIdentity: userId,
|
||||
senderName,
|
||||
event,
|
||||
}).catch((err) => log.error('Sender-approval flow threw', { err }));
|
||||
}
|
||||
|
||||
// 'public' should have been handled before the gate; fall through silently.
|
||||
}
|
||||
|
||||
setSenderResolver(extractAndUpsertUser);
|
||||
@@ -411,18 +419,23 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
// Click authorization is the guard's channels.register decision (./guard.ts):
|
||||
// the delivered approver, or an admin of the pending row's anchor agent group.
|
||||
const clickerId = payload.userId
|
||||
? payload.userId.includes(':')
|
||||
? payload.userId
|
||||
: `${payload.channelType}:${payload.userId}`
|
||||
: null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
if (!isAuthorized) {
|
||||
const decision = guard(channelsRegister, {
|
||||
actor: { kind: 'human', userId: clickerId ?? '' },
|
||||
payload: { questionId: payload.questionId },
|
||||
});
|
||||
if (!clickerId || decision.effect !== 'allow') {
|
||||
log.warn('Channel registration click rejected — unauthorized clicker', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
reason: decision.reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user