Skip to content

Commit bc263d7

Browse files
grypezclaude
andcommitted
feat(session): add provision editor, visibility, and auto-provisioned timeline entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8b353c5 commit bc263d7

15 files changed

Lines changed: 896 additions & 86 deletions

File tree

packages/caprock/bin/hook.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* eslint-disable n/no-process-env */
2+
import { execFile, spawn } from 'node:child_process';
3+
import { mkdtemp, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import { promisify } from 'node:util';
8+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9+
10+
const execFileAsync = promisify(execFile);
11+
12+
const HOOK_BIN = fileURLToPath(
13+
new URL('../dist/bin/hook.mjs', import.meta.url),
14+
);
15+
const PKG_DIR = fileURLToPath(new URL('..', import.meta.url));
16+
17+
/**
18+
* Spawn hook.mjs with a JSON payload on stdin and collect all output.
19+
*
20+
* @param payload - The hook event payload to send.
21+
* @param env - Extra environment variables.
22+
* @param timeoutMs - Kill timeout in milliseconds.
23+
* @returns stdout, stderr, and exit code.
24+
*/
25+
async function runHook(
26+
payload: unknown,
27+
env: NodeJS.ProcessEnv,
28+
timeoutMs: number,
29+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
30+
return new Promise((resolve, reject) => {
31+
const child = spawn('node', [HOOK_BIN], {
32+
stdio: ['pipe', 'pipe', 'pipe'],
33+
env: { ...process.env, ...env },
34+
});
35+
36+
let stdout = '';
37+
let stderr = '';
38+
39+
child.stdout.on('data', (chunk: Buffer) => {
40+
stdout += chunk.toString();
41+
});
42+
child.stderr.on('data', (chunk: Buffer) => {
43+
stderr += chunk.toString();
44+
});
45+
46+
const timer = setTimeout(() => {
47+
child.kill();
48+
reject(new Error(`Hook timed out after ${timeoutMs}ms`));
49+
}, timeoutMs);
50+
51+
child.on('close', (code) => {
52+
clearTimeout(timer);
53+
resolve({ stdout, stderr, exitCode: code ?? -1 });
54+
});
55+
56+
child.on('error', (error) => {
57+
clearTimeout(timer);
58+
reject(error);
59+
});
60+
61+
child.stdin.write(JSON.stringify(payload));
62+
child.stdin.end();
63+
});
64+
}
65+
66+
describe('hook binary', () => {
67+
let ocapHome: string;
68+
69+
beforeAll(async () => {
70+
await execFileAsync('yarn', ['build'], { cwd: PKG_DIR });
71+
ocapHome = await mkdtemp(join(tmpdir(), 'caprock-hook-test-'));
72+
}, 60_000);
73+
74+
afterAll(async () => {
75+
await rm(ocapHome, { recursive: true, force: true });
76+
});
77+
78+
it('loads without SES globals (SessionStart)', async () => {
79+
const { stderr, exitCode } = await runHook(
80+
{
81+
hook_event_name: 'SessionStart',
82+
session_id: 'hook-integration-test',
83+
transcript_path: '/dev/null',
84+
},
85+
{ OCAP_HOME: ocapHome },
86+
8_000,
87+
);
88+
89+
expect(exitCode).toBe(0);
90+
expect(stderr).not.toMatch(/harden is not defined/u);
91+
expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u);
92+
expect(stderr).not.toMatch(/missing globalThis\.assert/u);
93+
}, 8_000);
94+
95+
it('loads without SES globals (PreToolUse)', async () => {
96+
const { stdout, stderr, exitCode } = await runHook(
97+
{
98+
hook_event_name: 'PreToolUse',
99+
session_id: 'hook-integration-test',
100+
transcript_path: '/dev/null',
101+
tool_name: 'Bash',
102+
tool_input: { command: 'ls -la' },
103+
},
104+
{ OCAP_HOME: ocapHome },
105+
8_000,
106+
);
107+
108+
expect(exitCode).toBe(0);
109+
expect(stderr).not.toMatch(/harden is not defined/u);
110+
expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u);
111+
expect(stderr).not.toMatch(/missing globalThis\.assert/u);
112+
// With no daemon running the hook must not block — it passes through.
113+
expect(stdout).toContain('"continue":true');
114+
}, 8_000);
115+
});

packages/caprock/bin/hook.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ import {
3636
pingDaemon,
3737
sendCommand,
3838
decodeCapData,
39+
decodeSmallcapsStrings,
3940
createKernelSession,
4041
authorizeRequest,
42+
recordProvisioned,
4143
} from '../src/rpc.ts';
4244
import {
4345
loadSessionState,
@@ -262,6 +264,32 @@ async function vatAddSection(
262264
});
263265
}
264266

