SLOPSHOPPER

ensemblr-mods

Ensemblr's Claude Code function hooks: refuse git moves that break the workspace model, redact known secret values, keep control-tool ids through compaction…

newguardpromptprocessnetwork
★ 8v0.1.0Apache-2.0updated 2026-10-09ensemblr-hq/ensemblr/apps/desktop/resources/agent-mods
A shopper browsing a rack in a slop shop
README

<img alt="Ensemblr" src="./apps/desktop/assets/wordmark.gif" width="588">

Ensemblr™

A desktop orchestrator for multi-agent coding work, driving the Pi CLI or the Claude Code CLI — whichever you already run.

This is the Ensemblr monorepo. The app itself — what it does, how to install it, and its documentation — lives in apps/desktop/. Start with its README.

Install

The install guide covers requirements, channels, and building from source.

Repository layout

PathWhat lives there
apps/desktop/Ensemblr, the Electron desktop app: source, tests, docs, JSON Schemas, packaging, changelog.
apps/website/The marketing and documentation site. Not started yet.
packages/shared/Code the apps share, such as UI pieces lifted out of the desktop app. Not started yet.

The root holds only what every workspace shares: the Bun workspace manifest and lockfile, the Nix flake and its dev shell, the house Biome config, CI, agent tooling, the Nix flake entrypoint, and the community files.

Development

Development happens inside the flake's dev shell, which provides Node 24.x, Bun 1.4, and the native-module toolchain. It needs Nix with flakes enabled and runs on Linux (x86-64 and arm64) and Apple-silicon Macs. Bun installs packages and runs scripts; Node stays the runtime.

nix develop          # enter the dev shell (or prefix any command with `nix develop -c`)
bun install          # every workspace, from one lockfile
bun run check        # lockfile check, Biome, then every workspace's own checks
bun run typecheck    # every workspace's type check
bun run test         # every workspace's tests

cd apps/desktop
bun run dev          # the desktop app

CONTRIBUTING.md covers how to propose a change and which gates must pass; AGENTS.md holds the repository's binding policies. Security reports go to SECURITY.md, never to a public issue.

License

Licensed under the Apache License, Version 2.0. Copyright 2026 Philipp Soldunov. Bundled third-party components and their licenses are listed in NOTICE.

Ensemblr™ is a trademark of Philipp Soldunov; the license does not grant use of the name or logo. See Trademark.

