mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af0d7c6153 | |||
| 4ef7d7367c | |||
| 97d986b1db | |||
| 76984ef395 | |||
| 87a2d3af8d | |||
| 68ebc02114 | |||
| d4e73c5523 | |||
| 9a103f4f93 | |||
| a8554c6248 | |||
| 2480ae7cb8 | |||
| 763a3f757b | |||
| 8943c1cbf4 | |||
| 559bb5ca1a | |||
| 04b364e828 | |||
| 627ab23bad | |||
| 8986fef586 | |||
| 7f49450c0c | |||
| cdc519e1f4 | |||
| 1276645c3b | |||
| d3499b7d70 | |||
| c2cf19ec28 | |||
| 44f351349a | |||
| e8a32207d8 | |||
| 1dda751a48 | |||
| 4f1b17c737 | |||
| 967aee2c27 | |||
| 5aac750aa5 |
@@ -1,68 +0,0 @@
|
||||
# Remove /add-audit
|
||||
|
||||
Reverses every change apply made. The audit day-files under `data/audit/` are
|
||||
the operator's records — this removes the recorder, not the recordings; delete
|
||||
`data/audit/` yourself if you also want the history gone.
|
||||
|
||||
## 1. Delete every copied file
|
||||
|
||||
```bash
|
||||
rm -rf src/audit
|
||||
rm -f src/audit-wiring.test.ts \
|
||||
src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts \
|
||||
src/cli/resources/audit.ts \
|
||||
src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts \
|
||||
src/modules/approvals/approvals-observer.audit.ts \
|
||||
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
|
||||
src/modules/permissions/permissions.audit.ts src/modules/permissions/permissions.audit.test.ts \
|
||||
src/modules/agent-to-agent/create-agent.audit.ts src/modules/agent-to-agent/create-agent.audit.test.ts
|
||||
```
|
||||
|
||||
## 2. Revert the seam compositions (DELETE lines, do not comment out)
|
||||
|
||||
- `src/cli/resources/index.ts`: delete the `import './audit.js';` line.
|
||||
- `src/cli/dispatch.ts`: delete the `import { withAudit } from './dispatch.audit.js';`
|
||||
line and the `export const dispatch = withAudit(dispatchInner);` block (with
|
||||
its comment); rename `async function dispatchInner(` back to
|
||||
`export async function dispatch(`.
|
||||
- `src/modules/approvals/primitive.ts`: delete the
|
||||
`import { auditRequestApproval } from './approvals.audit.js';` line (and its
|
||||
comment) and the `export const requestApproval = auditRequestApproval(...)`
|
||||
block; rename `async function requestApprovalInner(` back to
|
||||
`export async function requestApproval(`.
|
||||
- `src/modules/approvals/response-handler.ts`: delete the
|
||||
`import { runApprovedHandler } from './approvals.audit.js';` line; replace the
|
||||
`await runApprovedHandler(...)` call (and its comment) with:
|
||||
`await handler({ session, payload, userId, approvalId: approval.approval_id, notify });`
|
||||
- `src/modules/approvals/onecli-approvals.ts`: delete the adapter import line,
|
||||
the `recordOneCliHold` const (change `recordOneCliHold({` back to
|
||||
`createPendingApproval({`), and the three wrapper consts (with comments);
|
||||
rename the three `...Inner` functions back (`resolveOneCLIApprovalInner` →
|
||||
`export function resolveOneCLIApproval`, `expireApprovalInner` →
|
||||
`expireApproval`, `sweepStaleApprovalsInner` → `sweepStaleApprovals`).
|
||||
- `src/modules/permissions/index.ts`: delete the adapter import line; restore
|
||||
the three plain registrations:
|
||||
```ts
|
||||
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
|
||||
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
|
||||
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
|
||||
```
|
||||
- `src/index.ts`: delete the audit block in `main()` (the comment, the two
|
||||
`await import(...)` lines, and `initAuditLog();`).
|
||||
- `src/host-sweep.ts`: delete the audit-maintenance `try { ... }` block in
|
||||
`sweep()` (with its comment).
|
||||
|
||||
## 3. Revert config and env
|
||||
|
||||
- `src/config.ts`: remove `'AUDIT_ENABLED',` and `'AUDIT_RETENTION_DAYS',`
|
||||
from the `readEnvFile([...])` array, and delete the
|
||||
`AUDIT_ENABLED` / `AUDIT_RETENTION_DAYS` export block (with its comments).
|
||||
- `.env`: remove the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
Then restart the service.
|
||||
@@ -1,303 +0,0 @@
|
||||
---
|
||||
name: add-audit
|
||||
description: Add an opt-in local audit log — every ncl command (both transports, including denials), every host-routed approval (pending/decision/terminal, correlated by approval id), permissions card decisions, OneCLI credential holds, and ungated agent creation, written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; export exporters via registerAuditHook. Off until AUDIT_ENABLED=true.
|
||||
---
|
||||
|
||||
# /add-audit — opt-in local audit log
|
||||
|
||||
Records one canonical, SIEM-shaped event per action — actor, origin, dotted
|
||||
action, touched resources, outcome, approval correlation — to append-only
|
||||
NDJSON day-files under `data/audit/`. Feature is **opt-in**: with
|
||||
`AUDIT_ENABLED` unset nothing is persisted, the emit seam no-ops at one
|
||||
boolean check, and `data/audit/` is never created.
|
||||
|
||||
Architecture: `src/audit/` is a **domain-free leaf** (schema, emit seam,
|
||||
store, reader, post-write hooks, shared vocabulary). How each domain
|
||||
describes itself lives in **domain-owned `*.audit.ts` adapter files** next to
|
||||
the code they observe; each composes at its module's edge in one line.
|
||||
Business logic contains zero audit calls: `grep emitAuditEvent src/` matches
|
||||
only `src/audit/` and `*.audit.ts`.
|
||||
|
||||
The adapters compose on seams trunk already ships (`DispatchTrace`,
|
||||
`ApprovalHold`, `SenderApprovalResult`/`ChannelApprovalResult`,
|
||||
`CreateAgentResult`, the `userId` arg on `resolveOneCLIApproval`). If an edit
|
||||
below is already present, skip it — apply is safe to re-run.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Copy the files
|
||||
|
||||
```bash
|
||||
cp -R .claude/skills/add-audit/add/src/. src/
|
||||
```
|
||||
|
||||
Adds `src/audit/` (the leaf + its tests), the domain adapters
|
||||
(`src/cli/dispatch.audit.ts`, `src/modules/approvals/approvals.audit.ts`,
|
||||
`src/modules/approvals/approvals-observer.audit.ts`,
|
||||
`src/modules/permissions/permissions.audit.ts`,
|
||||
`src/modules/agent-to-agent/create-agent.audit.ts`), their tests, the
|
||||
`ncl audit` resource (`src/cli/resources/audit.ts`), and
|
||||
`src/audit-wiring.test.ts`. No dependency is added — the module is stdlib-only.
|
||||
|
||||
### 2. Register the `ncl audit` resource
|
||||
|
||||
Append to `src/cli/resources/index.ts`:
|
||||
|
||||
```ts
|
||||
import './audit.js';
|
||||
```
|
||||
|
||||
### 3. Add the two config vars to `src/config.ts`
|
||||
|
||||
Add both names to the `readEnvFile([...])` array:
|
||||
|
||||
```ts
|
||||
'AUDIT_ENABLED',
|
||||
'AUDIT_RETENTION_DAYS',
|
||||
```
|
||||
|
||||
Then append after the `ONECLI_GATEWAY_CONTAINER` export block:
|
||||
|
||||
```ts
|
||||
// Local audit log — opt-in (installed by /add-audit). Off by default: the
|
||||
// audit emitter no-ops and data/audit/ is never created.
|
||||
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
|
||||
// Audit day-files older than this many days are unlinked (a hard delete).
|
||||
// 0 = keep forever. Read only when AUDIT_ENABLED=true.
|
||||
const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10);
|
||||
export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw;
|
||||
```
|
||||
|
||||
### 4. Compose the dispatch middleware — `src/cli/dispatch.ts`
|
||||
|
||||
Add to the import block:
|
||||
|
||||
```ts
|
||||
import { withAudit } from './dispatch.audit.js';
|
||||
```
|
||||
|
||||
Rename the dispatcher (signature line only):
|
||||
|
||||
```ts
|
||||
export async function dispatch( → async function dispatchInner(
|
||||
```
|
||||
|
||||
Insert immediately after `dispatchInner`'s closing brace (before the
|
||||
`registerApprovalHandler('cli_command', ...)` call):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Public dispatcher — the audit middleware wraps the inner dispatcher, so the
|
||||
* socket server, the container delivery-action, and the approved replay are
|
||||
* all covered without changing a call site.
|
||||
*/
|
||||
export const dispatch = withAudit(dispatchInner);
|
||||
```
|
||||
|
||||
### 5. Decorate requestApproval — `src/modules/approvals/primitive.ts`
|
||||
|
||||
Add to the import block:
|
||||
|
||||
```ts
|
||||
// Sibling adapter import; it imports this module back type-only (no cycle).
|
||||
import { auditRequestApproval } from './approvals.audit.js';
|
||||
```
|
||||
|
||||
Rename the request function (signature line only):
|
||||
|
||||
```ts
|
||||
export async function requestApproval( → async function requestApprovalInner(
|
||||
```
|
||||
|
||||
Insert immediately after `requestApprovalInner`'s closing brace:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Public export — the audit decorator wraps the inner request so every gated
|
||||
* hold emits its pending event from one place. Pass-through: callers see the
|
||||
* hold exactly as the inner returns it.
|
||||
*/
|
||||
export const requestApproval = auditRequestApproval(requestApprovalInner);
|
||||
```
|
||||
|
||||
### 6. Wrap the approved-handler run — `src/modules/approvals/response-handler.ts`
|
||||
|
||||
Add to the import block:
|
||||
|
||||
```ts
|
||||
import { runApprovedHandler } from './approvals.audit.js';
|
||||
```
|
||||
|
||||
Replace the handler invocation inside `handleRegisteredApproval`:
|
||||
|
||||
```ts
|
||||
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// runApprovedHandler wraps the invocation to emit the gated chain's
|
||||
// terminal audit event; rethrows, so the catch below behaves as before.
|
||||
await runApprovedHandler(
|
||||
handler,
|
||||
{ session, payload, userId, approvalId: approval.approval_id, notify },
|
||||
approval,
|
||||
session,
|
||||
);
|
||||
```
|
||||
|
||||
### 7. Wrap the OneCLI paths — `src/modules/approvals/onecli-approvals.ts`
|
||||
|
||||
Add to the import block:
|
||||
|
||||
```ts
|
||||
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
|
||||
```
|
||||
|
||||
Four compositions:
|
||||
|
||||
a. After the `shortApprovalId()` function, add:
|
||||
|
||||
```ts
|
||||
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
|
||||
const recordOneCliHold = auditOneCliHold(createPendingApproval);
|
||||
```
|
||||
|
||||
and inside `handleRequest`, change the row insert `createPendingApproval({`
|
||||
to `recordOneCliHold({`.
|
||||
|
||||
b. Rename `export function resolveOneCLIApproval(` to
|
||||
`function resolveOneCLIApprovalInner(` (signature line only) and insert after
|
||||
its closing brace:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* The audit wrapper records the decision with the clicking admin as actor
|
||||
* (OneCLI rows never reach notifyApprovalResolved, so the shared observer
|
||||
* can't cover them).
|
||||
*/
|
||||
export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner);
|
||||
```
|
||||
|
||||
c. Rename `async function expireApproval(` to
|
||||
`async function expireApprovalInner(` and insert after its closing brace:
|
||||
|
||||
```ts
|
||||
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
|
||||
const expireApproval = auditOneCliExpiry(expireApprovalInner);
|
||||
```
|
||||
|
||||
d. Rename `async function sweepStaleApprovals(` to
|
||||
`async function sweepStaleApprovalsInner(` and insert after its closing brace:
|
||||
|
||||
```ts
|
||||
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
|
||||
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
|
||||
getPendingApprovalsByAction(ONECLI_ACTION),
|
||||
);
|
||||
```
|
||||
|
||||
### 8. Wrap the permissions decisions — `src/modules/permissions/index.ts`
|
||||
|
||||
Add to the import block:
|
||||
|
||||
```ts
|
||||
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
|
||||
```
|
||||
|
||||
Replace the three registration coercions:
|
||||
|
||||
```ts
|
||||
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
|
||||
→ registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
|
||||
|
||||
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
|
||||
→ registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
|
||||
|
||||
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
|
||||
→ registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));
|
||||
```
|
||||
|
||||
### 9. Boot wiring — `src/index.ts`
|
||||
|
||||
Insert inside `main()`, after the `migrateGroupsToClaudeLocal();` step and
|
||||
before the container-runtime step:
|
||||
|
||||
```ts
|
||||
// Audit log (optional — installed by /add-audit; AUDIT_ENABLED gates writes).
|
||||
// The observer import self-registers approvals.decide; initAuditLog asserts
|
||||
// data/audit/ is writable when enabled (throw → exit 1) and starts hooks.
|
||||
await import('./modules/approvals/approvals-observer.audit.js');
|
||||
const { initAuditLog } = await import('./audit/index.js');
|
||||
initAuditLog();
|
||||
```
|
||||
|
||||
### 10. Sweep wiring — `src/host-sweep.ts`
|
||||
|
||||
Insert inside `sweep()`, immediately before the `setTimeout(sweep, SWEEP_INTERVAL_MS);` reschedule:
|
||||
|
||||
```ts
|
||||
// Audit maintenance (installed by /add-audit) — retention prune (throttled
|
||||
// to once per UTC day inside the module) + post-write hooks' maintain().
|
||||
try {
|
||||
const { maintainAudit } = await import('./audit/index.js');
|
||||
maintainAudit();
|
||||
} catch (err) {
|
||||
log.error('Audit maintenance failed', { err });
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Enable it
|
||||
|
||||
Append to `.env` (this is the point of installing the skill — but the switch
|
||||
stays yours):
|
||||
|
||||
```bash
|
||||
AUDIT_ENABLED=true
|
||||
# Optional; default 90. 0 = keep forever.
|
||||
#AUDIT_RETENTION_DAYS=90
|
||||
```
|
||||
|
||||
### 12. Verify
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/audit src/audit-wiring.test.ts src/cli/dispatch.audit.test.ts \
|
||||
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
|
||||
src/modules/approvals/approvals.audit.test.ts src/modules/permissions/permissions.audit.test.ts \
|
||||
src/modules/agent-to-agent/create-agent.audit.test.ts
|
||||
pnpm test # full suite — the composed system must stay green
|
||||
```
|
||||
|
||||
Then restart the service (macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
|
||||
Linux: `systemctl --user restart nanoclaw`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
ncl audit list # newest first, default limit 100
|
||||
ncl audit list --actor host:moshe --since 7d
|
||||
ncl audit list --outcome denied --since 90d --format ndjson # SIEM export
|
||||
ncl audit list --correlation appr-... # everything on one gated chain
|
||||
```
|
||||
|
||||
Host socket + `cli_scope: global` agents only; group-scoped agents never see
|
||||
the resource (audit spans groups — exclusion fails closed). On a disabled box
|
||||
the command errors with "audit log is disabled" rather than returning an
|
||||
empty list that would read as history.
|
||||
|
||||
Exporters: call `registerAuditHook(...)` (from `src/audit/index.js`) in a
|
||||
module imported at boot — `onEvent` fires only after a successful local
|
||||
append, so an exporter can never know an event the source of truth doesn't.
|
||||
|
||||
## Semantics worth knowing
|
||||
|
||||
- **Fail-open + loud**: a failed append is `log.error`'d and the action
|
||||
proceeds; at boot (enabled only) the host refuses to start if `data/audit/`
|
||||
isn't writable.
|
||||
- **Append-only**: nothing updates a line; retention prune unlinks whole
|
||||
day-files past `AUDIT_RETENTION_DAYS` (a literal hard delete).
|
||||
- **No message bodies**: message-bearing events record shape only
|
||||
(`body_chars`, attachment names); a recursive key-pattern redactor masks
|
||||
secret-looking values at the emit seam.
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Wiring test for the /add-audit skill's two boot-path code edits.
|
||||
*
|
||||
* The skill inserts one colocated block into src/index.ts (dynamic import of
|
||||
* the self-registering approvals observer + `initAuditLog()`), and one into
|
||||
* src/host-sweep.ts (`maintainAudit()` inside the sweep, fail-isolated). A
|
||||
* behavioral test can't see whether those edits are present and correctly
|
||||
* placed — booting the real host is too heavy — so this asserts them
|
||||
* structurally, via the TypeScript AST:
|
||||
* - the observer module is dynamically imported by its correct path and
|
||||
* initAuditLog() runs after it (registration before enablement),
|
||||
* - both are DIRECT statements of main()'s body, after DB init and before
|
||||
* the boot-complete log,
|
||||
* - maintainAudit() is called inside sweep() before the reschedule.
|
||||
*
|
||||
* Delete or misplace an edit and this goes red. The seam wrappers themselves
|
||||
* are covered behaviorally (dispatch.audit.test.ts and friends).
|
||||
*
|
||||
* Ships with the skill; apply copies it to src/.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ts from 'typescript';
|
||||
|
||||
function bodyOf(file: string, fnName: string): { stmts: ts.NodeArray<ts.Statement>; sf: ts.SourceFile } {
|
||||
const source = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8');
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
||||
let body: ts.NodeArray<ts.Statement> | undefined;
|
||||
sf.forEachChild((n) => {
|
||||
if (ts.isFunctionDeclaration(n) && n.name?.text === fnName && n.body) {
|
||||
body = n.body.statements;
|
||||
}
|
||||
});
|
||||
if (!body) throw new Error(`${fnName}() not found in ${file}`);
|
||||
return { stmts: body, sf };
|
||||
}
|
||||
|
||||
/** `await import('<path>')` as a bare expression statement. */
|
||||
function isAwaitedImportStatement(s: ts.Statement, importPath: string): boolean {
|
||||
return (
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isAwaitExpression(s.expression) &&
|
||||
ts.isCallExpression(s.expression.expression) &&
|
||||
s.expression.expression.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
ts.isStringLiteral(s.expression.expression.arguments[0]!) &&
|
||||
(s.expression.expression.arguments[0] as ts.StringLiteral).text === importPath
|
||||
);
|
||||
}
|
||||
|
||||
/** `const { ... } = await import('<path>')` as a statement. */
|
||||
function isDynamicImportBinding(s: ts.Statement, importPath: string): boolean {
|
||||
if (!ts.isVariableStatement(s)) return false;
|
||||
const init = s.declarationList.declarations[0]?.initializer;
|
||||
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
|
||||
const call = init.expression;
|
||||
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
|
||||
const arg = call.arguments[0];
|
||||
return !!arg && ts.isStringLiteral(arg) && arg.text === importPath;
|
||||
}
|
||||
|
||||
function isBareCall(s: ts.Statement, callee: string): boolean {
|
||||
return (
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
ts.isIdentifier(s.expression.expression) &&
|
||||
s.expression.expression.text === callee
|
||||
);
|
||||
}
|
||||
|
||||
describe('/add-audit wiring in src/index.ts', () => {
|
||||
it('imports the self-registering observer, then initAuditLog(), colocated in main() after DB init and before the boot-complete log', () => {
|
||||
const { stmts, sf } = bodyOf('src/index.ts', 'main');
|
||||
const observerIdx = stmts.findIndex((s) =>
|
||||
isAwaitedImportStatement(s, './modules/approvals/approvals-observer.audit.js'),
|
||||
);
|
||||
const importIdx = stmts.findIndex((s) => isDynamicImportBinding(s, './audit/index.js'));
|
||||
const callIdx = stmts.findIndex((s) => isBareCall(s, 'initAuditLog'));
|
||||
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
|
||||
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
|
||||
|
||||
expect(observerIdx, 'the observer must be dynamically imported in main()').toBeGreaterThanOrEqual(0);
|
||||
expect(importIdx, "dynamic import('./audit/index.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
|
||||
expect(callIdx, 'initAuditLog() must be a statement of main()').toBeGreaterThanOrEqual(0);
|
||||
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
|
||||
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
|
||||
expect(observerIdx, 'the observer import must come after DB init').toBeGreaterThan(migrateIdx);
|
||||
expect(importIdx, 'the audit import must come after the observer import').toBeGreaterThan(observerIdx);
|
||||
expect(callIdx, 'initAuditLog() must come after its import (colocated)').toBeGreaterThan(importIdx);
|
||||
expect(callIdx, 'initAuditLog() must run before the boot-complete log').toBeLessThan(runningIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/add-audit wiring in src/host-sweep.ts', () => {
|
||||
it('calls maintainAudit() inside sweep(), fail-isolated, before the reschedule', () => {
|
||||
const { stmts, sf } = bodyOf('src/host-sweep.ts', 'sweep');
|
||||
const auditIdx = stmts.findIndex((s) => ts.isTryStatement(s) && s.getText(sf).includes('maintainAudit()'));
|
||||
const rescheduleIdx = stmts.findIndex((s) => s.getText(sf).includes('setTimeout(sweep'));
|
||||
|
||||
expect(auditIdx, 'a try-wrapped maintainAudit() must be a statement of sweep()').toBeGreaterThanOrEqual(0);
|
||||
expect(rescheduleIdx, 'setTimeout(sweep) anchor not found').toBeGreaterThanOrEqual(0);
|
||||
expect(auditIdx, 'maintainAudit() must run before the sweep reschedules itself').toBeLessThan(rescheduleIdx);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* emitAuditEvent — the single opt-in check and the single append point.
|
||||
*
|
||||
* Only src/audit/ and the domain-owned `*.audit.ts` adapter files may call
|
||||
* this (adapters compose it at their module's edge; business logic never
|
||||
* does): `grep emitAuditEvent src/` outside those files must stay empty.
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { AUDIT_ENABLED } from '../config.js';
|
||||
import { log } from '../log.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 {
|
||||
// Lazy inputs keep a disabled box at literally zero audit work (and shield
|
||||
// the action from assembly errors — origin lookups can touch the DB).
|
||||
if (typeof input === 'function') input = input();
|
||||
const event: AuditEvent = {
|
||||
event_id: randomUUID(),
|
||||
time: new Date().toISOString(),
|
||||
schema_version: 1,
|
||||
// Directory-enrichment fields stamp null until the adapter lands.
|
||||
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);
|
||||
// Post-write hooks: fired only after the append succeeded, so an exporter
|
||||
// can never know an event the source of truth doesn't. Failures are
|
||||
// isolated inside notifyAuditHooks.
|
||||
notifyAuditHooks(event, line);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the contract: auditing must never take down the audited action
|
||||
} catch (err) {
|
||||
// Fail-open + loud: the audited action must proceed even when the log
|
||||
// can't be written (a full disk must not brick recovery commands).
|
||||
const action = typeof input === 'function' ? undefined : input.action;
|
||||
log.error('Audit append failed — action proceeding (fail-open)', { action, err });
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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 60s host-sweep (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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sweep lifecycle — periodic 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 sweep)
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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 { initAuditLog, maintainAudit } from './init.js';
|
||||
export { type AuditHook, registerAuditHook } from './hooks.js';
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Boot-time audit wiring — composed in src/index.ts alongside the import of
|
||||
* the domain observer (`approvals-observer.audit.ts`, which self-registers).
|
||||
*
|
||||
* When enabled: assert data/audit/ is writable (refusing to start beats
|
||||
* running with a silent audit gap), run the boot prune, and start the
|
||||
* registered post-write hooks' lifecycle (init here, maintain via the host
|
||||
* sweep, shutdown via the host's graceful-shutdown registry).
|
||||
*/
|
||||
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import { onShutdown } from '../response-registry.js';
|
||||
import { initAuditHooks, maintainAuditHooks, shutdownAuditHooks } from './hooks.js';
|
||||
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
|
||||
|
||||
export function initAuditLog(): void {
|
||||
if (!AUDIT_ENABLED) return;
|
||||
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 → main() exit 1, same posture as the writability assert
|
||||
onShutdown(() => shutdownAuditHooks());
|
||||
log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-sweep tick: retention prune (throttled to once per UTC day internally)
|
||||
* plus every hook's periodic maintenance. No-op when audit is disabled.
|
||||
*/
|
||||
export function maintainAudit(): void {
|
||||
pruneAuditLogIfDue();
|
||||
if (AUDIT_ENABLED) maintainAuditHooks();
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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;
|
||||
},
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 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 { AUDIT_ENABLED } from '../config.js';
|
||||
import { log } from '../log.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,
|
||||
}));
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Recursive details redaction — runs at the single emit seam so every current
|
||||
* and future surface is guarded by default. Two rules:
|
||||
* 1. Any key matching the sensitive pattern is masked to '[REDACTED]'
|
||||
* (the value is never inspected or recursed into).
|
||||
* 2. Strings are truncated to ~2 KB post-redaction.
|
||||
*
|
||||
* Message bodies are excluded upstream by the per-surface mappers (shape only:
|
||||
* body_chars, attachment names) — this mask is defense-in-depth, not the
|
||||
* mechanism that keeps chat content out of the log.
|
||||
*/
|
||||
|
||||
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;
|
||||
// Depth cap doubles as a cheap cycle guard — details payloads are
|
||||
// JSON-serializable in practice, but the emit path must never throw.
|
||||
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);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Config + log are mocked so store/emit resolve a per-test temp DATA_DIR and
|
||||
// audit toggles. 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;
|
||||
},
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Audit day-file store — the tree's first structured file writer.
|
||||
*
|
||||
* Daily NDJSON files under data/audit/, named <UTC-day>.ndjson. Append-only is
|
||||
* structural: nothing in the system can update a line. Retention is unlinking
|
||||
* whole day-files past the horizon — a literal hard delete, no VACUUM. The
|
||||
* host process is the single writer by construction (both ncl transports and
|
||||
* every approval converge host-side).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS, DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.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;
|
||||
|
||||
/** UTC day string (YYYY-MM-DD) — day-file names and prune boundaries. */
|
||||
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
|
||||
* (fail-open + loud lives there, not here).
|
||||
*/
|
||||
export function appendAuditLine(line: string): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(utcDay()), line + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time writability assert — called only when AUDIT_ENABLED. A zero-byte
|
||||
* append is a true write probe (fs.access can pass on read-only mounts).
|
||||
* Throws so main() refuses to start rather than run with a silent audit gap.
|
||||
*/
|
||||
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. Lexicographic compare is correct for ISO days.
|
||||
*/
|
||||
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
|
||||
} catch {
|
||||
return; // Directory absent — nothing recorded yet.
|
||||
}
|
||||
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 stubborn file must not stop the rest of the prune
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Host-sweep throttle: the sweep ticks every 60s but retention only needs to
|
||||
// move once per UTC day.
|
||||
let lastPruneDay: string | null = null;
|
||||
|
||||
/** Sweep hook — no-op unless audit is enabled with a finite retention. */
|
||||
export function pruneAuditLogIfDue(): void {
|
||||
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
|
||||
const today = utcDay();
|
||||
if (lastPruneDay === today) return;
|
||||
lastPruneDay = today;
|
||||
pruneAuditLog();
|
||||
}
|
||||
|
||||
/** Called after the boot-time prune so the first sweep tick doesn't re-prune. */
|
||||
export function markPrunedToday(): void {
|
||||
lastPruneDay = utcDay();
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Canonical audit event vocabulary — schema_version 1.
|
||||
*
|
||||
* Fields are chosen to project losslessly onto OCSF and Elastic ECS; the
|
||||
* field mapping lives with the design docs and stays documentation until a
|
||||
* SIEM forwarder exists.
|
||||
*/
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored record — one NDJSON line. The directory-enrichment fields (email,
|
||||
* user_id, group_ids) ship in the schema but stamp null until the directory
|
||||
* adapter lands; they are filled at write time, never backfilled.
|
||||
*/
|
||||
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 (OneCLI expiry, startup sweeps, ghost rejects). */
|
||||
export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' };
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Domain-free event vocabulary — actor and origin constructors shared by the
|
||||
* domain-owned `*.audit.ts` adapters. 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,
|
||||
* OneCLI rows) lives in the adapter file of the domain that owns it.
|
||||
*/
|
||||
import os from 'os';
|
||||
|
||||
import { getMessagingGroup } from '../db/messaging-groups.js';
|
||||
import type { Session } from '../types.js';
|
||||
import { type AuditActor, type AuditOrigin, SYSTEM_ACTOR } 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';
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty resolver id (sweep/timer paths) → the system actor. */
|
||||
export function humanOrSystemActor(namespacedUserId: string): AuditActor {
|
||||
return namespacedUserId ? { type: 'human', id: namespacedUserId } : SYSTEM_ACTOR;
|
||||
}
|
||||
|
||||
export function originForSession(session: Session): AuditOrigin {
|
||||
return containerOrigin(session.id, session.messaging_group_id);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Approval decisions arrive as card clicks on a chat platform. */
|
||||
export function channelOriginForUser(namespacedUserId: string): AuditOrigin {
|
||||
const idx = namespacedUserId.indexOf(':');
|
||||
const channel = idx > 0 ? namespacedUserId.slice(0, idx) : undefined;
|
||||
return channel ? { transport: 'channel', channel } : { transport: 'channel' };
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Audit middleware behavior of the exported dispatch — what gets recorded,
|
||||
* for whom, and when nothing must be recorded. Mirrors dispatch.test.ts
|
||||
* mocking; audit is force-enabled and the store's append is captured.
|
||||
*/
|
||||
import os from 'os';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
|
||||
vi.mock('../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config.js')>();
|
||||
return { ...actual, AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: 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' })),
|
||||
}));
|
||||
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
|
||||
}));
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
import { register } from './registry.js';
|
||||
|
||||
register({
|
||||
name: 'groups-test',
|
||||
description: 'echo command on the groups resource',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-get',
|
||||
description: 'echo command for dash-joined id resolution',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'not on the group-scope allowlist',
|
||||
resource: 'wirings',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [],
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-fail',
|
||||
description: 'handler that throws',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-gated',
|
||||
description: 'approval-gated command',
|
||||
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 events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
appended.lines.length = 0;
|
||||
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 nothing for an approval-pending response — the pending event belongs to requestApproval', async () => {
|
||||
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');
|
||||
expect(appended.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records an approved replay with the approval id as correlation_id', async () => {
|
||||
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX, {
|
||||
approved: true,
|
||||
approvalId: 'appr-123-abc',
|
||||
});
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'success', correlation_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' });
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { emitAuditEvent } from '../audit/emit.js';
|
||||
import { hostUser } from '../audit/vocab.js';
|
||||
import { containerOrigin } from '../audit/vocab.js';
|
||||
import { type AuditActor, type AuditOrigin, type AuditOutcome, type AuditResource } from '../audit/types.js';
|
||||
import { getResource } from './crud.js';
|
||||
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import type { CommandDef } from './registry.js';
|
||||
// Type-only import from the module this adapter wraps — erased at runtime,
|
||||
// so dispatch.ts importing this file back is not a cycle.
|
||||
import type { DispatchOptions, DispatchTrace } from './dispatch.js';
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── The dispatch middleware ──
|
||||
|
||||
type DispatchInner = (req: RequestFrame, ctx: CallerContext, opts?: DispatchOptions) => Promise<ResponseFrame>;
|
||||
|
||||
/**
|
||||
* Dispatch middleware — the exported `dispatch` is the wrapped function, so
|
||||
* the socket server, the container delivery-action, and the approved replay
|
||||
* are all covered without changing a call site.
|
||||
*
|
||||
* Outcome derives from the response frame: ok → success, forbidden → denied
|
||||
* (captures pre-handler scope denials), anything else → failure.
|
||||
* approval-pending responses are skipped — the requestApproval decorator owns
|
||||
* every pending event. On replays, opts.approvalId becomes the terminal
|
||||
* event's correlation_id.
|
||||
*/
|
||||
export function withAudit(inner: DispatchInner): DispatchInner {
|
||||
return async (req, ctx, opts = {}) => {
|
||||
// Trace out-param: dispatch reassigns `req` internally (dash-id fallback,
|
||||
// group auto-fill), so the resolved command + effective args surface here.
|
||||
const trace: DispatchTrace = {};
|
||||
const res = await inner(req, ctx, { ...opts, trace });
|
||||
|
||||
if (!res.ok && res.error.code === 'approval-pending') return res;
|
||||
|
||||
emitAuditEvent(() => {
|
||||
const cmd = trace.cmd;
|
||||
const args = normalizeArgKeys(trace.args ?? req.args);
|
||||
const outcome: AuditOutcome = res.ok ? 'success' : res.error.code === 'forbidden' ? 'denied' : 'failure';
|
||||
const details: Record<string, unknown> = { ...args };
|
||||
if (!res.ok) {
|
||||
details.error = res.error.code;
|
||||
details.reason = res.error.message;
|
||||
}
|
||||
if (!cmd) details.command = trace.command ?? req.command;
|
||||
return {
|
||||
actor: actorForCaller(ctx),
|
||||
origin: originForCaller(ctx),
|
||||
action: cmd ? cmd.action : 'cli.unknown-command',
|
||||
resources: cmd ? resourcesForCli(cmd, args) : [],
|
||||
outcome,
|
||||
correlationId: opts.approvalId ?? null,
|
||||
details,
|
||||
};
|
||||
});
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* `ncl audit` — read-only query surface over the local audit log
|
||||
* (append-only NDJSON day-files under data/audit/, see src/audit/).
|
||||
*
|
||||
* Scope: host callers and global-scope agents only. `audit` is deliberately
|
||||
* NOT on the dispatcher's group-scope allowlist, so group-scoped agents are
|
||||
* refused before any handler runs (fails closed) — the log spans groups.
|
||||
* Requires AUDIT_ENABLED=true; a disabled box errors rather than returning an
|
||||
* empty list that would read as "no actions happened".
|
||||
*/
|
||||
import { listAuditEvents } from '../../audit/reader.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'audit_event',
|
||||
plural: 'audit',
|
||||
// File-backed, not a table — safe because no generic operations are
|
||||
// declared, so nothing ever queries this name.
|
||||
table: '(data/audit/*.ndjson)',
|
||||
description: 'Local audit log — one event per ncl command and host-routed approval. Newest first.',
|
||||
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.',
|
||||
handler: async (args) => listAuditEvents(args),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* agents.create adapter for the ungated door. The inner is a stub — the
|
||||
* contract is "call through, emit success/failure naming parent and child".
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../db/messaging-groups.js', () => ({
|
||||
getMessagingGroup: () => undefined,
|
||||
}));
|
||||
|
||||
import type { Session } from '../../types.js';
|
||||
import { auditCreateAgentDirect } from './create-agent.audit.js';
|
||||
|
||||
const SESSION: Session = {
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-parent',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appended.lines.length = 0;
|
||||
});
|
||||
|
||||
describe('auditCreateAgentDirect', () => {
|
||||
it('emits agents.create success naming parent and child', async () => {
|
||||
const wrapped = auditCreateAgentDirect(async () => ({
|
||||
ok: true,
|
||||
agentGroupId: 'ag-child',
|
||||
name: 'Scout',
|
||||
localName: 'scout',
|
||||
folder: 'scout',
|
||||
}));
|
||||
const result = await wrapped('Scout', 'be helpful', SESSION, { id: 'ag-parent' } as never, () => {});
|
||||
expect(result.ok).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'agent', id: 'ag-parent' },
|
||||
action: 'agents.create',
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: { name: 'Scout', parent: 'ag-parent', folder: 'scout', instructions_chars: 10 },
|
||||
});
|
||||
expect(event.resources).toEqual([
|
||||
{ type: 'agent_group', id: 'ag-parent' },
|
||||
{ type: 'agent_group', id: 'ag-child' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits failure with the reason when creation is refused', async () => {
|
||||
const wrapped = auditCreateAgentDirect(async () => ({ ok: false, reason: 'destination name collision' }));
|
||||
await wrapped('Scout', null, SESSION, { id: 'ag-parent' } as never, () => {});
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ outcome: 'failure', details: { reason: 'destination name collision' } });
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Agent-creation audit adapter (installed by /add-audit) — agents.create for
|
||||
* the ungated door. Wraps ONLY the global-scope direct call: the
|
||||
* approval-gated path is covered by the pending/decide/terminal chain
|
||||
* (runApprovedHandler wraps applyCreateAgent's run) — wrapping the shared
|
||||
* body would double-emit.
|
||||
*/
|
||||
import { emitAuditEvent } from '../../audit/emit.js';
|
||||
import type { AuditResource } from '../../audit/types.js';
|
||||
import { originForSession } from '../../audit/vocab.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
// Type-only import from the module this adapter wraps — erased at runtime,
|
||||
// so create-agent.ts importing this file back is not a cycle.
|
||||
import type { CreateAgentResult } from './create-agent.js';
|
||||
|
||||
type PerformCreateAgentFn = (
|
||||
name: string,
|
||||
instructions: string | null,
|
||||
session: Session,
|
||||
sourceGroup: AgentGroup,
|
||||
notify: (text: string) => void,
|
||||
) => Promise<CreateAgentResult>;
|
||||
|
||||
export function auditCreateAgentDirect(inner: PerformCreateAgentFn): PerformCreateAgentFn {
|
||||
return async (name, instructions, session, sourceGroup, notify) => {
|
||||
const result = await inner(name, instructions, session, sourceGroup, notify);
|
||||
emitAuditEvent(() => {
|
||||
const resources: AuditResource[] = [{ type: 'agent_group', id: sourceGroup.id }];
|
||||
const details: Record<string, unknown> = {
|
||||
name,
|
||||
parent: sourceGroup.id,
|
||||
instructions_chars: instructions ? instructions.length : 0,
|
||||
};
|
||||
if (result.ok) {
|
||||
resources.push({ type: 'agent_group', id: result.agentGroupId });
|
||||
details.folder = result.folder;
|
||||
} else {
|
||||
details.reason = result.reason;
|
||||
}
|
||||
return {
|
||||
actor: { type: 'agent', id: session.agent_group_id },
|
||||
origin: originForSession(session),
|
||||
action: 'agents.create',
|
||||
resources,
|
||||
outcome: result.ok ? 'success' : 'failure',
|
||||
details,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Decision events (installed by /add-audit) — approvals.decide, one per
|
||||
* resolved approval, riding the existing approval-resolved hook. Covers every
|
||||
* requestApproval-backed action (cli_command, self-mod, create_agent, a2a
|
||||
* gate); OneCLI never reaches notifyApprovalResolved and emits its decisions
|
||||
* from its own wrappers in approvals.audit.ts.
|
||||
*
|
||||
* Self-registers on import (the tree's observer idiom) — imported once from
|
||||
* the audit block in src/index.ts.
|
||||
*/
|
||||
import { emitAuditEvent } from '../../audit/emit.js';
|
||||
import { channelOriginForUser, humanOrSystemActor } from '../../audit/vocab.js';
|
||||
// Direct file import (not the approvals barrel) to keep the graph tight.
|
||||
import { type ApprovalResolvedEvent, registerApprovalResolvedHandler } from './primitive.js';
|
||||
import { auditActionForApproval, resourcesForApproval } from './approvals.audit.js';
|
||||
|
||||
export function onApprovalResolved(event: ApprovalResolvedEvent): void {
|
||||
emitAuditEvent(() => {
|
||||
const payload = safeParse(event.approval.payload);
|
||||
return {
|
||||
// '' resolver = sweep/timer (e.g. the awaiting-reason ghost sweep) → system.
|
||||
actor: humanOrSystemActor(event.userId),
|
||||
// Decisions are card clicks on a chat platform, even system-finalized
|
||||
// ones — the card lifecycle is the surface.
|
||||
origin: channelOriginForUser(event.userId),
|
||||
action: 'approvals.decide',
|
||||
resources: [
|
||||
...resourcesForApproval(event.approval.action, payload, event.session),
|
||||
{ type: 'approval', id: event.approval.approval_id },
|
||||
],
|
||||
outcome: event.outcome === 'approve' ? 'approved' : 'rejected',
|
||||
correlationId: event.approval.approval_id,
|
||||
details: {
|
||||
gated_action: auditActionForApproval(event.approval.action, payload),
|
||||
requested_by: event.session.agent_group_id,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function safeParse(json: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break the decision event
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
registerApprovalResolvedHandler(onApprovalResolved);
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* OneCLI credential wrappers: hold + the three resolution paths. Inners are
|
||||
* stubs — each wrapper's contract is "call through, emit the right event
|
||||
* (or none)". The requestApproval decorator and runApprovedHandler are
|
||||
* covered end-to-end in primitive.audit.test.ts / response-handler.audit.test.ts.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
const db = vi.hoisted(() => ({ row: undefined as Record<string, unknown> | undefined }));
|
||||
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../db/sessions.js', () => ({
|
||||
getPendingApproval: () => db.row,
|
||||
}));
|
||||
|
||||
vi.mock('../../db/messaging-groups.js', () => ({
|
||||
getMessagingGroup: () => undefined,
|
||||
}));
|
||||
|
||||
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
|
||||
|
||||
const ONECLI_ROW = {
|
||||
approval_id: 'oa-abc123',
|
||||
agent_group_id: 'ag-1',
|
||||
channel_type: 'telegram',
|
||||
payload: JSON.stringify({
|
||||
oneCliRequestId: 'uuid-1',
|
||||
method: 'POST',
|
||||
host: 'api.notion.com',
|
||||
path: '/v1/pages',
|
||||
bodyPreview: 'SECRET BODY CONTENT',
|
||||
agent: { externalId: 'ag-1', name: 'Agent' },
|
||||
approver: 'slack:U0ADMIN',
|
||||
}),
|
||||
};
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appended.lines.length = 0;
|
||||
db.row = undefined;
|
||||
});
|
||||
|
||||
describe('OneCLI wrappers', () => {
|
||||
it('hold: emits pending from the row — shape only, never the body preview', () => {
|
||||
const inner = vi.fn();
|
||||
auditOneCliHold(inner)(ONECLI_ROW);
|
||||
expect(inner).toHaveBeenCalledOnce();
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'agent', id: 'ag-1' },
|
||||
origin: { transport: 'container' },
|
||||
action: 'onecli.credential.use',
|
||||
outcome: 'pending',
|
||||
correlation_id: 'oa-abc123',
|
||||
details: { method: 'POST', host: 'api.notion.com', path: '/v1/pages', body_preview_chars: 19 },
|
||||
});
|
||||
expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U0ADMIN' });
|
||||
expect(JSON.stringify(event)).not.toContain('SECRET BODY CONTENT');
|
||||
});
|
||||
|
||||
it('decision: emits approvals.decide with the clicking human when resolved', () => {
|
||||
db.row = ONECLI_ROW;
|
||||
const wrapped = auditOneCliDecision(() => true);
|
||||
expect(wrapped('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'human', id: 'slack:U0ADMIN' },
|
||||
origin: { transport: 'channel', channel: 'slack' },
|
||||
action: 'approvals.decide',
|
||||
outcome: 'approved',
|
||||
correlation_id: 'oa-abc123',
|
||||
details: { gated_action: 'onecli.credential.use' },
|
||||
});
|
||||
});
|
||||
|
||||
it('decision: emits nothing when the resolver is gone (inner returns false)', () => {
|
||||
db.row = ONECLI_ROW;
|
||||
expect(auditOneCliDecision(() => false)('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(false);
|
||||
expect(appended.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('expiry: the system actor rejects with the reason', async () => {
|
||||
db.row = ONECLI_ROW;
|
||||
await auditOneCliExpiry(async () => {})('oa-abc123', 'no response');
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'system', id: 'host' },
|
||||
origin: { transport: 'channel', channel: 'telegram' },
|
||||
outcome: 'rejected',
|
||||
details: { reason: 'expired: no response' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sweep: one system rejection per orphaned row', async () => {
|
||||
const rows = [ONECLI_ROW, { ...ONECLI_ROW, approval_id: 'oa-second' }];
|
||||
await auditOneCliSweep(
|
||||
async () => {},
|
||||
() => rows as never,
|
||||
)();
|
||||
const all = events();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((e) => e.correlation_id)).toEqual(['oa-abc123', 'oa-second']);
|
||||
expect(all.every((e) => e.actor.type === 'system' && e.details.reason === 'host restarted')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* Approvals audit adapter (installed by /add-audit) — owns how the approvals
|
||||
* domain describes itself to the audit log: the requestApproval decorator
|
||||
* (every gated hold → pending event), the post-approve handler runner (the
|
||||
* gated chain's terminal event), the OneCLI credential wrappers (hold + three
|
||||
* resolution paths), and the approval-payload mapping they share. Composed at
|
||||
* this module's edges; business logic contains zero audit calls.
|
||||
*/
|
||||
import { emitAuditEvent } from '../../audit/emit.js';
|
||||
import {
|
||||
type AuditActor,
|
||||
type AuditEventInput,
|
||||
type AuditOrigin,
|
||||
type AuditOutcome,
|
||||
type AuditResource,
|
||||
SYSTEM_ACTOR,
|
||||
} from '../../audit/types.js';
|
||||
import { channelOriginForUser, humanOrSystemActor, originForSession } from '../../audit/vocab.js';
|
||||
import { resourcesForCli } from '../../cli/dispatch.audit.js';
|
||||
import type { RequestFrame } from '../../cli/frame.js';
|
||||
import { lookup } from '../../cli/registry.js';
|
||||
import { getPendingApproval } from '../../db/sessions.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
// Type-only imports from the module this adapter wraps — erased at runtime,
|
||||
// so primitive.ts importing this file back is not a cycle.
|
||||
import type { ApprovalHandler, ApprovalHandlerContext, ApprovalHold, RequestApprovalOptions } from './primitive.js';
|
||||
|
||||
// ── Approval-payload mapping ──
|
||||
|
||||
/** Dotted audit action per approval type; cli_command derives from its frame. */
|
||||
export const APPROVAL_AUDIT_ACTIONS: Record<string, string> = {
|
||||
install_packages: 'self-mod.install-packages',
|
||||
add_mcp_server: 'self-mod.add-mcp-server',
|
||||
create_agent: 'agents.create',
|
||||
a2a_message_gate: 'messages.a2a-gate',
|
||||
onecli_credential: 'onecli.credential.use',
|
||||
};
|
||||
|
||||
export function auditActionForApproval(approvalAction: string, payload: Record<string, unknown>): string {
|
||||
if (approvalAction === 'cli_command') {
|
||||
const frame = payload.frame as RequestFrame | undefined;
|
||||
return (frame && lookup(frame.command)?.action) ?? 'cli.unknown-command';
|
||||
}
|
||||
return APPROVAL_AUDIT_ACTIONS[approvalAction] ?? approvalAction.replace(/_/g, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* details for an approval's pending/terminal events. Message-bearing payloads
|
||||
* (a2a gate) record shape only — body_chars and attachment names, never
|
||||
* content; the emit-seam redactor is defense-in-depth, not the mechanism.
|
||||
*/
|
||||
export function detailsForApprovalPayload(
|
||||
approvalAction: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
switch (approvalAction) {
|
||||
case 'cli_command': {
|
||||
const frame = payload.frame as RequestFrame | undefined;
|
||||
return { ...(frame?.args ?? {}) };
|
||||
}
|
||||
case 'create_agent': {
|
||||
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
|
||||
return { name: payload.name, instructions_chars: instructions ? instructions.length : 0 };
|
||||
}
|
||||
case 'a2a_message_gate': {
|
||||
const { text, files } = messageShape(payload.content);
|
||||
return { to: payload.platform_id, body_chars: text.length, attachments: files };
|
||||
}
|
||||
default:
|
||||
// install_packages, add_mcp_server, and future types: metadata payloads
|
||||
// pass through as-is — the emit-seam redactor masks sensitive keys.
|
||||
return { ...payload };
|
||||
}
|
||||
}
|
||||
|
||||
export function resourcesForApproval(
|
||||
approvalAction: string,
|
||||
payload: Record<string, unknown>,
|
||||
session: Session,
|
||||
): AuditResource[] {
|
||||
const base: AuditResource[] = [{ type: 'agent_group', id: session.agent_group_id }];
|
||||
if (approvalAction === 'cli_command') {
|
||||
const frame = payload.frame as RequestFrame | undefined;
|
||||
const cmd = frame && lookup(frame.command);
|
||||
if (cmd && frame) {
|
||||
const cliResources = resourcesForCli(cmd, frame.args);
|
||||
for (const r of cliResources) {
|
||||
if (!base.some((b) => b.type === r.type && b.id === r.id)) base.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (approvalAction === 'a2a_message_gate' && typeof payload.platform_id === 'string' && payload.platform_id) {
|
||||
if (payload.platform_id !== session.agent_group_id) {
|
||||
base.push({ type: 'agent_group', id: payload.platform_id });
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Mirror of agent-route's message-content parse — shape extraction only. */
|
||||
function messageShape(content: unknown): { text: string; files: string[] } {
|
||||
if (typeof content !== 'string') return { text: '', files: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { text?: unknown; files?: unknown };
|
||||
return {
|
||||
text: typeof parsed.text === 'string' ? parsed.text : '',
|
||||
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
|
||||
};
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- non-JSON content is the raw body; shape extraction must not throw
|
||||
} catch {
|
||||
return { text: content, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── requestApproval decorator ──
|
||||
|
||||
type RequestApprovalFn = (opts: RequestApprovalOptions) => Promise<ApprovalHold | null>;
|
||||
|
||||
/**
|
||||
* requestApproval decorator — owns every pending event, for all gated
|
||||
* surfaces at once: only this seam holds the action, payload, minted approval
|
||||
* id, and the approver the card actually went to. Pass-through: callers see
|
||||
* the hold exactly as the inner returns it. No hold → no event.
|
||||
*/
|
||||
export function auditRequestApproval(inner: RequestApprovalFn): RequestApprovalFn {
|
||||
return async (opts) => {
|
||||
const hold = await inner(opts);
|
||||
if (!hold) return hold;
|
||||
emitAuditEvent(() => ({
|
||||
actor: { type: 'agent', id: opts.session.agent_group_id },
|
||||
origin: originForSession(opts.session),
|
||||
action: auditActionForApproval(opts.action, opts.payload),
|
||||
resources: [
|
||||
...resourcesForApproval(opts.action, opts.payload, opts.session),
|
||||
{ type: 'approval', id: hold.approvalId },
|
||||
// Who was asked: the picked approver is part of the record.
|
||||
{ type: 'user', id: hold.approverUserId },
|
||||
],
|
||||
outcome: 'pending',
|
||||
correlationId: hold.approvalId,
|
||||
details: detailsForApprovalPayload(opts.action, opts.payload),
|
||||
}));
|
||||
return hold;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Post-approve handler runner ──
|
||||
|
||||
/**
|
||||
* Emits the terminal event of a gated chain (success/failure from whether the
|
||||
* handler threw), correlated by the approval id. cli_command is skipped: its
|
||||
* replay goes back through the dispatch middleware, which alone sees the real
|
||||
* response frame. Rethrows so the response handler's own catch/notify
|
||||
* behavior is unchanged.
|
||||
*/
|
||||
export async function runApprovedHandler(
|
||||
handler: ApprovalHandler,
|
||||
ctx: ApprovalHandlerContext,
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
): Promise<void> {
|
||||
const skip = approval.action === 'cli_command';
|
||||
const terminal = (outcome: AuditOutcome, error?: string): void => {
|
||||
if (skip) return;
|
||||
emitAuditEvent(() => ({
|
||||
actor: { type: 'agent', id: session.agent_group_id },
|
||||
origin: originForSession(session),
|
||||
action: auditActionForApproval(approval.action, ctx.payload),
|
||||
resources: [
|
||||
...resourcesForApproval(approval.action, ctx.payload, session),
|
||||
{ type: 'approval', id: approval.approval_id },
|
||||
],
|
||||
outcome,
|
||||
correlationId: approval.approval_id,
|
||||
details: error
|
||||
? { ...detailsForApprovalPayload(approval.action, ctx.payload), error }
|
||||
: detailsForApprovalPayload(approval.action, ctx.payload),
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
await handler(ctx);
|
||||
} catch (err) {
|
||||
terminal('failure', err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
terminal('success');
|
||||
}
|
||||
|
||||
// ── OneCLI credential approvals (hold + three resolution paths) ──
|
||||
|
||||
/** = APPROVAL_AUDIT_ACTIONS.onecli_credential; named for the OneCLI wrappers. */
|
||||
export const ONECLI_AUDIT_ACTION = 'onecli.credential.use';
|
||||
|
||||
interface OneCliRowShape {
|
||||
approval_id: string;
|
||||
agent_group_id?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface OneCliPayload {
|
||||
oneCliRequestId?: string;
|
||||
method?: string;
|
||||
host?: string;
|
||||
path?: string;
|
||||
bodyPreview?: string;
|
||||
agent?: { externalId?: string | null; name?: string };
|
||||
approver?: string;
|
||||
}
|
||||
|
||||
function oneCliPayload(row: OneCliRowShape): OneCliPayload {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(row.payload);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as OneCliPayload) : {};
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break event assembly
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape-only: the request body's presence is auditable, its content never is. */
|
||||
function oneCliDetails(p: OneCliPayload): Record<string, unknown> {
|
||||
return {
|
||||
method: p.method,
|
||||
host: p.host,
|
||||
path: p.path,
|
||||
one_cli_request_id: p.oneCliRequestId,
|
||||
body_preview_chars: typeof p.bodyPreview === 'string' ? p.bodyPreview.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function oneCliResources(row: OneCliRowShape): AuditResource[] {
|
||||
const out: AuditResource[] = [];
|
||||
if (row.agent_group_id) out.push({ type: 'agent_group', id: row.agent_group_id });
|
||||
out.push({ type: 'approval', id: row.approval_id });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pending event for a OneCLI credential hold, derived from its row. */
|
||||
export function oneCliHoldEvent(row: OneCliRowShape): AuditEventInput {
|
||||
const p = oneCliPayload(row);
|
||||
const resources = oneCliResources(row);
|
||||
if (p.approver) resources.push({ type: 'user', id: p.approver });
|
||||
return {
|
||||
actor: { type: 'agent', id: row.agent_group_id ?? p.agent?.externalId ?? p.agent?.name ?? 'unknown' },
|
||||
// OneCLI requests come from inside an agent container but carry no session.
|
||||
origin: { transport: 'container' },
|
||||
action: ONECLI_AUDIT_ACTION,
|
||||
resources,
|
||||
outcome: 'pending',
|
||||
correlationId: row.approval_id,
|
||||
details: oneCliDetails(p),
|
||||
};
|
||||
}
|
||||
|
||||
/** approvals.decide for a OneCLI resolution (click, expiry, or startup sweep). */
|
||||
export function oneCliDecideEvent(
|
||||
row: OneCliRowShape & { channel_type?: string | null },
|
||||
args: { actor: AuditActor; origin: AuditOrigin; outcome: 'approved' | 'rejected'; reason?: string },
|
||||
): AuditEventInput {
|
||||
const details: Record<string, unknown> = {
|
||||
gated_action: ONECLI_AUDIT_ACTION,
|
||||
...oneCliDetails(oneCliPayload(row)),
|
||||
};
|
||||
if (args.reason) details.reason = args.reason;
|
||||
return {
|
||||
actor: args.actor,
|
||||
origin: args.origin,
|
||||
action: 'approvals.decide',
|
||||
resources: oneCliResources(row),
|
||||
outcome: args.outcome,
|
||||
correlationId: row.approval_id,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps the hold's row insert — emits the pending event derived from the row. */
|
||||
export function auditOneCliHold<T extends OneCliRowShape>(inner: (row: T) => void): (row: T) => void {
|
||||
return (row) => {
|
||||
inner(row);
|
||||
emitAuditEvent(() => oneCliHoldEvent(row));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the card-click resolver. The row is pre-read because the inner
|
||||
* deletes it on resolution; `userId` is the resolving admin (already part of
|
||||
* the resolver's contract — empty means a timer/sweep, i.e. the system).
|
||||
*/
|
||||
export function auditOneCliDecision(
|
||||
inner: (approvalId: string, selectedOption: string, userId: string) => boolean,
|
||||
): (approvalId: string, selectedOption: string, userId: string) => boolean {
|
||||
return (approvalId, selectedOption, userId) => {
|
||||
const row = getPendingApproval(approvalId);
|
||||
const resolved = inner(approvalId, selectedOption, userId);
|
||||
if (resolved && row) {
|
||||
emitAuditEvent(() =>
|
||||
oneCliDecideEvent(row, {
|
||||
actor: humanOrSystemActor(userId),
|
||||
origin: channelOriginForUser(userId),
|
||||
outcome: selectedOption === 'approve' ? 'approved' : 'rejected',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps the expiry timer's finalizer — the system actor rejects. */
|
||||
export function auditOneCliExpiry(
|
||||
inner: (approvalId: string, reason: string) => Promise<void>,
|
||||
): (approvalId: string, reason: string) => Promise<void> {
|
||||
return async (approvalId, reason) => {
|
||||
const row = getPendingApproval(approvalId);
|
||||
await inner(approvalId, reason);
|
||||
if (row) {
|
||||
emitAuditEvent(() =>
|
||||
oneCliDecideEvent(row, {
|
||||
actor: SYSTEM_ACTOR,
|
||||
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
|
||||
outcome: 'rejected',
|
||||
reason: `expired: ${reason}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps the startup sweep — one system rejection per orphaned row. */
|
||||
export function auditOneCliSweep(inner: () => Promise<void>, listRows: () => PendingApproval[]): () => Promise<void> {
|
||||
return async () => {
|
||||
const rows = listRows();
|
||||
await inner();
|
||||
for (const row of rows) {
|
||||
emitAuditEvent(() =>
|
||||
oneCliDecideEvent(row, {
|
||||
actor: SYSTEM_ACTOR,
|
||||
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
|
||||
outcome: 'rejected',
|
||||
reason: 'host restarted',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Pending-event coverage for the decorated requestApproval: the audit
|
||||
* decorator emits exactly one `pending` event per successfully queued hold,
|
||||
* naming the approval and the picked approver — and nothing when no hold
|
||||
* reached an approver.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-primitive-audit', AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-primitive-audit';
|
||||
|
||||
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('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// No adapter: card delivery is skipped, the hold still succeeds.
|
||||
vi.mock('../../delivery.js', () => ({
|
||||
getDeliveryAdapter: () => null,
|
||||
}));
|
||||
|
||||
// Resolve any approver to a reachable fake DM without the platform stack.
|
||||
vi.mock('../permissions/user-dm.js', () => ({
|
||||
ensureUserDm: vi.fn(async () => ({
|
||||
id: 'mg-dm',
|
||||
channel_type: 'telegram',
|
||||
platform_id: 'dm-owner',
|
||||
})),
|
||||
}));
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, getPendingApproval } from '../../db/sessions.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { requestApproval } from './primitive.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
let session: Session;
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
appended.lines.length = 0;
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
session = {
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
};
|
||||
createSession(session);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function grantOwner(): void {
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
}
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
describe('decorated requestApproval', () => {
|
||||
it('emits one pending event naming the approval and the picked approver', async () => {
|
||||
grantOwner();
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: 'Agent',
|
||||
action: 'install_packages',
|
||||
payload: { apt: ['jq'], npm: [], reason: 'need jq' },
|
||||
title: 'Install Packages Request',
|
||||
question: 'ok?',
|
||||
});
|
||||
|
||||
expect(appended.lines).toHaveLength(1);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'agent', id: 'ag-1' },
|
||||
origin: { transport: 'container', session_id: 'sess-1' },
|
||||
action: 'self-mod.install-packages',
|
||||
outcome: 'pending',
|
||||
details: { apt: ['jq'], npm: [], reason: 'need jq' },
|
||||
});
|
||||
|
||||
const approvalRef = event.resources.find((r: { type: string }) => r.type === 'approval');
|
||||
expect(approvalRef.id).toMatch(/^appr-/);
|
||||
expect(event.correlation_id).toBe(approvalRef.id);
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-1' });
|
||||
expect(event.resources).toContainEqual({ type: 'user', id: 'telegram:owner' });
|
||||
// The event references the row that was actually created.
|
||||
expect(getPendingApproval(approvalRef.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('emits nothing when no approver is configured (no hold was created)', async () => {
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: 'Agent',
|
||||
action: 'install_packages',
|
||||
payload: { apt: ['jq'], npm: [] },
|
||||
title: 'Install Packages Request',
|
||||
question: 'ok?',
|
||||
});
|
||||
|
||||
expect(appended.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records shape only for the message-bearing a2a gate — never the body', async () => {
|
||||
grantOwner();
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: 'Agent',
|
||||
action: 'a2a_message_gate',
|
||||
approverUserId: 'telegram:owner',
|
||||
payload: {
|
||||
id: 'msg-1',
|
||||
platform_id: 'ag-target',
|
||||
content: JSON.stringify({ text: 'hello world', files: ['report.pdf'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
title: 'Message approval',
|
||||
question: 'ok?',
|
||||
});
|
||||
|
||||
const [event] = events();
|
||||
expect(event.action).toBe('messages.a2a-gate');
|
||||
expect(event.details).toEqual({ to: 'ag-target', body_chars: 11, attachments: ['report.pdf'] });
|
||||
expect(JSON.stringify(event)).not.toContain('hello world');
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-target' });
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Decision + terminal audit events across the approval resolution paths:
|
||||
* approvals.decide from the resolved observer, the terminal event from the
|
||||
* wrapped handler run (skipped for cli_command), and the system actor for
|
||||
* sweep-finalized rejects.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-response-handler-audit', AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-response-handler-audit';
|
||||
|
||||
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('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Self-registers the approvals.decide observer on import (the composed wiring).
|
||||
import './approvals-observer.audit.js';
|
||||
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createPendingApproval, createSession, getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import { registerApprovalHandler } from './primitive.js';
|
||||
import { handleApprovalsResponse } from './response-handler.js';
|
||||
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
appended.lines.length = 0;
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedApproval(id: string, action: string, payload: Record<string, unknown>): void {
|
||||
createPendingApproval({
|
||||
approval_id: id,
|
||||
session_id: 'sess-1',
|
||||
request_id: id,
|
||||
action,
|
||||
payload: JSON.stringify(payload),
|
||||
created_at: now(),
|
||||
title: 'Test approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
function click(questionId: string, value: string) {
|
||||
return handleApprovalsResponse({
|
||||
questionId,
|
||||
value,
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
}
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
describe('approval resolution audit events', () => {
|
||||
it('approve → terminal success (correlated) + approvals.decide with the human approver', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages', handler);
|
||||
seedApproval('appr-ok', 'install_packages', { apt: ['jq'], npm: [] });
|
||||
|
||||
await click('appr-ok', 'approve');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ approvalId: 'appr-ok' }));
|
||||
|
||||
const all = events();
|
||||
expect(all).toHaveLength(2);
|
||||
const [terminal, decide] = all;
|
||||
expect(terminal).toMatchObject({
|
||||
actor: { type: 'agent', id: 'ag-1' },
|
||||
action: 'self-mod.install-packages',
|
||||
outcome: 'success',
|
||||
correlation_id: 'appr-ok',
|
||||
});
|
||||
expect(terminal.resources).toContainEqual({ type: 'approval', id: 'appr-ok' });
|
||||
expect(decide).toMatchObject({
|
||||
actor: { type: 'human', id: 'telegram:owner' },
|
||||
origin: { transport: 'channel', channel: 'telegram' },
|
||||
action: 'approvals.decide',
|
||||
outcome: 'approved',
|
||||
correlation_id: 'appr-ok',
|
||||
details: { gated_action: 'self-mod.install-packages', requested_by: 'ag-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('handler failure → terminal failure with the error, decision still recorded', async () => {
|
||||
registerApprovalHandler('add_mcp_server', async () => {
|
||||
throw new Error('rebuild exploded');
|
||||
});
|
||||
seedApproval('appr-fail', 'add_mcp_server', { name: 'notion', command: 'npx' });
|
||||
|
||||
await click('appr-fail', 'approve');
|
||||
|
||||
const all = events();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0]).toMatchObject({
|
||||
action: 'self-mod.add-mcp-server',
|
||||
outcome: 'failure',
|
||||
correlation_id: 'appr-fail',
|
||||
});
|
||||
expect(all[0].details.error).toContain('rebuild exploded');
|
||||
expect(all[1]).toMatchObject({ action: 'approvals.decide', outcome: 'approved' });
|
||||
});
|
||||
|
||||
it('cli_command approve → decide only; the terminal event belongs to the dispatch middleware', async () => {
|
||||
registerApprovalHandler('cli_command', vi.fn().mockResolvedValue(undefined));
|
||||
seedApproval('appr-cli', 'cli_command', {
|
||||
frame: { id: '1', command: 'groups-update', args: { id: 'ag-1' } },
|
||||
callerContext: { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' },
|
||||
});
|
||||
|
||||
await click('appr-cli', 'approve');
|
||||
|
||||
const all = events();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toMatchObject({ action: 'approvals.decide', outcome: 'approved', correlation_id: 'appr-cli' });
|
||||
});
|
||||
|
||||
it('reject click → approvals.decide rejected, no terminal event', async () => {
|
||||
registerApprovalHandler('install_packages', vi.fn());
|
||||
seedApproval('appr-rej', 'install_packages', { apt: ['jq'], npm: [] });
|
||||
|
||||
await click('appr-rej', 'reject');
|
||||
|
||||
const all = events();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toMatchObject({
|
||||
actor: { type: 'human', id: 'telegram:owner' },
|
||||
action: 'approvals.decide',
|
||||
outcome: 'rejected',
|
||||
correlation_id: 'appr-rej',
|
||||
});
|
||||
expect(getPendingApproval('appr-rej')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sweep-finalized reject (empty resolver id) → the system actor decides', async () => {
|
||||
seedApproval('appr-ghost', 'install_packages', { apt: ['jq'], npm: [] });
|
||||
const approval = getPendingApproval('appr-ghost')!;
|
||||
const session = getSession('sess-1')!;
|
||||
|
||||
await finalizeReject(approval, session, '', 'approver never replied');
|
||||
|
||||
const all = events();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toMatchObject({
|
||||
actor: { type: 'system', id: 'host' },
|
||||
origin: { transport: 'channel' },
|
||||
action: 'approvals.decide',
|
||||
outcome: 'rejected',
|
||||
correlation_id: 'appr-ghost',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Permissions audit adapters: card decisions and the name interceptor.
|
||||
* Inners are stubs — each wrapper's contract is "call through, emit the
|
||||
* right event (or none), coerce back to claimed".
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, AUDIT_ENABLED: true };
|
||||
});
|
||||
|
||||
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
|
||||
|
||||
const PAYLOAD = {
|
||||
questionId: 'q1',
|
||||
value: 'approve',
|
||||
userId: 'U1',
|
||||
channelType: 'slack',
|
||||
platformId: 'p',
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appended.lines.length = 0;
|
||||
});
|
||||
|
||||
describe('auditSenderDecision', () => {
|
||||
const decision = {
|
||||
approved: true,
|
||||
senderIdentity: 'slack:U0NEW',
|
||||
agentGroupId: 'ag-1',
|
||||
messagingGroupId: 'mg-1',
|
||||
approverId: 'slack:U0ADMIN',
|
||||
channelType: 'slack',
|
||||
};
|
||||
|
||||
it('emits senders.allow success on approve and coerces claimed', async () => {
|
||||
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision }));
|
||||
expect(await wrapped(PAYLOAD)).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
actor: { type: 'human', id: 'slack:U0ADMIN' },
|
||||
origin: { transport: 'channel', channel: 'slack' },
|
||||
action: 'senders.allow',
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: {},
|
||||
});
|
||||
expect(event.resources).toEqual([
|
||||
{ type: 'user', id: 'slack:U0NEW' },
|
||||
{ type: 'agent_group', id: 'ag-1' },
|
||||
{ type: 'messaging_group', id: 'mg-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits rejected on deny', async () => {
|
||||
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision: { ...decision, approved: false } }));
|
||||
await wrapped(PAYLOAD);
|
||||
expect(events()[0].outcome).toBe('rejected');
|
||||
});
|
||||
|
||||
it('emits nothing without a decision (unclaimed or unauthorized click)', async () => {
|
||||
const unclaimed = auditSenderDecision(async () => ({ claimed: false }));
|
||||
const unauthorized = auditSenderDecision(async () => ({ claimed: true }));
|
||||
expect(await unclaimed(PAYLOAD)).toBe(false);
|
||||
expect(await unauthorized(PAYLOAD)).toBe(true);
|
||||
expect(appended.lines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditChannelDecision / auditChannelNameInterceptor', () => {
|
||||
const base = { messagingGroupId: 'mg-1', approverId: 'slack:U0ADMIN', channelType: 'slack' };
|
||||
|
||||
it('maps connected → success with the wired agent group', async () => {
|
||||
const wrapped = auditChannelDecision(async () => ({
|
||||
claimed: true,
|
||||
decision: { kind: 'connected' as const, agentGroupId: 'ag-1', createdAgentGroup: false, ...base },
|
||||
}));
|
||||
await wrapped(PAYLOAD);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
action: 'channels.register',
|
||||
outcome: 'success',
|
||||
details: { created_agent_group: false },
|
||||
});
|
||||
expect(event.resources).toEqual([
|
||||
{ type: 'messaging_group', id: 'mg-1' },
|
||||
{ type: 'agent_group', id: 'ag-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps rejected and failed outcomes', async () => {
|
||||
await auditChannelDecision(async () => ({ claimed: true, decision: { kind: 'rejected' as const, ...base } }))(
|
||||
PAYLOAD,
|
||||
);
|
||||
await auditChannelDecision(async () => ({
|
||||
claimed: true,
|
||||
decision: { kind: 'failed' as const, reason: 'target agent group no longer exists', ...base },
|
||||
}))(PAYLOAD);
|
||||
const all = events();
|
||||
expect(all[0]).toMatchObject({ action: 'channels.register', outcome: 'rejected' });
|
||||
expect(all[1]).toMatchObject({ action: 'channels.register', outcome: 'failure' });
|
||||
expect(all[1].details.reason).toContain('no longer exists');
|
||||
});
|
||||
|
||||
it('records the interceptor-created agent group — the third creation door', async () => {
|
||||
const wrapped = auditChannelNameInterceptor(async () => ({
|
||||
claimed: true,
|
||||
decision: {
|
||||
kind: 'connected' as const,
|
||||
agentGroupId: 'ag-new',
|
||||
createdAgentGroup: true,
|
||||
agentName: 'Scout',
|
||||
...base,
|
||||
},
|
||||
}));
|
||||
expect(await wrapped({} as never)).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event.details).toMatchObject({ created_agent_group: true, agent_name: 'Scout' });
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-new' });
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Permissions audit adapter (installed by /add-audit) — senders.allow and
|
||||
* channels.register events for the card/interceptor stack that never touches
|
||||
* the approvals primitive. The inner handlers return domain decision
|
||||
* descriptors (SenderApprovalResult / ChannelApprovalResult, defined with the
|
||||
* handlers); these adapters emit the event and coerce back to the boolean the
|
||||
* response registry / router expect.
|
||||
*/
|
||||
import { emitAuditEvent } from '../../audit/emit.js';
|
||||
import type { AuditResource } from '../../audit/types.js';
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import type { ResponsePayload } from '../../response-registry.js';
|
||||
// Type-only import from the module this adapter wraps — erased at runtime,
|
||||
// so permissions/index.ts importing this file back is not a cycle.
|
||||
import type { ChannelApprovalResult, ChannelDecision, SenderApprovalResult } from './index.js';
|
||||
|
||||
export function auditSenderDecision(
|
||||
inner: (payload: ResponsePayload) => Promise<SenderApprovalResult>,
|
||||
): (payload: ResponsePayload) => Promise<boolean> {
|
||||
return async (payload) => {
|
||||
const result = await inner(payload);
|
||||
const d = result.decision;
|
||||
if (d) {
|
||||
emitAuditEvent(() => ({
|
||||
actor: { type: 'human', id: d.approverId },
|
||||
origin: { transport: 'channel', channel: d.channelType || undefined },
|
||||
action: 'senders.allow',
|
||||
resources: [
|
||||
{ type: 'user', id: d.senderIdentity },
|
||||
{ type: 'agent_group', id: d.agentGroupId },
|
||||
{ type: 'messaging_group', id: d.messagingGroupId },
|
||||
],
|
||||
outcome: d.approved ? 'success' : 'rejected',
|
||||
// The withheld message is never read here — ids only.
|
||||
details: {},
|
||||
}));
|
||||
}
|
||||
return result.claimed;
|
||||
};
|
||||
}
|
||||
|
||||
function emitChannelDecision(d: ChannelDecision): void {
|
||||
emitAuditEvent(() => {
|
||||
const resources: AuditResource[] = [{ type: 'messaging_group', id: d.messagingGroupId }];
|
||||
if (d.agentGroupId) resources.push({ type: 'agent_group', id: d.agentGroupId });
|
||||
const details: Record<string, unknown> = {};
|
||||
if (d.createdAgentGroup !== undefined) details.created_agent_group = d.createdAgentGroup;
|
||||
if (d.agentName) details.agent_name = d.agentName;
|
||||
if (d.reason) details.reason = d.reason;
|
||||
return {
|
||||
actor: { type: 'human', id: d.approverId },
|
||||
origin: { transport: 'channel', channel: d.channelType || undefined },
|
||||
action: 'channels.register',
|
||||
resources,
|
||||
outcome: d.kind === 'connected' ? 'success' : d.kind === 'rejected' ? 'rejected' : 'failure',
|
||||
details,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function auditChannelDecision(
|
||||
inner: (payload: ResponsePayload) => Promise<ChannelApprovalResult>,
|
||||
): (payload: ResponsePayload) => Promise<boolean> {
|
||||
return async (payload) => {
|
||||
const result = await inner(payload);
|
||||
if (result.decision) emitChannelDecision(result.decision);
|
||||
return result.claimed;
|
||||
};
|
||||
}
|
||||
|
||||
/** The free-text name reply — the third agent-creation door (new group + wiring). */
|
||||
export function auditChannelNameInterceptor(
|
||||
inner: (event: InboundEvent) => Promise<ChannelApprovalResult>,
|
||||
): (event: InboundEvent) => Promise<boolean> {
|
||||
return async (event) => {
|
||||
const result = await inner(event);
|
||||
if (result.decision) emitChannelDecision(result.decision);
|
||||
return result.claimed;
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write # createLabel — auto-provisions the core-team label
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -30,6 +31,24 @@ jobs:
|
||||
|
||||
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
|
||||
|
||||
// Lowercase GitHub logins; keep in sync with the core team roster.
|
||||
const CORE_TEAM = ['gavrielc', 'koshkoshinsk', 'glifocat', 'gabi-simons', 'omri-maya', 'amit-shafnir', 'moshe-nanoco'];
|
||||
const author = context.payload.pull_request.user.login.toLowerCase();
|
||||
if (CORE_TEAM.includes(author)) {
|
||||
labels.push('core-team');
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'core-team',
|
||||
color: '1D76DB',
|
||||
description: 'PR opened by a core team member',
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 422) throw e; // 422: label already exists
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction, and a registry-walking conformance test fails CI on any unmapped mutating entry. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
|
||||
- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) if any privileged command or delivery action is registered without a guard-catalog mapping. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the same registry walk the conformance test uses (`src/guard-conformance.ts`, one shared exemption list) now runs in `main()` before the host accepts a message, and a bad registration crashes at skill-install time instead of running unguarded. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded.
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
|
||||
@@ -15,11 +15,11 @@ If you are a fresh install (you ran `git clone`, not `git pull`) and there are n
|
||||
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
Personal AI assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
|
||||
## Quick Context
|
||||
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls the agent, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
|
||||
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
|
||||
|
||||
@@ -32,7 +32,7 @@ agent_group_members (user_id, agent_group_id) — unprivileged access gate
|
||||
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
|
||||
|
||||
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, engage_mode/engage_pattern, sender_scope, priority)
|
||||
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
|
||||
|
||||
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
|
||||
@@ -44,8 +44,8 @@ Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolatio
|
||||
|
||||
Each session has **two** SQLite files under `data/v2-sessions/<session_id>/`:
|
||||
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, session_state.
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, delivered, destinations, session_routing.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, processing_ack, session_state, container_state.
|
||||
|
||||
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
|
||||
|
||||
@@ -65,7 +65,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `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` | Runtime selection (Docker vs Apple containers), orphan cleanup |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. All four handler registries require a guard spec or an explicit `unguarded(<reason>)` declaration at the registration site. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` |
|
||||
| `src/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) |
|
||||
@@ -115,7 +116,7 @@ Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.
|
||||
|
||||
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
|
||||
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud, Signal, WeChat, DeltaChat, Emacs (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`.
|
||||
|
||||
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
|
||||
@@ -170,7 +171,7 @@ No container restart needed — the gateway looks up secrets per request.
|
||||
|
||||
Approval-gating credentialed actions is a **two-sided** flow:
|
||||
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@2.2.5`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
|
||||
|
||||
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
|
||||
@@ -216,7 +217,7 @@ Run commands directly — don't tell the user to run them.
|
||||
|
||||
```bash
|
||||
# Host (Node + pnpm)
|
||||
pnpm run dev # Host with hot reload
|
||||
pnpm run dev # Host via tsx (no watch)
|
||||
pnpm run build # Compile host TypeScript (src/)
|
||||
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
|
||||
pnpm test # Host tests (vitest)
|
||||
@@ -297,7 +298,7 @@ The agent container runs on **Bun**; the host runs on **Node** (pnpm). They comm
|
||||
- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally.
|
||||
- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree.
|
||||
- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~503) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
|
||||
|
||||
## CJK font support
|
||||
|
||||
+4
-4
@@ -75,7 +75,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
|
||||
|
||||
#### 3. Operational skills (instruction-only)
|
||||
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — the coding agent follows the instructions to perform a task.
|
||||
|
||||
**Location:** `.claude/skills/` on `main`
|
||||
|
||||
@@ -88,13 +88,13 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill —
|
||||
|
||||
#### 4. Container skills (agent runtime)
|
||||
|
||||
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
Skills that run inside the agent container, not on the host. These teach the NanoClaw agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
|
||||
|
||||
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
**Guidelines:**
|
||||
- Follow the same SKILL.md + frontmatter format
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
|
||||
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,7 +49,7 @@ bash migrate-v2.sh
|
||||
|
||||
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
|
||||
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.
|
||||
|
||||
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
|
||||
|
||||
@@ -78,11 +78,11 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
|
||||
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Scheduled tasks** — recurring jobs executed by the agent, which can message you the results
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Container isolation** — agents are sandboxed in Docker containers (macOS/Linux/WSL2)
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle via `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -124,10 +124,7 @@ This keeps trunk as pure registry and infra, and every fork stays lean — users
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd like to see:
|
||||
|
||||
**Communication Channels**
|
||||
- `/add-signal` — Add Signal as a channel
|
||||
No channel or provider skills are currently requested — propose one via an issue.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -142,7 +139,7 @@ Skills we'd like to see:
|
||||
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
|
||||
```
|
||||
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
|
||||
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
|
||||
|
||||
@@ -165,7 +162,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ type RequestFrame = {
|
||||
};
|
||||
|
||||
type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
// `human` mirrors src/cli/frame.ts: an optional server-rendered string we
|
||||
// print verbatim instead of running our own (drift-prone) formatter.
|
||||
| { id: string; ok: true; data: unknown; human?: string }
|
||||
| { id: string; ok: false; error: { code: string; message: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -244,6 +246,9 @@ if (!resp) {
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||
} else if (resp.ok && resp.human !== undefined) {
|
||||
// Server-rendered view — print verbatim.
|
||||
process.stdout.write(resp.human + '\n');
|
||||
} else {
|
||||
const output = formatHuman(resp);
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -449,7 +449,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
yield { type: 'result', text, isError: m.is_error === true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
|
||||
yield { type: 'error', message: 'API retry', retryable: true };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
|
||||
} else if (message.type === 'rate_limit_event') {
|
||||
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||
|
||||
@@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js
|
||||
|
||||
### Security Through True Isolation
|
||||
|
||||
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
|
||||
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
|
||||
|
||||
### Built for the Individual User
|
||||
|
||||
@@ -47,23 +47,23 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
|
||||
Skills we'd like to see contributed:
|
||||
|
||||
### Communication Channels
|
||||
- `/add-signal` - Add Signal as a channel
|
||||
- `/add-matrix` - Add Matrix integration
|
||||
|
||||
> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
|
||||
None currently — Signal and Matrix have since shipped as skills.
|
||||
|
||||
> **Note:** Telegram, Slack, Discord, Gmail, Signal, and Matrix skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
A personal Claude assistant accessible via messaging, with minimal custom code.
|
||||
A personal AI assistant accessible via messaging, with minimal custom code.
|
||||
|
||||
**Core components:**
|
||||
- **Claude Agent SDK** as the core agent
|
||||
- **Containers** for isolated agent execution (Linux VMs)
|
||||
- **Containers** for isolated agent execution (Docker)
|
||||
- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
|
||||
- **Persistent memory** per conversation and globally
|
||||
- **Scheduled tasks** that run Claude and can message back
|
||||
- **Scheduled tasks** executed by the agent, which can message back
|
||||
- **Web access** for search and browsing
|
||||
- **Browser automation** via agent-browser
|
||||
|
||||
@@ -93,14 +93,14 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
|
||||
- Sessions auto-compact when context gets too long, preserving critical information
|
||||
|
||||
### Container Isolation
|
||||
- All agents run inside containers (lightweight Linux VMs)
|
||||
- All agents run inside Docker containers
|
||||
- Each agent invocation spawns a container with mounted directories
|
||||
- Containers provide filesystem isolation - agents can only see mounted paths
|
||||
- Bash access is safe because commands run inside the container, not on the host
|
||||
- Browser automation via agent-browser with Chromium in the container
|
||||
|
||||
### Scheduled Tasks
|
||||
- Users can ask Claude to schedule recurring or one-time tasks from any group
|
||||
- Users can ask the agent to schedule recurring or one-time tasks from any group
|
||||
- Tasks run as full agents in the context of the group that created them
|
||||
- Tasks have access to all tools including Bash (safe in container)
|
||||
- Tasks can optionally send messages to their group via `send_message` tool, or complete silently
|
||||
|
||||
+471
-434
File diff suppressed because it is too large
Load Diff
+240
-161
@@ -14,37 +14,62 @@ The boundary: the agent-runner decides **what** to send and **what to do** with
|
||||
|
||||
## AgentProvider Interface
|
||||
|
||||
Provider-wide settings (MCP servers, env, additional directories, model, effort,
|
||||
assistant name) are passed to the provider **constructor** via `ProviderOptions`, not
|
||||
per query. `QueryInput` carries only what changes turn to turn: the prompt, the
|
||||
continuation token to resume, the working directory, and system context to inject.
|
||||
|
||||
```typescript
|
||||
interface AgentProvider {
|
||||
/** True if the SDK handles slash commands natively and wants them passed
|
||||
* through raw. When false, the poll-loop formats them like any chat message. */
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/** Opt-in: scaffold a persistent memory/ tree at boot. Providers with native
|
||||
* memory (Claude's CLAUDE.local.md) omit it. Never gated on a provider name. */
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/** Optional. Called after each completed exchange so providers whose harness
|
||||
* keeps no on-disk transcript can persist it themselves. Claude (the SDK
|
||||
* writes its own .jsonl) omits this. */
|
||||
onExchangeComplete?(exchange: ProviderExchange): void;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
/** True if the error means the stored continuation is invalid (missing
|
||||
* transcript, unknown session) and should be cleared. */
|
||||
isSessionInvalid(err: unknown): boolean;
|
||||
|
||||
/** Optional pre-resume maintenance: given the stored continuation, return a
|
||||
* reason string to drop it and start fresh (e.g. transcript too large/old to
|
||||
* cold-resume before the host idle ceiling), or null to keep resuming. */
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
interface ProviderOptions {
|
||||
assistantName?: string;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
env?: Record<string, string | undefined>;
|
||||
additionalDirectories?: string[];
|
||||
model?: string; // alias (sonnet/opus/haiku) or full model ID
|
||||
effort?: string; // low | medium | high | xhigh | max
|
||||
}
|
||||
|
||||
interface QueryInput {
|
||||
/** Initial prompt (already formatted by agent-runner).
|
||||
* String for text-only. ContentBlock[] for multimodal (images, PDFs, audio). */
|
||||
prompt: string | ContentBlock[];
|
||||
/** Initial prompt, already formatted by the agent-runner into a string. */
|
||||
prompt: string;
|
||||
|
||||
/** Session ID to resume, if any */
|
||||
sessionId?: string;
|
||||
/** Opaque continuation token from a previous query. The provider decides
|
||||
* what it means (session ID, thread ID, or nothing). */
|
||||
continuation?: string;
|
||||
|
||||
/** Resume from a specific point in the session (provider-specific, may be ignored) */
|
||||
resumeAt?: string;
|
||||
|
||||
/** Working directory inside the container */
|
||||
/** Working directory inside the container. */
|
||||
cwd: string;
|
||||
|
||||
/** MCP server configurations (normalized format — provider translates) */
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
|
||||
/** System prompt / developer instructions */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** Environment variables for the SDK process */
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
/** Additional directories the agent can access */
|
||||
additionalDirectories?: string[];
|
||||
/** System context to inject; the provider translates it into whatever its
|
||||
* SDK expects (preset append, full system prompt, per-turn injection). */
|
||||
systemContext?: { instructions?: string };
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
@@ -54,40 +79,42 @@ interface McpServerConfig {
|
||||
}
|
||||
|
||||
interface AgentQuery {
|
||||
/** Push a follow-up message into the active query */
|
||||
/** Push a follow-up message into the active query. */
|
||||
push(message: string): void;
|
||||
|
||||
/** Signal that no more input will be sent */
|
||||
/** Signal that no more input will be sent. */
|
||||
end(): void;
|
||||
|
||||
/** Output event stream */
|
||||
/** Output event stream. */
|
||||
events: AsyncIterable<ProviderEvent>;
|
||||
|
||||
/** Force-stop the query (e.g., container shutting down) */
|
||||
/** Force-stop the query (e.g., container shutting down). */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type ProviderEvent =
|
||||
| { type: 'init'; sessionId: string }
|
||||
| { type: 'result'; text: string | null }
|
||||
| { type: 'init'; continuation: string }
|
||||
| { type: 'result'; text: string | null; isError?: boolean }
|
||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||
| { type: 'progress'; message: string };
|
||||
| { type: 'progress'; message: string }
|
||||
| { type: 'activity' };
|
||||
```
|
||||
|
||||
### What the interface does NOT include
|
||||
|
||||
- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string.
|
||||
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreCompact, PreToolUse, etc.). Other providers don't need them.
|
||||
- **Tool allowlists** — Claude uses `allowedTools`. Codex uses `approvalPolicy`. OpenCode uses `permission`. Each provider configures this internally based on the same intent: "allow everything, no prompting."
|
||||
- **Session persistence** — Claude persists sessions to disk automatically. Codex and OpenCode manage their own session state. The agent-runner doesn't control this — it just passes `sessionId` and `resumeAt`.
|
||||
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreToolUse, PostToolUse, PreCompact). Other providers don't need them.
|
||||
- **Tool allowlists** — Claude uses `allowedTools` + `disallowedTools`. Other SDKs use their own equivalents. Each provider configures this internally.
|
||||
- **Session persistence** — the agent-runner stores one opaque `continuation` token per provider (see [Session Resume](#session-resume)) and passes it back as `QueryInput.continuation`. What it means is provider-private; Claude persists its own `.jsonl` transcript on disk keyed by the continuation (session ID).
|
||||
- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally.
|
||||
|
||||
### Provider event semantics
|
||||
|
||||
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `sessionId` for future resume.
|
||||
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). The agent-runner writes each result to messages_out.
|
||||
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota', 'auth', 'transport').
|
||||
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `continuation` and persists it for future resume.
|
||||
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). `isError` is set when the SDK flagged the turn as an error (e.g. a non-retryable billing error) so the poll-loop still surfaces the text instead of dropping it. The agent-runner writes each result to messages_out.
|
||||
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota').
|
||||
- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them.
|
||||
- **`activity`** — a liveness signal. Providers MUST yield it on every underlying SDK event (tool call, thinking, partial message) so the poll-loop's idle timer stays honest during long tool runs.
|
||||
|
||||
## Provider Implementations
|
||||
|
||||
@@ -97,58 +124,82 @@ Only the `claude` provider ships in trunk. The Codex and OpenCode sections below
|
||||
|
||||
Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`.
|
||||
|
||||
The provider takes its settings (`mcpServers`, `env`, `additionalDirectories`,
|
||||
`model`, `effort`, `assistantName`) in its constructor via `ProviderOptions`; `query()`
|
||||
only reads the per-turn `QueryInput`.
|
||||
|
||||
```typescript
|
||||
class ClaudeProvider implements AgentProvider {
|
||||
readonly supportsNativeSlashCommands = true;
|
||||
// ...constructor stores options.mcpServers, .env, .additionalDirectories,
|
||||
// .model, .effort, .assistantName...
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const stream = new MessageStream(); // AsyncIterable<SDKUserMessage>
|
||||
stream.push(input.prompt);
|
||||
|
||||
const sdkQuery = query({
|
||||
const sdkResult = sdkQuery({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: input.cwd,
|
||||
resume: input.sessionId,
|
||||
resumeSessionAt: input.resumeAt,
|
||||
systemPrompt: input.systemPrompt
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
|
||||
additionalDirectories: this.additionalDirectories,
|
||||
resume: input.continuation,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: input.systemContext?.instructions
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
|
||||
: undefined,
|
||||
mcpServers: input.mcpServers, // already the right shape
|
||||
additionalDirectories: input.additionalDirectories,
|
||||
env: input.env,
|
||||
allowedTools: NANOCLAW_TOOL_ALLOWLIST,
|
||||
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
|
||||
// server — without the explicit MCP patterns the SDK's allowedTools
|
||||
// filter silently drops every MCP namespace.
|
||||
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
effort: this.effort,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [preCompactHook] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }],
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
PostToolUse: [{ hooks: [postToolUseHook] }],
|
||||
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let aborted = false;
|
||||
return {
|
||||
push: (msg) => stream.push(msg),
|
||||
end: () => stream.end(),
|
||||
abort: () => sdkQuery.close(),
|
||||
events: translateClaudeEvents(sdkQuery),
|
||||
// Abort doesn't call into the SDK — it flips a flag the event generator
|
||||
// checks and ends the input stream so the query drains and stops.
|
||||
abort: () => { aborted = true; stream.end(); },
|
||||
events: translateEvents(sdkResult, () => aborted),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`translateClaudeEvents` is an async generator that maps SDK messages to `ProviderEvent`:
|
||||
- `message.type === 'system' && message.subtype === 'init'` → `{ type: 'init', sessionId }`
|
||||
- `message.type === 'result'` → `{ type: 'result', text }`
|
||||
- `message.type === 'system' && message.subtype === 'api_retry'` → `{ type: 'error', retryable: true }`
|
||||
- `message.type === 'system' && message.subtype === 'rate_limit_event'` → `{ type: 'error', retryable: false, classification: 'quota' }`
|
||||
- `message.type === 'system' && message.subtype === 'task_notification'` → `{ type: 'progress', message }`
|
||||
- Everything else → logged, not emitted
|
||||
`translateEvents` is an async generator that yields `{ type: 'activity' }` for **every**
|
||||
SDK message (so the idle timer stays honest) and maps recognized messages to `ProviderEvent`:
|
||||
- `system`/`init` → `{ type: 'init', continuation: session_id }`
|
||||
- `result` → `{ type: 'result', text, isError }` — `text` is `result.result`, or the joined `result.errors[]` on error subtypes (billing/quota), so the notice still reaches the user
|
||||
- `system`/`api_retry` → `{ type: 'error', retryable: true }`
|
||||
- `system`/`rate_limit_event` → `{ type: 'error', retryable: false, classification: 'quota' }`
|
||||
- `system`/`compact_boundary` → `{ type: 'result', text: 'Context compacted…' }`
|
||||
- `system`/`task_notification` → `{ type: 'progress', message }`
|
||||
- when the `aborted` flag is set → the generator returns immediately
|
||||
|
||||
**Claude-specific features preserved inside the provider:**
|
||||
- `MessageStream` for async iterable input (push-based)
|
||||
- `resumeSessionAt` for resume at specific message UUID
|
||||
- PreCompact hook for transcript archiving
|
||||
- PreToolUse hook for sanitizing bash env vars
|
||||
- Full tool allowlist
|
||||
**Claude-specific behavior inside the provider:**
|
||||
- `MessageStream` for async iterable input (push-based follow-ups)
|
||||
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
|
||||
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
|
||||
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
|
||||
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
|
||||
- **PreCompact** hook archives the transcript to `conversations/` before compaction
|
||||
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
|
||||
- `additionalDirectories` for multi-directory access
|
||||
|
||||
### Codex Provider
|
||||
@@ -159,8 +210,8 @@ Wraps `@openai/codex-sdk`.
|
||||
class CodexProvider implements AgentProvider {
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const codex = new Codex(this.buildOptions(input));
|
||||
const thread = input.sessionId
|
||||
? codex.resumeThread(input.sessionId, this.threadOptions(input))
|
||||
const thread = input.continuation
|
||||
? codex.resumeThread(input.continuation, this.threadOptions(input))
|
||||
: codex.startThread(this.threadOptions(input));
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -188,13 +239,13 @@ class CodexProvider implements AgentProvider {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let continuation: string | undefined;
|
||||
let resultText = '';
|
||||
|
||||
for await (const event of streamed.events) {
|
||||
if (event.type === 'thread.started') {
|
||||
sessionId = event.thread_id;
|
||||
yield { type: 'init', sessionId };
|
||||
continuation = event.thread_id;
|
||||
yield { type: 'init', continuation };
|
||||
}
|
||||
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
|
||||
resultText = event.item.text || resultText;
|
||||
@@ -264,7 +315,7 @@ class OpenCodeProvider implements AgentProvider {
|
||||
|
||||
private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable<ProviderEvent> {
|
||||
const session = await client.session.create();
|
||||
yield { type: 'init', sessionId: session.data.id };
|
||||
yield { type: 'init', continuation: session.data.id };
|
||||
|
||||
await client.session.promptAsync({
|
||||
path: { id: session.data.id },
|
||||
@@ -356,62 +407,58 @@ The agent-runner transforms messages_in rows into a prompt string. The provider
|
||||
|
||||
**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out.
|
||||
|
||||
**Single message formatting by kind:**
|
||||
Every kind renders to a single self-contained XML element. The `id` attribute is the
|
||||
message's `seq` (the agent-facing message ID it passes to `edit_message` / `add_reaction`).
|
||||
The `from` attribute is the origin destination name (resolved from the routing fields via
|
||||
the destination map), so the agent always knows where a message came from — routing fields
|
||||
themselves are never shown.
|
||||
|
||||
- **`chat`** — format into message XML:
|
||||
- **`chat`** — one `<message>` per row:
|
||||
```xml
|
||||
<message sender="John" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
</message>
|
||||
<message id="5" from="family" sender="John" time="Jan 1, 10:00 AM">Check this PR</message>
|
||||
```
|
||||
A reply carries a `reply_to` attribute and an inline `<quoted_message from="…">…</quoted_message>`.
|
||||
|
||||
- **`chat-sdk`** — extract fields from serialized Chat SDK message:
|
||||
- **`chat-sdk`** — same `<message>` shape, fields extracted from the serialized Chat SDK
|
||||
message. Attachments are appended inline: `[image: screenshot.png — saved to /workspace/…]`
|
||||
or `[image: screenshot.png (https://signed-url…)]`. Images/PDFs that Claude handles
|
||||
natively are also passed as content blocks (see Media Handling below).
|
||||
|
||||
- **`task`** — a `<task>` element, script output first when present:
|
||||
```xml
|
||||
<message sender="John (john@slack)" time="2024-01-01 10:00">
|
||||
Check this PR
|
||||
[image: screenshot.png — https://signed-url...]
|
||||
</message>
|
||||
```
|
||||
Attachments are listed inline. Images/PDFs that Claude handles natively are passed as content blocks (see Media Handling below).
|
||||
|
||||
- **`task`** — task prompt, optionally with script output:
|
||||
```
|
||||
[SCHEDULED TASK]
|
||||
|
||||
Script output:
|
||||
{"data": ...}
|
||||
<task from="scheduler" time="Jan 1, 9:00 AM">Script output:
|
||||
{"data": …}
|
||||
|
||||
Instructions:
|
||||
Review open PRs
|
||||
Review open PRs</task>
|
||||
```
|
||||
|
||||
- **`webhook`** — webhook payload:
|
||||
```
|
||||
[WEBHOOK: github/pull_request]
|
||||
|
||||
{"action": "opened", "pull_request": {...}}
|
||||
- **`webhook`** — a `<webhook>` element wrapping the JSON payload:
|
||||
```xml
|
||||
<webhook from="github" source="github" event="pull_request">{"action": "opened", …}</webhook>
|
||||
```
|
||||
|
||||
- **`system`** — host action result (response to an earlier system request):
|
||||
```
|
||||
[SYSTEM RESPONSE]
|
||||
|
||||
Action: register_agent_group
|
||||
Status: success
|
||||
Result: {"agent_group_id": "ag-456"}
|
||||
- **`system`** — host action result, rendered as `<system_response>`:
|
||||
```xml
|
||||
<system_response from="host" action="create_agent" status="success">{"agent_group_id": "ag-456"}</system_response>
|
||||
```
|
||||
|
||||
**Batch formatting:** Multiple pending messages are combined into one prompt:
|
||||
**Batch formatting:** All pending messages are combined into one prompt. The prompt opens
|
||||
with a self-closing `<context timezone="<IANA>" />` header (so the agent interprets every
|
||||
timestamp — and every time it schedules — in the user's zone), then the chat messages
|
||||
concatenated as consecutive `<message>` blocks, then any task/webhook/system elements,
|
||||
joined by blank lines:
|
||||
|
||||
```xml
|
||||
<context timezone="America/Los_Angeles">
|
||||
<messages>
|
||||
<message sender="John" time="10:00">Check this PR</message>
|
||||
<message sender="Jane" time="10:01">Already on it</message>
|
||||
</messages>
|
||||
<context timezone="America/Los_Angeles" />
|
||||
<message id="2" from="family" sender="John" time="10:00">Check this PR</message>
|
||||
<message id="4" from="family" sender="Jane" time="10:01">Already on it</message>
|
||||
```
|
||||
|
||||
Mixed kinds (e.g., a chat message + a system response) are combined with clear delimiters. Each section is labeled by kind.
|
||||
There is **no** outer `<messages>` envelope — an earlier revision wrapped multi-message
|
||||
batches that way, but the Claude Agent SDK answered the wrapped shape with a synthetic
|
||||
"No response requested." stub instead of calling the API (#2555). Dropping the wrapper made
|
||||
the single-message path just the N=1 case of the same concatenation.
|
||||
|
||||
**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset).
|
||||
|
||||
@@ -430,54 +477,70 @@ interface RoutingContext {
|
||||
|
||||
When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message."
|
||||
|
||||
MCP tools that target a different destination (e.g., `send_to_agent`, `send_message` with explicit channel) override the routing context for that specific messages_out row.
|
||||
MCP tools that target a named destination (`send_message` / `send_file` with a `to`
|
||||
argument) resolve routing through the session's destination map instead of the default
|
||||
reply context — including agent-to-agent sends, which are just a `to` pointing at an
|
||||
`agent`-type destination.
|
||||
|
||||
### Status Management
|
||||
|
||||
The agent-runner manages the `status` and `status_changed` fields on messages_in:
|
||||
`inbound.db` is a read-only mount inside the container, so the agent-runner never writes
|
||||
`messages_in`. It tracks processing status in the `processing_ack` table in the
|
||||
container-owned `outbound.db`; the host reads `processing_ack` and mirrors completion
|
||||
back onto `messages_in.status`.
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed (if provider returns error and max retries exhausted)
|
||||
processing_ack: (no row) → processing → completed
|
||||
```
|
||||
|
||||
- **Pick up:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)`
|
||||
- **Complete:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)`
|
||||
- **Error:** Agent-runner does NOT set `failed` — it leaves the message as `processing`. The host detects stale processing via `status_changed` and handles retry logic (reset to pending with backoff). This keeps retry policy on the host side.
|
||||
- **Pick up:** `INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', now())` for each claimed row (`markProcessing`). Pending queries skip any row already present in `processing_ack`.
|
||||
- **Complete:** same upsert with `status = 'completed'` (`markCompleted`). Every consumed batch ends here — error outcomes included. On a provider error the poll-loop writes an error **chat message** to `messages_out` (so the user sees it), then still acks the batch completed; errors surface as messages, not as an ack status. (A `markFailed` helper exists in `messages-in.ts` but currently has no callers.)
|
||||
- The host's `syncProcessingAcks` mirrors acked ids onto `messages_in.status = 'completed'`. Its stale/retry policy is driven off the `.heartbeat` file mtime and the `processing_ack` claim timestamps. On startup the agent-runner clears leftover `processing` acks (crash recovery) so orphaned claims re-process.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
The agent-runner runs an MCP server that exposes NanoClaw tools to the agent. All tools write to the session DB.
|
||||
|
||||
**DB path:** The MCP server receives the session DB path via environment variable. It opens a second connection to the same SQLite file (WAL mode allows concurrent access).
|
||||
The agent-runner runs an MCP server (stdio) that exposes NanoClaw tools to the agent. The
|
||||
tool modules use the same two-DB connection layer as the rest of the runner
|
||||
(`container/agent-runner/src/db/connection.ts`): they read the host-written `inbound.db`
|
||||
at `/workspace/inbound.db` **read-only** (destinations, session routing, question
|
||||
responses, task lists) and write to the container-owned `outbound.db` at
|
||||
`/workspace/outbound.db`. There is no shared single-file connection and no WAL — both files
|
||||
are `journal_mode=DELETE` because WAL's memory-mapped `-shm` file does not stay coherent
|
||||
across the VirtioFS host↔container mount.
|
||||
|
||||
#### send_message
|
||||
|
||||
Send a chat message to the current conversation (or a specified destination).
|
||||
Send a chat message to a named destination. Agents address destinations by name, never by
|
||||
raw platform/channel/thread IDs — the destination map (`destinations` table in `inbound.db`,
|
||||
written by the host) resolves the name to routing fields.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_message',
|
||||
params: {
|
||||
text: string, // message content
|
||||
channel?: string, // optional: target channel type (default: reply to origin)
|
||||
platformId?: string, // optional: target platform ID
|
||||
threadId?: string, // optional: target thread ID
|
||||
text: string, // message content (required)
|
||||
to?: string, // destination name (e.g. "family", "worker-1").
|
||||
// Optional when the agent has exactly one destination.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `kind: 'chat'`. If channel/platformId/threadId are provided, use those as routing. Otherwise, copy from the current routing context.
|
||||
Implementation: `resolveRouting(to)` looks up the destination. With no `to`, it defaults to
|
||||
the session's own reply routing (`session_routing`); if the destination resolves to the same
|
||||
channel the session is bound to, the session's `thread_id` is preserved so the reply lands
|
||||
in-thread, otherwise `thread_id` is null. The tool then writes a `messages_out` row with
|
||||
`kind: 'chat'` and content `{ text }`, and returns the new `seq` as the message id.
|
||||
|
||||
#### send_file
|
||||
|
||||
Send a file to the current conversation.
|
||||
Send a file to a named destination (same destination model as `send_message`).
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_file',
|
||||
params: {
|
||||
path: string, // file path (relative to /workspace/agent/ or absolute)
|
||||
path: string, // file path (relative to /workspace/agent/ or absolute) (required)
|
||||
to?: string, // destination name; optional if the agent has one destination
|
||||
text?: string, // optional accompanying message
|
||||
filename?: string, // display name (default: basename of path)
|
||||
}
|
||||
@@ -485,10 +548,10 @@ Send a file to the current conversation.
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Generate a message ID
|
||||
2. Create `outbox/{messageId}/` directory
|
||||
3. Copy the file into the outbox directory
|
||||
4. Write a `messages_out` row with `files: [filename]` in the content
|
||||
1. Resolve routing via `resolveRouting(to)` (as `send_message`)
|
||||
2. Generate a message ID and create `/workspace/outbox/{messageId}/`
|
||||
3. Copy the file into that outbox directory
|
||||
4. Write a `messages_out` row (`kind: 'chat'`) with content `{ text, files: [filename] }`
|
||||
|
||||
#### send_card
|
||||
|
||||
@@ -523,11 +586,11 @@ Send an interactive question and wait for the user's response. This is a **block
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Generate a `questionId`
|
||||
2. Write a `messages_out` row with `operation: 'ask_question'`, the question, options, and questionId
|
||||
3. Poll `messages_in` for a row with matching `questionId` in content
|
||||
4. When found, return the `selectedOption` as the tool result
|
||||
5. If timeout expires, return a timeout error as the tool result
|
||||
1. Generate a `questionId` and normalize each option to `{ label, selectedLabel, value }`
|
||||
2. Write a `messages_out` row with `kind: 'chat-sdk'` and content `{ type: 'ask_question', questionId, title, question, options }`
|
||||
3. Poll `inbound.db` (read-only) for a pending `messages_in` row whose content carries the matching `questionId` (`findQuestionResponse`), skipping any already in `processing_ack`
|
||||
4. When found, `markCompleted` the response row (a `processing_ack` write in `outbound.db`) and return its `selectedOption` as the tool result
|
||||
5. If the deadline passes, return a timeout error as the tool result
|
||||
|
||||
The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop.
|
||||
|
||||
@@ -563,22 +626,13 @@ Add an emoji reaction to a message.
|
||||
|
||||
Implementation: write a `messages_out` row with `operation: 'reaction'`.
|
||||
|
||||
#### send_to_agent
|
||||
#### Agent-to-agent sends (no dedicated tool)
|
||||
|
||||
Send a message to another agent group.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'send_to_agent',
|
||||
params: {
|
||||
agentGroupId: string, // target agent group
|
||||
text: string, // message content
|
||||
sessionId?: string, // optional: target specific session
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `channel_type: 'agent'`, `platform_id: agentGroupId`, `thread_id: sessionId`.
|
||||
There is no `send_to_agent` tool. Agents and channels share one destination namespace, so
|
||||
messaging another agent is just `send_message(to="<agent-name>")` where the named
|
||||
destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row with
|
||||
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
|
||||
validates the send and routes it into the target session's `inbound.db`.
|
||||
|
||||
#### schedule_task
|
||||
|
||||
@@ -627,25 +681,42 @@ Modify a scheduled task.
|
||||
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### register_agent_group
|
||||
#### create_agent
|
||||
|
||||
Register a new agent group (admin only).
|
||||
Create a long-lived companion sub-agent. The `name` becomes a destination the creating
|
||||
agent can address. (There is no `register_agent_group` tool — this replaced it.)
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'register_agent_group',
|
||||
name: 'create_agent',
|
||||
params: {
|
||||
name: string,
|
||||
folder: string,
|
||||
platformId: string, // messaging group to wire to
|
||||
channelType: string,
|
||||
triggerRules?: object,
|
||||
sessionMode?: 'shared' | 'per-thread',
|
||||
name: string, // human-readable name; also the destination name (required)
|
||||
instructions?: string, // CLAUDE.md content for the new agent (role, personality)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_out` row with `kind: 'system'`, `action: 'register_agent_group'`. The host reads, validates admin permission, creates the entity rows in the central DB, and writes a `system` messages_in response.
|
||||
Implementation: fire-and-forget. Writes a `messages_out` row with `kind: 'system'`,
|
||||
`action: 'create_agent'`, `requestId`, `name`, and `instructions`. The container is
|
||||
untrusted and does not gate itself; the host authorizes by CLI scope — trusted owner groups
|
||||
(scope `global`) create directly, confined groups require admin approval
|
||||
(`src/modules/agent-to-agent/create-agent.ts`) — then creates the entity rows and notifies
|
||||
the agent via a chat message when the agent is ready.
|
||||
|
||||
#### Self-modification: install_packages, add_mcp_server
|
||||
|
||||
Two fire-and-forget system-action tools let an agent extend its own runtime (both require
|
||||
admin approval, applied host-side):
|
||||
|
||||
- **`install_packages`** — `{ apt?: string[], npm?: string[], reason?: string }`. Package
|
||||
names are validated at the tool boundary and re-validated on the host. On approval the
|
||||
host rebuilds the per-agent image and restarts the container.
|
||||
- **`add_mcp_server`** — `{ name, command, args?, env? }`. Wires an existing third-party MCP
|
||||
server into the agent's `container.json`; on approval the host updates the config and
|
||||
restarts (no rebuild — Bun runs the TS directly).
|
||||
|
||||
Both write a `messages_out` row with `kind: 'system'` and the matching `action`, then return
|
||||
immediately; the host notifies the agent when approval resolves.
|
||||
|
||||
### Media Handling
|
||||
|
||||
@@ -701,12 +772,20 @@ Archive location: `/workspace/agent/conversations/{date}-{summary}.md`
|
||||
|
||||
### Session Resume
|
||||
|
||||
The agent-runner tracks `sessionId` and `resumeAt` across queries:
|
||||
The agent-runner tracks a single opaque `continuation` token per provider:
|
||||
|
||||
- `sessionId` — captured from `ProviderEvent { type: 'init' }`. Passed back to `QueryInput.sessionId` on the next query.
|
||||
- `resumeAt` — Claude-specific (last assistant message UUID). Stored by the agent-runner, passed to `QueryInput.resumeAt`. Providers that don't support this ignore it.
|
||||
- Captured from `ProviderEvent { type: 'init', continuation }` and persisted to the
|
||||
`session_state` table in `outbound.db` under the key `continuation:<provider>` (keyed per
|
||||
provider because a continuation is provider-private — a Claude session id is meaningless to
|
||||
another provider).
|
||||
- Passed back as `QueryInput.continuation` on the next query. For Claude that becomes the
|
||||
SDK `resume` option; the SDK reloads its on-disk `.jsonl` transcript for that session id.
|
||||
|
||||
These are ephemeral to the container's lifetime. When the container is killed and restarted, the host passes the stored `sessionId` from the central DB's sessions table. `resumeAt` is lost on container restart (the provider resumes from the end of the session).
|
||||
Because it lives in the session folder's `outbound.db`, the continuation survives container
|
||||
teardown and restart — a fresh container reads it back and resumes. `/clear` deletes the row
|
||||
to start a clean session. Before resuming, `maybeRotateContinuation` may archive and drop an
|
||||
oversized/aged transcript (so a cold container isn't killed reloading it), and
|
||||
`isSessionInvalid` clears a continuation whose backing transcript has gone missing.
|
||||
|
||||
### Container Startup
|
||||
|
||||
@@ -714,7 +793,7 @@ The agent-runner receives configuration via:
|
||||
|
||||
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
|
||||
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
|
||||
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
- **Fixed mount paths:** Host-written `inbound.db` (read-only) at `/workspace/inbound.db` and container-owned `outbound.db` at `/workspace/outbound.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
|
||||
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
|
||||
|
||||
|
||||
+4
-2
@@ -13,6 +13,8 @@ interface ChannelSetup {
|
||||
|
||||
// Host callbacks
|
||||
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
|
||||
// Admin-transport adapters (e.g. CLI) route to an arbitrary channel via this instead of onInbound
|
||||
onInboundEvent(event: InboundEvent): void;
|
||||
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ interface ChannelAdapter {
|
||||
isConnected(): boolean;
|
||||
|
||||
// Outbound delivery
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
|
||||
|
||||
// Optional
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
@@ -307,7 +309,7 @@ function createWhatsAppChannel(): ChannelAdapter {
|
||||
**Ask user question:**
|
||||
```json
|
||||
{
|
||||
"operation": "ask_question",
|
||||
"type": "ask_question",
|
||||
"questionId": "q-123",
|
||||
"title": "Failing Test",
|
||||
"question": "How should we handle the failing test?",
|
||||
|
||||
@@ -311,7 +311,6 @@ erDiagram
|
||||
string name
|
||||
string folder
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
messaging_groups {
|
||||
int id
|
||||
@@ -344,14 +343,15 @@ erDiagram
|
||||
int messaging_group_id
|
||||
int agent_group_id
|
||||
string session_mode
|
||||
json trigger_rules
|
||||
string engage_mode "pattern | mention | mention-sticky"
|
||||
string sender_scope "all | known"
|
||||
int priority
|
||||
}
|
||||
sessions {
|
||||
int id
|
||||
int agent_group_id
|
||||
int messaging_group_id
|
||||
string sdk_session_id
|
||||
string thread_id
|
||||
string status
|
||||
}
|
||||
</pre>
|
||||
@@ -391,7 +391,7 @@ flowchart LR
|
||||
Container -->|writes · odd seq| Out
|
||||
Container -->|touch every poll| HB
|
||||
HostSweep[Host sweep] -->|stat mtime| HB
|
||||
HostSweep -->|reads processing_ack| In
|
||||
HostSweep -->|reads processing_ack| Out
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,18 +28,18 @@ flowchart TB
|
||||
Approvals["configureManualApproval<br/>-> pending_approvals"]
|
||||
end
|
||||
|
||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||
subgraph Session["Per-Session Container (Docker)"]
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
|
||||
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
|
||||
end
|
||||
|
||||
subgraph Groups["Agent Group Filesystem (groups/*)"]
|
||||
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container_config"]
|
||||
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container.json (materialized from container_configs)"]
|
||||
end
|
||||
|
||||
P1 & P2 & P3 & P4 & P5 --> Bridge
|
||||
@@ -140,7 +140,6 @@ erDiagram
|
||||
string name
|
||||
string folder
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
messaging_groups {
|
||||
int id
|
||||
@@ -173,14 +172,15 @@ erDiagram
|
||||
int messaging_group_id
|
||||
int agent_group_id
|
||||
string session_mode "agent-shared | shared | per-thread"
|
||||
json trigger_rules
|
||||
string engage_mode "pattern | mention | mention-sticky"
|
||||
string sender_scope "all | known"
|
||||
int priority
|
||||
}
|
||||
sessions {
|
||||
int id
|
||||
int agent_group_id
|
||||
int messaging_group_id
|
||||
string sdk_session_id
|
||||
string thread_id
|
||||
string status
|
||||
}
|
||||
```
|
||||
@@ -209,7 +209,7 @@ flowchart LR
|
||||
Container -->|"writes only<br/>(odd seq)"| Out
|
||||
Container -->|touch every poll| HB
|
||||
HostSweep[Host sweep] -->|stat mtime| HB
|
||||
HostSweep -->|reads processing_ack| In
|
||||
HostSweep -->|reads processing_ack| Out
|
||||
|
||||
note1["Each file has exactly ONE writer.<br/>Eliminates SQLite cross-process write contention.<br/>Collision-free seq numbering."]
|
||||
```
|
||||
|
||||
+167
-100
@@ -4,7 +4,17 @@
|
||||
|
||||
## Core Idea
|
||||
|
||||
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
|
||||
Each agent session has a **pair** of mounted SQLite DBs. They are the one and only IO
|
||||
mechanism between host and container. No IPC files, no stdin piping. `inbound.db` carries
|
||||
host → agent-runner messages (`messages_in`); `outbound.db` carries agent-runner → host
|
||||
messages (`messages_out`) plus the container's processing acks. Everything is a message.
|
||||
|
||||
The split exists so each file has exactly one writer: the host writes `inbound.db` (the
|
||||
container opens it read-only) and the container writes `outbound.db` (the host opens it
|
||||
read-only). One writer per file means no cross-process lock contention over the
|
||||
host↔container mount. Both files run `journal_mode=DELETE`, **not** WAL: WAL's memory-mapped
|
||||
`-shm` coherency does not propagate across VirtioFS, so a WAL reader in the guest would
|
||||
freeze on an early snapshot and never see new host writes.
|
||||
|
||||
## Two-Level DB
|
||||
|
||||
@@ -13,15 +23,18 @@ Each agent session has a mounted SQLite DB. The DB is the one and only IO mechan
|
||||
- Maps platform IDs → agent groups → sessions
|
||||
- Channel adapters don't touch this directly — the host does the lookup
|
||||
|
||||
**Per-session DB (mounted into container):**
|
||||
- messages_in (written by host, read by agent-runner)
|
||||
- messages_out (written by agent-runner, read by host)
|
||||
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use these two tables
|
||||
- One DB per session, not per agent group
|
||||
**Per-session DBs (mounted into container):**
|
||||
- `inbound.db` → `messages_in` (written by host, read-only in container) plus host-written
|
||||
lookup tables the container reads live: `destinations`, `session_routing`, `delivered`
|
||||
- `outbound.db` → `messages_out` (written by agent-runner, read by host) plus
|
||||
`processing_ack`, `session_state`, and `container_state` (all container-owned)
|
||||
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use
|
||||
`messages_in` / `messages_out`
|
||||
- One pair per session, not per agent group
|
||||
|
||||
## Agent Groups vs Sessions
|
||||
|
||||
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own DB mounted at a known path. Each session = a separate container with the same agent group's filesystem but a different session DB.
|
||||
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own `inbound.db`/`outbound.db` pair mounted at known paths. Each session = a separate container with the same agent group's filesystem but a different DB pair.
|
||||
|
||||
## Message Flow
|
||||
|
||||
@@ -30,13 +43,13 @@ Platform event
|
||||
→ Channel adapter (trigger check, ID extraction)
|
||||
→ Returns: { platformChannelId, platformThreadId, triggered }
|
||||
→ Host maps platformChannelId + platformThreadId → agent group + session
|
||||
→ Host writes message to session's DB
|
||||
→ Host writes messages_in row to the session's inbound.db
|
||||
→ Host calls wakeUpAgent(session)
|
||||
→ Container spins up (or is already running)
|
||||
→ Agent-runner polls its session DB, finds new messages
|
||||
→ Agent-runner processes with Claude
|
||||
→ Agent-runner writes response to session DB
|
||||
→ Host polls active session DBs for responses
|
||||
→ Agent-runner polls inbound.db (read-only), finds new messages
|
||||
→ Agent-runner processes with the configured provider
|
||||
→ Agent-runner writes response to messages_out in outbound.db
|
||||
→ Host polls active sessions' outbound.db for responses
|
||||
→ Host reads response, looks up conversation, delivers through channel adapter
|
||||
```
|
||||
|
||||
@@ -131,7 +144,7 @@ The host is an orchestrator:
|
||||
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
|
||||
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
|
||||
|
||||
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
|
||||
When a container spins up, the agent-runner immediately starts polling `inbound.db`. Messages are already there waiting.
|
||||
|
||||
## Media Handling
|
||||
|
||||
@@ -185,50 +198,66 @@ Dedup is the channel adapter's responsibility. Chat SDK handles this internally.
|
||||
|
||||
## Session DB Schema
|
||||
|
||||
Two tables. JSON blobs for content — schema-free, format varies by `kind`.
|
||||
Split across the two files. JSON blobs for content — schema-free, format varies by `kind`.
|
||||
`seq` is a global ordering counter with a **disjoint parity**: the host writes even seqs to
|
||||
`messages_in`, the container writes odd seqs to `messages_out`. Each side reads the other's
|
||||
MAX(seq) to pick its next value, so seq is a single monotonic message id across both tables —
|
||||
which is why the agent-facing message id it returns from `send_message` (and accepts in
|
||||
`edit_message` / `add_reaction`) is unambiguous.
|
||||
|
||||
```sql
|
||||
-- Host writes, agent-runner reads
|
||||
-- inbound.db — host writes, container opens read-only
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- even (host-assigned)
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
|
||||
status_changed TEXT, -- ISO timestamp of last status change
|
||||
status TEXT DEFAULT 'pending', -- host-owned; the host mirrors the container's
|
||||
-- processing_ack terminal states onto this column
|
||||
process_after TEXT, -- ISO timestamp. NULL = process immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
series_id TEXT, -- groups a recurring task's occurrences
|
||||
tries INTEGER DEFAULT 0, -- number of processing attempts
|
||||
|
||||
-- routing (agent-runner copies to messages_out; agent never sees these)
|
||||
platform_id TEXT,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = accumulated context (don't wake), 1 = wake
|
||||
platform_id TEXT, -- routing (stripped before the agent sees content)
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- payload (structure depends on kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
content TEXT NOT NULL, -- JSON blob (structure depends on kind)
|
||||
source_session_id TEXT, -- a2a return path: source session that emitted the trigger
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on a container's first poll
|
||||
);
|
||||
|
||||
-- Agent-runner writes, host reads
|
||||
-- outbound.db — container writes, host opens read-only
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
in_reply_to TEXT, -- references messages_in.id (optional)
|
||||
seq INTEGER UNIQUE, -- odd (container-assigned)
|
||||
in_reply_to TEXT, -- references messages_in.id (optional)
|
||||
timestamp TEXT NOT NULL,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
|
||||
-- routing (default: copied from messages_in by agent-runner)
|
||||
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
|
||||
platform_id TEXT,
|
||||
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
|
||||
recurrence TEXT, -- cron expression. NULL = one-shot.
|
||||
kind TEXT NOT NULL, -- copied from messages_in by default
|
||||
platform_id TEXT, -- routing (default: copied from messages_in)
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
|
||||
-- payload (format matches kind)
|
||||
content TEXT NOT NULL -- JSON blob
|
||||
content TEXT NOT NULL -- JSON blob (format matches kind)
|
||||
);
|
||||
|
||||
-- outbound.db — container's processing status (it can't write inbound.db).
|
||||
-- Host reads this to drive the message lifecycle; stale 'processing' rows are
|
||||
-- cleared on container startup (crash recovery).
|
||||
CREATE TABLE processing_ack (
|
||||
message_id TEXT PRIMARY KEY, -- references messages_in.id
|
||||
status TEXT NOT NULL, -- 'processing' | 'completed' | 'failed'
|
||||
status_changed TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Delivery is tracked host-side in a `delivered` table in `inbound.db` (not a column on
|
||||
`messages_out`, which the host can't write). `inbound.db` also holds the host-written
|
||||
`destinations` and `session_routing` tables the container reads live; `outbound.db` also
|
||||
holds `session_state` (the resume continuation, per provider) and `container_state`
|
||||
(current tool in flight).
|
||||
|
||||
### Scheduling
|
||||
|
||||
One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
@@ -237,10 +266,10 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
|
||||
**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift.
|
||||
|
||||
**Host sweep** (every ~60s across all session DBs):
|
||||
- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
|
||||
- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → stale detection, increment tries, reset to pending with backoff
|
||||
- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → deliver
|
||||
**Host sweep** (every ~60s across all sessions):
|
||||
- `inbound.db` → `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
|
||||
- A `processing_ack` (in `outbound.db`) whose claim, or the `.heartbeat` mtime, is older than the stale threshold → stale detection, increment tries, reschedule `process_after` with backoff
|
||||
- `outbound.db` → due `messages_out` rows not yet in the host's `delivered` table (in `inbound.db`) → deliver
|
||||
- After completing/delivering a row with `recurrence`, insert next occurrence
|
||||
|
||||
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
|
||||
@@ -332,7 +361,7 @@ Two patterns, both handled at the host level:
|
||||
|
||||
In both cases, the approval and action execution happen on the host side, not the agent side.
|
||||
|
||||
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/access.ts`, `src/user-dm.ts`.
|
||||
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/modules/permissions/access.ts` and `src/modules/permissions/user-dm.ts` (`ensureUserDm`); the approver-picking primitives live in `src/modules/approvals/primitive.ts`.
|
||||
|
||||
**Editing a sent message:**
|
||||
|
||||
@@ -409,14 +438,16 @@ This is documented as a pattern, not a built-in feature.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `session.db` and the Claude SDK's `.claude/` directory. The session identity IS the folder — no need to track Claude SDK session IDs.
|
||||
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `inbound.db`, `outbound.db`, and — when using the claude provider — the SDK's `.claude/` directory. The session identity IS the folder. The SDK's resume token (session id) is persisted in `outbound.db`'s `session_state` table, so a fresh container picks the conversation back up without the host tracking it centrally.
|
||||
|
||||
**Container mount structure:**
|
||||
|
||||
```
|
||||
/workspace/ ← mount: session folder (read-write)
|
||||
.claude/ ← Claude SDK session data (auto-created)
|
||||
session.db ← session SQLite DB
|
||||
.claude/ ← Claude SDK session data / transcripts (auto-created)
|
||||
inbound.db ← host writes, container reads (opened read-only)
|
||||
outbound.db ← container writes, host reads (opened read-only)
|
||||
.heartbeat ← container touches this; host watches its mtime
|
||||
outbox/ ← agent-runner writes outbound files here
|
||||
agent/ ← mount: agent group folder (nested, read-write)
|
||||
CLAUDE.md ← agent instructions
|
||||
@@ -424,11 +455,17 @@ This is documented as a pattern, not a built-in feature.
|
||||
... working files
|
||||
```
|
||||
|
||||
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace). The session DB is at `/workspace/session.db`.
|
||||
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace).
|
||||
|
||||
This works on both Docker (nested bind mounts) and Apple Container (directory mounts only — no file-level mounts, but nested directory mounts are supported).
|
||||
The runtime is Docker (`src/container-runtime.ts` hardcodes the `docker` binary); nested bind mounts make this layout straightforward. The layout deliberately sticks to directory mounts (no file-level mounts) so it stays portable to runtimes that only support directory mounts.
|
||||
|
||||
**Session DB concurrent access:** The host writes messages_in, the agent-runner writes messages_out. Both access the same SQLite file simultaneously. WAL mode handles this — SQLite allows concurrent readers, and the two sides write to different tables so writer contention is minimal. The host enables WAL mode when creating the session DB.
|
||||
**Cross-mount DB access:** The two files exist precisely so each has a single writer — the
|
||||
host writes `inbound.db`, the container writes `outbound.db` — which removes writer
|
||||
contention across the mount. Both files use `journal_mode=DELETE`, **not** WAL: WAL keeps its
|
||||
index in a memory-mapped `-shm` file, and VirtioFS does not propagate that mmap coherency
|
||||
from host to guest, so a WAL reader in the container would freeze on an early snapshot and
|
||||
silently never see new host writes. Readers that must see fresh host writes promptly (the
|
||||
`messages_in` poll) open `inbound.db` with `mmap_size = 0` to bypass SQLite's page cache.
|
||||
|
||||
**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder.
|
||||
|
||||
@@ -439,7 +476,7 @@ This works on both Docker (nested bind mounts) and Apple Container (directory mo
|
||||
3. More messages arrive before container starts → host finds the existing session, writes to the same session DB
|
||||
4. Container starts, mounts the folder, agent-runner finds messages waiting
|
||||
|
||||
The central DB session row creation is the serialization point. No Claude SDK session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
|
||||
The central DB session row creation is the serialization point. No provider session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
|
||||
|
||||
**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row.
|
||||
|
||||
@@ -449,7 +486,9 @@ The central DB session row creation is the serialization point. No Claude SDK se
|
||||
|
||||
### Output Delivery
|
||||
|
||||
NanoClaw does not stream tokens to users. The Claude Agent SDK's `query()` yields complete results. The agent-runner writes one complete message to messages_out per result. The host delivers complete messages to channels.
|
||||
NanoClaw does not stream tokens to users. The provider's query interface yields complete results, but a result's text is not delivered as-is: the agent-runner parses it for `<message to="name">...</message>` blocks (`dispatchResultText` in poll-loop.ts) and writes one messages_out row per block, addressed to that destination with its thread context resolved per destination. Everything outside a block — including `<internal>...</internal>` — is scratchpad: logged, never sent. A block naming an unknown destination is dropped into the scratchpad log.
|
||||
|
||||
If a result produced text but no valid block, the agent-runner pushes a one-time `<system>` nudge into the live turn asking the agent to re-wrap its response. The exception is a non-retryable error result (e.g. a billing error) with no envelope, which is delivered as an error notice instead of being dropped as scratchpad. Mid-turn interim updates go out through the `send_message` MCP tool; the final-text envelope parsing is how a turn's reply reaches the user. The host delivers complete messages_out rows to channels.
|
||||
|
||||
Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism.
|
||||
|
||||
@@ -457,21 +496,30 @@ Typing indicators: host sets typing when a container is active for a session, cl
|
||||
|
||||
### Message Batching
|
||||
|
||||
When multiple messages arrive while the container is down, they accumulate as `handled = 0` rows in messages_in. When the container wakes up, the agent-runner queries all unhandled messages and processes them as a batch — multiple messages are formatted into a single `<messages>` XML block.
|
||||
When multiple messages arrive while the container is down, they accumulate as `status = 'pending'` rows in `messages_in`. When the container wakes up, the agent-runner reads all pending messages (those not yet in `processing_ack`) and processes them as a batch — formatted as a `<context timezone="…" />` header followed by the messages concatenated as consecutive `<message>` blocks. (There is no `<messages>` wrapper element; see [agent-runner-details.md](agent-runner-details.md#message-formatting).)
|
||||
|
||||
### Message Lifecycle
|
||||
|
||||
```
|
||||
pending → processing → completed
|
||||
→ failed (after max retries)
|
||||
messages_in.status: pending ──────────► completed (mirrored from ack)
|
||||
└─────────► failed (host-set, retries exhausted)
|
||||
processing_ack.status: processing → completed
|
||||
```
|
||||
|
||||
- **pending**: Written by host. Ready to be picked up (if `process_after` is null or past).
|
||||
- **processing**: Agent-runner sets this when it picks up the message. `status_changed` is set to now. Prevents other polls from re-picking the same message.
|
||||
- **completed**: Agent-runner sets this after successful processing.
|
||||
- **failed**: Set after max retries exhausted.
|
||||
Because `inbound.db` is read-only in the container, the agent-runner never mutates
|
||||
`messages_in.status`. It records lifecycle in `processing_ack` (in `outbound.db`); the host
|
||||
reads that and mirrors completion back.
|
||||
|
||||
**Stale detection**: If a message is `processing` but `status_changed` is too old (e.g., >10 minutes), the host assumes the container crashed. It resets the message to `pending`, increments `tries`, and sets `process_after` with exponential backoff.
|
||||
- **pending**: Host writes the `messages_in` row. Ready to be picked up (if `process_after` is null or past).
|
||||
- **processing**: Agent-runner upserts a `processing_ack` row (`status = 'processing'`) when it claims the message. Subsequent polls skip any id already in `processing_ack`, so it isn't re-picked.
|
||||
- **completed**: Agent-runner sets `processing_ack.status = 'completed'` for **every** consumed batch, error outcomes included — a provider error is surfaced to the user as an error chat message in `messages_out`, then the batch is still acked completed. The host's `syncProcessingAcks` copies it onto `messages_in.status`.
|
||||
- **failed**: Set by the **host** (sweep's `markMessageFailed`) when retries are exhausted — never by the container.
|
||||
|
||||
**Liveness / stale detection**: The container touches a `/workspace/.heartbeat` file rather
|
||||
than writing the DB. The host sweep watches that mtime (widening its tolerance when
|
||||
`container_state` shows a long-declared Bash running) to decide a container has crashed, then
|
||||
increments `tries` and reschedules `process_after` with exponential backoff. On the next
|
||||
container startup, leftover `processing` acks are cleared so orphaned claims re-process.
|
||||
|
||||
### Error Handling and Retries
|
||||
|
||||
@@ -595,13 +643,15 @@ src/db/
|
||||
- **No inline ALTER TABLE.** A migration runner with a `schema_version` table replaces `try { ALTER TABLE } catch { /* exists */ }` blocks. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`.
|
||||
- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches).
|
||||
|
||||
**Agent-runner session DB** uses the same pattern but lighter — no migrations needed since session DBs are created fresh by the host:
|
||||
**Agent-runner session DBs** use the same pattern but lighter — no migrations needed since the DB files are created fresh by the host:
|
||||
|
||||
```
|
||||
container/agent-runner/src/db/
|
||||
connection.ts ← open session.db at fixed path, WAL mode
|
||||
messages-in.ts ← read pending, update status
|
||||
messages-out.ts ← write results, outbox queries
|
||||
connection.ts ← open inbound.db (read-only) + outbound.db (DELETE mode) at fixed paths
|
||||
messages-in.ts ← read pending from inbound.db, ack via processing_ack in outbound.db
|
||||
messages-out.ts ← write results/outbox rows to outbound.db (odd seq)
|
||||
session-state.ts ← resume continuation, keyed per provider
|
||||
session-routing.ts ← read the host-written default reply routing
|
||||
index.ts ← barrel
|
||||
```
|
||||
|
||||
@@ -664,9 +714,13 @@ CREATE TABLE agent_groups (
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
agent_provider TEXT, -- default for sessions (null = system default)
|
||||
container_config TEXT, -- JSON: { additionalMounts, timeout }
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- Container config is NOT a column here — it lives in a separate container_configs
|
||||
-- table (migration 014), keyed by agent_group_id, with columns: provider, model,
|
||||
-- effort, image_tag, assistant_name, max_messages_per_prompt, cli_scope, and JSON
|
||||
-- columns skills / mcp_servers / packages_apt / packages_npm / additional_mounts.
|
||||
-- The host materializes it into /workspace/agent/container.json for the container.
|
||||
|
||||
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
|
||||
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
|
||||
@@ -721,16 +775,21 @@ CREATE TABLE user_dms (
|
||||
PRIMARY KEY (user_id, channel_type)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups, with what rules
|
||||
-- Which agent groups handle which messaging groups, with what rules.
|
||||
-- The opaque trigger_rules JSON + response_scope enum were replaced (migration
|
||||
-- 010) by four orthogonal axes:
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
|
||||
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
|
||||
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
|
||||
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
|
||||
created_at TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
engage_mode TEXT NOT NULL DEFAULT 'mention', -- 'pattern' | 'mention' | 'mention-sticky'
|
||||
engage_pattern TEXT, -- regex; required for engage_mode='pattern'
|
||||
-- ('.' = match every message, the "always" flavor)
|
||||
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
|
||||
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
@@ -795,7 +854,7 @@ stopped → running → idle → stopped
|
||||
|
||||
## Agent-Runner Architecture
|
||||
|
||||
The agent-runner is the process inside the container. It mediates between the session DB and the Claude SDK — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
|
||||
The agent-runner is the process inside the container. It mediates between the session DB and the agent provider — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
|
||||
|
||||
### IO Model
|
||||
|
||||
@@ -808,50 +867,58 @@ All IO goes through the session DB. No stdin, no stdout markers, no IPC files.
|
||||
|
||||
### Poll Loop
|
||||
|
||||
1. Query `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`
|
||||
2. If rows found: set `status = 'processing'`, `status_changed = now()` on each
|
||||
1. Query `inbound.db` (read-only) for `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`, skipping any id already in `processing_ack`
|
||||
2. If rows found: upsert `processing_ack` rows with `status = 'processing'` in `outbound.db` (the container can't write `messages_in`)
|
||||
3. Batch messages into a single prompt (strip routing fields, format by kind)
|
||||
4. Push into Claude SDK's MessageStream
|
||||
4. Push into the provider's input stream
|
||||
5. Process agent output → write `messages_out` rows
|
||||
6. Set processed messages to `status = 'completed'`
|
||||
6. Set the processed ids' `processing_ack.status = 'completed'` (the host mirrors that onto `messages_in.status`)
|
||||
7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout)
|
||||
|
||||
### Message Formatting by Kind
|
||||
|
||||
Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content.
|
||||
|
||||
- **`chat`** — format into `<messages>` XML block
|
||||
- **`chat-sdk`** — extract text, author, attachments from serialized message; format into `<messages>` XML
|
||||
- **`task`** — format as `[SCHEDULED TASK]` prefix + prompt. Run pre-script if present.
|
||||
- **`webhook`** — format as `[WEBHOOK: source/event]` + JSON payload
|
||||
- **`system`** — host action results (e.g., "register_group succeeded"). Format as system context, not chat.
|
||||
- **`chat`** — format into a `<message id="…" from="…" sender="…" time="…">` element
|
||||
- **`chat-sdk`** — extract text, author, attachments from serialized message; same `<message>` element
|
||||
- **`task`** — format as a `<task from="…" time="…">` element (script output first if present). Run pre-script if present.
|
||||
- **`webhook`** — format as a `<webhook source="…" event="…">` element wrapping the JSON payload
|
||||
- **`system`** — host action results, formatted as `<system_response action="…" status="…">`, not chat
|
||||
|
||||
Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, create an agent, self-modify) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
|
||||
**Core tools:**
|
||||
**Messaging & interaction:**
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
|
||||
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
|
||||
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
|
||||
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
|
||||
|
||||
**New tools:**
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `ask_user_question` | Write `messages_out` with question card. Hold tool call open, poll `messages_in` for response matching `questionId`. Return selection as tool result. |
|
||||
| `edit_message` | Write `messages_out` with `operation: 'edit'` |
|
||||
| `send_message` | Resolve `to` (destination name) → routing, write `messages_out` row, `kind: 'chat'`. Omit `to` to reply in place. Also the agent-to-agent path: a `to` naming an `agent`-type destination. |
|
||||
| `send_file` | Copy file to `outbox/{msg_id}/`, write `messages_out` (`kind: 'chat'`) with filenames, same `to` resolution |
|
||||
| `send_card` | Write `messages_out`, `kind: 'chat-sdk'`, content `{ type: 'card', … }` |
|
||||
| `ask_user_question` | Write `messages_out` (`kind: 'chat-sdk'`, `type: 'ask_question'`). Hold tool call open, poll `inbound.db` for the response matching `questionId`. Return selection as tool result. |
|
||||
| `edit_message` | Write `messages_out` with `operation: 'edit'` (targets the original message's destination) |
|
||||
| `add_reaction` | Write `messages_out` with `operation: 'reaction'` |
|
||||
| `send_to_agent` | Write `messages_out` with `channel_type: 'agent'`, `platform_id: '{target}'` |
|
||||
| `send_card` | Write `messages_out` with card structure |
|
||||
|
||||
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
|
||||
|
||||
**Scheduling** (all emit `kind: 'system'` actions except the read-only `list_tasks`):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `schedule_task` | `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `inbound.db` (read-only) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` / `update_task` | matching `action`; host updates the live `messages_in` row(s) |
|
||||
|
||||
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `create_agent` | `action: 'create_agent'` (name + instructions); host creates the agent group (replaces the old `register_agent_group`) |
|
||||
| `install_packages` | `action: 'install_packages'`; on approval host rebuilds the per-agent image and restarts |
|
||||
| `add_mcp_server` | `action: 'add_mcp_server'`; on approval host updates `container.json` and restarts |
|
||||
|
||||
See [agent-runner-details.md](agent-runner-details.md) for full MCP tool parameter definitions.
|
||||
|
||||
@@ -887,11 +954,11 @@ The command lists are hardcoded in the agent-runner. Admin verification happens
|
||||
|
||||
The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds.
|
||||
|
||||
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking Claude.
|
||||
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking the provider.
|
||||
|
||||
### Agent-to-Agent Messaging
|
||||
|
||||
**Outbound:** Agent calls `send_to_agent` tool → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to target session's messages_in.
|
||||
**Outbound:** Agent calls `send_message(to="<agent-name>")` where the named destination is of type `agent` → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to the target session's `inbound.db` (recording `source_session_id` so the reply routes back to this exact session).
|
||||
|
||||
**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message.
|
||||
|
||||
@@ -907,7 +974,7 @@ Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent
|
||||
|
||||
- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global?
|
||||
- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time?
|
||||
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The session DB is at a fixed mount path. System prompt comes from CLAUDE.md. Provider name comes from env. What else?
|
||||
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The DB files are at fixed mount paths. System prompt comes from CLAUDE.md. Provider name comes from `container.json` (materialized from the `container_configs` table), not env. What else?
|
||||
- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently.
|
||||
|
||||
## Related Documents
|
||||
|
||||
@@ -33,13 +33,13 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
|
||||
## Supply chain
|
||||
|
||||
- **Host + global CLIs** (pnpm): `minimumReleaseAge: 4320` (3-day hold on new versions), `onlyBuiltDependencies` allowlist for postinstall scripts. See `pnpm-workspace.yaml` and `docs/SECURITY.md`.
|
||||
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus version-pinned CLIs/Bun itself via Dockerfile ARGs. When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
|
||||
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus a version-pinned Bun itself via a Dockerfile ARG (global CLIs are pinned separately in `container/cli-tools.json`). When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
|
||||
|
||||
## Image build surface
|
||||
|
||||
`container/Dockerfile` is a single-stage build on `node:22-slim`:
|
||||
|
||||
- **Pinned ARGs** — `BUN_VERSION`, `CLAUDE_CODE_VERSION`, `AGENT_BROWSER_VERSION`, `VERCEL_VERSION`. Bump deliberately in PRs.
|
||||
- **Pinned ARGs** — `BUN_VERSION`, `PNPM_VERSION`, `INSTALL_CJK_FONTS`. Bump deliberately in PRs. Global CLI versions (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`) are pinned separately in `container/cli-tools.json`, not as ARGs.
|
||||
- **CJK fonts** — `ARG INSTALL_CJK_FONTS=false`. `container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content.
|
||||
- **BuildKit cache mounts** — `/var/cache/apt`, `/var/lib/apt`, `/root/.bun/install/cache`, `/root/.cache/pnpm`. Rebuilds where `package.json`/`bun.lock` haven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat).
|
||||
- **`tini` as init** — reaps Chromium zombies, forwards signals so in-flight `outbound.db` writes finalize on SIGTERM.
|
||||
@@ -49,7 +49,7 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
|
||||
## Session wake (two paths)
|
||||
|
||||
1. **Base image ENTRYPOINT** — used for stdin-piped test invocations like the sample in `container/build.sh`: `tini --> entrypoint.sh` captures stdin to `/tmp/input.json`, then `exec bun run src/index.ts`.
|
||||
2. **Host-spawned session** — `src/container-runner.ts` at line ~301 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
|
||||
2. **Host-spawned session** — `src/container-runner.ts` at line ~503 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
|
||||
|
||||
Both paths end with Bun running the same source file from `/app/src/index.ts`.
|
||||
|
||||
|
||||
+105
-24
@@ -2,7 +2,7 @@
|
||||
|
||||
Complete reference for `data/v2.db`, the host-owned admin-plane database. Start with [db.md](db.md) for the three-DB overview, the map, and the cross-mount rules.
|
||||
|
||||
Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (comments only — actual creation runs via migrations in `src/db/migrations/`).
|
||||
Access layer: `src/db/`. `src/db/schema.ts`'s `SCHEMA` constant is a *reference copy* of the core tables for orientation — it is not exhaustive: several tables (`agent_destinations`, `pending_approvals`, `container_configs`, `agent_message_policies`, `pending_channel_approvals`, and others) exist only in their migration files under `src/db/migrations/`, which remain the actual source of truth for what's created at runtime.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,20 +55,24 @@ Wiring: which agent group handles which messaging group. Many-to-many — the sa
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
engage_mode TEXT NOT NULL DEFAULT 'mention',
|
||||
-- 'pattern' | 'mention' | 'mention-sticky'
|
||||
engage_pattern TEXT, -- regex; required when engage_mode='pattern';
|
||||
-- '.' means "match every message"
|
||||
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
|
||||
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
- `session_mode`: `shared` (one session per channel), `per-thread` (one per thread), `agent-shared` (one per agent group across all channels).
|
||||
- `trigger_rules`: JSON; e.g. regex for native channels.
|
||||
- `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy`: four orthogonal axes (migration 010) that replaced v1's opaque `trigger_rules` JSON + `response_scope` enum. `engage_mode='pattern'` requires `engage_pattern` (`'.'` matches every message — the "always respond" flavor); `sender_scope='known'` restricts engagement to group members; `ignored_message_policy='accumulate'` keeps ignored messages as context instead of dropping them.
|
||||
- **Side effect:** creating a wiring must also populate `agent_destinations` — don't mutate one without the other (see §1.10).
|
||||
|
||||
### 1.4 `users`
|
||||
@@ -323,6 +327,71 @@ CREATE TABLE container_configs (
|
||||
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||
|
||||
### 1.16 `pending_sender_approvals`
|
||||
|
||||
In-flight state for the `unknown_sender_policy = 'request_approval'` flow. A row exists while an admin-approval card is outstanding for a first-time sender in a wired messaging group; `UNIQUE(messaging_group_id, sender_identity)` dedups concurrent attempts from the same sender instead of spamming the admin with repeat cards.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_sender_approvals (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle)
|
||||
sender_name TEXT,
|
||||
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
|
||||
approver_user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', -- added by migration 013
|
||||
options_json TEXT NOT NULL DEFAULT '[]', -- added by migration 013
|
||||
UNIQUE(messaging_group_id, sender_identity)
|
||||
);
|
||||
```
|
||||
|
||||
Deleted on admin approve (after adding the sender as a member) or deny.
|
||||
|
||||
- Access layer: `src/modules/permissions/db/pending-sender-approvals.ts`
|
||||
- **Readers/writers:** `src/modules/permissions/sender-approval.ts`, `src/modules/permissions/index.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
|
||||
|
||||
### 1.17 `pending_channel_approvals`
|
||||
|
||||
In-flight state for the unknown-channel registration flow. When a channel with no `messaging_group_agents` wiring receives a mention or DM, the router escalates to the owner; `PRIMARY KEY(messaging_group_id)` gives free in-flight dedup via `INSERT OR IGNORE` — a second mention while a card is pending drops silently.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pending_channel_approvals (
|
||||
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
-- agent the approved wiring will target (earliest
|
||||
-- agent_group by created_at, picked at request time)
|
||||
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
|
||||
approver_user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', -- added by migration 013
|
||||
options_json TEXT NOT NULL DEFAULT '[]' -- added by migration 013
|
||||
);
|
||||
```
|
||||
|
||||
Approve creates the `messaging_group_agents` wiring and replays the triggering event; deny sets `messaging_groups.denied_at` so future messages on that channel drop without re-prompting. Either way, this row is deleted.
|
||||
|
||||
- Access layer: `src/modules/permissions/db/pending-channel-approvals.ts`
|
||||
- **Readers/writers:** `src/modules/permissions/channel-approval.ts`, `src/modules/permissions/index.ts`, `src/router.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
|
||||
|
||||
### 1.18 `agent_message_policies`
|
||||
|
||||
Per-message approval gate on an agent-to-agent connection between two agent groups. No row for a `(from, to)` pair means free flow (no approval required); a row names the `approver` who must sign off on each message.
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_message_policies (
|
||||
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
approver TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
- Access layer: `src/modules/agent-to-agent/db/agent-message-policies.ts`
|
||||
- **Readers/writers:** `src/cli/resources/policies.ts`; approved messages create a row in `pending_approvals` (see §1.11) via the a2a send path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration system
|
||||
@@ -330,21 +399,33 @@ CREATE TABLE container_configs (
|
||||
Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMigrations()` in `src/db/migrations/index.ts`. It:
|
||||
|
||||
1. Creates `schema_version` if absent.
|
||||
2. Reads `MAX(version)` — call it `current`.
|
||||
3. For each migration with `version > current`, executes `up(db)` inside a transaction and appends a `schema_version` row.
|
||||
2. Reads every already-applied `name` from `schema_version` into a `Set` and filters the `migrations` barrel array down to the ones whose `name` isn't in that set — dedup is by **name**, not by the numeric `version` field.
|
||||
3. Runs each pending migration's `up(db)` inside a transaction, in the barrel array's literal order (which is *not* sorted by `version`), then inserts a `schema_version` row.
|
||||
4. The `version` column stored in `schema_version` is **not** the migration's own `version` field — it's `COALESCE(MAX(version), 0) + 1`, i.e. an auto-assigned applied-order number computed at insert time. The `version` field on the `Migration` object is just an ordering hint for humans reading the barrel file; it lets module migrations (installed later by skills) pick arbitrary numbers without coordinating with trunk.
|
||||
|
||||
| # | File | Introduces |
|
||||
|---|------|------------|
|
||||
| 001 | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
|
||||
| 002 | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
|
||||
| 003 | `003-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
|
||||
| 004 | `004-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
A few migrations also set `disableForeignKeys: true` (needed for table recreates — SQLite can't relax a table-level `UNIQUE` without DROP+RENAME, which fails FK integrity checks with live child rows). The runner toggles `PRAGMA foreign_keys` around the transaction and runs `PRAGMA foreign_key_check` inside it, snapshotting pre-existing violations so it only fails on violations the migration itself introduced.
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
Several early migrations were later renamed/retired and replaced by "module" files (their original `name` is retained on the new file so already-migrated DBs don't re-run them):
|
||||
|
||||
| Ver. | Name (stored in `schema_version`) | File | Introduces |
|
||||
|---|---|------|------------|
|
||||
| 1 | `initial-v2-schema` | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents` (with the original `trigger_rules`/`response_scope` columns — see v10), `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
|
||||
| 2 | `chat-sdk-state` | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
|
||||
| 3 | `pending-approvals` | `module-approvals-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
|
||||
| 4 | `agent-destinations` | `module-agent-to-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
|
||||
| 7 | `pending-approvals-title-options` | `module-approvals-title-options.ts` | Retroactive `ALTER TABLE pending_approvals` add `title`, `options_json` for DBs that ran migration 3 before its `CREATE TABLE` was edited to include those columns |
|
||||
| 8 | `dropped-messages` | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 9 | `drop-pending-credentials` | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 10 | `engage-modes` | `010-engage-modes.ts` | `messaging_group_agents`: add `engage_mode`, `engage_pattern`, `sender_scope`, `ignored_message_policy`; backfill from `trigger_rules`/`response_scope`; drop those two legacy columns (see §1.3) |
|
||||
| 11 | `pending-sender-approvals` | `011-pending-sender-approvals.ts` | `pending_sender_approvals` (see §1.16) |
|
||||
| 12 | `channel-registration` | `012-channel-registration.ts` | `messaging_groups.denied_at` + `pending_channel_approvals` (see §1.17) |
|
||||
| 13 | `approval-render-metadata` | `013-approval-render-metadata.ts` | `title`, `options_json` columns on `pending_channel_approvals` and `pending_sender_approvals` |
|
||||
| 14 | `container-configs` | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 15 | `cli-scope` | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
| 16 | `messaging-group-instance` | `016-messaging-group-instance.ts` | `messaging_groups` gets an `instance` column (adapter-instance dimension); table recreate (`disableForeignKeys: true`) backfills `instance = channel_type` on every existing row and relaxes the `UNIQUE` to `(channel_type, platform_id, instance)` |
|
||||
| 17 | `agent-message-policies` | `017-agent-message-policies.ts` | `agent_message_policies` (see §1.18) |
|
||||
| 18 | `approvals-approver-user-id` | `018-approvals-approver-user-id.ts` | `pending_approvals.approver_user_id` — names a single required approver for a2a message-gate policies |
|
||||
|
||||
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
Session DB schemas (`INBOUND_SCHEMA`, `OUTBOUND_SCHEMA`) are **not** versioned here. They're `CREATE TABLE IF NOT EXISTS` so new columns land via the session-DB lazy migration helpers (`migrateDeliveredTable()` etc.) when a session file from an older build is reopened. See [db-session.md](db-session.md).
|
||||
|
||||
+21
-1
@@ -10,13 +10,15 @@ Schemas live in `src/db/schema.ts` as the `INBOUND_SCHEMA` and `OUTBOUND_SCHEMA`
|
||||
|
||||
```
|
||||
data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
inbound.db ← host writes, container reads (read-only mount)
|
||||
inbound.db ← host writes, container reads (read-only open)
|
||||
outbound.db ← container writes, host reads (read-only open)
|
||||
.heartbeat ← mtime touched by container (not a DB write)
|
||||
inbox/<message_id>/ ← user attachments, decoded from inbound message content
|
||||
outbox/<message_id>/ ← attachments the agent produced
|
||||
```
|
||||
|
||||
The session directory itself is mounted read-write into the container (`src/container-runner.ts`) — read-only is *not* a mount property. The container opens `inbound.db` with `{ readonly: true }` at the SQLite connection layer (`container/agent-runner/src/db/connection.ts`), so the container could technically write to the underlying file via another path, but every code path that touches `inbound.db` from inside the container goes through that read-only handle.
|
||||
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
|
||||
|
||||
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
|
||||
@@ -177,6 +179,24 @@ CREATE TABLE session_state (
|
||||
|
||||
Access: `container/agent-runner/src/db/session-state.ts`.
|
||||
|
||||
### 4.4 `container_state`
|
||||
|
||||
Single-row (`id=1`) tool-in-flight tracker. The container records the currently-running tool on `PreToolUse` and clears it on `PostToolUse`/`PostToolUseFailure`; the host reads it during the stale-container sweep to widen its stuck-tolerance window when `Bash` is running with a user-declared `timeout` over the normal threshold, so long-running scripts aren't killed as "stuck".
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_tool TEXT,
|
||||
tool_declared_timeout_ms INTEGER,
|
||||
tool_started_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **Writer (container):** `setContainerToolInFlight()` / `clearContainerToolInFlight()` in `container/agent-runner/src/db/connection.ts`, called from the `preToolUseHook` / `postToolUseHook` in `container/agent-runner/src/providers/claude.ts`.
|
||||
- **Reader (host):** `getContainerState()` in `src/db/session-db.ts`; consumed by the sweep's `bashTimeoutMs()` helper in `src/host-sweep.ts`.
|
||||
- `CREATE TABLE IF NOT EXISTS` — forward-compatible with `outbound.db` files created before this table existed; `getContainerState()` returns `null` if the table or row is absent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Schema evolution
|
||||
|
||||
@@ -80,7 +80,7 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
|
||||
↕ many-to-many
|
||||
messaging_groups (a specific channel/chat/group on a platform)
|
||||
via
|
||||
messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
|
||||
```
|
||||
|
||||
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
|
||||
|
||||
@@ -10,11 +10,11 @@ Find out what is running and what is required:
|
||||
|
||||
```bash
|
||||
cat versions.json # the sanctioned pin
|
||||
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
|
||||
curl -s http://127.0.0.1:10254/api/health # liveness check; `version` field is typically "unknown", not the gateway version
|
||||
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
|
||||
```
|
||||
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's `ONECLI_URL` in `.env`; `NANOCLAW_ONECLI_API_HOST` is a setup-time override only, not persisted to `.env`).
|
||||
|
||||
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
|
||||
|
||||
|
||||
+2
-2
@@ -179,7 +179,7 @@ So during this step:
|
||||
marker.
|
||||
|
||||
The level-2 log still gets an entry (`auth [interactive] → success`
|
||||
with the method — subscription / oauth-token / api-key). Level-3 captures
|
||||
with the method — subscription / oauth / api). Level-3 captures
|
||||
are optional here; mirroring `script -q` output is tricky and the risk of
|
||||
leaking the token to disk outweighs the debugging value.
|
||||
|
||||
@@ -190,7 +190,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
|
||||
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
|
||||
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/logs.ts` | The logging primitives (`step`, `userInput`, `complete`, `stepRawLog`, `reset`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
|
||||
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
|
||||
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
|
||||
@@ -14,7 +14,7 @@ Last updated: 2026-04-09
|
||||
- Container clears stale `processing_ack` entries on startup (crash recovery)
|
||||
- Files: `src/db/schema.ts` (INBOUND_SCHEMA + OUTBOUND_SCHEMA), `src/session-manager.ts`, `src/delivery.ts`, `src/host-sweep.ts`, `container/agent-runner/src/db/connection.ts`, `messages-in.ts`, `messages-out.ts`, `poll-loop.ts`, `mcp-tools/scheduling.ts`, `mcp-tools/interactive.ts`
|
||||
- Container image rebuilt with tsconfig (`container/agent-runner/tsconfig.json`)
|
||||
- E2E verified: host → Docker container → Claude responds → "E2E works!" ✓
|
||||
- E2E verified: host → Docker container → agent responds → "E2E works!" ✓
|
||||
|
||||
### OneCLI Integration
|
||||
- `ensureAgent()` call added before `applyContainerConfig()` in `src/container-runner.ts`
|
||||
@@ -65,11 +65,11 @@ Added `session_mode: 'agent-shared'` for cross-channel shared sessions (e.g. Git
|
||||
|
||||
### Entity Model
|
||||
```
|
||||
agent_groups (id, name, folder, agent_provider, container_config)
|
||||
↕ many-to-many
|
||||
messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy)
|
||||
agent_groups (id, name, folder, agent_provider)
|
||||
↕ many-to-many (container runtime config lives in the separate container_configs table)
|
||||
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
|
||||
via
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority)
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
|
||||
|
||||
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
|
||||
|
||||
+9
-15
@@ -2,27 +2,21 @@
|
||||
|
||||
A **template** is a reusable folder you stamp into a working agent group: it
|
||||
carries the agent's standing instructions, its MCP tool servers, and its skills,
|
||||
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
|
||||
but **no secrets and no provider**. Point `ncl` at one and
|
||||
you get a configured agent in seconds; you choose the runtime/provider
|
||||
separately.
|
||||
|
||||
Templates are purely additive: no DB migration, no new dependency. **At runtime,
|
||||
templates are resolved only from a local directory**: `templates/` at the
|
||||
Templates are purely additive: no DB migration, no new dependency. **Templates
|
||||
are resolved only from a local directory**: `templates/` at the
|
||||
project root by default (committed but shipped empty), or whatever
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
|
||||
discover templates from the public registry
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
|
||||
and copy a chosen one into your local `templates/` before stamping.
|
||||
is a manual copy source — clone or download it yourself and copy the chosen
|
||||
template into your local `templates/` before stamping.
|
||||
|
||||
## Using a template
|
||||
|
||||
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
|
||||
setup**, then either **NanoClaw template library** (clones the public registry,
|
||||
copies the template you pick into your local `templates/`) or **Local templates**
|
||||
(lists what's already in `templates/`). The normal auth step then picks the
|
||||
runtime, and the wizard stamps and wires your first agent.
|
||||
|
||||
**Anytime, via the CLI:**
|
||||
**Via the CLI:**
|
||||
|
||||
```bash
|
||||
ncl groups create --template sales/sdr --name "SDR Agent"
|
||||
@@ -40,8 +34,8 @@ e.g. `sales/sdr` → `templates/sales/sdr`.
|
||||
|
||||
For safety the ref must stay inside the templates directory: absolute paths, a
|
||||
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
|
||||
the setup wizard's library option), then stamp.
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, e.g.
|
||||
copying from the public registry), then stamp.
|
||||
|
||||
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
|
||||
is never a URL and never changes at runtime.
|
||||
|
||||
@@ -80,7 +80,7 @@ Tasks can exist before a session is awake — the host sweep creates/wakes the c
|
||||
|
||||
**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value.
|
||||
|
||||
Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`).
|
||||
Note: auto-created agents default to `all` secret mode — every vault secret whose host pattern matches is injected automatically. See the "Secret modes" section of the root CLAUDE.md if you want per-agent control (`onecli agents set-secret-mode --mode selective`).
|
||||
|
||||
**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.38",
|
||||
"version": "2.1.40",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
|
||||
<title>208k tokens, 104% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
|
||||
<title>213k tokens, 106% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
|
||||
<text x="71" y="14">208k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
|
||||
<text x="71" y="14">213k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+8
-2
@@ -43,8 +43,14 @@ async function main(): Promise<void> {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(formatResponse(res, json ? 'json' : 'human'));
|
||||
process.exit(res.ok ? 0 : 1);
|
||||
const output =
|
||||
!json && res.ok && res.human !== undefined
|
||||
? res.human + '\n' // server-rendered view — print verbatim
|
||||
: formatResponse(res, json ? 'json' : 'human');
|
||||
// Exit only after stdout drains: process.exit() discards buffered pipe
|
||||
// writes, silently truncating any response past the 64KB pipe buffer
|
||||
// (bit `ncl sessions list --json` at scale).
|
||||
process.stdout.write(output, () => process.exit(res.ok ? 0 : 1));
|
||||
}
|
||||
|
||||
function pickTransport(): Transport {
|
||||
|
||||
+32
-14
@@ -5,11 +5,10 @@
|
||||
* ncl groups help — show group resource details (verbs, columns, enums)
|
||||
*/
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getResource, getResources } from '../crud.js';
|
||||
import { getResources } from '../crud.js';
|
||||
import { renderVerbHelp, summaryLine } from '../help-render.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import { listCommands, register } from '../registry.js';
|
||||
|
||||
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, register } from '../registry.js';
|
||||
|
||||
function getCliScope(ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller !== 'agent') return undefined;
|
||||
@@ -76,13 +75,28 @@ export function registerResourceHelpCommands(): void {
|
||||
try {
|
||||
register({
|
||||
name: `${res.plural}-help`,
|
||||
description: `Show ${res.name} resource details.`,
|
||||
description: `Show ${res.name} resource details; \`${res.plural} help <verb>\` for one verb in depth.`,
|
||||
access: 'open',
|
||||
resource: res.plural,
|
||||
parseArgs: () => ({}),
|
||||
handler: async (_args, ctx) => {
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
const lines: string[] = [];
|
||||
|
||||
// `ncl <resource> help <verb>` arrives via the dispatcher's
|
||||
// longest-prefix fallback (`groups-help-create` → `groups-help` +
|
||||
// id=`create`) and renders that verb's deep help — full description,
|
||||
// flags, examples. No new routing. Group-scope auto-fill also puts
|
||||
// the caller's agent group ID into `id` on groups/destinations —
|
||||
// that's not a verb request, so ignore it.
|
||||
const autoFilled = ctx.caller === 'agent' && args.id === ctx.agentGroupId;
|
||||
const verbArg = !autoFilled && typeof args.id === 'string' ? args.id : null;
|
||||
if (verbArg) {
|
||||
const deep = renderVerbHelp(res, verbArg);
|
||||
if (!deep) throw new Error(`no verb "${verbArg}" on ${res.plural} — run \`ncl ${res.plural} help\``);
|
||||
return deep;
|
||||
}
|
||||
|
||||
lines.push(`${res.plural}: ${res.description}`);
|
||||
|
||||
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
|
||||
@@ -92,23 +106,27 @@ export function registerResourceHelpCommands(): void {
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Verbs
|
||||
// Verbs — one summary line each; deep help is a verb away. Only the
|
||||
// exceptional access levels are tagged: `open` is the unmarked default.
|
||||
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
|
||||
const idHint = idAutoFilled ? '' : ' <id>';
|
||||
const tag = (access: string | undefined) => (!access || access === 'open' ? '' : ` [${access}]`);
|
||||
const verbs: string[] = [];
|
||||
if (res.operations.list) verbs.push(`list [open]`);
|
||||
if (res.operations.get) verbs.push(`get${idHint} [open]`);
|
||||
if (res.operations.create) verbs.push(`create [approval]`);
|
||||
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
|
||||
if (res.operations.list) verbs.push(`list${tag(res.operations.list)}`);
|
||||
if (res.operations.get) verbs.push(`get${idHint}${tag(res.operations.get)}`);
|
||||
if (res.operations.create) verbs.push(`create${tag(res.operations.create)}`);
|
||||
if (res.operations.update) verbs.push(`update${idHint}${tag(res.operations.update)}`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint}${tag(res.operations.delete)}`);
|
||||
if (res.customOperations) {
|
||||
for (const [verb, op] of Object.entries(res.customOperations)) {
|
||||
verbs.push(`${verb} [${op.access}] — ${op.description}`);
|
||||
verbs.push(`${verb}${tag(op.access)} — ${summaryLine(op.description)}`);
|
||||
}
|
||||
}
|
||||
lines.push('Verbs:');
|
||||
for (const v of verbs) lines.push(` ${v}`);
|
||||
lines.push('');
|
||||
lines.push(`Run \`ncl ${res.plural} help <verb>\` (or add --help to any command) for flags and examples.`);
|
||||
lines.push('');
|
||||
|
||||
// Columns
|
||||
const autoFilledFields =
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../db/connection.js', () => ({ getDb: vi.fn() }));
|
||||
vi.mock('../db/container-configs.js', () => ({
|
||||
getContainerConfig: vi.fn(() => ({ cli_scope: 'group' })),
|
||||
}));
|
||||
|
||||
import { registerResource, validateArgs } from './crud.js';
|
||||
import { registerResourceHelpCommands } from './commands/help.js';
|
||||
import { lookup } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
// --- validateArgs unit ---
|
||||
|
||||
describe('validateArgs', () => {
|
||||
const defs = [
|
||||
{ name: 'target', type: 'string' as const, description: 'Target.', required: true },
|
||||
{ name: 'count', type: 'number' as const, description: 'Count.', default: 1 },
|
||||
{ name: 'force', type: 'boolean' as const, description: 'Force.' },
|
||||
{ name: 'meta', type: 'json' as const, description: 'Meta.' },
|
||||
{ name: 'size', type: 'string' as const, description: 'Size.', enum: ['s', 'm', 'l'] },
|
||||
];
|
||||
|
||||
it('coerces types per declaration', () => {
|
||||
const out = validateArgs(defs, { target: 'x', count: '5', force: 'true', meta: '{"a":1}' });
|
||||
expect(out).toMatchObject({ target: 'x', count: 5, force: true, meta: { a: 1 } });
|
||||
});
|
||||
|
||||
it('applies defaults for absent optional flags', () => {
|
||||
expect(validateArgs(defs, { target: 'x' }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a missing required flag', () => {
|
||||
expect(() => validateArgs(defs, {})).toThrow('--target is required');
|
||||
});
|
||||
|
||||
it('rejects unknown flags', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', bogus: '1' })).toThrow('unknown flag --bogus');
|
||||
});
|
||||
|
||||
it('tolerates dispatch-injected keys without declaration', () => {
|
||||
const out = validateArgs(defs, { target: 'x', id: 'ag-1', agent_group_id: 'ag-1', group: 'ag-1' });
|
||||
expect(out.id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('rejects enum violations', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', size: 'xl' })).toThrow('--size must be one of: s, m, l');
|
||||
});
|
||||
|
||||
it('rejects non-numeric values for number flags', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', count: 'many' })).toThrow('--count must be a number');
|
||||
});
|
||||
|
||||
it('rejects a value-less flag on a non-boolean (client sends true)', () => {
|
||||
expect(() => validateArgs(defs, { target: true })).toThrow('--target requires a value');
|
||||
});
|
||||
|
||||
it('accepts a value-less boolean flag', () => {
|
||||
expect(validateArgs(defs, { target: 'x', force: true }).force).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', () => {
|
||||
expect(() => validateArgs(defs, { target: 'x', meta: '{nope' })).toThrow('--meta must be valid JSON');
|
||||
});
|
||||
});
|
||||
|
||||
// --- registerResource wiring: strict where declared, lenient otherwise ---
|
||||
|
||||
registerResource({
|
||||
name: 'widget',
|
||||
plural: 'widgets',
|
||||
table: 'widgets',
|
||||
description: 'Test widgets.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
ping: {
|
||||
access: 'open',
|
||||
description: 'Ping a widget.',
|
||||
args: [{ name: 'target', type: 'string', description: 'Where to ping.', required: true }],
|
||||
examples: ['ncl widgets ping --target prod'],
|
||||
handler: async (args) => ({ echo: args }),
|
||||
},
|
||||
legacy: {
|
||||
access: 'open',
|
||||
description: 'Legacy op without declared args.',
|
||||
handler: async (args) => ({ echo: args }),
|
||||
},
|
||||
},
|
||||
});
|
||||
registerResourceHelpCommands();
|
||||
|
||||
describe('strict validation wiring (declared args)', () => {
|
||||
const parse = lookup('widgets-ping')!.parseArgs;
|
||||
|
||||
it('passes and coerces valid args', () => {
|
||||
expect(parse({ target: 'prod' })).toMatchObject({ target: 'prod' });
|
||||
});
|
||||
|
||||
it('failure carries the verb usage block (error + fix in one round-trip)', () => {
|
||||
let message = '';
|
||||
try {
|
||||
parse({});
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
expect(message).toContain('--target is required');
|
||||
expect(message).toContain('ncl widgets ping'); // usage line
|
||||
expect(message).toContain('Flags:');
|
||||
expect(message).toContain('Examples:');
|
||||
});
|
||||
|
||||
it('rejects unknown flags with the usage block', () => {
|
||||
expect(() => parse({ target: 'prod', bogus: '1' })).toThrow(/unknown flag --bogus[\s\S]*Flags:/);
|
||||
});
|
||||
|
||||
it('normalizes dashed flags before validating', () => {
|
||||
// --target arrives as raw key "target"; a dashed alias like "tar-get" would
|
||||
// normalize to underscores — prove normalize runs before validate.
|
||||
expect(() => parse({ 'bogus-flag': '1', target: 'x' })).toThrow('unknown flag --bogus-flag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lenient ops (no declared args) keep legacy behavior', () => {
|
||||
it('passes stray flags through untouched', () => {
|
||||
const parse = lookup('widgets-legacy')!.parseArgs;
|
||||
expect(parse({ anything: 'goes', 'dash-key': '1' })).toMatchObject({ anything: 'goes', dash_key: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- resource help: deep verb view + group-scope auto-fill guard ---
|
||||
|
||||
describe('resource help command', () => {
|
||||
const helpCmd = lookup('widgets-help')!;
|
||||
const host: CallerContext = { caller: 'host' };
|
||||
const agent: CallerContext = {
|
||||
caller: 'agent',
|
||||
sessionId: 'sess-1',
|
||||
agentGroupId: 'ag-1',
|
||||
messagingGroupId: 'mg-1',
|
||||
};
|
||||
|
||||
it('renders the resource overview with verb summaries', async () => {
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({}), host)) as string;
|
||||
expect(out).toContain('widgets: Test widgets.');
|
||||
expect(out).toContain('ping — Ping a widget.');
|
||||
expect(out).toContain('help <verb>');
|
||||
});
|
||||
|
||||
it('renders deep help for `help <verb>` (id from prefix fallback)', async () => {
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ping' }), host)) as string;
|
||||
expect(out).toContain('ncl widgets ping');
|
||||
expect(out).toContain('--target');
|
||||
expect(out).toContain('Examples:');
|
||||
});
|
||||
|
||||
it('errors on an unknown verb', async () => {
|
||||
await expect(helpCmd.handler(helpCmd.parseArgs({ id: 'bogus' }), host)).rejects.toThrow(
|
||||
'no verb "bogus" on widgets',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an auto-filled agent group id as no verb (scoped agent, plain help)', async () => {
|
||||
// dispatch auto-fills id=ctx.agentGroupId on groups/destinations; the
|
||||
// handler must show the overview, not "no verb <uuid>".
|
||||
const out = (await helpCmd.handler(helpCmd.parseArgs({ id: 'ag-1' }), agent)) as string;
|
||||
expect(out).toContain('widgets: Test widgets.');
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { renderVerbHelp } from './help-render.js';
|
||||
import { register } from './registry.js';
|
||||
import type { Access } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
@@ -37,9 +38,21 @@ export interface ColumnDef {
|
||||
|
||||
export interface CustomOperation {
|
||||
access: Access;
|
||||
/** First line = one-line summary (resource help). Full text renders in the
|
||||
* per-verb deep help (`ncl <resource> help <verb>` / `--help`). */
|
||||
description: string;
|
||||
/**
|
||||
* Declaring args opts this verb into strict validation: required/enum/type
|
||||
* checks plus unknown-flag rejection, with the verb's generated usage block
|
||||
* appended to every failure. Omit to keep the legacy lenient behavior
|
||||
* (handler validates by hand, stray flags ignored).
|
||||
*/
|
||||
args?: ColumnDef[];
|
||||
/** Ready-to-paste invocations, rendered under EXAMPLES in deep help. */
|
||||
examples?: string[];
|
||||
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
|
||||
/** Presentational renderer for human mode — see CommandDef.formatHuman. */
|
||||
formatHuman?: (data: unknown) => string;
|
||||
}
|
||||
|
||||
export interface ResourceDef {
|
||||
@@ -110,8 +123,11 @@ function genericList(def: ResourceDef) {
|
||||
}
|
||||
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
|
||||
params.push(limit);
|
||||
// Newest first: without an ORDER BY the LIMIT silently hides the most
|
||||
// recently inserted rows once a table outgrows it (bit `sessions list`
|
||||
// past 200 sessions — a just-created session was invisible).
|
||||
return getDb()
|
||||
.prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`)
|
||||
.prepare(`SELECT ${cols} FROM ${def.table}${where} ORDER BY rowid DESC LIMIT ?`)
|
||||
.all(...params);
|
||||
};
|
||||
}
|
||||
@@ -222,6 +238,85 @@ function normalizeArgs(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strict arg validation (opt-in via CustomOperation.args)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Keys the dispatcher may inject into `req.args` before parseArgs runs
|
||||
* (group-scope auto-fill). Strict validation must tolerate them even when a
|
||||
* verb doesn't declare them, or every scoped agent call breaks.
|
||||
*/
|
||||
const DISPATCH_INJECTED_KEYS = ['id', 'agent_group_id', 'group'] as const;
|
||||
|
||||
/**
|
||||
* Validate `args` (already underscore-normalized) against a ColumnDef list:
|
||||
* unknown-flag rejection, required, enum, and type coercion per the declared
|
||||
* type. Returns a coerced copy; throws with a focused message on the first
|
||||
* problem. Works from any ColumnDef list so generic CRUD resources can opt
|
||||
* into the same strictness later without a second validator.
|
||||
*/
|
||||
export function validateArgs(
|
||||
defs: ColumnDef[],
|
||||
args: Record<string, unknown>,
|
||||
opts: { allowExtra?: readonly string[] } = {},
|
||||
): Record<string, unknown> {
|
||||
const declared = new Map(defs.map((d) => [d.name, d]));
|
||||
const allowed = new Set<string>([...declared.keys(), ...(opts.allowExtra ?? DISPATCH_INJECTED_KEYS)]);
|
||||
|
||||
for (const key of Object.keys(args)) {
|
||||
if (!allowed.has(key)) {
|
||||
throw new Error(`unknown flag --${key.replace(/_/g, '-')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = { ...args };
|
||||
for (const def of defs) {
|
||||
const flag = `--${def.name.replace(/_/g, '-')}`;
|
||||
const v = args[def.name];
|
||||
if (v === undefined) {
|
||||
if (def.required) throw new Error(`${flag} is required`);
|
||||
if (def.default !== undefined) out[def.name] = def.default;
|
||||
continue;
|
||||
}
|
||||
// The client parses a value-less `--flag` as boolean true.
|
||||
if (v === true && def.type !== 'boolean') {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
switch (def.type) {
|
||||
case 'number': {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new Error(`${flag} must be a number, got "${v}"`);
|
||||
out[def.name] = n;
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
if (v === true || v === 'true' || v === '1') out[def.name] = true;
|
||||
else if (v === false || v === 'false' || v === '0') out[def.name] = false;
|
||||
else throw new Error(`${flag} must be true or false, got "${v}"`);
|
||||
break;
|
||||
}
|
||||
case 'json': {
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
out[def.name] = JSON.parse(v);
|
||||
} catch {
|
||||
throw new Error(`${flag} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'string':
|
||||
out[def.name] = String(v);
|
||||
break;
|
||||
}
|
||||
if (def.enum && !def.enum.includes(String(out[def.name]))) {
|
||||
throw new Error(`${flag} must be one of: ${def.enum.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerResource
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,17 +386,31 @@ export function registerResource(def: ResourceDef): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Custom operations
|
||||
// Custom operations. Declaring `args` opts the verb into strict validation;
|
||||
// every failure carries the verb's usage block so a caller (human or agent)
|
||||
// can fix the invocation without a second help round-trip.
|
||||
if (def.customOperations) {
|
||||
for (const [verb, op] of Object.entries(def.customOperations)) {
|
||||
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,
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
parseArgs: declared
|
||||
? (raw) => {
|
||||
try {
|
||||
return validateArgs(declared, normalizeArgs(raw));
|
||||
} catch (e) {
|
||||
const usage = renderVerbHelp(def, verb);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(usage ? `${msg}\n\n${usage}` : msg);
|
||||
}
|
||||
}
|
||||
: (raw) => normalizeArgs(raw),
|
||||
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
|
||||
formatHuman: op.formatHuman,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+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'),
|
||||
);
|
||||
|
||||
+342
-7
@@ -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.
|
||||
@@ -174,6 +179,17 @@ register({
|
||||
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
|
||||
});
|
||||
|
||||
// A dash-joined command whose custom-operation key contains spaces
|
||||
// ('config update') — used by the --help space/dash bridging test.
|
||||
register({
|
||||
name: 'groups-config-update',
|
||||
description: 'bare registry description (should not be the help answer)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
// The real `sessions-get` name — triggers the pre-handler ownership check.
|
||||
register({
|
||||
name: 'sessions-get',
|
||||
@@ -461,10 +477,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(),
|
||||
});
|
||||
@@ -473,6 +499,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 () => {
|
||||
@@ -595,18 +688,260 @@ describe('CLI scope enforcement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
|
||||
// Multi-segment command, to prove the longest-prefix match (verb itself has dashes).
|
||||
register({
|
||||
name: 'groups-cfg-get',
|
||||
description: 'test multi-segment command',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
describe('dash-joined positional id resolution', () => {
|
||||
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
describe('positional dashed-id resolution', () => {
|
||||
const host = { caller: 'host' as const };
|
||||
|
||||
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
||||
it('resolves a long dashed id to command + intact id (no shredding)', async () => {
|
||||
const id = 'task-374f0630-d3e0-4965-81da-fe4bf7a6a442';
|
||||
const resp = await dispatch({ id: '1', command: `groups-test-${id}`, args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id } });
|
||||
});
|
||||
|
||||
it('matches the LONGEST command prefix when the verb itself has dashes', async () => {
|
||||
const resp = await dispatch({ id: '2', command: 'groups-cfg-get-abc-123', args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'abc-123' } });
|
||||
});
|
||||
|
||||
it('leaves a registered no-id command alone', async () => {
|
||||
const resp = await dispatch({ id: '3', command: 'groups-test', args: {} }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: {} });
|
||||
});
|
||||
|
||||
it('does not override an explicit --id', async () => {
|
||||
const resp = await dispatch({ id: '4', command: 'groups-test-tail', args: { id: 'explicit' } }, host);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.data).toEqual({ echo: { id: 'explicit' } });
|
||||
});
|
||||
});
|
||||
|
||||
// --- `--help` interception: answer with generated help, execute nothing ---
|
||||
|
||||
describe('--help interception', () => {
|
||||
it('returns command help instead of executing (open command)', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe(uuid);
|
||||
// No deep resource def for 'test' → falls back to the description.
|
||||
expect(resp.data).toBe('test command (non-group resource)');
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the help text in `human` so clients print it verbatim', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(typeof resp.data).toBe('string');
|
||||
expect(resp.human).toBe(resp.data);
|
||||
}
|
||||
});
|
||||
|
||||
it('never mints an approval card for --help on an approval-gated command', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: { help: true } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(approvalState.requestApproval).not.toHaveBeenCalled();
|
||||
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
|
||||
});
|
||||
|
||||
it('renders deep verb help when the resource def is available', async () => {
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'id',
|
||||
columns: [],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
test: {
|
||||
access: 'open',
|
||||
description: 'Deep test op.',
|
||||
args: [{ name: 'foo', type: 'string', description: 'A foo.', required: true }],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toContain('ncl groups test');
|
||||
expect(resp.data).toContain('--foo');
|
||||
expect(resp.data).toContain('(required)');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders deep verb help for a multi-word custom-operation key (spaces vs dashes)', async () => {
|
||||
// registerResource stores the op under 'config update' but registers the
|
||||
// command as 'groups-config-update'; help must bridge the two.
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'id',
|
||||
columns: [],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
'config update': {
|
||||
access: 'open',
|
||||
description: 'Update container config.',
|
||||
args: [{ name: 'model', type: 'string', description: 'Model override.' }],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-config-update', args: { help: true } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.data).toContain('ncl groups config update');
|
||||
expect(resp.data).toContain('--model');
|
||||
// Not the bare registry description fallback:
|
||||
expect(resp.data).not.toBe('bare registry description (should not be the help answer)');
|
||||
}
|
||||
});
|
||||
|
||||
it('still enforces group scope before answering --help', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'wirings-list', args: { help: true } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Unknown-command errors carry their fix ---
|
||||
|
||||
describe('unknown-command errors', () => {
|
||||
it('lists the resource verbs when the command names a known resource', async () => {
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
plural === 'groups'
|
||||
? {
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
table: 'agent_groups',
|
||||
description: 'Agent groups.',
|
||||
idColumn: 'id',
|
||||
columns: [],
|
||||
operations: { list: 'open', get: 'open' },
|
||||
customOperations: {
|
||||
restart: { access: 'approval', description: 'Restart.', handler: async () => ({}) },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-restrat', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('unknown-command');
|
||||
expect(resp.error.message).toContain('verbs for groups: list, get, restart');
|
||||
expect(resp.error.message).toContain('ncl groups help');
|
||||
}
|
||||
});
|
||||
|
||||
it('suggests the closest command name for near-miss typos', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cm', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('unknown-command');
|
||||
expect(resp.error.message).toContain('did you mean "test-cmd"?');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a plain pointer when nothing is close', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'zzz-qqq-vvv', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.message).toContain('no command "zzz-qqq-vvv"');
|
||||
expect(resp.error.message).toContain('ncl help');
|
||||
expect(resp.error.message).not.toContain('did you mean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- formatHuman hook: server-rendered human view on the frame ---
|
||||
|
||||
describe('formatHuman hook', () => {
|
||||
register({
|
||||
name: 'render-cmd',
|
||||
description: 'command with a human renderer',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [{ id: 'x1', status: 'live' }],
|
||||
formatHuman: (rows) => `TABLE(${(rows as { id: string }[]).map((r) => r.id).join(',')})`,
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'render-throws',
|
||||
description: 'command whose renderer throws',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({ fine: true }),
|
||||
formatHuman: () => {
|
||||
throw new Error('renderer bug');
|
||||
},
|
||||
});
|
||||
|
||||
it('attaches human alongside data', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'render-cmd', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.human).toBe('TABLE(x1)');
|
||||
expect(resp.data).toEqual([{ id: 'x1', status: 'live' }]); // machine contract intact
|
||||
}
|
||||
});
|
||||
|
||||
it('a throwing renderer degrades to a plain frame, never an error', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'render-throws', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.human).toBeUndefined();
|
||||
expect(resp.data).toEqual({ fine: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('commands without a renderer stay human-less', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) expect(resp.human).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+153
-79
@@ -3,37 +3,36 @@
|
||||
* 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 baseline 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 { getResource } from './crud.js';
|
||||
import { type CommandDef, lookup } from './registry.js';
|
||||
import { listVerbs, renderVerbHelp } from './help-render.js';
|
||||
import { commandGuard, listCommands, lookup } from './registry.js';
|
||||
|
||||
/**
|
||||
* Resolution out-param for middleware that wraps dispatch: dispatch reassigns
|
||||
* `req` internally (dash-joined id fallback, group-scope auto-fill), so a
|
||||
* wrapper can't see the resolved command or effective args on the frame it
|
||||
* passed in.
|
||||
*/
|
||||
export type DispatchTrace = {
|
||||
cmd?: CommandDef;
|
||||
command?: string;
|
||||
args?: Record<string, unknown>;
|
||||
type DispatchOptions = {
|
||||
/** Verified approval row when a command is replayed after approval. */
|
||||
grant?: PendingApproval;
|
||||
};
|
||||
|
||||
export type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
approved?: boolean;
|
||||
/** Id of the approval that authorized this replay — correlation for middleware/observers. */
|
||||
approvalId?: string;
|
||||
/** Filled by dispatch with the resolved command + effective args. */
|
||||
trace?: DispatchTrace;
|
||||
};
|
||||
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,
|
||||
@@ -42,21 +41,22 @@ export async function dispatch(
|
||||
): Promise<ResponseFrame> {
|
||||
let cmd = lookup(req.command);
|
||||
|
||||
// Fallback: if the full command isn't registered, split the dash-joined
|
||||
// command and treat the longest registered prefix as the command, with the
|
||||
// re-joined remainder as the target ID. Clients join all positional args
|
||||
// with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"),
|
||||
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
|
||||
// so trimming a single trailing segment isn't enough — walk prefixes from
|
||||
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
|
||||
// "groups-get" + id "<uuid-with-dashes>".
|
||||
// Fallback: if the full command isn't registered, find the LONGEST registered
|
||||
// command that is a dash-prefix of req.command; the remainder is the target ID,
|
||||
// kept intact (dashes and all). This lets clients join all positional args with
|
||||
// dashes — e.g. `ncl groups get abc123` → "groups-get-abc123" → "groups-get" +
|
||||
// id "abc123", and crucially `ncl tasks cancel task-374f-...-442` →
|
||||
// "tasks-cancel" + id "task-374f-...-442" (a dashed id is no longer shredded).
|
||||
// Trimming from the end (longest→shortest) means a multi-segment verb like
|
||||
// "groups-config-add-mcp-server" still matches before any shorter prefix.
|
||||
if (!cmd) {
|
||||
const parts = req.command.split('-');
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const shortened = parts.slice(0, i).join('-');
|
||||
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 = parts.slice(i).join('-');
|
||||
const tail = req.command.slice(shortened.length + 1); // full remainder = id, dashes intact
|
||||
cmd = fallback;
|
||||
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
break;
|
||||
@@ -64,54 +64,17 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.trace) {
|
||||
opts.trace.cmd = cmd;
|
||||
opts.trace.command = req.command;
|
||||
opts.trace.args = req.args;
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
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 baseline, 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') {
|
||||
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !allowed.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> = {
|
||||
@@ -124,7 +87,6 @@ export async function dispatch(
|
||||
fill.id = req.args.id ?? ctx.agentGroupId;
|
||||
}
|
||||
req = { ...req, args: { ...req.args, ...fill } };
|
||||
if (opts.trace) opts.trace.args = req.args;
|
||||
|
||||
// Fail-closed pre-handler check for sessions-get: returns "not found"
|
||||
// regardless of whether the UUID exists in another group, preventing an
|
||||
@@ -138,7 +100,33 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
|
||||
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 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
|
||||
// as clean multi-line text instead of a JSON-stringified blob.
|
||||
const helpText = commandHelp(cmd.name, cmd.resource, cmd.description);
|
||||
return { id: req.id, ok: true, data: helpText, human: helpText };
|
||||
}
|
||||
|
||||
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.');
|
||||
@@ -209,18 +197,27 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Server-render the human view once, so every transport — host CLI and
|
||||
// the Bun container client (which can't import host formatters) — prints
|
||||
// one canonical rendering. Runs after scope filtering; a throwing
|
||||
// formatter degrades to plain `data`, never fails the response.
|
||||
if (cmd.formatHuman) {
|
||||
try {
|
||||
return { id: req.id, ok: true, data, human: cmd.formatHuman(data) };
|
||||
} catch {
|
||||
// fall through to the plain frame
|
||||
}
|
||||
}
|
||||
return { id: req.id, ok: true, data };
|
||||
} catch (e) {
|
||||
return err(req.id, 'handler-error', errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
registerApprovalHandler('cli_command', async ({ payload, notify, approvalId }) => {
|
||||
registerApprovalHandler('cli_command', async ({ payload, approval, notify }) => {
|
||||
const frame = payload.frame as RequestFrame;
|
||||
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
|
||||
// approvalId rides opts so middleware/observers around dispatch can
|
||||
// correlate the replay with the approval that authorized it.
|
||||
const response = await dispatch(frame, callerContext, { approved: true, approvalId });
|
||||
const response = await dispatch(frame, callerContext, { grant: approval });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
@@ -250,6 +247,83 @@ function parseCallerContext(value: unknown): CallerContext | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Help text for a resolved command: deep verb help when derivable, else description. */
|
||||
function commandHelp(name: string, resource: string | undefined, description: string): string {
|
||||
if (resource && name.startsWith(`${resource}-`)) {
|
||||
const res = getResource(resource);
|
||||
const verb = name.slice(resource.length + 1);
|
||||
const deep = res && renderVerbHelp(res, verb);
|
||||
if (deep) return deep;
|
||||
// Custom-operation KEYS may contain spaces ('config update') while command
|
||||
// names are dash-joined ('groups-config-update'). Resolve by matching keys
|
||||
// normalized the same way registerResource builds command names.
|
||||
if (res?.customOperations) {
|
||||
const spaced = Object.keys(res.customOperations).find((k) => k.replace(/ /g, '-') === verb);
|
||||
const deepSpaced = spaced && renderVerbHelp(res, spaced);
|
||||
if (deepSpaced) return deepSpaced;
|
||||
}
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown-command error that carries its fix: if the command names a known
|
||||
* resource, list that resource's verbs; otherwise suggest the closest
|
||||
* registered command. Resource detection walks dash-prefixes longest-first,
|
||||
* same as the ID fallback above, so multi-word plurals (messaging-groups,
|
||||
* user-dms) resolve.
|
||||
*/
|
||||
function unknownCommandMessage(command: string): string {
|
||||
const parts = command.split('-');
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const prefix = parts.slice(0, i).join('-');
|
||||
const res = getResource(prefix);
|
||||
if (res) {
|
||||
return (
|
||||
`no command "${command}" — verbs for ${res.plural}: ${listVerbs(res).join(', ')}. ` +
|
||||
`Run \`ncl ${res.plural} help <verb>\` for flags and examples.`
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = listCommands()
|
||||
.filter((c) => c.access !== 'hidden')
|
||||
.map((c) => c.name);
|
||||
const closest = closestName(command, names);
|
||||
return `no command "${command}"${closest ? ` — did you mean "${closest}"?` : ''} Run \`ncl help\`.`;
|
||||
}
|
||||
|
||||
/** Closest name by edit distance, only when convincingly close (≤2 edits). */
|
||||
function closestName(input: string, names: string[]): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestDist = 3;
|
||||
for (const name of names) {
|
||||
if (Math.abs(name.length - input.length) >= bestDist) continue;
|
||||
const d = editDistance(input, name, bestDist);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function editDistance(a: string, b: string, cap: number): number {
|
||||
const prev = new Array(b.length + 1).fill(0).map((_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diag = prev[0];
|
||||
prev[0] = i;
|
||||
let rowMin = prev[0];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = prev[j];
|
||||
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
diag = tmp;
|
||||
rowMin = Math.min(rowMin, prev[j]);
|
||||
}
|
||||
if (rowMin >= cap) return cap;
|
||||
}
|
||||
return prev[b.length];
|
||||
}
|
||||
|
||||
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
|
||||
return { id, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
+8
-1
@@ -18,7 +18,14 @@ export type RequestFrame = {
|
||||
};
|
||||
|
||||
export type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
// `human` is an optional server-rendered presentational string. It lets
|
||||
// every transport — host CLI and the Bun container client — print one
|
||||
// canonical rendering without importing host-only formatters (the two
|
||||
// runtimes share no modules, so client-side formatters drift). `data`
|
||||
// stays the machine contract; --json callers ignore `human`. Additive:
|
||||
// old clients that don't know the field just fall back to their own
|
||||
// rendering of `data`.
|
||||
| { id: string; ok: true; data: unknown; human?: string }
|
||||
| { id: string; ok: false; error: { code: ErrorCode; message: string } };
|
||||
|
||||
export type ErrorCode =
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* CLI guard adapter — the command registry's catalog derivation and
|
||||
* structural baseline, moved verbatim out of dispatch.ts (guarded-actions
|
||||
* phase 2). Declaration is registration: registry.register() derives one
|
||||
* catalog entry per command from the CommandDef itself; no second file is
|
||||
* edited when a command is added.
|
||||
*
|
||||
* The baseline carries today's decisions exactly:
|
||||
* 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),
|
||||
approvalAction: cmd.access === 'approval' ? 'cli_command' : undefined,
|
||||
// Bind a cli_command grant to the exact command it was approved for.
|
||||
grantMatches: (grant) => {
|
||||
try {
|
||||
const payload = JSON.parse(grant.payload) as { frame?: { command?: string } };
|
||||
return payload.frame?.command === cmd.name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (input) => commandBaseline(cmd, input),
|
||||
};
|
||||
}
|
||||
|
||||
function commandBaseline(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');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { ResourceDef } from './crud.js';
|
||||
import { listVerbs, renderVerbHelp, summaryLine } from './help-render.js';
|
||||
|
||||
const res: ResourceDef = {
|
||||
name: 'widget',
|
||||
plural: 'widgets',
|
||||
table: 'widgets',
|
||||
description: 'Test widgets.',
|
||||
idColumn: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'name', type: 'string', description: 'Display name.', required: true, updatable: true },
|
||||
{ name: 'size', type: 'string', description: 'Widget size.', enum: ['s', 'm', 'l'], default: 'm' },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
customOperations: {
|
||||
ping: {
|
||||
access: 'open',
|
||||
description: 'Ping a widget.\nLonger prose that only deep help shows.',
|
||||
args: [
|
||||
{ name: 'target', type: 'string', description: 'Where to ping.', required: true },
|
||||
{ name: 'count', type: 'number', description: 'How many times.', default: 1 },
|
||||
],
|
||||
examples: ['ncl widgets ping --target prod --count 3'],
|
||||
handler: async () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('listVerbs', () => {
|
||||
it('lists enabled generics then custom verbs', () => {
|
||||
expect(listVerbs(res)).toEqual(['list', 'get', 'create', 'update', 'ping']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVerbHelp — custom operation', () => {
|
||||
it('renders usage, full description, flags with tags, and examples', () => {
|
||||
const out = renderVerbHelp(res, 'ping')!;
|
||||
expect(out).toContain('ncl widgets ping');
|
||||
expect(out).toContain('Longer prose that only deep help shows.');
|
||||
expect(out).toContain('--target');
|
||||
expect(out).toContain('(required)');
|
||||
expect(out).toContain('--count');
|
||||
expect(out).toContain('default: 1');
|
||||
expect(out).toContain('Examples:');
|
||||
expect(out).toContain('ncl widgets ping --target prod --count 3');
|
||||
});
|
||||
|
||||
it('tags non-open access on the usage line', () => {
|
||||
const gated: ResourceDef = {
|
||||
...res,
|
||||
customOperations: { ping: { ...res.customOperations!.ping, access: 'approval' } },
|
||||
};
|
||||
expect(renderVerbHelp(gated, 'ping')).toContain('ncl widgets ping [approval]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVerbHelp — generic verbs', () => {
|
||||
it('create renders non-generated columns as flags', () => {
|
||||
const out = renderVerbHelp(res, 'create')!;
|
||||
expect(out).toContain('ncl widgets create [approval]');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).toContain('(required)');
|
||||
expect(out).toContain('--size');
|
||||
expect(out).toContain('values: s | m | l');
|
||||
expect(out).not.toContain('--id'); // generated
|
||||
});
|
||||
|
||||
it('update renders only updatable columns and takes <id>', () => {
|
||||
const out = renderVerbHelp(res, 'update')!;
|
||||
expect(out).toContain('ncl widgets update <id> [approval]');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).not.toContain('--size');
|
||||
});
|
||||
|
||||
it('list renders filter flags plus --limit, never marked required', () => {
|
||||
const out = renderVerbHelp(res, 'list')!;
|
||||
expect(out).toContain('--limit');
|
||||
expect(out).toContain('--name');
|
||||
expect(out).not.toContain('(required)');
|
||||
});
|
||||
|
||||
it('returns undefined for verbs the resource does not have', () => {
|
||||
expect(renderVerbHelp(res, 'delete')).toBeUndefined(); // not in operations
|
||||
expect(renderVerbHelp(res, 'bogus')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summaryLine', () => {
|
||||
it('returns only the first line', () => {
|
||||
expect(summaryLine('Ping a widget.\nLonger prose.')).toBe('Ping a widget.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Pure renderers for command help. Single source for three surfaces that must
|
||||
* never disagree:
|
||||
* - `ncl <resource> help [<verb>]` (commands/help.ts)
|
||||
* - `--help` on any command (dispatch interception)
|
||||
* - the usage block appended to invalid-args errors (crud.ts validation)
|
||||
*
|
||||
* Imports only types from crud.ts, so crud.ts can import these functions at
|
||||
* runtime without a cycle.
|
||||
*/
|
||||
import type { ColumnDef, CustomOperation, ResourceDef } from './crud.js';
|
||||
|
||||
const GENERIC_VERBS = ['list', 'get', 'create', 'update', 'delete'] as const;
|
||||
type GenericVerb = (typeof GENERIC_VERBS)[number];
|
||||
|
||||
export function flagName(col: Pick<ColumnDef, 'name'>): string {
|
||||
return `--${col.name.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
/** First line of a possibly multi-paragraph description. */
|
||||
export function summaryLine(description: string): string {
|
||||
return description.split('\n', 1)[0];
|
||||
}
|
||||
|
||||
/** Indent every non-empty line of a block by `pad`. */
|
||||
export function indent(text: string, pad: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((l) => (l ? pad + l : l))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function flagLine(col: ColumnDef, extraTags: string[] = []): string {
|
||||
const tags: string[] = [...extraTags];
|
||||
if (col.required) tags.push('required');
|
||||
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
|
||||
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
|
||||
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
|
||||
return ` ${flagName(col).padEnd(28)} ${summaryLine(col.description)}${tagStr}`;
|
||||
}
|
||||
|
||||
/** All verbs a resource exposes, generics first, in help order. */
|
||||
export function listVerbs(res: ResourceDef): string[] {
|
||||
const verbs: string[] = GENERIC_VERBS.filter((v) => res.operations[v]);
|
||||
if (res.customOperations) verbs.push(...Object.keys(res.customOperations));
|
||||
return verbs;
|
||||
}
|
||||
|
||||
/** Flags a generic verb accepts, derived from the resource's columns. */
|
||||
function genericFlags(res: ResourceDef, verb: GenericVerb): ColumnDef[] {
|
||||
switch (verb) {
|
||||
case 'create':
|
||||
return res.columns.filter((c) => !c.generated);
|
||||
case 'update':
|
||||
return res.columns.filter((c) => c.updatable);
|
||||
case 'list':
|
||||
// Non-generated columns double as equality filters.
|
||||
return [
|
||||
...res.columns.filter((c) => !c.generated).map((c) => ({ ...c, required: false })),
|
||||
{ name: 'limit', type: 'number', description: 'Max rows returned.', default: 200 } as ColumnDef,
|
||||
];
|
||||
case 'get':
|
||||
case 'delete':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function genericSummary(res: ResourceDef, verb: GenericVerb): string {
|
||||
switch (verb) {
|
||||
case 'list':
|
||||
return `List ${res.plural}. Flags below act as equality filters.`;
|
||||
case 'get':
|
||||
return `Get a ${res.name} by ID.`;
|
||||
case 'create':
|
||||
return `Create a new ${res.name}.`;
|
||||
case 'update':
|
||||
return `Update a ${res.name} by ID. Provide at least one updatable flag.`;
|
||||
case 'delete':
|
||||
return `Delete a ${res.name} by ID.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep help for one verb: usage line, full description, flags, examples.
|
||||
* `verb` is a custom-operation key or a generic CRUD verb. Returns undefined
|
||||
* for a verb the resource doesn't have.
|
||||
*/
|
||||
export function renderVerbHelp(res: ResourceDef, verb: string): string | undefined {
|
||||
const op: CustomOperation | undefined = res.customOperations?.[verb];
|
||||
const generic = !op && (GENERIC_VERBS as readonly string[]).includes(verb) ? (verb as GenericVerb) : undefined;
|
||||
if (!op && !generic) return undefined;
|
||||
if (generic && !res.operations[generic]) return undefined;
|
||||
|
||||
const access = op ? op.access : res.operations[generic!];
|
||||
const accessTag = access && access !== 'open' ? ` [${access}]` : '';
|
||||
const needsId = generic === 'get' || generic === 'update' || generic === 'delete';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`ncl ${res.plural} ${verb}${needsId ? ' <id>' : ''}${accessTag}`);
|
||||
lines.push('');
|
||||
lines.push(op ? op.description : genericSummary(res, generic!));
|
||||
|
||||
const flags = op ? (op.args ?? []) : genericFlags(res, generic!);
|
||||
if (flags.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
for (const f of flags) lines.push(flagLine(f));
|
||||
}
|
||||
if (op?.examples?.length) {
|
||||
lines.push('');
|
||||
lines.push('Examples:');
|
||||
for (const ex of op.examples) lines.push(indent(ex, ' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
+41
-15
@@ -8,14 +8,30 @@
|
||||
* 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';
|
||||
|
||||
export type Access = 'open' | 'approval';
|
||||
/**
|
||||
* Resources an agent under `cli_scope=group` may touch. Single source —
|
||||
* consumed by both dispatch enforcement and `ncl help` filtering, so the
|
||||
* agent is never shown a resource the gate would reject (or vice versa).
|
||||
*/
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
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
|
||||
@@ -31,30 +47,40 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
* Custom operations return ad-hoc shapes and leave this undefined.
|
||||
*/
|
||||
generic?: 'list' | 'get';
|
||||
/**
|
||||
* Canonical dotted action name, e.g. `groups.config.add-mcp-server` — for
|
||||
* middleware and observability surfaces that need namespaced verbs. Stamped
|
||||
* explicitly by `registerResource` (which knows verb segment boundaries);
|
||||
* hand-registered commands may omit it and get `name` with dashes→dots.
|
||||
*/
|
||||
action: string;
|
||||
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
|
||||
parseArgs: (raw: Record<string, unknown>) => TArgs;
|
||||
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
|
||||
};
|
||||
|
||||
/** `register()` input — `action` is defaulted, everything else as stored. */
|
||||
export type CommandInput<TArgs = unknown, TData = unknown> = Omit<CommandDef<TArgs, TData>, 'action'> & {
|
||||
action?: string;
|
||||
/**
|
||||
* Optional presentational renderer. When set, dispatch attaches its output
|
||||
* as the response frame's `human` field (server-rendered once, printed
|
||||
* verbatim by every client in human mode). Runs after post-handler scope
|
||||
* filtering, so it only ever sees data the caller is allowed to see. A
|
||||
* throwing formatter is ignored — clients fall back to rendering `data`.
|
||||
*/
|
||||
formatHuman?: (data: TData) => string;
|
||||
};
|
||||
|
||||
const registry = new Map<string, CommandDef>();
|
||||
const commandGuards = new Map<string, GuardedAction>();
|
||||
|
||||
export function register<TArgs, TData>(def: CommandInput<TArgs, TData>): void {
|
||||
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, action: def.action ?? def.name.replace(/-/g, '.') } as CommandDef);
|
||||
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',
|
||||
baseline: () => 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();
|
||||
});
|
||||
});
|
||||
|
||||
+120
-17
@@ -20,12 +20,13 @@ import {
|
||||
markDeliveryFailed,
|
||||
migrateDeliveredTable,
|
||||
} from './db/session-db.js';
|
||||
import { guard, isUnguarded, type GuardedAction, 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 +394,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 baseline is
|
||||
* re-checked live. Plain actions (scheduling self-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 +414,115 @@ export type DeliveryActionHandler = (
|
||||
inDb: Database.Database,
|
||||
) => Promise<void>;
|
||||
|
||||
const actionHandlers = new Map<string, DeliveryActionHandler>();
|
||||
/** 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 function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void {
|
||||
if (actionHandlers.has(action)) {
|
||||
log.warn('Delivery action handler overwritten', { action });
|
||||
}
|
||||
actionHandlers.set(action, handler);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
|
||||
type DeliveryEntry =
|
||||
| { guard: Unguarded; handler: DeliveryActionHandler }
|
||||
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
|
||||
|
||||
const deliveryActions = new Map<string, DeliveryEntry>();
|
||||
|
||||
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
|
||||
return isUnguarded(entry.guard);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function runGuarded(
|
||||
action: string,
|
||||
entry: Extract<DeliveryEntry, { guard: DeliveryGuardSpec }>,
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
grant: PendingApproval | null,
|
||||
): Promise<void> {
|
||||
const spec = entry.guard;
|
||||
if (spec.precheck && !(await spec.precheck(content, session))) return;
|
||||
|
||||
const decision = guard(spec.guardAction, {
|
||||
actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id },
|
||||
payload: content,
|
||||
grant,
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
log.warn('Delivery action denied by guard', { action, reason: decision.reason });
|
||||
spec.onDeny?.(content, session, decision.reason);
|
||||
return;
|
||||
}
|
||||
if (decision.effect === 'hold') {
|
||||
await spec.requestHold(content, session);
|
||||
return;
|
||||
}
|
||||
await entry.handler(content, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve continuation for a guard-wrapped delivery action: re-enter the
|
||||
* entry with the approval row as the grant. The guard treats the grant as
|
||||
* hold-satisfied but re-runs the structural baseline, so approve-then-revoke
|
||||
* 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, 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, content, session, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,7 +538,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,70 @@
|
||||
/**
|
||||
* Boot-time guard sanity — the one cross-registry invariant left to check
|
||||
* after all import-time registrations have run.
|
||||
*
|
||||
* The old registry walk is gone: everything it detected is now
|
||||
* unconstructible at the API level.
|
||||
* - A consult site cannot name a missing catalog entry — guard() takes the
|
||||
* GuardedAction VALUE returned by defineGuardedAction, so a dropped
|
||||
* module-edge import or a typo'd action is a compile error (and a forged
|
||||
* value is denied at runtime), not a silent allow.
|
||||
* - A handler cannot register unguarded by omission — every registry
|
||||
* (delivery actions, response handlers, interceptors, CLI commands)
|
||||
* requires a guard spec or an explicit unguarded(<reason>) declaration
|
||||
* at the registration site.
|
||||
*
|
||||
* What remains is completeness ACROSS registries: a guarded action that
|
||||
* holds via `approvalAction` needs a registered approval handler, or an
|
||||
* approved card resolves into nothing — the hold has no continuation. That
|
||||
* pairing only exists once every module has loaded (catalog entries and
|
||||
* approval handlers register from different modules), so it stays a boot
|
||||
* check with the fail-closed posture: the host refuses to start, surfacing
|
||||
* the mis-composition at skill-install time instead of at the first
|
||||
* approved card.
|
||||
*/
|
||||
import { listGuardedActions } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
import { getApprovalHandler } from './modules/approvals/primitive.js';
|
||||
|
||||
/** Holding actions with no approve continuation. Empty = conformant. */
|
||||
export function grantContinuationGaps(): string[] {
|
||||
return listGuardedActions()
|
||||
.filter((spec) => spec.approvalAction && !getApprovalHandler(spec.approvalAction))
|
||||
.map(
|
||||
(spec) =>
|
||||
`guarded action "${spec.action}" holds via approval action "${spec.approvalAction}" ` +
|
||||
'but no approval handler is registered — an approved hold would have no continuation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot check: refuse to start when a holding action has no continuation.
|
||||
* Call after all import-time registrations (any point in main()).
|
||||
*/
|
||||
export function enforceGuardConformance(): void {
|
||||
const gaps = grantContinuationGaps();
|
||||
if (gaps.length === 0) return;
|
||||
|
||||
console.error(
|
||||
[
|
||||
'',
|
||||
'='.repeat(64),
|
||||
'NanoClaw stopped: guard conformance failure',
|
||||
'='.repeat(64),
|
||||
'A guarded action can hold for approval, but no approval handler is',
|
||||
'registered for its approval action — an admin could click Approve',
|
||||
'and nothing would execute. This usually means a module (or skill)',
|
||||
'defined a holding baseline without registering its continuation.',
|
||||
'',
|
||||
...gaps.map((g) => ` - ${g}`),
|
||||
'',
|
||||
'Register the approval handler (registerApprovalHandler) in the same',
|
||||
'module that defines the guarded action, or drop approvalAction from',
|
||||
'the definition if the action can never hold.',
|
||||
'='.repeat(64),
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
log.error('Guard conformance failure — refusing to start', { gaps });
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Guard conformance — the boot invariant, checked with the real registries.
|
||||
*
|
||||
* The old registry walk is gone: an unmapped consult or an undeclared
|
||||
* unguarded registration is now unconstructible — guard() takes the defined
|
||||
* GuardedAction value (a dropped module-edge import or typo'd name is a
|
||||
* compile error), and every registry requires a guard spec or an explicit
|
||||
* unguarded(<reason>) declaration. What's left to verify structurally is the
|
||||
* cross-registry pairing the compiler can't see: every holding action has a
|
||||
* registered approve continuation. The check runs here in CI and at every
|
||||
* boot (enforceGuardConformance refuses to start) — CI can't see
|
||||
* skill-installed registrations, the boot check can.
|
||||
*/
|
||||
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 { grantContinuationGaps } from '../guard-conformance.js';
|
||||
import { getApprovalHandler } from '../modules/approvals/primitive.js';
|
||||
import { defineGuardedAction, listGuardedActions } from './guard-actions.js';
|
||||
import { HOLD } from './types.js';
|
||||
|
||||
describe('guard conformance', () => {
|
||||
it('the grant-continuation check (shared with the boot check) reports zero gaps', () => {
|
||||
expect(grantContinuationGaps()).toEqual([]);
|
||||
});
|
||||
|
||||
it('every holding action pairs with a registered approval handler', () => {
|
||||
const holding = listGuardedActions().filter((spec) => spec.approvalAction);
|
||||
expect(holding.length).toBeGreaterThan(0);
|
||||
|
||||
const dangling = holding.filter((spec) => !getApprovalHandler(spec.approvalAction 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).approvalAction !== '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', baseline: () => HOLD('x') });
|
||||
expect(() => defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') })).toThrow(
|
||||
/already defined/,
|
||||
);
|
||||
});
|
||||
|
||||
// KEEP LAST: defines a holding action with no continuation into the shared
|
||||
// per-worker catalog, so every gap check after this point sees it.
|
||||
it('the check names a holding action with no approve continuation (what boot refuses on)', () => {
|
||||
defineGuardedAction({
|
||||
action: 'test.dangling-hold',
|
||||
approvalAction: 'test_dangling_hold_approved',
|
||||
baseline: () => HOLD('always'),
|
||||
});
|
||||
|
||||
const gaps = grantContinuationGaps();
|
||||
expect(gaps).toHaveLength(1);
|
||||
expect(gaps[0]).toContain('test.dangling-hold');
|
||||
expect(gaps[0]).toContain('no approval handler');
|
||||
});
|
||||
});
|
||||
@@ -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 baseline is a symbol reference the compiler checks. A dropped
|
||||
* module-edge import or a typo'd action name is a build error, not a
|
||||
* runtime fail-open — there is no lookup that can miss.
|
||||
*
|
||||
* Definitions are still recorded by name so boot can enumerate them: the
|
||||
* grant-continuation check (src/guard-conformance.ts) pairs every holding
|
||||
* action with its registered approval handler, and duplicate names are
|
||||
* refused at definition time (grants match on the name).
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
baseline: (input: GuardInput) => GuardDecision;
|
||||
/**
|
||||
* The pending_approvals.action its holds resolve through — a grant is only
|
||||
* accepted when its row carries this action. Omit for actions that can
|
||||
* never be held (deny/allow-only baselines).
|
||||
*/
|
||||
approvalAction?: string;
|
||||
/**
|
||||
* Extra domain binding between a grant and the replayed input (e.g. the
|
||||
* a2a target must match the held message). Runs in addition to the
|
||||
* approvalAction + live-row checks.
|
||||
*/
|
||||
grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean;
|
||||
}
|
||||
|
||||
declare const guardedActionBrand: unique symbol;
|
||||
/**
|
||||
* A defined guarded action — only defineGuardedAction can mint one. The
|
||||
* brand makes the type nominal: a hand-rolled { action, baseline } object
|
||||
* 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: the baseline is the decision (allow /
|
||||
* hold / deny returned as-is), grant semantics (satisfies holds, never
|
||||
* denies; invalid → refuse), the runtime backstop against forged action
|
||||
* values, and the fail-closed posture on a throwing baseline.
|
||||
*
|
||||
* 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('the baseline is the decision', () => {
|
||||
it('baseline allow → allow', () => {
|
||||
const action = defineGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') });
|
||||
expect(guard(action, input()).effect).toBe('allow');
|
||||
});
|
||||
|
||||
it('baseline hold → hold, default approver chain', () => {
|
||||
const action = defineGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') });
|
||||
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('baseline hold → hold, carrying a named approver', () => {
|
||||
const action = defineGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') });
|
||||
const d = guard(action, input());
|
||||
expect(d.effect).toBe('hold');
|
||||
if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana');
|
||||
});
|
||||
|
||||
it('baseline deny → deny, carrying the reason', () => {
|
||||
const action = defineGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') });
|
||||
const d = guard(action, input());
|
||||
expect(d.effect).toBe('deny');
|
||||
if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized');
|
||||
});
|
||||
|
||||
it('a forged action value (not from defineGuardedAction) is denied', () => {
|
||||
const forged = { action: 't.forged', baseline: () => ALLOW('never vetted') } as unknown as GuardedAction;
|
||||
const 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',
|
||||
approvalAction: 'g1_approved',
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
const grant = grantRow('g1_approved');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('allow');
|
||||
});
|
||||
|
||||
it('a grant never satisfies a deny — the baseline is re-checked live', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g2',
|
||||
approvalAction: 'g2_approved',
|
||||
baseline: () => 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',
|
||||
approvalAction: 'g3_approved',
|
||||
baseline: () => 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',
|
||||
approvalAction: 'g4_approved',
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
const grant = grantRow('other_action');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('deny');
|
||||
});
|
||||
|
||||
it('a domain grantMatches binding can refuse a payload mismatch', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g5',
|
||||
approvalAction: 'g5_approved',
|
||||
grantMatches: () => false,
|
||||
baseline: () => 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',
|
||||
approvalAction: 'g6_approved',
|
||||
baseline: () => 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 baseline denies', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.f1',
|
||||
baseline: () => {
|
||||
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 structural baseline — today's code checks,
|
||||
* defined per action at the module edges. The consult site holds the
|
||||
* GuardedAction value itself (defineGuardedAction), so there is no name
|
||||
* lookup and no fail-open path for an unknown action: an unwired consult is
|
||||
* a compile error, and a value that didn't come from defineGuardedAction is
|
||||
* denied at runtime. Policy-as-data (tighten-only rule sources composing
|
||||
* with the baseline) is deliberately deferred to phase 3 of the
|
||||
* guarded-actions design, where the generalized rules table arrives with its
|
||||
* first operator-visible consumer; until then the one policy table
|
||||
* (agent_message_policies) is consulted inside the a2a.send baseline.
|
||||
*
|
||||
* Grants: an approved replay carries the verified approval row. A valid
|
||||
* grant (live pending row whose action matches the entry's approval action,
|
||||
* plus any domain binding) satisfies a hold — the human already decided —
|
||||
* but NEVER a deny: the baseline is re-checked live, so approve-then-revoke
|
||||
* no longer executes. A grant that is present but invalid fails closed to
|
||||
* deny (no second card).
|
||||
*
|
||||
* The guard itself fails closed: a throwing baseline denies.
|
||||
*/
|
||||
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 baseline 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.baseline(input);
|
||||
} catch (err) {
|
||||
log.error('Guard evaluation threw — failing closed', { action: action.action, err });
|
||||
return DENY('guard failure (failing closed)');
|
||||
}
|
||||
|
||||
if (!input.grant || decision.effect !== 'hold') {
|
||||
// A grant never loosens a deny (the baseline re-check is live), and a
|
||||
// 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.approvalAction) return false;
|
||||
if (grant.action !== action.approvalAction) return false;
|
||||
// The row must still be live — resolution deletes it, so a grant can only
|
||||
// execute once and a fabricated row object doesn't pass.
|
||||
const live = getPendingApproval(grant.approval_id);
|
||||
if (!live || live.action !== action.approvalAction) return false;
|
||||
if (action.grantMatches && !action.grantMatches(grant, input)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Guard — the privileged-action decision seam (guarded-actions phase 2).
|
||||
*
|
||||
* See the guarded-actions decisions doc on the team hub. 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 baselines 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,74 @@
|
||||
/**
|
||||
* 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 structural baseline 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 baseline is re-checked live on every replay.
|
||||
*/
|
||||
grant?: PendingApproval | null;
|
||||
}
|
||||
|
||||
const unguardedBrand = Symbol('unguarded');
|
||||
/**
|
||||
* A registration that deliberately carries no guard. Omission is not
|
||||
* representable — every registry 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,
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { initDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
|
||||
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
|
||||
import { enforceGuardConformance } from './guard-conformance.js';
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { routeInbound } from './router.js';
|
||||
import { log } from './log.js';
|
||||
@@ -69,6 +70,12 @@ async function main(): Promise<void> {
|
||||
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
|
||||
enforceUpgradeTripwire();
|
||||
|
||||
// 0.6 Guard conformance — every import-time registration has already run;
|
||||
// refuse to start if any privileged command / delivery action is unmapped
|
||||
// (CI can't see skill-installed code — this makes the invariant hold at
|
||||
// the boundary where third-party registrations enter).
|
||||
enforceGuardConformance();
|
||||
|
||||
// 1. Init central DB
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
|
||||
@@ -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 baseline (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` baseline — 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 — baseline 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` baseline
|
||||
* (./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,40 +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);
|
||||
};
|
||||
|
||||
/** What creation did — the created group's ids on success, the reason on failure. */
|
||||
export type CreateAgentResult =
|
||||
| { ok: true; agentGroupId: string; name: string; localName: string; folder: string }
|
||||
| { ok: false; reason: string };
|
||||
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(
|
||||
@@ -131,13 +101,13 @@ async function performCreateAgent(
|
||||
session: Session,
|
||||
sourceGroup: AgentGroup,
|
||||
notify: (text: string) => void,
|
||||
): Promise<CreateAgentResult> {
|
||||
): Promise<void> {
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
return { ok: false, reason: 'destination name collision' };
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
||||
@@ -154,7 +124,7 @@ async function performCreateAgent(
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notify(`Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
return { ok: false, reason: 'invalid folder path' };
|
||||
return;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -213,5 +183,4 @@ async function performCreateAgent(
|
||||
|
||||
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
return { ok: true, agentGroupId, name, localName, folder };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
approvalAction: 'create_agent',
|
||||
// Bind a create_agent grant to the name that was approved.
|
||||
grantMatches: (grant, input) => {
|
||||
try {
|
||||
return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (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',
|
||||
approvalAction: A2A_MESSAGE_GATE_ACTION,
|
||||
// Bind an a2a grant to the exact held message target.
|
||||
grantMatches: (grant, input) => {
|
||||
try {
|
||||
return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (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 baseline 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,22 +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',
|
||||
approvalId: 'appr-test',
|
||||
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
|
||||
// baseline runs 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,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* + approval handlers via this module's public API.
|
||||
*/
|
||||
import { onDeliveryAdapterReady } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import { registerResponseHandler, onShutdown } from '../../response-registry.js';
|
||||
import { handleApprovalsResponse } from './response-handler.js';
|
||||
import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js';
|
||||
@@ -34,7 +35,10 @@ export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions }
|
||||
// loads reason-capture.js, registering its message-interceptor on import.
|
||||
export { sweepAwaitingReasonRejects } from './reason-capture.js';
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
registerResponseHandler(
|
||||
handleApprovalsResponse,
|
||||
unguarded('self-authorizing — resolves each pending_approvals row against its own approver rules before dispatching'),
|
||||
);
|
||||
|
||||
onDeliveryAdapterReady((adapter) => {
|
||||
startOneCLIApprovalHandler(adapter);
|
||||
|
||||
@@ -64,12 +64,8 @@ function shortApprovalId(): string {
|
||||
return `oa-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the approvals response handler when a card button is clicked.
|
||||
* `userId` is the namespaced id of the clicking admin — the resolution's
|
||||
* identity, recorded here (empty when the resolver is a timer/sweep).
|
||||
*/
|
||||
export function resolveOneCLIApproval(approvalId: string, selectedOption: string, userId: string): boolean {
|
||||
/** Called from the approvals response handler when a card button is clicked. */
|
||||
export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean {
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return false;
|
||||
pending.delete(approvalId);
|
||||
@@ -82,7 +78,7 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
|
||||
deletePendingApproval(approvalId);
|
||||
|
||||
state.resolve(decision);
|
||||
log.info('OneCLI approval resolved', { approvalId, decision, decidedBy: userId || 'system' });
|
||||
log.info('OneCLI approval resolved', { approvalId, decision });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,14 @@ 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;
|
||||
/** pending_approvals id of the approval that authorized this run. */
|
||||
approvalId: string;
|
||||
/** Send a system chat message to the requesting agent's session. */
|
||||
notify: (text: string) => void;
|
||||
}
|
||||
@@ -216,31 +220,19 @@ export interface RequestApprovalOptions {
|
||||
approverUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A successfully queued hold: the minted approval id and the approver the
|
||||
* card was actually delivered to — both decided in here and otherwise
|
||||
* invisible to callers. Null when no hold reached an approver.
|
||||
*/
|
||||
export interface ApprovalHold {
|
||||
approvalId: string;
|
||||
/** The approver the card was actually delivered to. */
|
||||
approverUserId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an approval request. Picks an approver, delivers the card to their
|
||||
* DM, and records the pending_approvals row. Fire-and-forget from the
|
||||
* caller's perspective (the returned hold is informational) — the admin's
|
||||
* response kicks off the registered approval handler for this action via the
|
||||
* response dispatcher.
|
||||
* caller's perspective — the admin's response kicks off the registered
|
||||
* approval handler for this action via the response dispatcher.
|
||||
*/
|
||||
export async function requestApproval(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
|
||||
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
|
||||
const { session, action, payload, title, question, agentName, approverUserId } = opts;
|
||||
|
||||
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
|
||||
if (approvers.length === 0) {
|
||||
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const originChannelType = session.messaging_group_id
|
||||
@@ -250,7 +242,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<App
|
||||
const target = await pickApprovalDelivery(approvers, originChannelType);
|
||||
if (!target) {
|
||||
notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -286,10 +278,9 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<App
|
||||
} catch (err) {
|
||||
log.error('Failed to deliver approval card', { action, approvalId, err });
|
||||
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
|
||||
return { approvalId, approverUserId: target.userId };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import {
|
||||
deletePendingApproval,
|
||||
getExpiredAwaitingReasonApprovals,
|
||||
@@ -151,7 +152,10 @@ export async function captureReasonReply(event: InboundEvent): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
registerMessageInterceptor(captureReasonReply);
|
||||
registerMessageInterceptor(
|
||||
captureReasonReply,
|
||||
unguarded('self-authorizing — only captures a DM from the admin whose reject click armed it (keyed by userId + DM)'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value, namespacedUserId(payload) ?? '')) {
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
// Row exists but the in-memory resolver is gone (timer fired or the process
|
||||
@@ -112,7 +112,7 @@ async function handleRegisteredApproval(
|
||||
|
||||
const payload = JSON.parse(approval.payload);
|
||||
try {
|
||||
await handler({ session, payload, userId, approvalId: approval.approval_id, 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 });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
@@ -56,4 +57,7 @@ async function handleInteractiveResponse(payload: ResponsePayload): Promise<bool
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleInteractiveResponse);
|
||||
registerResponseHandler(
|
||||
handleInteractiveResponse,
|
||||
unguarded('not privileged — relays an in-chat answer back into the asking session; only the question row is touched'),
|
||||
);
|
||||
|
||||
@@ -54,7 +54,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';
|
||||
@@ -438,6 +442,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 guarded
|
||||
// interceptor allows (still an eligible approver) 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 guard's baseline 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
|
||||
* approvalAction: 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',
|
||||
baseline: (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',
|
||||
baseline: (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');
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,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, unguarded } from '../../guard/index.js';
|
||||
import { channelsRegister, sendersAdmit } from './guard.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import {
|
||||
buildAgentSelectionOptions,
|
||||
@@ -129,43 +131,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 baseline (./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);
|
||||
@@ -208,22 +216,6 @@ setSenderScopeGate(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* What a sender-approval card click decided — who was allowed/denied into
|
||||
* which group, and which admin decided. The response registry consumes only
|
||||
* `claimed`; the decision descriptor is for observers composed around the
|
||||
* handler (and for tests).
|
||||
*/
|
||||
export interface SenderDecision {
|
||||
approved: boolean;
|
||||
senderIdentity: string;
|
||||
agentGroupId: string;
|
||||
messagingGroupId: string;
|
||||
approverId: string;
|
||||
channelType: string;
|
||||
}
|
||||
export type SenderApprovalResult = { claimed: boolean; decision?: SenderDecision };
|
||||
|
||||
/**
|
||||
* Response handler for the unknown-sender approval card.
|
||||
*
|
||||
@@ -238,9 +230,9 @@ export type SenderApprovalResult = { claimed: boolean; decision?: SenderDecision
|
||||
* Deny: delete the row (no "deny list" — a future message re-triggers a
|
||||
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
|
||||
*/
|
||||
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<SenderApprovalResult> {
|
||||
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const row = getPendingSenderApproval(payload.questionId);
|
||||
if (!row) return { claimed: false };
|
||||
if (!row) return false;
|
||||
|
||||
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
|
||||
// with the channel type so it matches users(id) format. Some platforms
|
||||
@@ -259,18 +251,10 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<S
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
});
|
||||
return { claimed: true }; // claim the response so it's not unclaimed-logged, but do nothing
|
||||
return true; // claim the response so it's not unclaimed-logged, but do nothing
|
||||
}
|
||||
const approverId = clickerId;
|
||||
const approved = payload.value === 'approve';
|
||||
const decision = {
|
||||
approved,
|
||||
senderIdentity: row.sender_identity,
|
||||
agentGroupId: row.agent_group_id,
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverId,
|
||||
channelType: payload.channelType,
|
||||
};
|
||||
|
||||
if (approved) {
|
||||
addMember({
|
||||
@@ -296,7 +280,7 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<S
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after sender approval', { approvalId: row.id, err });
|
||||
}
|
||||
return { claimed: true, decision };
|
||||
return true;
|
||||
}
|
||||
|
||||
log.info('Unknown sender denied', {
|
||||
@@ -306,10 +290,15 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<S
|
||||
approverId,
|
||||
});
|
||||
deletePendingSenderApproval(row.id);
|
||||
return { claimed: true, decision };
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
|
||||
registerResponseHandler(
|
||||
handleSenderApprovalResponse,
|
||||
unguarded(
|
||||
'self-authorizing — verifies the clicker inline (delivered approver or group admin) before adding a member',
|
||||
),
|
||||
);
|
||||
|
||||
// ── Unknown-channel registration flow ──
|
||||
|
||||
@@ -317,23 +306,6 @@ setChannelRequestGate(async (mg, event) => {
|
||||
await requestChannelApproval({ messagingGroupId: mg.id, event });
|
||||
});
|
||||
|
||||
/**
|
||||
* What a channel-registration flow decided: connected (to which agent group,
|
||||
* created or existing), rejected, or failed — and which admin drove it. Like
|
||||
* SenderDecision, the registry consumes only `claimed`.
|
||||
*/
|
||||
export interface ChannelDecision {
|
||||
kind: 'connected' | 'rejected' | 'failed';
|
||||
messagingGroupId: string;
|
||||
approverId: string;
|
||||
channelType?: string;
|
||||
agentGroupId?: string;
|
||||
createdAgentGroup?: boolean;
|
||||
agentName?: string;
|
||||
reason?: string;
|
||||
}
|
||||
export type ChannelApprovalResult = { claimed: boolean; decision?: ChannelDecision };
|
||||
|
||||
/**
|
||||
* Response handler for the unknown-channel registration card.
|
||||
*
|
||||
@@ -348,31 +320,19 @@ export type ChannelApprovalResult = { claimed: boolean; decision?: ChannelDecisi
|
||||
* captures the reply and creates immediately)
|
||||
* reject — set denied_at, delete pending row
|
||||
*/
|
||||
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<ChannelApprovalResult> {
|
||||
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return { claimed: false };
|
||||
if (!row) return false;
|
||||
|
||||
// Click authorization is the guard's channels.register baseline (./guard.ts),
|
||||
// consulted by the response-registry wrapper before this handler runs.
|
||||
const clickerId = payload.userId
|
||||
? payload.userId.includes(':')
|
||||
? payload.userId
|
||||
: `${payload.channelType}:${payload.userId}`
|
||||
: null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
if (!isAuthorized) {
|
||||
log.warn('Channel registration click rejected — unauthorized clicker', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
});
|
||||
return { claimed: true };
|
||||
}
|
||||
if (!clickerId) return true; // unreachable behind the guard wrapper; fail closed
|
||||
const approverId = clickerId;
|
||||
const decisionBase = {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverId,
|
||||
channelType: payload.channelType,
|
||||
};
|
||||
|
||||
// ── Reject / Cancel ──
|
||||
if (payload.value === REJECT_VALUE) {
|
||||
@@ -382,7 +342,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverId,
|
||||
});
|
||||
return { claimed: true, decision: { kind: 'rejected', ...decisionBase } };
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Choose existing agent — send agent-selection follow-up card ──
|
||||
@@ -393,11 +353,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) return { claimed: true };
|
||||
if (!adapter) return true;
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const options = buildAgentSelectionOptions(agentGroups, approverId);
|
||||
@@ -424,7 +384,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
err,
|
||||
});
|
||||
}
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Create new agent — prompt for free-text name ──
|
||||
@@ -435,7 +395,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
@@ -443,7 +403,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
log.error('Channel registration: no delivery adapter for name prompt', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
});
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
awaitingNameInput.set(row.approver_user_id, {
|
||||
@@ -467,7 +427,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
});
|
||||
awaitingNameInput.delete(row.approver_user_id);
|
||||
}
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resolve target agent group (connect to existing or create new) ──
|
||||
@@ -482,10 +442,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
targetAgentGroupId,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return {
|
||||
claimed: true,
|
||||
decision: { kind: 'failed', reason: 'target agent group no longer exists', ...decisionBase },
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
|
||||
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
|
||||
@@ -493,14 +450,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
targetAgentGroupId,
|
||||
approverId,
|
||||
});
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
log.warn('Channel registration: unknown response value', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
value: payload.value,
|
||||
});
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Wire + replay (shared path for connect and create) ──
|
||||
@@ -513,7 +470,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = event.threadId !== null;
|
||||
@@ -561,26 +518,28 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
err,
|
||||
});
|
||||
}
|
||||
return {
|
||||
claimed: true,
|
||||
decision: { kind: 'connected', agentGroupId: targetAgentGroupId, createdAgentGroup: false, ...decisionBase },
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
|
||||
registerResponseHandler(handleChannelApprovalResponse, {
|
||||
action: channelsRegister,
|
||||
claims: (payload) => getPendingChannelApproval(payload.questionId) !== undefined,
|
||||
});
|
||||
|
||||
// ── Free-text name interceptor ──
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
// creates the agent immediately, wires the channel, and replays. The router
|
||||
// wraps it with the guard: the free-texter must still be an eligible
|
||||
// channel-registration approver at reply time — a privilege revoked between
|
||||
// the click and the reply now denies, and the arming is disarmed.
|
||||
|
||||
async function channelNameInterceptor(event: InboundEvent): Promise<ChannelApprovalResult> {
|
||||
const captureAgentNameReply = async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return { claimed: false };
|
||||
if (!userId) return false;
|
||||
|
||||
const pending = awaitingNameInput.get(userId);
|
||||
if (!pending) return { claimed: false };
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId)
|
||||
return { claimed: false };
|
||||
if (!pending) return false;
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
|
||||
|
||||
awaitingNameInput.delete(userId);
|
||||
|
||||
@@ -594,11 +553,11 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
|
||||
|
||||
if (!text) {
|
||||
log.warn('Channel registration: empty name reply, ignoring', { userId });
|
||||
return { claimed: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
const row = getPendingChannelApproval(pending.channelMgId);
|
||||
if (!row) return { claimed: true };
|
||||
if (!row) return true;
|
||||
|
||||
const ag = createNewAgentGroup(text);
|
||||
log.info('Channel registration: new agent group created', {
|
||||
@@ -607,14 +566,6 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
|
||||
agentName: ag.name,
|
||||
folder: ag.folder,
|
||||
});
|
||||
const decisionBase = {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverId: userId,
|
||||
channelType: event.channelType,
|
||||
agentGroupId: ag.id,
|
||||
createdAgentGroup: true,
|
||||
agentName: ag.name,
|
||||
};
|
||||
|
||||
let originalEvent: InboundEvent;
|
||||
try {
|
||||
@@ -625,8 +576,7 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
// The group WAS created above — the decision descriptor must say so.
|
||||
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = originalEvent.threadId !== null;
|
||||
@@ -690,7 +640,21 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
return { claimed: true, decision: { kind: 'connected', ...decisionBase } };
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
|
||||
registerMessageInterceptor(captureAgentNameReply, {
|
||||
action: channelsRegister,
|
||||
claims: (event) => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return null;
|
||||
const pending = awaitingNameInput.get(userId);
|
||||
if (!pending) return null;
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return null;
|
||||
return { actor: { kind: 'human', userId }, payload: { questionId: pending.channelMgId } };
|
||||
},
|
||||
onDeny: (event) => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (userId) awaitingNameInput.delete(userId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* module piggybacks on the core schema.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import {
|
||||
handleCancelTask,
|
||||
handlePauseTask,
|
||||
@@ -27,8 +28,10 @@ import {
|
||||
handleUpdateTask,
|
||||
} from './actions.js';
|
||||
|
||||
registerDeliveryAction('schedule_task', handleScheduleTask);
|
||||
registerDeliveryAction('cancel_task', handleCancelTask);
|
||||
registerDeliveryAction('pause_task', handlePauseTask);
|
||||
registerDeliveryAction('resume_task', handleResumeTask);
|
||||
registerDeliveryAction('update_task', handleUpdateTask);
|
||||
const selfAction = unguarded('agent self-action — mutates only its own task rows; not a privileged class (yet)');
|
||||
|
||||
registerDeliveryAction('schedule_task', handleScheduleTask, selfAction);
|
||||
registerDeliveryAction('cancel_task', handleCancelTask, selfAction);
|
||||
registerDeliveryAction('pause_task', handlePauseTask, selfAction);
|
||||
registerDeliveryAction('resume_task', handleResumeTask, selfAction);
|
||||
registerDeliveryAction('update_task', handleUpdateTask, selfAction);
|
||||
|
||||
@@ -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
|
||||
* baseline 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 structural baseline 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 selfModBaseline(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',
|
||||
approvalAction: 'install_packages',
|
||||
baseline: selfModBaseline('install_packages'),
|
||||
});
|
||||
|
||||
export const selfModAddMcpServer = defineGuardedAction({
|
||||
action: 'self_mod.add_mcp_server',
|
||||
approvalAction: 'add_mcp_server',
|
||||
baseline: selfModBaseline('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 baseline 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,
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
* which triggers module registrations that would otherwise happen before
|
||||
* index.ts's own const initializers have run.
|
||||
*
|
||||
* Keep this file dependency-free (log.js is fine, but nothing from
|
||||
* modules/* or index.ts itself). Any file imported here must not in turn
|
||||
* import from src/index.ts, or the cycle returns.
|
||||
* Keep this file dependency-free (log.js and the guard leaf are fine, but
|
||||
* nothing from modules/* or index.ts itself). Any file imported here must
|
||||
* not in turn import from src/index.ts, or the cycle returns.
|
||||
*
|
||||
* A handler whose click performs a privileged operation registers with a
|
||||
* guard spec: the registry wraps it so the guard's decision stands between
|
||||
* the click and the handler, and the wrapped path is the only path. `claims`
|
||||
* is the handler's own claim test (does this questionId belong to me?) so an
|
||||
* unauthorized click is claimed-and-dropped without stealing other handlers'
|
||||
* responses. The guard argument is not optional — a handler that runs
|
||||
* unguarded must declare so with `unguarded(<reason>)`, at the registration
|
||||
* site, in the diff that adds it.
|
||||
*/
|
||||
import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
export interface ResponsePayload {
|
||||
questionId: string;
|
||||
@@ -23,10 +34,46 @@ export interface ResponsePayload {
|
||||
|
||||
export type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
export interface ResponseGuardSpec {
|
||||
/** Guard action consulted before the handler runs — the defined value, not a name. */
|
||||
action: GuardedAction;
|
||||
/** Would this handler claim the response? (Its own row lookup.) */
|
||||
claims: (payload: ResponsePayload) => boolean;
|
||||
}
|
||||
|
||||
const responseHandlers: ResponseHandler[] = [];
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void {
|
||||
responseHandlers.push(handler);
|
||||
function responseActor(payload: ResponsePayload): GuardActor {
|
||||
if (!payload.userId) return { kind: 'human', userId: '' };
|
||||
const userId = payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
return { kind: 'human', userId };
|
||||
}
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void {
|
||||
if (isUnguarded(guardDecl)) {
|
||||
// Explicitly declared unguarded — the carried reason is the reviewable record.
|
||||
responseHandlers.push(handler);
|
||||
return;
|
||||
}
|
||||
const spec = guardDecl;
|
||||
responseHandlers.push(async (payload) => {
|
||||
if (!spec.claims(payload)) return false;
|
||||
const decision = guard(spec.action, {
|
||||
actor: responseActor(payload),
|
||||
payload: { questionId: payload.questionId, value: payload.value },
|
||||
});
|
||||
if (decision.effect !== 'allow') {
|
||||
// Claim the response so it's not unclaimed-logged, but do nothing.
|
||||
log.warn('Response click rejected by guard', {
|
||||
action: spec.action.action,
|
||||
questionId: payload.questionId,
|
||||
userId: payload.userId,
|
||||
reason: decision.reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return handler(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponseHandlers(): readonly ResponseHandler[] {
|
||||
|
||||
+43
-2
@@ -20,6 +20,7 @@
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -117,13 +118,53 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
* Used by modules to capture free-text DM replies during multi-step approval
|
||||
* flows — the permissions module (agent naming during channel registration)
|
||||
* and the approvals module (reject-with-reason capture).
|
||||
*
|
||||
* An interceptor whose capture performs a privileged operation (the
|
||||
* channel-registration name capture creates an agent group + wiring)
|
||||
* registers with a guard spec: the registry wraps it so the guard's decision
|
||||
* stands between the free-text reply and the handler. `claims` returns the
|
||||
* guard consult for events the interceptor would act on (null = not mine,
|
||||
* pass through); a deny consumes the message without acting. The guard
|
||||
* argument is not optional — an interceptor that runs unguarded must declare
|
||||
* so with `unguarded(<reason>)` at the registration site.
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
export interface InterceptorGuardSpec {
|
||||
/** Guard action consulted before the interceptor acts — the defined value, not a name. */
|
||||
action: GuardedAction;
|
||||
/** The guard consult for events this interceptor would act on; null = not mine. */
|
||||
claims: (event: InboundEvent) => { actor: GuardActor; payload: Record<string, unknown> } | null;
|
||||
/** Domain cleanup when the guard denies (e.g. disarm the capture). */
|
||||
onDeny?: (event: InboundEvent) => void;
|
||||
}
|
||||
|
||||
const messageInterceptors: MessageInterceptorFn[] = [];
|
||||
|
||||
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptors.push(fn);
|
||||
export function registerMessageInterceptor(
|
||||
fn: MessageInterceptorFn,
|
||||
guardDecl: InterceptorGuardSpec | Unguarded,
|
||||
): void {
|
||||
if (isUnguarded(guardDecl)) {
|
||||
// Explicitly declared unguarded — the carried reason is the reviewable record.
|
||||
messageInterceptors.push(fn);
|
||||
return;
|
||||
}
|
||||
const guardSpec = guardDecl;
|
||||
messageInterceptors.push(async (event) => {
|
||||
const consult = guardSpec.claims(event);
|
||||
if (!consult) return fn(event);
|
||||
const decision = guard(guardSpec.action, { actor: consult.actor, payload: consult.payload });
|
||||
if (decision.effect !== 'allow') {
|
||||
log.warn('Interceptor capture rejected by guard — consuming without acting', {
|
||||
action: guardSpec.action.action,
|
||||
reason: decision.reason,
|
||||
});
|
||||
guardSpec.onDeny?.(event);
|
||||
return true;
|
||||
}
|
||||
return fn(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-3
@@ -12,9 +12,11 @@ must stay inside this directory — absolute paths, `~`, and `../` escapes are
|
||||
rejected. Override the location with `NANOCLAW_TEMPLATES_DIR=/another/local/path`
|
||||
(a local path only — never a URL).
|
||||
|
||||
The setup wizard's **Template setup → NanoClaw template library** option clones
|
||||
the public registry and copies your chosen template *into this folder*, after
|
||||
which it stamps from the local copy. **Local templates** lists whatever is here.
|
||||
To use a template from the public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)),
|
||||
clone or download it yourself and copy the chosen template *into this folder*,
|
||||
then stamp from the local copy. There is no remote fetch — templates are only
|
||||
ever resolved from here.
|
||||
|
||||
## Anatomy of a template
|
||||
|
||||
|
||||
Reference in New Issue
Block a user