267+
/**
268+
* Return the first provision that matches the given tool and invocations,
269+
* or null if none match.
270+
*
271+
* @param rootKref - The vat's root kref.
272+
* @param tool - The tool name.
273+
* @param invocations - The parsed command components.
274+
* @returns The matching provision, or null.
275+
*/
276+
async function vatFindMatch(
277+
rootKref: string,
278+
tool: string,
279+
invocations: ParsedInvocation[],
280+
): Promise<Provision | null> {
281+
const response = await sendCommand({
282+
socketPath: SOCKET_PATH,
283+
method: 'queueMessage',
284+
params: [rootKref, 'findMatch', [tool, invocations]],
285+
});
286+
if (isJsonRpcFailure(response)) {
287+
return null;
288+
}
289+
const raw = decodeCapData(response.result as CapData);
290+
return decodeSmallcapsStrings(raw) as Provision | null;
291+
}
292+
265293
/**
266294
* Return the number of entries in the permission vat's allow set.
267295
*
@@ -517,6 +545,22 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
517545
});
518546

519547
if (vatResponse === 'allow') {
548+
if (state.kernelSessionId && invocations !== null) {
549+
const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`;
550+
vatFindMatch(state.rootKref, tool_name, invocations)
551+
.then(async (matched) =>
552+
recordProvisioned(
553+
SOCKET_PATH,
554+
state.kernelSessionId,
555+
autoDescription,
556+
{
557+
invocations,
558+
...(matched === null ? {} : { provision: matched }),
559+
},
560+
),
561+
)
562+
.catch(() => undefined);
563+
}
520564
process.stdout.write(JSON.stringify({ continue: true }));
521565
return;
522566
}
@@ -546,7 +590,9 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
546590
if (!isNoSubscriber && errorStr.includes('Session not found')) {
547591
try {
548592
const ks = await createKernelSession(SOCKET_PATH, session_id);
593+
// eslint-disable-next-line require-atomic-updates
549594
state.kernelSessionId = ks.sessionId;
595+
// eslint-disable-next-line require-atomic-updates
550596
state.ocapUrl = ks.ocapUrl;
551597
await saveSessionState(session_id, state);
552598
connectId = ks.sessionId;
@@ -593,7 +639,7 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
593639
feedback: decision.feedback,
594640
});
595641
process.stdout.write(
596-
`${preToolUseDeny(decision.feedback || 'Rejected via TUI')}\n`,
642+
`${preToolUseDeny(decision.feedback ?? 'Rejected via TUI')}\n`,
597643
);
598644
}
599645
}

packages/caprock/src/rpc.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { ParsedInvocation } from '@metamask/kernel-utils/session/provision';
1+
import type {
2+
ParsedInvocation,
3+
Provision,
4+
} from '@metamask/kernel-utils/session/provision';
25
import type { JsonRpcResponse } from '@metamask/utils';
36
import { assertIsJsonRpcResponse, isJsonRpcFailure } from '@metamask/utils';
47
import { randomUUID } from 'node:crypto';
@@ -265,6 +268,32 @@ export async function authorizeRequest(
265268
return response.result as Decision;
266269
}
267270

271+
/**
272+
* Record a request that was auto-accepted by a standing provision.
273+
*
274+
* @param socketPath - The UNIX socket path.
275+
* @param sessionId - The kernel session ID.
276+
* @param description - Human-readable description of the auto-accepted operation.
277+
* @param options - Optional parameters.
278+
* @param options.invocations - Parsed invocations to forward to the TUI.
279+
* @param options.provision - The standing provision that approved the request.
280+
*/
281+
export async function recordProvisioned(
282+
socketPath: string,
283+
sessionId: string,
284+
description: string,
285+
options?: { invocations?: ParsedInvocation[]; provision?: Provision },
286+
): Promise<void> {
287+
const params: Record<string, unknown> = { sessionId, description };
288+
if (options?.invocations !== undefined) {
289+
params.invocations = options.invocations;
290+
}
291+
if (options?.provision !== undefined) {
292+
params.provision = options.provision;
293+
}
294+
await sendCommand({ socketPath, method: 'session.record', params });
295+
}
296+
268297
/**
269298
* Decode a CapData body to a JavaScript value.
270299
*
@@ -282,3 +311,31 @@ export function decodeCapData(capData: CapData): unknown {
282311
}
283312
throw new Error(`Unexpected CapData body format: ${body.slice(0, 40)}`);
284313
}
314+
315+
/**
316+
* Recursively strip the smallcaps `!` escape prefix from string values in a
317+
* decoded CapData object. In smallcaps encoding, strings that begin with a
318+
* sigil character (including `-` for negative special floats) are prefixed
319+
* with `!` to distinguish them from encoding markers. This reversal is needed
320+
* when decoding complex objects like Provision (whose argv may contain flags
321+
* like `--oneline` that become `!--oneline` after encoding).
322+
*
323+
* @param value - A JSON-parsed smallcaps value.
324+
* @returns The value with all `!`-escaped strings decoded.
325+
*/
326+
export function decodeSmallcapsStrings(value: unknown): unknown {
327+
if (typeof value === 'string') {
328+
return value.startsWith('!') ? value.slice(1) : value;
329+
}
330+
if (Array.isArray(value)) {
331+
return value.map(decodeSmallcapsStrings);
332+
}
333+
if (typeof value === 'object' && value !== null) {
334+
const result: Record<string, unknown> = {};
335+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
336+
result[key] = decodeSmallcapsStrings(val);
337+
}
338+
return result;
339+
}
340+
return value;
341+
}