Source 10 files
hooks/register.ts 23 lines
1/**
2 * Ensemblr's Claude Code mods, registered together from the one hooks module
3 * the plugin's `hooks/hooks.json` names. Each mod is UI-free: Ensemblr hosts
4 * Claude Code over the SDK, where no pane, band, status line, or toast draws.
5 */
6import type { Register } from 'claude-code';
7
8import { registerCompactKeeper } from './compact-keeper.ts';
9import { registerGitGuard } from './git-guard.ts';
10import { registerSecretRedact } from './secret-redact.ts';
11import { registerTicketContext } from './ticket-context.ts';
12
13/**
14 * Registers every mod's hooks.
15 * @param on - The plugin's registrar.
16 */
17export const register: Register = (on) => {
18	registerGitGuard(on);
19	registerSecretRedact(on);
20	registerCompactKeeper(on);
21	registerTicketContext(on);
22};
23
hooks/compact-keeper.ts 66 lines
1/**
2 * compact-keeper: tells every compaction what an Ensemblr orchestrator cannot
3 * work without afterwards. A summary that drops a child's `agentSessionId` or a
4 * queued `jobId` leaves the agent unable to wait on, steer, or close the work it
5 * already started, and it re-spawns or re-queues instead.
6 */
7import type { EngineInterface, On } from 'claude-code';
8
9/** How long reading the current branch may take before compaction goes on without it. */
10const BRANCH_PROBE_TIMEOUT_MS = 5_000;
11
12/** What every compaction is told to carry over verbatim. */
13export const KEEP_INSTRUCTIONS = `This session runs inside Ensemblr. The summary must carry these over verbatim, each id copied exactly, because the next turn cannot recover them from anywhere else:
14- Every child conversation started with ensemblr_start_conversation: its agentSessionId, its chatTabId, its title, whether it is still running or settled, and the gist of its report if one came back.
15- Every compute-queue job queued with ensemblr_run_queued: its jobId, the command, and its last known state (queued, running, finished with exit code, timed out).
16- The workspace's git branch, and whether work on it is committed, pushed, or has a pull request.
17- Every open diff-review comment id from ensemblr_get_diff_comments or ensemblr_add_diff_comments, which ones were fixed, and which are still open and why.
18- Every decision made so far — by the user, or by you and stated to the user — with its reason, and every question still waiting on the user.`;
19
20/**
21 * Reads the branch the session's checkout is on.
22 * @param $ - The engine interface.
23 * @returns The branch name, or null when git cannot say.
24 */
25async function readBranch($: EngineInterface): Promise<string | null> {
26	try {
27		const probe = await $.process.run(['git', 'branch', '--show-current'], {
28			timeoutMs: BRANCH_PROBE_TIMEOUT_MS,
29		});
30		const branch = probe.stdout.trim();
31		return probe.exitCode === 0 && branch !== '' ? branch : null;
32	} catch {
33		return null;
34	}
35}
36
37/**
38 * Joins the person's or another plugin's instructions with Ensemblr's.
39 * @param instructions - The instructions already set, if any.
40 * @param branch - The current branch, if known.
41 * @returns The instructions the summarizer is given.
42 */
43function composeInstructions(
44	instructions: string | undefined,
45	branch: string | null,
46): string {
47	const branchLine =
48		branch === null ? null : `The workspace branch is currently \`${branch}\`.`;
49	return [instructions?.trim() || null, KEEP_INSTRUCTIONS, branchLine]
50		.filter((part) => part !== null)
51		.join('\n\n');
52}
53
54/**
55 * Registers compact-keeper's compaction hook.
56 * @param on - The plugin's registrar.
57 */
58export function registerCompactKeeper(on: On): void {
59	on('session.compact', async ($, e, next) =>
60		next({
61			...e,
62			instructions: composeInstructions(e.instructions, await readBranch($)),
63		}),
64	);
65}
66
hooks/git-guard.ts 115 lines
1/**
2 * git-guard: refuses, before they run, the git moves Ensemblr's playbook forbids
3 * — `git branch -m`, the shared stash stack outside its recipe, and git pointed
4 * at a checkout other than this session's own. The playbook already says all
5 * of this; a model that forgets it now hears it at the moment it matters.
6 */
7import type { EngineInterface, On } from 'claude-code';
8
9import { findGitInvocations, type GitTarget } from './git-command.ts';
10import { judgeGitInvocation, type WorktreeScope } from './git-policy.ts';
11
12/** How long the one-off `git rev-parse` that finds the worktree may take. */
13const SCOPE_PROBE_TIMEOUT_MS = 5_000;
14
15/**
16 * Resolves a path through symbolic links, so a link into a sibling checkout is
17 * judged where it lands; a path that does not exist keeps its own spelling.
18 * @param $ - The engine interface.
19 * @param path - An absolute path.
20 * @returns The real path, or the path as given.
21 */
22async function realPathOf($: EngineInterface, path: string): Promise<string> {
23	try {
24		return (await $.fs.stat(path, { resolve: true })).realPath ?? path;
25	} catch {
26		return path;
27	}
28}
29
30/**
31 * Finds the checkout this session owns: the worktree root and its git directory,
32 * from git itself, falling back to the session's directory alone.
33 * @param $ - The engine interface.
34 * @returns The session's worktree scope.
35 */
36async function readWorktreeScope($: EngineInterface): Promise<WorktreeScope> {
37	const cwd = await $.session.cwd();
38	try {
39		const probe = await $.process.run(
40			['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'],
41			{ cwd, timeoutMs: SCOPE_PROBE_TIMEOUT_MS },
42		);
43		const [root, gitDir] = probe.stdout.trim().split('\n');
44		if (probe.exitCode === 0 && root && gitDir) {
45			return {
46				gitDir: await realPathOf($, gitDir),
47				root: await realPathOf($, root),
48			};
49		}
50	} catch {
51		return { gitDir: null, root: await realPathOf($, cwd) };
52	}
53	return { gitDir: null, root: await realPathOf($, cwd) };
54}
55
56/**
57 * Resolves a target's path through symbolic links.
58 * @param $ - The engine interface.
59 * @param target - One redirection of git.
60 * @returns The target with its real path.
61 */
62async function realTarget(
63	$: EngineInterface,
64	target: GitTarget,
65): Promise<GitTarget> {
66	return target.path === null
67		? target
68		: { ...target, path: await realPathOf($, target.path) };
69}
70
71/**
72 * Decides a Bash command: the first git invocation in it that breaks a rule.
73 * @param $ - The engine interface.
74 * @param command - The Bash command line.
75 * @param scope - The session's worktree scope.
76 * @returns The refusal, or null to let the command run.
77 */
78async function judgeCommand(
79	$: EngineInterface,
80	command: string,
81	scope: WorktreeScope,
82): Promise<string | null> {
83	const home = (await $.env.get('HOME')) ?? null;
84	const invocations = findGitInvocations(command, {
85		cwd: await $.session.cwd(),
86		home,
87	});
88	for (const invocation of invocations) {
89		const targets = await Promise.all(
90			invocation.targets.map((target) => realTarget($, target)),
91		);
92		const denial = judgeGitInvocation({ ...invocation, targets }, scope);
93		if (denial !== null) {
94			return denial;
95		}
96	}
97	return null;
98}
99
100/**
101 * Registers git-guard's Bash hook.
102 * @param on - The plugin's registrar.
103 */
104export function registerGitGuard(on: On): void {
105	let scope: Promise<WorktreeScope> | null = null;
106	on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
107		if (!/\bgit\b/.test(e.command)) {
108			return next(e);
109		}
110		scope ??= readWorktreeScope($);
111		const denial = await judgeCommand($, e.command, await scope);
112		return denial === null ? next(e) : { deny: denial };
113	});
114}
115
hooks/secret-redact.ts 141 lines
1/**
2 * secret-redact: replaces the exact values of this workspace's secrets — its
3 * Infisical secrets, its Keychain environment rows, and the control tokens the
4 * app minted — with `[redacted:NAME]` in every row the conversation keeps,
5 * before the model reads it or the next request sends it. A tool's structured
6 * record beside its result is stored as the tool made it, so the transcript
7 * file and a host drawing from that record can still show the raw output.
8 *
9 * The values never reach this module. Ensemblr matches them in its own process
10 * (`redactText` over the control server) and answers with the redacted text, so
11 * there is no file, variable, or reply here that would hand the model a secret
12 * it could otherwise not see. The session's own control token is the one value
13 * this module holds — the model's environment already carries it — and it is
14 * replaced locally too, so it stays hidden even while the app is unreachable.
15 *
16 * Rows are rewritten at `session.append`, the one place every row passes, so a
17 * secret is caught whichever tool, attachment, or delivery carried it. The
18 * person's own prompt and slash commands are left as typed: a value they paste
19 * on purpose is theirs to hand the model. When the app does not answer within
20 * {@link CONTROL_DEADLINE_MS}, the row goes on with the local pass alone:
21 * holding the session hostage to a slow main process is the worse failure.
22 */
23import type { EngineInterface, HttpResponse, On } from 'claude-code';
24
25import {
26	buildInvokeRequest,
27	CONTROL_DEADLINE_MS,
28	type ControlEndpoint,
29	readInvokeData,
30	toControlEndpoint,
31} from './control.ts';
32import {
33	createTextRedactor,
34	redactBlocks,
35	type TextRedactor,
36} from './row-redaction.ts';
37
38/** The doors whose rows the person typed, which are never rewritten. */
39const PERSON_DOORS = new Set(['prompt', 'command']);
40
41/** The placeholder the control token becomes. */
42const TOKEN_PLACEHOLDER = '[redacted:ENSEMBLR_CONTROL_TOKEN]';
43
44/**
45 * Reads the control endpoint Ensemblr injected into this session.
46 * @param $ - The engine interface.
47 * @returns The endpoint, or null outside an Ensemblr session.
48 */
49async function readEndpoint(
50	$: EngineInterface,
51): Promise<ControlEndpoint | null> {
52	return toControlEndpoint(
53		await $.env.get('ENSEMBLR_CONTROL_URL'),
54		await $.env.get('ENSEMBLR_CONTROL_TOKEN'),
55	);
56}
57
58/**
59 * Calls the app, giving up once the deadline passes.
60 * @param $ - The engine interface.
61 * @param request - The request to send.
62 * @returns The response, or null on a failure or a timeout.
63 */
64async function fetchWithinDeadline(
65	$: EngineInterface,
66	request: ReturnType<typeof buildInvokeRequest>,
67): Promise<HttpResponse | null> {
68	const timer = new AbortController();
69	const deadline = $.clock
70		.sleep(CONTROL_DEADLINE_MS, { signal: timer.signal })
71		.then(
72			() => null,
73			() => new Promise<never>(() => undefined),
74		);
75	try {
76		return await Promise.race([
77			$.http.fetch(request.url, request.init),
78			deadline,
79		]);
80	} catch {
81		return null;
82	} finally {
83		timer.abort();
84	}
85}
86
87/**
88 * Has the app redact one piece of text against the workspace's secrets.
89 * @param $ - The engine interface.
90 * @param endpoint - The session's control endpoint.
91 * @param piece - Text within the op's size limit.
92 * @returns The redacted text, or the piece unchanged when the app did not answer.
93 */
94async function redactRemotely(
95	$: EngineInterface,
96	endpoint: ControlEndpoint,
97	piece: string,
98): Promise<string> {
99	const request = buildInvokeRequest(endpoint, 'redactText', { text: piece });
100	const response = await fetchWithinDeadline($, request);
101	const data = (response === null ? null : readInvokeData(response.text)) as {
102		text?: unknown;
103	} | null;
104	return typeof data?.text === 'string' ? data.text : piece;
105}
106
107/**
108 * Builds this session's text redactor, or null outside Ensemblr.
109 * @param $ - The engine interface.
110 * @returns The redactor.
111 */
112async function createSessionRedactor(
113	$: EngineInterface,
114): Promise<TextRedactor | null> {
115	const endpoint = await readEndpoint($);
116	return endpoint === null
117		? null
118		: createTextRedactor(endpoint.token, TOKEN_PLACEHOLDER, (piece) =>
119				redactRemotely($, endpoint, piece),
120			);
121}
122
123/**
124 * Registers secret-redact's row hook.
125 * @param on - The plugin's registrar.
126 */
127export function registerSecretRedact(on: On): void {
128	on('session.append', async ($, e, next) => {
129		const redact = PERSON_DOORS.has(e.door)
130			? null
131			: await createSessionRedactor($);
132		if (redact === null) {
133			return next(e);
134		}
135		const content = await redactBlocks(e.message.content, redact);
136		return content === e.message.content
137			? next(e)
138			: next({ ...e, message: { ...e.message, content: [...content] } });
139	});
140}
141
hooks/ticket-context.ts 164 lines
1/**
2 * ticket-context: puts the Linear issue a workspace was created from into the
3 * context blocks of a conversation's first message — its identifier, title, and
4 * description — so the agent starts from the ticket's requirements rather than
5 * from a one-line prompt, and so does every later conversation in the workspace.
6 *
7 * The block rides the person's first message, which secret-redact leaves as
8 * typed, so it goes through `redactText` here: a key pasted into a ticket is as
9 * much a secret as one printed by a tool.
10 */
11import type { EngineInterface, On } from 'claude-code';
12
13import {
14	buildInvokeRequest,
15	CONTROL_DEADLINE_MS,
16	type ControlEndpoint,
17	readInvokeData,
18	toControlEndpoint,
19} from './control.ts';
20
21/** The linked issue as `getLinkedIssue` answers it. */
22interface LinkedIssue {
23	description: string | null;
24	identifier: string;
25	title: string;
26	url: string | null;
27}
28
29/** The name the block renders under. */
30export const BLOCK_NAME = 'ensemblrLinkedIssue';
31
32/** The tag the description is quoted inside. */
33const DESCRIPTION_TAG = 'issue-description';
34
35/** Anything a model would read as the description's closing tag. */
36const CLOSING_TAG = new RegExp(`<\\s*/\\s*${DESCRIPTION_TAG}\\s*>`, 'gi');
37
38/** The placeholder the control token becomes. */
39const TOKEN_PLACEHOLDER = '[redacted:ENSEMBLR_CONTROL_TOKEN]';
40
41/**
42 * Narrows `getLinkedIssue`'s payload to an issue.
43 * @param data - The op's `data`.
44 * @returns The issue, or null when there is none or the payload is malformed.
45 */
46function readIssue(data: unknown): LinkedIssue | null {
47	const issue = (data as { issue?: unknown } | null)?.issue;
48	if (typeof issue !== 'object' || issue === null) {
49		return null;
50	}
51	const { description, identifier, title, url } = issue as Record<
52		string,
53		unknown
54	>;
55	return typeof identifier === 'string' && typeof title === 'string'
56		? {
57				description: typeof description === 'string' ? description : null,
58				identifier,
59				title,
60				url: typeof url === 'string' ? url : null,
61			}
62		: null;
63}
64
65/**
66 * Renders the block's text. The description is quoted as data, since it is
67 * whatever the team wrote on the ticket and not an instruction from the user.
68 * @param issue - The linked issue.
69 * @returns The block text.
70 */
71function renderIssueBlock(issue: LinkedIssue): string {
72	const heading = `This workspace was created from Linear issue ${issue.identifier}: ${issue.title}`;
73	const link = issue.url === null ? null : `URL: ${issue.url}`;
74	const quoted = issue.description
75		?.trim()
76		.replace(CLOSING_TAG, `<\\/${DESCRIPTION_TAG}>`);
77	const description = quoted
78		? `Issue description, quoted from Linear (requirements context; the user's own messages take precedence over it):\n<${DESCRIPTION_TAG}>\n${quoted}\n</${DESCRIPTION_TAG}>`
79		: 'The issue has no description.';
80	return [heading, link, description]
81		.filter((part) => part !== null)
82		.join('\n');
83}
84
85/**
86 * Calls one control op, giving up once the deadline passes.
87 * @param $ - The engine interface.
88 * @param endpoint - The session's control endpoint.
89 * @param op - The op name.
90 * @param args - The op's arguments.
91 * @returns The op's `data`, or null on a refusal, a failure, or a timeout.
92 */
93async function callControl(
94	$: EngineInterface,
95	endpoint: ControlEndpoint,
96	op: string,
97	args: Record<string, unknown>,
98): Promise<unknown> {
99	const request = buildInvokeRequest(endpoint, op, args);
100	const timer = new AbortController();
101	const deadline = $.clock
102		.sleep(CONTROL_DEADLINE_MS, { signal: timer.signal })
103		.then(
104			() => null,
105			() => new Promise<never>(() => undefined),
106		);
107	try {
108		const response = await Promise.race([
109			$.http.fetch(request.url, request.init),
110			deadline,
111		]);
112		return response === null ? null : readInvokeData(response.text);
113	} catch {
114		return null;
115	} finally {
116		timer.abort();
117	}
118}
119
120/**
121 * Builds the linked issue's block, its text already redacted.
122 * @param $ - The engine interface.
123 * @returns The block text, or null outside Ensemblr or when none is linked.
124 */
125async function buildIssueBlock($: EngineInterface): Promise<string | null> {
126	const endpoint = toControlEndpoint(
127		await $.env.get('ENSEMBLR_CONTROL_URL'),
128		await $.env.get('ENSEMBLR_CONTROL_TOKEN'),
129	);
130	if (endpoint === null) {
131		return null;
132	}
133	const issue = readIssue(await callControl($, endpoint, 'getLinkedIssue', {}));
134	if (issue === null) {
135		return null;
136	}
137	const text = renderIssueBlock(issue)
138		.split(endpoint.token)
139		.join(TOKEN_PLACEHOLDER);
140	const redacted = (await callControl($, endpoint, 'redactText', { text })) as {
141		text?: unknown;
142	} | null;
143	return typeof redacted?.text === 'string' ? redacted.text : text;
144}
145
146/**
147 * Registers ticket-context's first-message hook.
148 * @param on - The plugin's registrar.
149 */
150export function registerTicketContext(on: On): void {
151	on('prompt.context', async ($, e, next) => {
152		const [context, text] = await Promise.all([next(e), buildIssueBlock($)]);
153		const isAlreadyThere = context.blocks.some(
154			(block) => block.name === BLOCK_NAME,
155		);
156		return text === null || isAlreadyThere
157			? context
158			: {
159					...context,
160					blocks: [...context.blocks, { name: BLOCK_NAME, text }],
161				};
162	});
163}
164
hooks/git-command.ts 494 lines
1/**
2 * Finds the `git` invocations in a Bash command and what each one points at:
3 * the subcommand and its arguments, and every path that moves git off the
4 * session's own checkout (`cd`/`pushd`, `-C`, `--git-dir`, `--work-tree`,
5 * `GIT_DIR`, `GIT_WORK_TREE`), resolved against the directory the command
6 * runs in.
7 */
8import { SUBSHELL_CLOSE, SUBSHELL_OPEN, splitCommands } from './shell-words.ts';
9
10/** How a path redirected git: a directory change, a global option, or an environment variable. */
11export type GitTargetSource =
12	| 'cd'
13	| '-C'
14	| '--git-dir'
15	| '--work-tree'
16	| 'GIT_DIR'
17	| 'GIT_WORK_TREE';
18
19/** One redirection of git, its path absolute, or null when it cannot be read. */
20export interface GitTarget {
21	path: string | null;
22	source: GitTargetSource;
23}
24
25/** One `git` the command would start. */
26export interface GitInvocation {
27	args: readonly string[];
28	subcommand: string | null;
29	targets: readonly GitTarget[];
30}
31
32/** The directory a command runs in (null once a `cd` lost it) and the home directory. */
33export interface ShellContext {
34	cwd: string | null;
35	home: string | null;
36}
37
38/** Words that lead a simple command without being the program it runs. */
39const COMMAND_PREFIXES = new Set([
40	'!',
41	'{',
42	'}',
43	'builtin',
44	'command',
45	'do',
46	'elif',
47	'else',
48	'exec',
49	'if',
50	'nohup',
51	'then',
52	'time',
53	'until',
54	'while',
55]);
56
57/** A program that runs another one: its option table and the positionals before the program. */
58interface Wrapper {
59	/** Options that run the program in another directory. */
60	chdirOptions?: ReadonlySet<string>;
61	positionals: number;
62	/** Options that name a placeholder the program's words are filled from. */
63	replaceOptions?: ReadonlySet<string>;
64	valueOptions: ReadonlySet<string>;
65}
66
67/** Wrappers a command in good faith is plausibly started through. */
68const WRAPPERS: ReadonlyMap<string, Wrapper> = new Map([
69	[
70		'env',
71		{
72			chdirOptions: new Set(['-C', '--chdir']),
73			positionals: 0,
74			valueOptions: new Set(['-u', '--unset', '-S', '-C', '--chdir']),
75		},
76	],
77	['nice', { positionals: 0, valueOptions: new Set(['-n', '--adjustment']) }],
78	[
79		'sudo',
80		{
81			chdirOptions: new Set(['-D', '--chdir']),
82			positionals: 0,
83			valueOptions: new Set([
84				'-u',
85				'-g',
86				'-C',
87				'-D',
88				'--chdir',
89				'-h',
90				'-p',
91				'-U',
92			]),
93		},
94	],
95	[
96		'timeout',
97		{
98			positionals: 1,
99			valueOptions: new Set(['-s', '--signal', '-k', '--kill-after']),
100		},
101	],
102	[
103		'xargs',
104		{
105			positionals: 0,
106			replaceOptions: new Set(['-I', '--replace']),
107			valueOptions: new Set([
108				'-a',
109				'-d',
110				'-E',
111				'-e',
112				'-I',
113				'-L',
114				'-n',
115				'-P',
116				'-s',
117			]),
118		},
119	],
120]);
121
122/** The placeholder `xargs -i` and a bare `--replace` fill. */
123const XARGS_DEFAULT_PLACEHOLDER = '{}';
124
125/** `cd` and `pushd` flags that change how a path resolves, not which. */
126const CD_FLAGS = /^-[LPe@]+$/;
127
128/** Environment variables that point git at another repository or checkout. */
129const TARGET_VARIABLES = new Set<GitTargetSource>(['GIT_DIR', 'GIT_WORK_TREE']);
130
131/** A shell variable assignment, `NAME=value`. */
132const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
133
134/** Git global options that take the next word as their value. */
135const VALUE_OPTIONS = new Set(['-c', '--config-env', '--namespace']);
136
137/**
138 * Expands a leading `~` the way the shell would.
139 * @param path - The path as written.
140 * @param home - The home directory, or null when unknown.
141 * @returns The expanded path, or null when it needs a home nobody knows.
142 */
143function expandHome(path: string, home: string | null): string | null {
144	if (path !== '~' && !path.startsWith('~/')) {
145		return path;
146	}
147	return home === null ? null : `${home}${path.slice(1)}`;
148}
149
150/**
151 * Resolves a path lexically against a directory, `~` expanded.
152 * @param base - Absolute directory relative paths start from, or null when unknown.
153 * @param path - The path as written.
154 * @param home - The home directory, or null when unknown.
155 * @returns The absolute path, or null when it cannot be read without running the shell.
156 */
157function resolvePath(
158	base: string | null,
159	path: string,
160	home: string | null,
161): string | null {
162	const expanded =
163		path.includes('$') || path.includes('`') ? null : expandHome(path, home);
164	if (expanded === null || (base === null && !expanded.startsWith('/'))) {
165		return null;
166	}
167	const joined = expanded.startsWith('/') ? expanded : `${base}/${expanded}`;
168	const kept: string[] = [];
169	for (const part of joined.split('/')) {
170		if (part === '..') {
171			kept.pop();
172		} else if (part !== '' && part !== '.') {
173			kept.push(part);
174		}
175	}
176	return `/${kept.join('/')}`;
177}
178
179/**
180 * Reads a `git` command's global options, from the word after `git` to its subcommand.
181 * @param words - The words after `git`.
182 * @param context - Where the command runs, for resolving `-C` and friends.
183 * @param inherited - Targets the environment already set (`GIT_DIR=...`).
184 * @returns The invocation.
185 */
186function readGitWords(
187	words: readonly string[],
188	context: ShellContext,
189	inherited: readonly GitTarget[],
190): GitInvocation {
191	const targets = [...inherited];
192	let directory = context.cwd;
193	let index = 0;
194	while (index < words.length) {
195		const word = words[index] ?? '';
196		if (!word.startsWith('-')) {
197			break;
198		}
199		const [flag, attached] = word.split(/=(.*)/s, 2);
200		const value = attached ?? words[index + 1] ?? '';
201		if (word === '-C') {
202			directory =
203				value === '' ? null : resolvePath(directory, value, context.home);
204			targets.push({ path: directory, source: '-C' });
205			index += 2;
206		} else if (flag === '--git-dir' || flag === '--work-tree') {
207			targets.push({
208				path: resolvePath(directory, value, context.home),
209				source: flag,
210			});
211			index += attached === undefined ? 2 : 1;
212		} else {
213			index += VALUE_OPTIONS.has(word) ? 2 : 1;
214		}
215	}
216	return {
217		args: words.slice(index + 1),
218		subcommand: words[index] ?? null,
219		targets,
220	};
221}
222
223/**
224 * Collects the git targets a run of assignments sets, resolved against the cwd.
225 * @param assignments - `NAME=value` words.
226 * @param context - Where the command runs.
227 * @returns The targets among them.
228 */
229function targetsFromAssignments(
230	assignments: readonly string[],
231	context: ShellContext,
232): GitTarget[] {
233	return assignments.flatMap((assignment) => {
234		const [, name, value] = ASSIGNMENT.exec(assignment) ?? [];
235		return name && TARGET_VARIABLES.has(name as GitTargetSource)
236			? [
237					{
238						path: resolvePath(context.cwd, value ?? '', context.home),
239						source: name as GitTargetSource,
240					},
241				]
242			: [];
243	});
244}
245
246/** What a run of leading words told about the program they start. */
247interface Prefixes {
248	assignments: string[];
249	/** The directory a wrapper runs the program in, as written; absent when none. */
250	directory?: string;
251	/** Placeholders a wrapper fills the program's words from. */
252	placeholders: string[];
253}
254
255/**
256 * Splits an option from a value written onto it: `--chdir=/x`, or a short
257 * option that takes a value written without a space (`-I{}`, `-n5`).
258 * @param word - The option word.
259 * @param valueOptions - The options that take a value.
260 * @returns The option, and its attached value when it has one.
261 */
262function splitOption(
263	word: string,
264	valueOptions: ReadonlySet<string>,
265): [string, string | undefined] {
266	if (word.startsWith('--')) {
267		const [flag = word, attached] = word.split(/=(.*)/s, 2);
268		return [flag, attached];
269	}
270	const short = word.slice(0, 2);
271	return word.length > 2 && valueOptions.has(short)
272		? [short, word.slice(2)]
273		: [word, undefined];
274}
275
276/**
277 * Reads one wrapper option, noting a directory or placeholder it sets.
278 * @param wrapper - The wrapper's option table.
279 * @param words - One simple command's words.
280 * @param index - Index of the option.
281 * @param found - What the leading words told so far; updated in place.
282 * @returns How many words the option took.
283 */
284function readWrapperOption(
285	wrapper: Wrapper,
286	words: readonly string[],
287	index: number,
288	found: Prefixes,
289): number {
290	const word = words[index] ?? '';
291	const [flag, attached] = splitOption(word, wrapper.valueOptions);
292	const value = attached ?? words[index + 1] ?? '';
293	if (wrapper.chdirOptions?.has(flag)) {
294		found.directory = value;
295	}
296	if (wrapper.replaceOptions?.has(flag)) {
297		const isBareLongForm = flag.startsWith('--') && attached === undefined;
298		found.placeholders.push(
299			isBareLongForm
300				? XARGS_DEFAULT_PLACEHOLDER
301				: value || XARGS_DEFAULT_PLACEHOLDER,
302		);
303	}
304	if (word === '-i' && wrapper.replaceOptions) {
305		found.placeholders.push(XARGS_DEFAULT_PLACEHOLDER);
306	}
307	return attached === undefined && wrapper.valueOptions.has(flag) ? 2 : 1;
308}
309
310/**
311 * Skips a wrapper's options and leading positionals.
312 * @param words - One simple command's words.
313 * @param start - Index of the first word after the wrapper's name.
314 * @param wrapper - The wrapper's option table.
315 * @param found - What the leading words told so far; updated in place.
316 * @returns Index of the first word of the program it runs.
317 */
318function skipWrapper(
319	words: readonly string[],
320	start: number,
321	wrapper: Wrapper,
322	found: Prefixes,
323): number {
324	let index = start;
325	while ((words[index] ?? '').startsWith('-')) {
326		index += readWrapperOption(wrapper, words, index, found);
327	}
328	return index + wrapper.positionals;
329}
330
331/**
332 * Drops the words that lead a command without naming its program: shell
333 * keywords, wrappers such as `sudo`, `env` or `timeout` and their options,
334 * and assignments. A word a wrapper fills from a placeholder (`xargs -I {}`)
335 * cannot be known without running the command, so it keeps a `$` in its place.
336 * @param words - One simple command's words.
337 * @returns What the leading words told, and the words from the program on.
338 */
339function stripPrefixes(
340	words: readonly string[],
341): Prefixes & { program: readonly string[] } {
342	const found: Prefixes = { assignments: [], placeholders: [] };
343	let index = 0;
344	while (index < words.length) {
345		const word = words[index] ?? '';
346		const wrapper = WRAPPERS.get(word);
347		if (ASSIGNMENT.test(word)) {
348			found.assignments.push(word);
349		} else if (wrapper) {
350			index = skipWrapper(words, index + 1, wrapper, found);
351			continue;
352		} else if (!COMMAND_PREFIXES.has(word)) {
353			break;
354		}
355		index += 1;
356	}
357	const program = words
358		.slice(index)
359		.map((word) =>
360			found.placeholders.reduce(
361				(filled, placeholder) => filled.replaceAll(placeholder, '$'),
362				word,
363			),
364		);
365	return { ...found, program };
366}
367
368/**
369 * Whether a word names the git program, bare or by path.
370 * @param word - The program word.
371 * @returns True for `git` and `/usr/bin/git`.
372 */
373function isGit(word: string | undefined): boolean {
374	return word === 'git' || (word?.endsWith('/git') ?? false);
375}
376
377/** Where the shell stands while a command line is read. */
378interface ShellState {
379	cwd: string | null;
380	exported: readonly GitTarget[];
381	pushed: readonly (string | null)[];
382}
383
384/**
385 * Resolves where `cd` or `pushd` lands.
386 * @param state - Where the shell stands.
387 * @param args - The command's arguments.
388 * @param home - The home directory.
389 * @returns The new directory, or null when it cannot be read (`cd -`, an unknown flag).
390 */
391function changeDirectory(
392	state: ShellState,
393	args: readonly string[],
394	home: string | null,
395): string | null {
396	const operands = args.filter((arg) => !CD_FLAGS.test(arg));
397	const [target] = operands;
398	if (target === '-' || target?.startsWith('-')) {
399		return null;
400	}
401	return resolvePath(state.cwd, target ?? '~', home);
402}
403
404/**
405 * Applies one simple command to where the shell stands.
406 * @param state - Where the shell stands before it.
407 * @param program - The command, prefixes stripped.
408 * @param home - The home directory.
409 * @returns Where the shell stands after it.
410 */
411function applyShellCommand(
412	state: ShellState,
413	program: readonly string[],
414	home: string | null,
415): ShellState {
416	const [name, ...rest] = program;
417	switch (name) {
418		case 'cd':
419			return { ...state, cwd: changeDirectory(state, rest, home) };
420		case 'pushd':
421			return {
422				...state,
423				cwd: rest.length === 0 ? null : changeDirectory(state, rest, home),
424				pushed: [...state.pushed, state.cwd],
425			};
426		case 'popd':
427			return {
428				...state,
429				cwd: state.pushed.length === 0 ? null : (state.pushed.at(-1) ?? null),
430				pushed: state.pushed.slice(0, -1),
431			};
432		case 'export':
433			return {
434				...state,
435				exported: [
436					...state.exported,
437					...targetsFromAssignments(rest, { cwd: state.cwd, home }),
438				],
439			};
440		default:
441			return state;
442	}
443}
444
445/**
446 * Finds every `git` a Bash command would start, following `cd`, `pushd`,
447 * `export`, `env -C`, and subshells through the command, so a git run after
448 * changing directory, a relative `-C`, or a `GIT_DIR` exported earlier in the
449 * line is read where it lands. A `cd` to a directory that cannot be read
450 * without running the shell (`cd "$(git rev-parse --show-toplevel)"`, `cd -`)
451 * is not counted as leaving the worktree: it is how agents return to it.
452 * @param command - The Bash command line.
453 * @param context - The directory the command starts in and the home directory.
454 * @returns Each git invocation, in order.
455 */
456export function findGitInvocations(
457	command: string,
458	context: ShellContext,
459): GitInvocation[] {
460	const { home } = context;
461	let state: ShellState = { cwd: context.cwd, exported: [], pushed: [] };
462	const subshells: ShellState[] = [];
463	const found: GitInvocation[] = [];
464	for (const words of splitCommands(command)) {
465		const { assignments, directory, program } = stripPrefixes(words);
466		const [name, ...rest] = program;
467		if (words.length === 1 && name === SUBSHELL_OPEN) {
468			subshells.push(state);
469		} else if (words.length === 1 && name === SUBSHELL_CLOSE) {
470			state = subshells.pop() ?? state;
471		} else if (isGit(name)) {
472			const cwd =
473				directory === undefined
474					? state.cwd
475					: resolvePath(state.cwd, directory, home);
476			const here: ShellContext = { cwd, home };
477			const moved: GitTarget[] =
478				cwd === null || cwd === context.cwd
479					? []
480					: [{ path: cwd, source: 'cd' }];
481			found.push(
482				readGitWords(rest, here, [
483					...moved,
484					...state.exported,
485					...targetsFromAssignments(assignments, here),
486				]),
487			);
488		} else if (directory === undefined) {
489			state = applyShellCommand(state, program, home);
490		}
491	}
492	return found;
493}
494
hooks/git-policy.ts 238 lines
1/**
2 * The git moves Ensemblr's playbook forbids, decided per invocation: renaming the
3 * branch behind the app, touching the stash stack every worktree shares, and
4 * writing to a checkout other than the session's own. Each refusal says what to
5 * do instead, because a bare "denied" only sends the model hunting for a
6 * spelling that gets through.
7 */
8import type { GitInvocation } from './git-command.ts';
9
10/** The checkout this session owns: its worktree root and its own git directory. */
11export interface WorktreeScope {
12	gitDir: string | null;
13	root: string;
14}
15
16/** Subcommands that only read, which may look at any repository. */
17const READ_ONLY_SUBCOMMANDS = new Set([
18	'blame',
19	'cat-file',
20	'check-ignore',
21	'cherry',
22	'count-objects',
23	'describe',
24	'diff',
25	'for-each-ref',
26	'grep',
27	'help',
28	'log',
29	'ls-files',
30	'ls-remote',
31	'ls-tree',
32	'merge-base',
33	'name-rev',
34	'rev-list',
35	'rev-parse',
36	'shortlog',
37	'show',
38	'show-ref',
39	'status',
40	'var',
41	'version',
42]);
43
44/** Subcommands that create a repository rather than write into one. */
45const CREATES_A_REPOSITORY = new Set(['clone', 'init']);
46
47/** `git branch` flags that only list. */
48const BRANCH_LIST_FLAGS = new Set([
49	'-a',
50	'--all',
51	'-l',
52	'--list',
53	'-r',
54	'--remotes',
55	'-v',
56	'-vv',
57	'--verbose',
58	'--show-current',
59	'--no-color',
60]);
61
62/** A full or abbreviated commit id, the only stash reference `apply` may take. */
63const COMMIT_ID = /^[0-9a-f]{7,40}$/i;
64
65const RENAME_DENIAL =
66	'ensemblr git-guard: renaming the branch with `git branch -m` moves it behind Ensemblr, which leaves the workspace pointing at a branch that no longer exists. When the user asked for a different branch name, call `mcp__ensemblr__ensemblr_set_branch_name` with `userRequested: true`; it renames the workspace and the branch together.';
67
68const STASH_RECIPE =
69	'Prefer a temporary WIP commit. If you must stash: `git stash push -u -m "<unique-tag>"`, capture its SHA with `git stash list --format=\'%H %gs\'`, restore with `git stash apply <sha>`, then re-find its `stash@{n}` by tag and `git stash drop stash@{n}`.';
70
71const STASH_DENIAL = `ensemblr git-guard: the stash stack is shared by every worktree of this repository, and other sessions push and pop it concurrently, so this could take or destroy another session's changes. ${STASH_RECIPE}`;
72
73/**
74 * Whether a word is an option rather than a positional argument.
75 * @param word - One argument.
76 * @returns True for `-x` and `--long` forms.
77 */
78function isOption(word: string): boolean {
79	return word.startsWith('-') && word !== '-';
80}
81
82/**
83 * Whether a `git branch` call renames a branch.
84 * @param args - The arguments after `branch`.
85 * @returns True for `-m`, `-M`, `--move`, and short clusters holding either.
86 */
87function isBranchRename(args: readonly string[]): boolean {
88	return args.some(
89		(arg) => arg === '--move' || (/^-[A-Za-z]+$/.test(arg) && /[mM]/.test(arg)),
90	);
91}
92
93/**
94 * Whether stash options carry a message, so the entry can be found by its tag.
95 * @param args - The options of `git stash push`.
96 * @returns True when `-m` or `--message` is present.
97 */
98function hasStashMessage(args: readonly string[]): boolean {
99	return args.some(
100		(arg) => /^--message(=|$)/.test(arg) || /^-[A-Za-z]*m/.test(arg),
101	);
102}
103
104/**
105 * Decides a `git stash` call against the shared-stack recipe.
106 * @param args - The arguments after `stash`.
107 * @returns The refusal, or null when the call keeps to the recipe.
108 */
109function judgeStash(args: readonly string[]): string | null {
110	const [first, ...rest] = args;
111	if (first === undefined) {
112		return STASH_DENIAL;
113	}
114	if (isOption(first) || first === 'push') {
115		const options = first === 'push' ? rest : args;
116		return hasStashMessage(options)
117			? null
118			: `ensemblr git-guard: a stash without a unique message cannot be found again by tag once other sessions push onto the shared stack. ${STASH_RECIPE}`;
119	}
120	const positionals = rest.filter((arg) => !isOption(arg));
121	switch (first) {
122		case 'list':
123		case 'show':
124		case 'create':
125		case 'store':
126			return null;
127		case 'apply':
128			return positionals.some((arg) => COMMIT_ID.test(arg))
129				? null
130				: `ensemblr git-guard: \`git stash apply\` must name your entry by its commit SHA, written literally, not by position or through a variable, because other sessions move the shared stack. ${STASH_RECIPE}`;
131		case 'drop':
132			return positionals.length > 0 ? null : STASH_DENIAL;
133		default:
134			return STASH_DENIAL;
135	}
136}
137
138/**
139 * Whether a call only reads, so it may look at a repository outside the worktree.
140 * @param invocation - The git invocation.
141 * @returns True for the read-only subcommands and the listing forms of the rest.
142 */
143function isReadOnly({ args, subcommand }: GitInvocation): boolean {
144	if (subcommand === null) {
145		return true;
146	}
147	if (READ_ONLY_SUBCOMMANDS.has(subcommand)) {
148		return !args.some((arg) => arg.startsWith('--output'));
149	}
150	switch (subcommand) {
151		case 'branch':
152			return args.every((arg) => BRANCH_LIST_FLAGS.has(arg));
153		case 'worktree':
154			return args[0] === 'list';
155		case 'remote':
156			return (
157				args.every((arg) => arg === '-v' || arg === '--verbose') ||
158				args[0] === 'get-url' ||
159				args[0] === 'show'
160			);
161		case 'stash':
162			return args[0] === 'list' || args[0] === 'show';
163		case 'reflog':
164			return (
165				args[0] === undefined ||
166				args[0] === 'show' ||
167				args[0] === 'exists' ||
168				isOption(args[0])
169			);
170		case 'config':
171			return args.some((arg) => /^(--get|--list$|-l$)/.test(arg));
172		default:
173			return false;
174	}
175}
176
177/**
178 * Whether a path lies inside a directory.
179 * @param path - The absolute path.
180 * @param directory - The absolute directory.
181 * @returns True for the directory itself and anything beneath it.
182 */
183function isWithin(path: string, directory: string): boolean {
184	const root = directory.replace(/\/+$/, '');
185	return path === root || path.startsWith(`${root}/`);
186}
187
188/**
189 * Decides whether every redirection of a call stays on the session's checkout.
190 * @param invocation - The git invocation.
191 * @param scope - The session's worktree root and git directory.
192 * @returns The refusal, or null when nothing points elsewhere or the call only reads.
193 */
194function judgeTargets(
195	invocation: GitInvocation,
196	scope: WorktreeScope,
197): string | null {
198	const stray = invocation.targets.find(
199		({ path }) =>
200			path === null ||
201			!(
202				isWithin(path, scope.root) ||
203				(scope.gitDir !== null && isWithin(path, scope.gitDir))
204			),
205	);
206	if (
207		stray === undefined ||
208		isReadOnly(invocation) ||
209		CREATES_A_REPOSITORY.has(invocation.subcommand ?? '')
210	) {
211		return null;
212	}
213	const where =
214		stray.path === null
215			? 'a path this guard cannot read without running the shell (write it as a literal path)'
216			: `\`${stray.path}\``;
217	return `ensemblr git-guard: through \`${stray.source}\`, this git command would act on ${where}, outside this session's worktree \`${scope.root}\`. Write only in your own worktree: a sibling workspace or the root checkout is not yours to change, and git writes there land in another session's index and branch. Read-only commands (status, log, diff, show, ...) may look elsewhere.`;
218}
219
220/**
221 * Decides one git invocation.
222 * @param invocation - The git invocation.
223 * @param scope - The session's worktree root and git directory.
224 * @returns The refusal the model reads, or null to let the call run.
225 */
226export function judgeGitInvocation(
227	invocation: GitInvocation,
228	scope: WorktreeScope,
229): string | null {
230	const ruleDenial =
231		invocation.subcommand === 'branch' && isBranchRename(invocation.args)
232			? RENAME_DENIAL
233			: invocation.subcommand === 'stash'
234				? judgeStash(invocation.args)
235				: null;
236	return ruleDenial ?? judgeTargets(invocation, scope);
237}
238
hooks/control.ts 97 lines
1/**
2 * The mods' one channel back to the Ensemblr app: its loopback control server.
3 *
4 * Ensemblr injects the server's URL and the session's bearer token into every
5 * agent process it starts, and the mods reach the app with them over `/invoke`,
6 * the route the Pi extension already uses. Nothing secret travels toward the
7 * mod: the ops it calls answer with redacted text or with issue metadata.
8 *
9 * Pure helpers only: the engine follows `$` into functions of the file that
10 * holds the hook and never across an import, so each mod makes its own
11 * `$.env.get` and `$.http.fetch` calls around these.
12 */
13import type { HttpInit } from 'claude-code';
14
15/**
16 * How long a mod waits for the app before going on without it. A hook's `$`
17 * calls do not count against its own budget, so without this a busy main
18 * process would hold every conversation row for as long as it took to answer.
19 */
20export const CONTROL_DEADLINE_MS = 4_000;
21
22/** Where the control server lives and how this session authenticates to it. */
23export interface ControlEndpoint {
24	token: string;
25	url: string;
26}
27
28/** The envelope every control op answers with. */
29type ControlEnvelope =
30	| { data: unknown; ok: true }
31	| { code: string; error: string; ok: false };
32
33/**
34 * Pairs the injected URL and token into an endpoint.
35 * @param url - `ENSEMBLR_CONTROL_URL`, if set.
36 * @param token - `ENSEMBLR_CONTROL_TOKEN`, if set.
37 * @returns The endpoint, or null outside an Ensemblr session.
38 */
39export function toControlEndpoint(
40	url: string | undefined,
41	token: string | undefined,
42): ControlEndpoint | null {
43	return url && token ? { token, url } : null;
44}
45
46/**
47 * Builds the request that invokes one control op.
48 * @param endpoint - The session's control endpoint.
49 * @param op - The op name, as `AGENT_CONTROL_OPS` spells it.
50 * @param args - The op's arguments.
51 * @returns The URL and the fetch options.
52 */
53export function buildInvokeRequest(
54	endpoint: ControlEndpoint,
55	op: string,
56	args: Record<string, unknown>,
57): { init: HttpInit; url: string } {
58	return {
59		init: {
60			body: JSON.stringify({ args, op }),
61			headers: {
62				authorization: `Bearer ${endpoint.token}`,
63				'content-type': 'application/json',
64			},
65			method: 'POST',
66		},
67		url: `${endpoint.url}/invoke`,
68	};
69}
70
71/**
72 * Narrows a parsed response body to the control envelope.
73 * @param body - The parsed JSON body.
74 * @returns True when the body has the envelope's shape.
75 */
76function isEnvelope(body: unknown): body is ControlEnvelope {
77	return (
78		typeof body === 'object' &&
79		body !== null &&
80		typeof (body as { ok?: unknown }).ok === 'boolean'
81	);
82}
83
84/**
85 * Reads an op's payload out of the server's reply.
86 * @param responseText - The reply body.
87 * @returns The op's `data`, or null when the op failed or the reply is not an envelope.
88 */
89export function readInvokeData(responseText: string): unknown {
90	try {
91		const body: unknown = JSON.parse(responseText);
92		return isEnvelope(body) && body.ok ? body.data : null;
93	} catch {
94		return null;
95	}
96}
97
hooks/row-redaction.ts 135 lines
1/**
2 * Walks the blocks of one conversation row and redacts every text in it — a
3 * text block's text, a tool result's content as a string or as nested blocks —
4 * handing back the very same objects wherever nothing changed, so an untouched
5 * row reaches the engine exactly as it made it.
6 */
7
8/** One content block of a row, as `session.append` hands it. */
9export type ContentBlock = { [field: string]: unknown; type: string };
10
11/** Redacts one text; resolves to the same string when nothing matched. */
12export type TextRedactor = (text: string) => Promise<string>;
13
14/**
15 * Longest text one `redactText` call carries. The control server refuses a body
16 * over 1,000,000 bytes, and JSON escaping and multi-byte UTF-8 can grow text
17 * several-fold on the wire.
18 */
19const CHUNK_CHARS = 150_000;
20
21/** Shortest text worth redacting: no secret is shorter. */
22const MINIMUM_CHARS = 4;
23
24/**
25 * Finds where to cut a piece: after the last line break inside the limit, else
26 * after the last whitespace, so a secret, which holds neither, is never split
27 * across two calls. Only a single unbroken run longer than the limit is cut
28 * mid-run.
29 * @param text - Text longer than the limit.
30 * @returns The length of the first piece.
31 */
32function cutPoint(text: string): number {
33	const lineBreak = text.lastIndexOf('\n', CHUNK_CHARS - 1);
34	if (lineBreak > 0) {
35		return lineBreak + 1;
36	}
37	let space = CHUNK_CHARS - 1;
38	while (space > 0 && !/\s/.test(text[space] ?? '')) {
39		space -= 1;
40	}
41	return space > 0 ? space + 1 : CHUNK_CHARS;
42}
43
44/**
45 * Cuts text into pieces the op accepts.
46 * @param text - The text to cut.
47 * @returns The pieces, in order, joining back to the text.
48 */
49export function chunkText(text: string): string[] {
50	if (text.length <= CHUNK_CHARS) {
51		return [text];
52	}
53	const end = cutPoint(text);
54	return [text.slice(0, end), ...chunkText(text.slice(end))];
55}
56
57/**
58 * Builds the redactor a row is walked with: one known value replaced locally,
59 * then the rest piece by piece through `remote`.
60 * @param localValue - A value this process knows and replaces itself.
61 * @param localPlaceholder - What that value becomes.
62 * @param remote - Redacts one piece against every other secret.
63 * @returns The text redactor.
64 */
65export function createTextRedactor(
66	localValue: string,
67	localPlaceholder: string,
68	remote: TextRedactor,
69): TextRedactor {
70	return async (text) => {
71		if (text.length < MINIMUM_CHARS) {
72			return text;
73		}
74		const local = text.split(localValue).join(localPlaceholder);
75		const pieces = await Promise.all(chunkText(local).map(remote));
76		return pieces.join('');
77	};
78}
79
80/**
81 * Redacts a tool result's content, which is a string or a list of blocks.
82 * @param content - The tool result's content.
83 * @param redact - The text redactor.
84 * @returns The redacted content, or `content` itself when nothing changed.
85 */
86async function redactToolContent(
87	content: unknown,
88	redact: TextRedactor,
89): Promise<unknown> {
90	if (typeof content === 'string') {
91		return redact(content);
92	}
93	return Array.isArray(content) ? redactBlocks(content, redact) : content;
94}
95
96/**
97 * Redacts the text one block carries. Every other block (images, thinking, tool
98 * calls) passes untouched, since the engine puts those back as made.
99 * @param block - One content block.
100 * @param redact - The text redactor.
101 * @returns The redacted block, or `block` itself when nothing changed.
102 */
103async function redactBlock(
104	block: ContentBlock,
105	redact: TextRedactor,
106): Promise<ContentBlock> {
107	if (block.type === 'text' && typeof block.text === 'string') {
108		const text = await redact(block.text);
109		return text === block.text ? block : { ...block, text };
110	}
111	if (block.type === 'tool_result') {
112		const content = await redactToolContent(block.content, redact);
113		return content === block.content ? block : { ...block, content };
114	}
115	return block;
116}
117
118/**
119 * Redacts a list of blocks.
120 * @param blocks - The blocks.
121 * @param redact - The text redactor.
122 * @returns The redacted blocks, or `blocks` itself when none changed.
123 */
124export async function redactBlocks(
125	blocks: readonly ContentBlock[],
126	redact: TextRedactor,
127): Promise<readonly ContentBlock[]> {
128	const redacted = await Promise.all(
129		blocks.map((block) => redactBlock(block, redact)),
130	);
131	return redacted.every((block, index) => block === blocks[index])
132		? blocks
133		: redacted;
134}
135
hooks/shell-words.ts 362 lines
1/**
2 * A best-effort reading of a Bash command line into the simple commands it runs.
3 *
4 * Not a shell: it knows quotes, backslash escapes, the list and pipe operators,
5 * subshell parentheses, command substitution (`$(...)` and backticks, inside
6 * double quotes too), arithmetic expansion, here-documents and here-strings,
7 * comments, and line continuations, which is enough to find every `git` a
8 * command would start without mistaking the words of a quoted commit message or
9 * a here-document body for one. An unquoted here-document's body is skipped
10 * whole, so a substitution inside it is not read.
11 *
12 * A substitution's own commands are read as commands of their own; in the word
13 * that holds it, the substitution leaves a `$` behind, so whoever reads that
14 * word knows it cannot be known without running the shell. A subshell's
15 * parentheses, and a substitution's edges, come out as the one-word commands
16 * `(` and `)`, so a reader can undo a `cd` made inside one.
17 */
18
19/** One quoting context: bare words, or inside single or double quotes. */
20type QuoteState = 'bare' | 'double' | 'single';
21
22/** The one-word command that marks a subshell opening. */
23export const SUBSHELL_OPEN = '(';
24
25/** The one-word command that marks a subshell closing. */
26export const SUBSHELL_CLOSE = ')';
27
28/** Characters that end a simple command when they appear unquoted. */
29const LIST_OPERATORS = new Set([';', '&', '|']);
30
31/** Characters that end a here-document delimiter word. */
32const DELIMITER_ENDS = /[\s;&|()<>]/;
33
34/** One level of reading: the top-level line, or the inside of a substitution. */
35interface Frame {
36	closer: ')' | '`' | null;
37	current: string[];
38	hasWord: boolean;
39	quotes: QuoteState[];
40	subshells: number;
41	word: string;
42}
43
44/** A here-document whose body starts at the next unquoted newline. */
45interface PendingHeredoc {
46	delimiter: string;
47	stripsTabs: boolean;
48}
49
50/** Everything the reader carries while it walks the line. */
51interface Reader {
52	commands: string[][];
53	frames: Frame[];
54	heredocs: PendingHeredoc[];
55	index: number;
56	line: string;
57}
58
59/**
60 * Starts a reading level.
61 * @param closer - What ends it: `)` or a backtick for a substitution, null for the line.
62 * @returns The fresh frame.
63 */
64function openFrame(closer: Frame['closer']): Frame {
65	return {
66		closer,
67		current: [],
68		hasWord: false,
69		quotes: ['bare'],
70		subshells: 0,
71		word: '',
72	};
73}
74
75/**
76 * Closes the word being read, if any, into the frame's command.
77 * @param frame - The reading level.
78 */
79function endWord(frame: Frame): void {
80	if (frame.hasWord) {
81		frame.current.push(frame.word);
82	}
83	frame.word = '';
84	frame.hasWord = false;
85}
86
87/**
88 * Closes the frame's command, if it has words, into the reader's commands.
89 * @param reader - The reader.
90 * @param frame - The reading level.
91 */
92function endCommand(reader: Reader, frame: Frame): void {
93	endWord(frame);
94	if (frame.current.length > 0) {
95		reader.commands.push(frame.current);
96	}
97	frame.current = [];
98}
99
100/**
101 * Appends text to the word being read.
102 * @param frame - The reading level.
103 * @param text - The text to append.
104 */
105function append(frame: Frame, text: string): void {
106	frame.word += text;
107	frame.hasWord = true;
108}
109
110/**
111 * Opens a substitution: the enclosing word keeps a `$` for it, and its commands
112 * are fenced as a subshell, which is where the shell runs them.
113 * @param reader - The reader.
114 * @param closer - What ends the substitution.
115 */
116function openSubstitution(reader: Reader, closer: ')' | '`'): void {
117	const outer = reader.frames.at(-1);
118	if (outer) {
119		append(outer, '$');
120	}
121	reader.commands.push([SUBSHELL_OPEN]);
122	reader.frames.push(openFrame(closer));
123}
124
125/**
126 * Closes the innermost substitution, handing its commands to the reader.
127 * @param reader - The reader.
128 */
129function closeSubstitution(reader: Reader): void {
130	const inner = reader.frames.pop();
131	if (inner) {
132		endCommand(reader, inner);
133		reader.commands.push([SUBSHELL_CLOSE]);
134	}
135}
136
137/**
138 * Reads a here-document's delimiter after `<<` or `<<-`, quotes removed.
139 * @param reader - The reader, positioned just after `<<`.
140 */
141function readHeredocOperator(reader: Reader): void {
142	const { line } = reader;
143	const stripsTabs = line[reader.index] === '-';
144	reader.index += stripsTabs ? 1 : 0;
145	while (line[reader.index] === ' ' || line[reader.index] === '\t') {
146		reader.index += 1;
147	}
148	let delimiter = '';
149	let quote: string | null = null;
150	while (reader.index < line.length) {
151		const character = line[reader.index] ?? '';
152		if (quote === null && DELIMITER_ENDS.test(character)) {
153			break;
154		}
155		reader.index += 1;
156		if (quote !== null && character === quote) {
157			quote = null;
158		} else if (quote === null && (character === "'" || character === '"')) {
159			quote = character;
160		} else if (character !== '\\') {
161			delimiter += character;
162		}
163	}
164	reader.heredocs.push({ delimiter, stripsTabs });
165}
166
167/**
168 * Skips the bodies of the here-documents opened on the line just ended.
169 * @param reader - The reader, positioned at the start of the first body line.
170 */
171function skipHeredocBodies(reader: Reader): void {
172	for (const { delimiter, stripsTabs } of reader.heredocs) {
173		while (reader.index < reader.line.length) {
174			const newline = reader.line.indexOf('\n', reader.index);
175			const end = newline === -1 ? reader.line.length : newline;
176			const body = reader.line.slice(reader.index, end);
177			reader.index = end + 1;
178			if ((stripsTabs ? body.replace(/^\t+/, '') : body) === delimiter) {
179				break;
180			}
181		}
182	}
183	reader.heredocs = [];
184}
185
186/**
187 * Reads one character inside single quotes.
188 * @param frame - The reading level.
189 * @param character - The character.
190 */
191function readSingleQuoted(frame: Frame, character: string): void {
192	if (character === "'") {
193		frame.quotes.pop();
194	} else {
195		append(frame, character);
196	}
197}
198
199/**
200 * Reads one character inside double quotes.
201 * @param reader - The reader.
202 * @param frame - The reading level.
203 * @param character - The character.
204 */
205function readDoubleQuoted(
206	reader: Reader,
207	frame: Frame,
208	character: string,
209): void {
210	if (character === '"') {
211		frame.quotes.pop();
212	} else if (character === '`') {
213		openSubstitution(reader, '`');
214	} else {
215		append(frame, character);
216	}
217}
218
219/**
220 * Reads a parenthesis outside quotes: a subshell's edge, or a substitution's end.
221 * @param reader - The reader.
222 * @param frame - The reading level.
223 * @param character - `(` or `)`.
224 */
225function readParenthesis(
226	reader: Reader,
227	frame: Frame,
228	character: string,
229): void {
230	endCommand(reader, frame);
231	if (character === '(') {
232		frame.subshells += 1;
233		reader.commands.push([SUBSHELL_OPEN]);
234	} else if (frame.subshells > 0) {
235		frame.subshells -= 1;
236		reader.commands.push([SUBSHELL_CLOSE]);
237	} else if (frame.closer === ')') {
238		closeSubstitution(reader);
239	}
240}
241
242/**
243 * Reads one character outside quotes.
244 * @param reader - The reader.
245 * @param frame - The reading level.
246 * @param character - The character.
247 */
248function readBare(reader: Reader, frame: Frame, character: string): void {
249	const { line } = reader;
250	if (character === "'" || character === '"') {
251		frame.quotes.push(character === "'" ? 'single' : 'double');
252		frame.hasWord = true;
253	} else if (character === '`') {
254		if (frame.closer === '`') {
255			closeSubstitution(reader);
256		} else {
257			openSubstitution(reader, '`');
258		}
259	} else if (character === '<' && line.startsWith('<<', reader.index)) {
260		reader.index += 2;
261		append(frame, '<<<');
262	} else if (character === '<' && line[reader.index] === '<') {
263		reader.index += 1;
264		endWord(frame);
265		readHeredocOperator(reader);
266	} else if (character === '#' && !frame.hasWord) {
267		skipComment(reader);
268	} else if (character === '\n') {
269		endCommand(reader, frame);
270		skipHeredocBodies(reader);
271	} else if (LIST_OPERATORS.has(character)) {
272		endCommand(reader, frame);
273	} else if (character === '(' || character === ')') {
274		readParenthesis(reader, frame, character);
275	} else if (character === ' ' || character === '\t') {
276		endWord(frame);
277	} else {
278		append(frame, character);
279	}
280}
281
282/**
283 * Reads a backslash: the next character taken literally, or, before a line
284 * break, a line continuation that joins the two lines.
285 * @param reader - The reader, positioned after the backslash.
286 * @param frame - The reading level.
287 */
288function readEscape(reader: Reader, frame: Frame): void {
289	const next = reader.line[reader.index] ?? '';
290	reader.index += 1;
291	if (next !== '\n') {
292		append(frame, next);
293	}
294}
295
296/**
297 * Reads `$(`: a command substitution, or `$((`, an arithmetic expansion that
298 * runs no command and is skipped whole.
299 * @param reader - The reader, positioned at the `(` after `$`.
300 * @param frame - The reading level.
301 */
302function readDollarParen(reader: Reader, frame: Frame): void {
303	if (reader.line[reader.index + 1] !== '(') {
304		reader.index += 1;
305		openSubstitution(reader, ')');
306		return;
307	}
308	let depth = 0;
309	do {
310		const character = reader.line[reader.index];
311		depth += character === '(' ? 1 : character === ')' ? -1 : 0;
312		reader.index += 1;
313	} while (depth > 0 && reader.index < reader.line.length);
314	append(frame, '$');
315}
316
317/**
318 * Skips a comment, up to but not including the line break that ends it.
319 * @param reader - The reader, positioned after the `#`.
320 */
321function skipComment(reader: Reader): void {
322	const newline = reader.line.indexOf('\n', reader.index);
323	reader.index = newline === -1 ? reader.line.length : newline;
324}
325
326/**
327 * Splits a command line into its simple commands, each as its unquoted words.
328 * @param line - The command line as the model wrote it.
329 * @returns Every simple command, substitutions included, in the order each ends.
330 */
331export function splitCommands(line: string): string[][] {
332	const reader: Reader = {
333		commands: [],
334		frames: [openFrame(null)],
335		heredocs: [],
336		index: 0,
337		line,
338	};
339	while (reader.index < line.length) {
340		const frame = reader.frames.at(-1) ?? openFrame(null);
341		const quote = frame.quotes.at(-1) ?? 'bare';
342		const character = line[reader.index] ?? '';
343		reader.index += 1;
344		if (quote === 'single') {
345			readSingleQuoted(frame, character);
346		} else if (character === '\\') {
347			readEscape(reader, frame);
348		} else if (character === '$' && line[reader.index] === '(') {
349			readDollarParen(reader, frame);
350		} else if (quote === 'double') {
351			readDoubleQuoted(reader, frame, character);
352		} else {
353			readBare(reader, frame, character);
354		}
355	}
356	while (reader.frames.length > 1) {
357		closeSubstitution(reader);
358	}
359	endCommand(reader, reader.frames[0] ?? openFrame(null));
360	return reader.commands;
361}
362