@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CONTRIBUTING.md +170 -0
  2. package/LICENSE +63 -136
  3. package/README.md +83 -66
  4. package/docs/README.md +44 -0
  5. package/docs/acknowledgements.md +14 -0
  6. package/docs/architecture.md +146 -0
  7. package/docs/assets/README.md +27 -0
  8. package/docs/assets/sevro-logo.png +0 -0
  9. package/docs/assets/sevro-symbol.svg +1 -0
  10. package/docs/assets/sevro-wordmark.svg +1 -0
  11. package/docs/builtin-graders-v1.md +41 -13
  12. package/docs/codex-host.md +184 -52
  13. package/docs/contracts.md +19 -0
  14. package/docs/creating-cases.md +139 -0
  15. package/docs/development-cli.md +190 -30
  16. package/docs/documentation-quality.md +61 -0
  17. package/docs/evaluation-workflow.md +110 -0
  18. package/docs/evidence/code-mode-controls-71.md +64 -0
  19. package/docs/evidence/code-mode-programs-85.md +64 -0
  20. package/docs/evidence/codex-state-73/README.md +204 -0
  21. package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
  22. package/docs/evidence/codex-state-73/results.json +661 -0
  23. package/docs/evidence/local-http-74.md +104 -0
  24. package/docs/evidence/runtime-startup-30/README.md +185 -0
  25. package/docs/evidence/runtime-startup-30/after.json +319 -0
  26. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
  27. package/docs/evidence/runtime-startup-30/before.json +403 -0
  28. package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
  29. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
  30. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
  31. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
  32. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
  33. package/docs/evidence/skill-reads-70/results.json +823 -0
  34. package/docs/evidence.md +21 -0
  35. package/docs/extension-protocol-v1.md +50 -16
  36. package/docs/fixture-cleanup-validation.md +3 -0
  37. package/docs/getting-started.md +102 -0
  38. package/docs/guide-evaluation.md +89 -0
  39. package/docs/guide-verification.json +239 -0
  40. package/docs/identity-v1.md +35 -1
  41. package/docs/installing.md +88 -0
  42. package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
  43. package/docs/licensing.md +42 -0
  44. package/docs/native-controls.md +76 -5
  45. package/docs/native-hosts.md +228 -0
  46. package/docs/reading-results.md +141 -0
  47. package/docs/recipes.md +489 -0
  48. package/docs/releases.md +81 -40
  49. package/docs/report-v1.md +29 -1
  50. package/docs/results-v1.md +180 -0
  51. package/docs/run-settings-v1.md +147 -0
  52. package/docs/running-evaluations.md +31 -0
  53. package/docs/runtime-v1.md +614 -0
  54. package/docs/specs/repository-guide.md +58 -0
  55. package/docs/troubleshooting.md +30 -0
  56. package/docs/typescript-quality.md +234 -0
  57. package/docs/usability-study.md +114 -0
  58. package/examples/basic/host.ts +2 -2
  59. package/examples/recipes/candidate.ts +26 -0
  60. package/examples/recipes/cases/coding-change.json +21 -0
  61. package/examples/recipes/cases/host-tools.json +21 -0
  62. package/examples/recipes/cases/mise.json +21 -0
  63. package/examples/recipes/cases/node.json +21 -0
  64. package/examples/recipes/cases/python-uv.json +21 -0
  65. package/examples/recipes/cases/semantic.json +17 -0
  66. package/examples/recipes/coding-change/check.ts +23 -0
  67. package/examples/recipes/extension-artifacts.ts +65 -0
  68. package/examples/recipes/extension-methods.ts +174 -0
  69. package/examples/recipes/extension.ts +37 -0
  70. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  71. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  72. package/examples/recipes/repository-skill/README.md +3 -0
  73. package/examples/recipes/run-inputs.ts +188 -0
  74. package/examples/recipes/run-options.ts +131 -0
  75. package/examples/recipes/run.ts +63 -0
  76. package/examples/recipes/runtime/empty.json +1 -0
  77. package/examples/recipes/runtime/goals.json +1 -0
  78. package/examples/recipes/runtime/mise.json +6 -0
  79. package/examples/recipes/runtime/path.json +1 -0
  80. package/examples/recipes/runtime/plugins.json +4 -0
  81. package/examples/recipes/runtime/python-uv.json +10 -0
  82. package/examples/recipes/semantic-judge.ts +29 -0
  83. package/examples/templates/coding/adapter.ts +17 -0
  84. package/examples/templates/coding/case.json +15 -0
  85. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  86. package/examples/templates/repository-skill/extension-data.ts +64 -0
  87. package/examples/templates/repository-skill/extension.ts +31 -0
  88. package/examples/templates/response/adapter.ts +12 -0
  89. package/examples/templates/response/case.json +13 -0
  90. package/package.json +39 -6
  91. package/schemas/report-v1.schema.json +9 -0
  92. package/schemas/run-evidence-v1.schema.json +2 -1
  93. package/schemas/runtime-v1.schema.json +193 -0
  94. package/src/advisory.ts +10 -5
  95. package/src/candidate-artifacts.ts +208 -0
  96. package/src/candidate-codex-state.ts +185 -0
  97. package/src/case-validation.ts +105 -0
  98. package/src/cli/cancellation.ts +20 -0
  99. package/src/cli/command.ts +81 -0
  100. package/src/cli/completion.ts +15 -0
  101. package/src/cli/demo.ts +59 -0
  102. package/src/cli/evaluation.ts +55 -0
  103. package/src/cli/extension.ts +79 -0
  104. package/src/cli/help.ts +222 -0
  105. package/src/cli/hosts.ts +59 -0
  106. package/src/cli/init-settings.ts +30 -0
  107. package/src/cli/init.ts +111 -0
  108. package/src/cli/invocation.ts +627 -0
  109. package/src/cli/loading.ts +151 -0
  110. package/src/cli/preparation.ts +163 -0
  111. package/src/cli/presentation.ts +91 -0
  112. package/src/cli/progress.ts +6 -0
  113. package/src/cli/run-manifest.ts +133 -0
  114. package/src/cli/run-options.ts +101 -0
  115. package/src/cli/run-overrides.ts +83 -0
  116. package/src/cli/settings.ts +160 -0
  117. package/src/cli/summary.ts +43 -0
  118. package/src/cli.ts +13 -843
  119. package/src/contract-types.ts +94 -0
  120. package/src/engine.ts +19 -2373
  121. package/src/evaluation/advisory.ts +150 -0
  122. package/src/evaluation/check-plan.ts +510 -0
  123. package/src/evaluation/collection.ts +88 -0
  124. package/src/evaluation/context.ts +10 -0
  125. package/src/evaluation/grading-host.ts +39 -0
  126. package/src/evaluation/grading.ts +258 -0
  127. package/src/evaluation/invocation-plan.ts +548 -0
  128. package/src/evaluation/plan.ts +736 -0
  129. package/src/evaluation/progress.ts +19 -0
  130. package/src/evaluation/resources.ts +241 -0
  131. package/src/evaluation/run.ts +206 -0
  132. package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
  133. package/src/evaluation/semantic.ts +312 -0
  134. package/src/evaluation/trial.ts +866 -0
  135. package/src/evaluation/types.ts +207 -0
  136. package/src/evidence/host-results.ts +92 -0
  137. package/src/evidence/retained-artifacts.ts +18 -0
  138. package/src/evidence/trial-writer.ts +203 -0
  139. package/src/evidence/usage.ts +17 -0
  140. package/src/extension-client.ts +228 -148
  141. package/src/extension-session.ts +326 -195
  142. package/src/fixtures/advisory.ts +338 -0
  143. package/src/fixtures/bin.ts +57 -0
  144. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  145. package/src/fixtures/create.ts +187 -0
  146. package/src/fixtures/generated.ts +300 -0
  147. package/src/fixtures/git-hooks.ts +24 -0
  148. package/src/fixtures/preparation.ts +270 -0
  149. package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
  150. package/src/fixtures/setup.ts +224 -0
  151. package/src/fixtures/tools.ts +88 -0
  152. package/src/fixtures/workspace-state.ts +212 -0
  153. package/src/generated/cli-result.cjs +2 -0
  154. package/src/generated/cli-result.d.cts +3 -0
  155. package/src/generated/extension.cjs +2 -0
  156. package/src/generated/extension.d.cts +3 -0
  157. package/src/generated/report.cjs +2 -0
  158. package/src/generated/report.d.cts +3 -0
  159. package/src/generated/run-evidence.cjs +2 -0
  160. package/src/generated/run-evidence.d.cts +3 -0
  161. package/src/generated/runtime.cjs +2 -0
  162. package/src/generated/runtime.d.cts +3 -0
  163. package/src/graders/artifact.ts +56 -23
  164. package/src/graders/git-head.ts +79 -75
  165. package/src/graders/output.ts +222 -140
  166. package/src/graders/semantic.ts +102 -43
  167. package/src/graders/shell.ts +379 -207
  168. package/src/host-diagnostic-redaction.ts +52 -0
  169. package/src/host-diagnostics.ts +319 -0
  170. package/src/hosts/claude-continuation.ts +63 -18
  171. package/src/hosts/claude-credential.ts +43 -62
  172. package/src/hosts/claude-events.ts +86 -51
  173. package/src/hosts/claude-keychain.ts +59 -0
  174. package/src/hosts/claude-native-goal.ts +57 -33
  175. package/src/hosts/claude-process.ts +111 -0
  176. package/src/hosts/claude-repository-invocation.ts +208 -117
  177. package/src/hosts/claude-settings.ts +240 -21
  178. package/src/hosts/claude-skill-activations.ts +166 -0
  179. package/src/hosts/claude-state.ts +403 -0
  180. package/src/hosts/claude-tool-calls.ts +171 -106
  181. package/src/hosts/claude.ts +561 -451
  182. package/src/hosts/codex-app-server.ts +326 -178
  183. package/src/hosts/codex-configuration.ts +44 -18
  184. package/src/hosts/codex-events.ts +97 -60
  185. package/src/hosts/codex-exec-controls.ts +516 -0
  186. package/src/hosts/codex-invocation.ts +70 -0
  187. package/src/hosts/codex-marketplace.ts +283 -0
  188. package/src/hosts/codex-native-calls.ts +546 -500
  189. package/src/hosts/codex-process.ts +202 -0
  190. package/src/hosts/codex-profile.ts +257 -28
  191. package/src/hosts/codex-session-files.ts +106 -0
  192. package/src/hosts/codex-state.ts +492 -0
  193. package/src/hosts/codex.ts +783 -832
  194. package/src/hosts/isolation-common.ts +94 -0
  195. package/src/hosts/isolation-roots.ts +54 -27
  196. package/src/hosts/isolation.ts +10 -0
  197. package/src/hosts/linux-sandbox.ts +159 -0
  198. package/src/hosts/mac-sandbox.ts +187 -61
  199. package/src/hosts/native-controls.ts +152 -62
  200. package/src/hosts/process-lifecycle.ts +37 -0
  201. package/src/hosts/protected-worktrees.ts +51 -19
  202. package/src/hosts/skill-hook-catalog.ts +131 -0
  203. package/src/hosts/skill-hook-handler.ts +374 -0
  204. package/src/hosts/skill-hooks.ts +580 -0
  205. package/src/hosts/workspace-fingerprint.ts +55 -35
  206. package/src/identity.ts +102 -48
  207. package/src/instrumentation.ts +109 -48
  208. package/src/native-transcript-state.ts +131 -0
  209. package/src/native-transcript-view.ts +90 -0
  210. package/src/native-transcripts.ts +286 -0
  211. package/src/process-lifecycle.ts +150 -0
  212. package/src/provenance.ts +161 -81
  213. package/src/prune.ts +215 -0
  214. package/src/report.ts +172 -140
  215. package/src/resolved-case.ts +33 -0
  216. package/src/results-lock.ts +29 -0
  217. package/src/results.ts +133 -86
  218. package/src/run-owner.ts +154 -63
  219. package/src/runtime/config.ts +456 -0
  220. package/src/runtime/discovery-apple.ts +90 -0
  221. package/src/runtime/discovery-providers.ts +63 -0
  222. package/src/runtime/discovery.ts +70 -0
  223. package/src/runtime/hook-processes.ts +97 -0
  224. package/src/runtime/hooks.ts +476 -0
  225. package/src/runtime/paths.ts +313 -0
  226. package/src/runtime/query.ts +85 -0
  227. package/src/runtime/seeds.ts +134 -0
  228. package/src/runtime/shared-cache.ts +143 -0
  229. package/src/runtime/state.ts +143 -0
  230. package/src/schema-types.ts +198 -0
  231. package/src/schema.ts +15 -19
  232. package/src/transcript-retention.ts +146 -0
  233. package/src/value-guards.ts +20 -0
  234. package/src/advisory-fixture.ts +0 -282
  235. package/src/fixture-bin.ts +0 -87
  236. package/src/fixture-setup.ts +0 -163
  237. package/src/generated-fixture.ts +0 -220
  238. package/src/git-hooks.ts +0 -63
  239. package/src/hosts/claude-nested-skills.ts +0 -218
  240. package/src/hosts/codex-command-output.ts +0 -399
  241. package/src/hosts/codex-executor-command.ts +0 -195
  242. package/src/hosts/codex-skill-reads.ts +0 -391
  243. package/src/preparation.ts +0 -211
