Skip to content

Commit 9c69236

Browse files
grypezclaude
andcommitted
feat(caprock): clause-level provision routing for independent bash operators
Split bash commands joined by &&/||/; into independent clauses, each routed separately through the permission sheaf. A compound command is auto-accepted only when every clause has a covering provision. The TUI provision editor shows one pattern-tuning block per clause and creates one Provision per clause on submit. Migrates provision?:Provision → provisions?:Provision[] throughout the session layer (Decision, SessionHistoryEntry, Channel.record, etc.). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bc263d7 commit 9c69236

13 files changed

Lines changed: 465 additions & 229 deletions

File tree

packages/caprock/bin/hook.ts

Lines changed: 79 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -309,34 +309,36 @@ async function vatSize(rootKref: string): Promise<number> {
309309
}
310310

311311
/**
312-
* Parse a tool invocation into a list of ParsedInvocation objects suitable for
313-
* sheaf dispatch. For Bash, uses tree-sitter to decompose the pipeline into
314-
* component commands. For other tools, treats the tool as a single command with
315-
* string field values as argv.
312+
* Parse a tool invocation into clause arrays suitable for per-clause sheaf
313+
* dispatch. For Bash, uses tree-sitter to decompose the command into independent
314+
* clauses (split on &&/||/;), each of which is a pipeline of commands. For
315+
* other tools, wraps the tool as a single one-invocation clause.
316316
*
317317
* Returns null when the command is dynamic or unparseable (no provision possible).
318318
*
319319
* @param toolName - The Claude Code tool name.
320320
* @param toolInput - The raw tool input object.
321-
* @returns Parsed invocations, or null for dynamic/unparseable Bash.
321+
* @returns Array of clauses (each clause is an array of ParsedInvocations), or null.
322322
*/
323-
function buildInvocations(
323+
function buildClauses(
324324
toolName: string,
325325
toolInput: Record<string, unknown>,
326-
): ParsedInvocation[] | null {
326+
): ParsedInvocation[][] | null {
327327
if (toolName === 'Bash') {
328328
const command =
329329
typeof toolInput.command === 'string' ? toolInput.command : '';
330330
const result = decompose(command);
331331
if (!result.ok) {
332332
return null;
333333
}
334-
return result.commands.map(({ name, argv }) => ({ name, argv }));
334+
return result.clauses.map((clause) =>
335+
clause.map(({ name, argv }) => ({ name, argv })),
336+
);
335337
}
336338
const argv = Object.values(toolInput).filter(
337339
(val): val is string => typeof val === 'string',
338340
);
339-
return [{ name: toolName, argv }];
341+
return [[{ name: toolName, argv }]];
340342
}
341343

342344
// ─── Session initialization ──────────────────────────────────────────────────
@@ -518,7 +520,7 @@ async function onSessionStart(payload: SessionStartPayload): Promise<void> {
518520
async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
519521
const { session_id, tool_name, tool_input } = payload;
520522
const sha = inputSha(tool_input);
521-
const invocations = buildInvocations(tool_name, tool_input);
523+
const clauses = buildClauses(tool_name, tool_input);
522524

523525
const state = await getOrInitSession(payload);
524526
if (!state) {
@@ -528,8 +530,16 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
528530

529531
let vatResponse = 'unknown';
530532
try {
531-
if (invocations !== null) {
532-
vatResponse = await vatRoute(state.rootKref, tool_name, invocations);
533+
if (clauses !== null) {
534+
let allAllow = true;
535+
for (const clause of clauses) {
536+
const verdict = await vatRoute(state.rootKref, tool_name, clause);
537+
if (verdict !== 'allow') {
538+
allAllow = false;
539+
break;
540+
}
541+
}
542+
vatResponse = allAllow ? 'allow' : 'ask';
533543
}
534544
} catch (error) {
535545
process.stderr.write(`[caprock] vatRoute failed: ${String(error)}\n`);
@@ -545,27 +555,37 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
545555
});
546556

547557
if (vatResponse === 'allow') {
548-
if (state.kernelSessionId && invocations !== null) {
558+
if (state.kernelSessionId !== undefined && clauses !== null) {
549559
const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`;
550-
vatFindMatch(state.rootKref, tool_name, invocations)
551-
.then(async (matched) =>
552-
recordProvisioned(
560+
Promise.all(
561+
clauses.map(async (clause) =>
562+
vatFindMatch(state.rootKref, tool_name, clause),
563+
),
564+
)
565+
.then(async (matches) => {
566+
const provisions = matches.filter(
567+
(matched): matched is Provision => matched !== null,
568+
);
569+
await recordProvisioned(
553570
SOCKET_PATH,
554571
state.kernelSessionId,
555572
autoDescription,
556573
{
557-
invocations,
558-
...(matched === null ? {} : { provision: matched }),
574+
// invocations is clauses.flat() — non-null because clauses !== null here
575+
invocations: clauses.flat(),
576+
clauses,
577+
...(provisions.length > 0 ? { provisions } : {}),
559578
},
560-
),
561-
)
579+
);
580+
return undefined;
581+
})
562582
.catch(() => undefined);
563583
}
564584
process.stdout.write(JSON.stringify({ continue: true }));
565585
return;
566586
}
567587

568-
if (!state.kernelSessionId) {
588+
if (state.kernelSessionId === undefined) {
569589
process.stdout.write(JSON.stringify({ continue: true }));
570590
return;
571591
}
@@ -578,7 +598,13 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
578598
SOCKET_PATH,
579599
state.kernelSessionId,
580600
description,
581-
invocations === null ? undefined : { invocations },
601+
clauses === null
602+
? undefined
603+
: {
604+
// invocations is clauses.flat() — non-null because clauses !== null here
605+
invocations: clauses.flat(),
606+
clauses,
607+
},
582608
);
583609
} catch (error) {
584610
const errorStr = String(error);
@@ -610,15 +636,18 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
610636
}
611637

612638
if (decision.verdict === 'accept') {
613-
if (decision.provision !== undefined) {
614-
await vatAddSection(state.rootKref, decision.provision).catch(
615-
() => undefined,
616-
);
617-
} else if (invocations !== null) {
618-
await vatAddSection(
619-
state.rootKref,
620-
invocationToProvision(tool_name, invocations),
621-
).catch(() => undefined);
639+
const decidedProvisions = decision.provisions;
640+
if (decidedProvisions !== undefined && decidedProvisions.length > 0) {
641+
for (const prov of decidedProvisions) {
642+
await vatAddSection(state.rootKref, prov).catch(() => undefined);
643+
}
644+
} else if (clauses !== null) {
645+
for (const clause of clauses) {
646+
await vatAddSection(
647+
state.rootKref,
648+
invocationToProvision(tool_name, clause),
649+
).catch(() => undefined);
650+
}
622651
}
623652
await appendEvent(session_id, {
624653
t: now(),
@@ -658,13 +687,15 @@ async function onPostToolUse(payload: PostToolUsePayload): Promise<void> {
658687
return;
659688
}
660689

661-
const invocations = buildInvocations(tool_name, tool_input);
662-
if (invocations !== null) {
690+
const postClauses = buildClauses(tool_name, tool_input);
691+
if (postClauses !== null) {
663692
try {
664-
await vatAddSection(
665-
state.rootKref,
666-
invocationToProvision(tool_name, invocations),
667-
);
693+
for (const clause of postClauses) {
694+
await vatAddSection(
695+
state.rootKref,
696+
invocationToProvision(tool_name, clause),
697+
);
698+
}
668699
} catch (error) {
669700
process.stderr.write(
670701
`[caprock] vatAddSection failed: ${String(error)}\n`,
@@ -707,15 +738,18 @@ async function onPermissionRequest(
707738
}
708739

709740
if (tool_name && tool_input) {
710-
const invocations = buildInvocations(tool_name, tool_input);
711-
if (invocations !== null) {
741+
const permClauses = buildClauses(tool_name, tool_input);
742+
if (permClauses !== null) {
712743
try {
713-
const vatResponse = await vatRoute(
714-
state.rootKref,
715-
tool_name,
716-
invocations,
717-
);
718-
if (vatResponse === 'allow') {
744+
let allAllow = true;
745+
for (const clause of permClauses) {
746+
const verdict = await vatRoute(state.rootKref, tool_name, clause);
747+
if (verdict !== 'allow') {
748+
allAllow = false;
749+
break;
750+
}
751+
}
752+
if (allAllow) {
719753
process.stdout.write(`${permissionAllow()}\n`);
720754
}
721755
} catch {

packages/caprock/bin/status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async function getBashSubcommands(sessionId: string): Promise<string[]> {
7878
if (typeof cmd !== 'string') {
7979
continue;
8080
}
81-
for (const parsed of decompose(cmd).commands) {
81+
for (const parsed of decompose(cmd).clauses.flat()) {
8282
names.push(parsed.name);
8383
}
8484
}
@@ -173,7 +173,7 @@ async function reportFromTranscript(sessionId: string): Promise<void> {
173173
if (typeof cmd !== 'string') {
174174
continue;
175175
}
176-
for (const parsed of decompose(cmd).commands) {
176+
for (const parsed of decompose(cmd).clauses.flat()) {
177177
bashCmds.push(parsed.name);
178178
}
179179
}

0 commit comments

Comments
 (0)