packages/caprock/vat/permission-tracker.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import type {
2222
Provision,
2323
ParsedInvocation,
2424
} from '@metamask/kernel-utils/session';
25-
import { computeAuthority, matchPattern } from '@metamask/kernel-utils/session';
25+
import {
26+
computeAuthority,
27+
matchPattern,
28+
matchProvision,
29+
} from '@metamask/kernel-utils/session';
2630
import {
2731
constant,
2832
leastAuthority,
@@ -79,9 +83,7 @@ function provisionToProvider(
7983
);
8084
}
8185
for (let i = 0; i < provision.patterns.length; i++) {
82-
const pattern = provision.patterns[
83-
i
84-
] as (typeof provision.patterns)[number];
86+
const pattern = provision.patterns[i];
8587
const inv = invocations[i] as ParsedInvocation;
8688
if (!matchPattern(pattern, inv.name, inv.argv)) {
8789
throw new Error(`pattern mismatch at index ${i}`);
@@ -162,6 +164,32 @@ export function buildRootObject(): ReturnType<typeof makeDefaultExo> {
162164
rebuildSection();
163165
},
164166

167+
/**
168+
* Return the first provision that matches the given tool and invocations,
169+
* or null if none match.
170+
*
171+
* @param tool - The tool name.
172+
* @param invocations - The parsed command components.
173+
* @returns The matching provision, or null.
174+
*/
175+
findMatch(tool: string, invocations: ParsedInvocation[]): Provision | null {
176+
for (const { provision } of sectionRecords) {
177+
if (matchProvision(provision, tool, invocations)) {
178+
return provision;
179+
}
180+
}
181+
return null;
182+
},
183+
184+
/**
185+
* Return all provisions currently in the sheaf.
186+
*
187+
* @returns Array of provisions, oldest first.
188+
*/
189+
listProvisions(): Provision[] {
190+
return sectionRecords.map(({ provision }) => provision);
191+
},
192+
165193
/**
166194
* Return the current section count (for session_end stats).
167195
*

packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function makeTestSession(overrides: Partial<Session> = {}): Session {
7373
verdict: 'accept' as const,
7474
feedback: '',
7575
}),
76+
recordProvisioned: vi.fn(),
7677
subscribe: vi.fn(),
7778
...overrides,
7879
};
@@ -343,6 +344,38 @@ describe('startRpcSocketServer — session.* methods', () => {
343344
});
344345
});
345346

347+
it('session.record calls recordProvisioned with description and invocations', async () => {
348+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
349+
const socketPath = makeSocketPath();
350+
const existing = makeTestSession({
351+
sessionId: 'alice',
352+
ocapUrl: 'ocap://alice',
353+
startedAt: '2026-01-01T00:00:00.000Z',
354+
});
355+
const registry = makeTestRegistry([existing]);
356+
357+
handle = await startRpcSocketServer({
358+
socketPath,
359+
kernel: {} as never,
360+
kernelDatabase: { executeQuery: vi.fn() } as never,
361+
channelFactory: {} as never,
362+
sessionRegistry: registry,
363+
});
364+
365+
const invocations = [{ name: 'git', argv: ['status'] }];
366+
const response = await sendRequest(socketPath, 'session.record', {
367+
sessionId: 'alice',
368+
description: 'Allow Bash({"command":"git status"})',
369+
invocations,
370+
});
371+
372+
expect(response.result).toBeNull();
373+
expect(existing.recordProvisioned).toHaveBeenCalledWith(
374+
'Allow Bash({"command":"git status"})',
375+
{ invocations },
376+
);
377+
});
378+
346379
it('session.authorize returns the decision from authorizeRequest()', async () => {
347380
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
348381
const socketPath = makeSocketPath();
@@ -376,8 +409,7 @@ describe('startRpcSocketServer — session.* methods', () => {
376409
expect(response.result).toStrictEqual(decision);
377410
expect(existing.authorizeRequest).toHaveBeenCalledWith(
378411
'Allow read access',
379-
'Needed for operation',
380-
undefined,
412+
{ reason: 'Needed for operation' },
381413
);
382414
});
383415
});

0 commit comments

Comments
 (0)