@@ -0,0 +1,97 @@
1
+ import { readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { isRecord } from "../value-guards";
4
+
5
+ export async function hookExecutions(
6
+ receipts: string,
7
+ ): Promise<Record<string, unknown>[]> {
8
+ return Promise.all(
9
+ (await readdir(receipts))
10
+ .filter((name) => name.startsWith("call."))
11
+ .map(async (name) => {
12
+ try {
13
+ const value: unknown = JSON.parse(
14
+ await readFile(join(receipts, name), "utf8"),
15
+ );
16
+ return isRecord(value) ? value : { status: "unknown" };
17
+ } catch {
18
+ return { status: "unknown" };
19
+ }
20
+ }),
21
+ );
22
+ }
23
+
24
+ async function processOutput(argv: string[]): Promise<string> {
25
+ const child = Bun.spawn(argv, { stdout: "pipe", stderr: "ignore" });
26
+ const output = await new Response(child.stdout).text();
27
+ await child.exited;
28
+ if (Buffer.byteLength(output) > 1024 * 1024)
29
+ throw new Error("hook process ownership exceeds size limit");
30
+ return output;
31
+ }
32
+
33
+ function ownedWrapper(
34
+ execution: Record<string, unknown>,
35
+ receipts: string,
36
+ ): execution is Record<string, unknown> & { pid: number; wrapper: string } {
37
+ return (
38
+ validProcessId(execution.pid) &&
39
+ typeof execution.wrapper === "string" &&
40
+ dirname(execution.wrapper) === dirname(receipts) &&
41
+ /^hook-[0-9]+\.sh$/.test(basename(execution.wrapper))
42
+ );
43
+ }
44
+
45
+ function validProcessId(pid: unknown): pid is number {
46
+ return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 1;
47
+ }
48
+
49
+ function processChildren(table: string, root: number): number[] {
50
+ const pairs = table
51
+ .trim()
52
+ .split("\n")
53
+ .map((line) => line.trim().split(/\s+/).map(Number));
54
+ const pending = [root],
55
+ children: number[] = [];
56
+ while (pending.length) {
57
+ const parent = pending.pop();
58
+ const found = pairs
59
+ .filter((pair) => pair[1] === parent)
60
+ .map((pair) => pair[0])
61
+ .filter((pid): pid is number => pid !== undefined);
62
+ pending.push(...found);
63
+ children.push(...found);
64
+ }
65
+ return children;
66
+ }
67
+
68
+ function stopProcess(pid: number): void {
69
+ try {
70
+ process.kill(pid, "SIGKILL");
71
+ } catch {
72
+ /* The owned process may already have exited. */
73
+ }
74
+ }
75
+
76
+ /** Stop only processes still bound to a runner-owned private hook wrapper. */
77
+ export async function releaseHookProcesses(receipts: string): Promise<void> {
78
+ await writeFile(join(receipts, "stopped"), "stopped\n", { mode: 0o600 });
79
+ for (const execution of await hookExecutions(receipts)) {
80
+ if (!ownedWrapper(execution, receipts)) continue;
81
+ const command = await processOutput([
82
+ "/bin/ps",
83
+ "-p",
84
+ String(execution.pid),
85
+ "-o",
86
+ "command=",
87
+ ]);
88
+ if (!command.includes(execution.wrapper)) continue;
89
+ const children = processChildren(
90
+ await processOutput(["/bin/ps", "-axo", "pid=,ppid="]),
91
+ execution.pid,
92
+ );
93
+ children.reverse().forEach(stopProcess);
94
+ await new Promise((resolve) => setTimeout(resolve, 50));
95
+ stopProcess(execution.pid);
96
+ }
97
+ }
@@ -0,0 +1,476 @@
1
+ import {
2
+ cp,
3
+ mkdir,
4
+ readFile,
5
+ readdir,
6
+ realpath,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import type { RuntimePolicy } from "./config";
11
+ import { insideRuntimeRoot, requireRuntimeReadRoots } from "./paths";
12
+ import { runtimeSeedDigest } from "./seeds";
13
+ import { prepareRuntimeState } from "./state";
14
+ import { sharedCacheDenyRoots, sharedCacheRoots } from "./shared-cache";
15
+ import { macSandboxProfile } from "../hosts/mac-sandbox";
16
+ import type { IsolatedCommand } from "../hosts/mac-sandbox";
17
+ import { prepareLinuxSandboxCommand } from "../hosts/linux-sandbox";
18
+ import { isRecord, isUnknownArray } from "../value-guards";
19
+ import { hookExecutions, releaseHookProcesses } from "./hook-processes";
20
+ import {
21
+ nativeStateParent,
22
+ requireNativeHookRoots,
23
+ } from "../native-transcript-state";
24
+
25
+ export interface HookMounts {
26
+ directories: string[];
27
+ receipts: string;
28
+ isolation: IsolatedCommand[];
29
+ plugins: {
30
+ name: string;
31
+ allowed: boolean;
32
+ sourceDigest: string;
33
+ effectiveDigest: string;
34
+ }[];
35
+ }
36
+ interface HookRewrite {
37
+ allowed: boolean;
38
+ root: string;
39
+ privateRoot: string;
40
+ execution: () => Promise<HookExecution>;
41
+ ordinal: number;
42
+ files: Set<string>;
43
+ }
44
+
45
+ interface HookExecution {
46
+ environment: Record<string, string>;
47
+ profile?: string;
48
+ isolation?: IsolatedCommand;
49
+ }
50
+
51
+ export function runtimeHooksEnabled(
52
+ policy: RuntimePolicy | undefined,
53
+ ): boolean {
54
+ const hooks = policy?.hooks;
55
+ if (!hooks) return false;
56
+ const plugins = hooks.plugins ?? [];
57
+ return hooks.nativeGoal === true || plugins.length > 0;
58
+ }
59
+
60
+ function object(value: unknown): Record<string, unknown> {
61
+ if (!isRecord(value)) throw new Error("invalid runtime hook declaration");
62
+ return value;
63
+ }
64
+
65
+ function quote(value: string): string {
66
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
67
+ }
68
+
69
+ /** Materialize immutable mounts; only declared plugin hooks reach the host. */
70
+ export async function prepareHookMounts(options: {
71
+ workspace: string;
72
+ stateRoot: string;
73
+ privateRoot: string;
74
+ pluginRoots: string[];
75
+ protectedRoots: string[];
76
+ policy: RuntimePolicy;
77
+ }): Promise<HookMounts> {
78
+ let mountRoot = join(options.stateRoot, "runtime-plugins");
79
+ const receipts = join(options.privateRoot, "hook-receipts");
80
+ await Promise.all(
81
+ [mountRoot, receipts].map((path) =>
82
+ mkdir(path, { recursive: true, mode: 0o700 }),
83
+ ),
84
+ );
85
+ mountRoot = await realpath(mountRoot);
86
+ let runtime: ReturnType<typeof prepareRuntimeState> | undefined;
87
+ const prepareRuntime = () =>
88
+ (runtime ??= prepareRuntimeState(
89
+ options.workspace,
90
+ options.policy,
91
+ "hooks",
92
+ join(options.privateRoot, "hook-runtime"),
93
+ ));
94
+ const selected = selectedPlugins(options.policy);
95
+ const plugins: HookMounts["plugins"] = [],
96
+ isolation: IsolatedCommand[] = [],
97
+ directories: string[] = [];
98
+ for (const [index, source] of options.pluginRoots.entries()) {
99
+ const root = join(mountRoot, `plugin-${index}`);
100
+ const sourceDigest = await runtimeSeedDigest(source);
101
+ await cp(source, root, {
102
+ recursive: true,
103
+ dereference: true,
104
+ force: false,
105
+ errorOnExist: true,
106
+ });
107
+ const manifest = object(
108
+ JSON.parse(
109
+ await readFile(join(root, ".claude-plugin/plugin.json"), "utf8"),
110
+ ),
111
+ );
112
+ const name = uniquePluginName(manifest, plugins);
113
+ const allowed = selected.delete(name);
114
+ const readRoots = await hookReadRoots(options, root);
115
+ const rewrite: HookRewrite = {
116
+ root,
117
+ allowed,
118
+ privateRoot: options.privateRoot,
119
+ ordinal: index * 10000,
120
+ files: new Set(),
121
+ execution: lazyHookExecution(
122
+ options,
123
+ index,
124
+ root,
125
+ readRoots,
126
+ prepareRuntime,
127
+ isolation,
128
+ ),
129
+ };
130
+ await rewritePlugin(manifest, rewrite);
131
+ plugins.push({
132
+ name,
133
+ allowed,
134
+ sourceDigest,
135
+ effectiveDigest: await runtimeSeedDigest(root),
136
+ });
137
+ directories.push(root);
138
+ }
139
+ if (selected.size)
140
+ throw new Error("runtime hook policy selects an unavailable plugin");
141
+ return { directories, receipts, plugins, isolation };
142
+ }
143
+
144
+ function lazyHookExecution(
145
+ options: Parameters<typeof prepareHookMounts>[0],
146
+ index: number,
147
+ root: string,
148
+ readRoots: { ordinary: string[]; curated: string[] },
149
+ prepareRuntime: () => ReturnType<typeof prepareRuntimeState>,
150
+ isolation: IsolatedCommand[],
151
+ ): () => Promise<HookExecution> {
152
+ let execution: Promise<HookExecution> | undefined;
153
+ return () =>
154
+ (execution ??= (async () => {
155
+ const runtime = await prepareRuntime();
156
+ const boundary = await prepareHookIsolation(
157
+ options,
158
+ index,
159
+ readRoots,
160
+ runtime,
161
+ );
162
+ if (boundary.isolation) isolation.push(boundary.isolation);
163
+ return {
164
+ ...boundary,
165
+ environment: {
166
+ ...runtime.environment,
167
+ PATH: hookPath(runtime.environment),
168
+ CLAUDE_PLUGIN_ROOT: root,
169
+ PLUGIN_ROOT: root,
170
+ },
171
+ };
172
+ })());
173
+ }
174
+
175
+ async function prepareHookIsolation(
176
+ options: Parameters<typeof prepareHookMounts>[0],
177
+ index: number,
178
+ roots: { ordinary: string[]; curated: string[] },
179
+ runtime: Awaited<ReturnType<typeof prepareRuntimeState>>,
180
+ ): Promise<{ profile?: string; isolation?: IsolatedCommand }> {
181
+ const cacheRoots = await sharedCacheRoots(
182
+ options.policy,
183
+ "hooks",
184
+ options.protectedRoots,
185
+ );
186
+ const protectedRoots = [
187
+ ...options.protectedRoots,
188
+ ...sharedCacheDenyRoots(options.policy, "hooks"),
189
+ ];
190
+ if (process.platform === "linux")
191
+ return {
192
+ isolation: await prepareLinuxSandboxCommand({
193
+ argv: ["/bin/true"],
194
+ workspace: options.workspace,
195
+ protectedRoots,
196
+ privateStateRoot: options.privateRoot,
197
+ denyNetwork: true,
198
+ readOnlyRoots: roots.ordinary,
199
+ hookReadRoots: roots.curated,
200
+ writableRuntimeRoot: runtime.root,
201
+ writableCacheRoots: cacheRoots,
202
+ }),
203
+ };
204
+ const profile = join(options.privateRoot, `hooks-${index}.sb`);
205
+ await writeFile(
206
+ profile,
207
+ macSandboxProfile(
208
+ protectedRoots,
209
+ true,
210
+ roots.ordinary,
211
+ runtime.root,
212
+ roots.curated,
213
+ options.workspace,
214
+ undefined,
215
+ cacheRoots,
216
+ ) + "\n(deny process-info* (require-not (target self)))\n",
217
+ { mode: 0o600 },
218
+ );
219
+ return { profile };
220
+ }
221
+
222
+ async function hookReadRoots(
223
+ options: Parameters<typeof prepareHookMounts>[0],
224
+ root: string,
225
+ ) {
226
+ const trusted = insideRuntimeRoot(await nativeStateParent(), root);
227
+ const ordinary = [
228
+ ...options.policy.readOnlyRoots,
229
+ ...(trusted ? [] : [root]),
230
+ ];
231
+ const curated = trusted ? [root] : [];
232
+ requireRuntimeReadRoots(ordinary, options.protectedRoots);
233
+ await requireNativeHookRoots(curated, options.privateRoot);
234
+ return { ordinary, curated };
235
+ }
236
+
237
+ function hookPath(environment: Record<string, string>): string {
238
+ return environment.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
239
+ }
240
+ function selectedPlugins(policy: RuntimePolicy): Set<string> {
241
+ return new Set(policy.hooks?.plugins ?? []);
242
+ }
243
+ function uniquePluginName(
244
+ manifest: Record<string, unknown>,
245
+ plugins: HookMounts["plugins"],
246
+ ): string {
247
+ if (typeof manifest.name !== "string")
248
+ throw new Error("runtime hook plugin has no name");
249
+ const name = manifest.name;
250
+ if (plugins.some((plugin) => plugin.name === name))
251
+ throw new Error("runtime hook plugin names overlap");
252
+ return name;
253
+ }
254
+
255
+ async function rewritePlugin(
256
+ manifest: Record<string, unknown>,
257
+ state: HookRewrite,
258
+ ): Promise<void> {
259
+ await requireHookOnlyPlugin(state.root);
260
+ await rewriteManifest(manifest, state, ".claude-plugin/plugin.json");
261
+ const native = join(state.root, ".codex-plugin/plugin.json");
262
+ if (await Bun.file(native).exists())
263
+ await rewriteManifest(
264
+ object(JSON.parse(await readFile(native, "utf8"))),
265
+ state,
266
+ ".codex-plugin/plugin.json",
267
+ );
268
+ const standard = join(state.root, "hooks/hooks.json");
269
+ if (await Bun.file(standard).exists()) await hookFile(standard, state);
270
+ await rewriteFrontmatter(state.root, state);
271
+ }
272
+
273
+ async function requireHookOnlyPlugin(root: string): Promise<void> {
274
+ for (const name of [".mcp.json", ".lsp.json"])
275
+ if (await Bun.file(join(root, name)).exists())
276
+ throw new Error("runtime auxiliary plugin execution is unsupported");
277
+ }
278
+
279
+ async function rewriteManifest(
280
+ manifest: Record<string, unknown>,
281
+ state: HookRewrite,
282
+ path: string,
283
+ ): Promise<void> {
284
+ if (manifest.mods !== undefined)
285
+ throw new Error("runtime plugin mods are unsupported");
286
+ delete manifest.mcpServers;
287
+ delete manifest.lspServers;
288
+ if (manifest.hooks !== undefined)
289
+ manifest.hooks = await hookSource(manifest.hooks, state);
290
+ await writeFile(join(state.root, path), JSON.stringify(manifest));
291
+ }
292
+
293
+ async function hookSource(
294
+ value: unknown,
295
+ state: HookRewrite,
296
+ ): Promise<unknown> {
297
+ if (typeof value === "string") {
298
+ const path = await realpath(join(state.root, value));
299
+ if (!insideRuntimeRoot(state.root, path))
300
+ throw new Error("runtime hook file escapes its plugin");
301
+ await hookFile(path, state);
302
+ return value;
303
+ }
304
+ if (isUnknownArray(value))
305
+ return Promise.all(value.map((source) => hookSource(source, state)));
306
+ const configuration = object(value);
307
+ if (configuration.hooks !== undefined)
308
+ return {
309
+ ...configuration,
310
+ hooks: await hookMap(configuration.hooks, state),
311
+ };
312
+ return hookMap(configuration, state);
313
+ }
314
+
315
+ async function hookFile(path: string, state: HookRewrite): Promise<void> {
316
+ if (state.files.has(path)) return;
317
+ state.files.add(path);
318
+ const value = object(JSON.parse(await readFile(path, "utf8")));
319
+ value.hooks = await hookMap(value.hooks, state);
320
+ await writeFile(path, JSON.stringify(value));
321
+ }
322
+
323
+ async function hookMap(
324
+ value: unknown,
325
+ state: HookRewrite,
326
+ ): Promise<Record<string, unknown>> {
327
+ if (!state.allowed) return {};
328
+ const entries: [string, unknown][] = [];
329
+ for (const [event, groups] of Object.entries(object(value))) {
330
+ if (!isUnknownArray(groups))
331
+ throw new Error("runtime hook groups must be arrays");
332
+ entries.push([
333
+ event,
334
+ await Promise.all(
335
+ groups.map(async (group) => {
336
+ const definition = object(group);
337
+ if (!isUnknownArray(definition.hooks))
338
+ throw new Error("runtime hook handlers must be arrays");
339
+ return {
340
+ ...definition,
341
+ hooks: await Promise.all(
342
+ definition.hooks.map((handler) => commandHook(handler, state)),
343
+ ),
344
+ };
345
+ }),
346
+ ),
347
+ ]);
348
+ }
349
+ return Object.fromEntries(entries);
350
+ }
351
+
352
+ async function commandHook(
353
+ value: unknown,
354
+ state: HookRewrite,
355
+ ): Promise<Record<string, unknown>> {
356
+ const handler = commandHandler(value);
357
+ if (handler.command.includes("\0") || handler.command.length > 65536)
358
+ throw new Error("runtime hook command is invalid or oversized");
359
+ const wrapper = join(state.privateRoot, `hook-${state.ordinal++}.sh`);
360
+ const execution = await state.execution();
361
+ const environment = Object.entries(execution.environment).map(
362
+ ([name, content]) => `${name}=${content}`,
363
+ );
364
+ const receipts = join(state.privateRoot, "hook-receipts");
365
+ const command = isolatedHookCommand(execution, environment, handler.command);
366
+ await writeFile(
367
+ wrapper,
368
+ `#!/bin/sh
369
+ set -u
370
+ receipt=$(/usr/bin/mktemp ${quote(join(receipts, "call.XXXXXX"))}) || exit 125
371
+ test ! -f ${quote(join(receipts, "stopped"))} || exit 125
372
+ printf '{"status":"running","pid":%s,"wrapper":%s}\\n' "$$" ${quote(JSON.stringify(wrapper))} > "$receipt"
373
+ ${command}
374
+ code=$?
375
+ printf '{"status":"completed","pid":%s,"exitCode":%s,"wrapper":%s}\\n' "$$" "$code" ${quote(JSON.stringify(wrapper))} > "$receipt"
376
+ exit "$code"
377
+ `,
378
+ { mode: 0o700 },
379
+ );
380
+ return { ...handler, command: `/bin/sh ${quote(wrapper)}` };
381
+ }
382
+
383
+ function isolatedHookCommand(
384
+ state: HookExecution,
385
+ environment: string[],
386
+ command: string,
387
+ ): string {
388
+ const prefix = state.isolation
389
+ ? state.isolation.argv.slice(0, -1)
390
+ : ["/usr/bin/sandbox-exec", "-f", state.profile ?? ""];
391
+ return [
392
+ ...prefix,
393
+ "/usr/bin/env",
394
+ "-i",
395
+ ...environment,
396
+ "/bin/sh",
397
+ "-c",
398
+ command,
399
+ ]
400
+ .map(quote)
401
+ .join(" ");
402
+ }
403
+
404
+ function commandHandler(
405
+ value: unknown,
406
+ ): Record<string, unknown> & { command: string } {
407
+ const handler = object(value);
408
+ if (handler.type !== "command" || typeof handler.command !== "string")
409
+ throw new Error("runtime policy supports command plugin hooks only");
410
+ if (handler.args !== undefined)
411
+ throw new Error("runtime exec-form hook arguments are unsupported");
412
+ return { ...handler, command: handler.command };
413
+ }
414
+
415
+ async function rewriteFrontmatter(
416
+ directory: string,
417
+ state: HookRewrite,
418
+ ): Promise<void> {
419
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
420
+ const path = join(directory, entry.name);
421
+ if (entry.isDirectory()) await rewriteFrontmatter(path, state);
422
+ else if (entry.name.endsWith(".md")) await rewriteMarkdown(path, state);
423
+ }
424
+ }
425
+
426
+ async function rewriteMarkdown(
427
+ path: string,
428
+ state: HookRewrite,
429
+ ): Promise<void> {
430
+ const body = await readFile(path, "utf8");
431
+ const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(body);
432
+ const metadata = hookFrontmatter(header?.[1]);
433
+ if (!metadata || !header) return;
434
+ if (state.allowed) metadata.hooks = await hookMap(metadata.hooks, state);
435
+ else delete metadata.hooks;
436
+ await writeFile(
437
+ path,
438
+ `---\n${Bun.YAML.stringify(metadata)}---\n${body.slice(header[0].length)}`,
439
+ );
440
+ }
441
+
442
+ function hookFrontmatter(
443
+ header: string | undefined,
444
+ ): Record<string, unknown> | undefined {
445
+ if (!header?.includes("hooks")) return undefined;
446
+ const metadata = object(Bun.YAML.parse(header));
447
+ return metadata.hooks === undefined ? undefined : metadata;
448
+ }
449
+
450
+ export async function runtimeHookObservation(mounts: HookMounts) {
451
+ await releaseHookProcesses(mounts.receipts);
452
+ const executions = (await hookExecutions(mounts.receipts)).map(
453
+ (execution) => ({
454
+ status: execution.status,
455
+ pid: execution.pid,
456
+ exitCode: execution.exitCode,
457
+ }),
458
+ );
459
+ return {
460
+ id: "sevro.host.hooks",
461
+ completeness: executions.every(
462
+ (execution) => execution.status === "completed",
463
+ )
464
+ ? ("complete" as const)
465
+ : ("partial" as const),
466
+ data: { plugins: mounts.plugins, executions },
467
+ };
468
+ }
469
+
470
+ export async function releaseRuntimeHooks(
471
+ mounts: HookMounts | undefined,
472
+ ): Promise<void> {
473
+ if (!mounts) return;
474
+ await releaseHookProcesses(mounts.receipts);
475
+ await Promise.all(mounts.isolation.map((item) => item.release()));
476
+ }