@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,313 @@
1
+ import {
2
+ access,
3
+ lstat,
4
+ readdir,
5
+ readlink,
6
+ realpath,
7
+ stat,
8
+ } from "node:fs/promises";
9
+ import { constants, lstatSync, readlinkSync, realpathSync } from "node:fs";
10
+ import { homedir, userInfo } from "node:os";
11
+ import { nativeStateProtectedRoots } from "../native-transcript-state";
12
+ import { workspaceStatePath } from "../fixtures/workspace-state";
13
+ import {
14
+ basename,
15
+ delimiter,
16
+ dirname,
17
+ isAbsolute,
18
+ join,
19
+ relative,
20
+ resolve,
21
+ sep,
22
+ } from "node:path";
23
+
24
+ export function insideRuntimeRoot(root: string, path: string): boolean {
25
+ const child = relative(root, path);
26
+ return (
27
+ child === "" ||
28
+ (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child))
29
+ );
30
+ }
31
+
32
+ export function isRuntimeHomeRoot(root: string): boolean {
33
+ return [homedir(), userInfo().homedir]
34
+ .map((home) => resolve(home))
35
+ .includes(root);
36
+ }
37
+
38
+ export function requireRuntimeReadRoots(
39
+ readRoots: string[],
40
+ protectedRoots: string[],
41
+ ): void {
42
+ const hardRoots = [
43
+ ...protectedRoots.filter((root) => !isRuntimeHomeRoot(root)),
44
+ ...runtimeCredentialRoots(),
45
+ ...nativeStateProtectedRoots(),
46
+ workspaceStatePath(),
47
+ ];
48
+ for (const root of readRoots) {
49
+ if (root === "/" || isRuntimeHomeRoot(root))
50
+ throw new Error("runtime read root is too broad");
51
+ if (overlapsRuntimeRoots([root], hardRoots))
52
+ throw new Error("runtime read root overlaps protected data");
53
+ }
54
+ }
55
+
56
+ function runtimeCredentialRoots(): string[] {
57
+ return credentialRoots().flatMap((root) => [
58
+ root,
59
+ canonicalCredentialRoot(root),
60
+ ]);
61
+ }
62
+
63
+ function overlapsRuntimeRoots(roots: string[], deniedRoots: string[]): boolean {
64
+ return roots.some((root) =>
65
+ deniedRoots.some(
66
+ (denied) =>
67
+ insideRuntimeRoot(denied, root) || insideRuntimeRoot(root, denied),
68
+ ),
69
+ );
70
+ }
71
+
72
+ function canonicalCredentialRoot(path: string): string {
73
+ try {
74
+ return realpathSync(path);
75
+ } catch (cause) {
76
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
77
+ const target = missingCredentialLink(path);
78
+ if (target) return canonicalCredentialRoot(target);
79
+ return join(canonicalCredentialRoot(dirname(path)), basename(path));
80
+ }
81
+ }
82
+
83
+ function missingCredentialLink(path: string): string | null {
84
+ try {
85
+ return lstatSync(path).isSymbolicLink()
86
+ ? resolve(dirname(path), readlinkSync(path))
87
+ : null;
88
+ } catch (cause) {
89
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
90
+ throw cause;
91
+ }
92
+ }
93
+
94
+ export function runtimeExecutableProtection(
95
+ readRoots: string[],
96
+ protectedRoots: string[],
97
+ allowHomeRuntime: boolean,
98
+ ): string[] {
99
+ if (!allowHomeRuntime) return protectedRoots;
100
+ requireRuntimeReadRoots(readRoots, []);
101
+ return protectedRoots.filter((root) => !isRuntimeHomeRoot(root));
102
+ }
103
+
104
+ function credentialRoots(): string[] {
105
+ return [homedir(), userInfo().homedir].flatMap((home) =>
106
+ [
107
+ ".ssh",
108
+ ".aws",
109
+ ".codex",
110
+ ".claude",
111
+ ".config/gcloud",
112
+ ".local/share/keyrings",
113
+ ].map((path) => join(home, path)),
114
+ );
115
+ }
116
+
117
+ export async function canonicalRuntimeRoot(path: string): Promise<string> {
118
+ try {
119
+ return await realpath(path);
120
+ } catch (cause) {
121
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
122
+ const target = await missingRuntimeLink(path);
123
+ if (target) return canonicalRuntimeRoot(target);
124
+ return join(await canonicalRuntimeRoot(dirname(path)), basename(path));
125
+ }
126
+ }
127
+
128
+ async function missingRuntimeLink(path: string): Promise<string | null> {
129
+ try {
130
+ return (await lstat(path)).isSymbolicLink()
131
+ ? resolve(dirname(path), await readlink(path))
132
+ : null;
133
+ } catch (cause) {
134
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
135
+ throw cause;
136
+ }
137
+ }
138
+
139
+ function installationRoot(directory: string): string | null {
140
+ if (basename(directory) !== "bin") return null;
141
+ const root = dirname(directory);
142
+ const broad = [
143
+ "/",
144
+ "/usr",
145
+ "/usr/local",
146
+ "/opt/homebrew",
147
+ ...[homedir(), userInfo().homedir].flatMap((home) => [
148
+ home,
149
+ join(home, ".local"),
150
+ ]),
151
+ ];
152
+ return broad.includes(root) ? null : root;
153
+ }
154
+
155
+ async function linkedToolRoots(directory: string): Promise<string[]> {
156
+ const entries = await readdir(directory, { withFileTypes: true });
157
+ if (entries.length > 4096)
158
+ throw new Error("runtime PATH directory exceeds entry limit");
159
+ const roots: string[] = [];
160
+ for (const entry of entries.filter((entry) => entry.isSymbolicLink())) {
161
+ const target = await linkedTarget(join(directory, entry.name));
162
+ if (!target) continue;
163
+ const bin = dirname(target);
164
+ roots.push(bin);
165
+ const installation = installationRoot(bin);
166
+ if (installation) roots.push(installation);
167
+ }
168
+ return roots;
169
+ }
170
+
171
+ async function linkedTarget(path: string): Promise<string | null> {
172
+ try {
173
+ const target = await realpath(path);
174
+ return (await stat(target)).isFile() ? target : null;
175
+ } catch (cause) {
176
+ if (
177
+ ["EACCES", "EPERM", "ENOENT"].includes(
178
+ (cause as NodeJS.ErrnoException).code ?? "",
179
+ )
180
+ )
181
+ return null;
182
+ throw cause;
183
+ }
184
+ }
185
+
186
+ /** Missing PATH entries are inert; relative entries cannot grant host access. */
187
+ export async function runtimePathDirectories(
188
+ path: string | undefined,
189
+ ): Promise<string[]> {
190
+ if (path === undefined) return [];
191
+ const roots: string[] = [];
192
+ for (const entry of path.split(delimiter)) {
193
+ roots.push(...(await pathEntryRoots(entry)));
194
+ }
195
+ return [...new Set(roots)].sort();
196
+ }
197
+
198
+ function containsControl(value: string): boolean {
199
+ return Array.from(value).some(
200
+ (character) =>
201
+ character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f,
202
+ );
203
+ }
204
+
205
+ function requirePathEntry(entry: string): void {
206
+ if (!isAbsolute(entry) || containsControl(entry))
207
+ throw new Error("runtime PATH entries must be absolute and nonempty");
208
+ }
209
+
210
+ /** Ambient tool discovery must not grant access to the operator's credentials. */
211
+ export async function runtimeInheritedPath(path: string): Promise<string> {
212
+ const deniedRoots = runtimeCredentialRoots();
213
+ const selected: string[] = [];
214
+ for (const entry of path.split(delimiter)) {
215
+ requirePathEntry(entry);
216
+ const paths = [resolve(entry), await canonicalRuntimeRoot(entry)];
217
+ if (overlapsRuntimeRoots(paths, deniedRoots)) continue;
218
+ if (overlapsRuntimeRoots(await pathEntryRoots(entry), deniedRoots))
219
+ continue;
220
+ selected.push(entry);
221
+ }
222
+ if (selected.length === 0)
223
+ throw new Error("runtime inherited PATH has no unprotected entries");
224
+ return selected.join(delimiter);
225
+ }
226
+
227
+ async function pathEntryRoots(entry: string): Promise<string[]> {
228
+ requirePathEntry(entry);
229
+ const directory = await existingDirectory(entry);
230
+ if (!directory) return [];
231
+ const installation = installationRoot(directory);
232
+ return [
233
+ resolve(entry),
234
+ directory,
235
+ ...(installation ? [installation] : []),
236
+ ...(await linkedToolRoots(directory)),
237
+ ];
238
+ }
239
+
240
+ async function existingDirectory(path: string): Promise<string | null> {
241
+ try {
242
+ const canonical = await realpath(path);
243
+ if (!(await stat(canonical)).isDirectory())
244
+ throw new Error("runtime PATH entry is not a directory");
245
+ await access(canonical, constants.R_OK | constants.X_OK);
246
+ return canonical;
247
+ } catch (cause) {
248
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
249
+ throw cause;
250
+ }
251
+ }
252
+
253
+ export function runtimeDeclaredPath(
254
+ path: string,
255
+ environment: Record<string, string>,
256
+ base: string,
257
+ ): string {
258
+ const expanded = path.replace(
259
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
260
+ (_match: string, name: string) => {
261
+ const value = environment[name];
262
+ if (value === undefined)
263
+ throw new Error(`runtime path variable is undeclared: ${name}`);
264
+ return value;
265
+ },
266
+ );
267
+ if (containsControl(expanded) || expanded.includes("${"))
268
+ throw new Error("runtime path contains invalid characters");
269
+ return expanded.startsWith("~/")
270
+ ? join(homedir(), expanded.slice(2))
271
+ : resolve(base, expanded);
272
+ }
273
+
274
+ export async function declaredRuntimeRoots(
275
+ paths: string[],
276
+ environment: Record<string, string>,
277
+ base: string,
278
+ ): Promise<string[]> {
279
+ const roots = await Promise.all(
280
+ paths.map(async (path) => {
281
+ const directory = await existingDirectory(
282
+ runtimeDeclaredPath(path, environment, base),
283
+ );
284
+ if (!directory) throw new Error("declared runtime read root is missing");
285
+ return [directory, runtimeDeclaredPath(path, environment, base)];
286
+ }),
287
+ );
288
+ return [...new Set(roots.flat())];
289
+ }
290
+
291
+ export async function optionalRuntimeRoots(
292
+ paths: string[],
293
+ environment: Record<string, string>,
294
+ base: string,
295
+ protectedRoots: string[] = [],
296
+ ): Promise<{ roots: string[]; skipped: string[] }> {
297
+ const roots: string[] = [],
298
+ skipped: string[] = [];
299
+ for (const path of paths) {
300
+ const declared = runtimeDeclaredPath(path, environment, base);
301
+ requireRuntimeReadRoots(
302
+ [declared, await canonicalRuntimeRoot(declared)],
303
+ protectedRoots,
304
+ );
305
+ const directory = await existingDirectory(declared);
306
+ if (directory) roots.push(directory, declared);
307
+ else skipped.push(declared);
308
+ }
309
+ return {
310
+ roots: [...new Set(roots)].sort(),
311
+ skipped: [...new Set(skipped)].sort(),
312
+ };
313
+ }
@@ -0,0 +1,85 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { terminateOwnedProcessGroup } from "../process-lifecycle";
4
+
5
+ export class RuntimeMetadataQueryError extends Error {
6
+ constructor(
7
+ message: string,
8
+ readonly noActiveDeveloper = false,
9
+ ) {
10
+ super(message);
11
+ }
12
+ }
13
+
14
+ function exitError(
15
+ code: number | null,
16
+ stderr: Buffer[],
17
+ ): RuntimeMetadataQueryError {
18
+ const noSelection =
19
+ code === 2 &&
20
+ Buffer.concat(stderr)
21
+ .toString("utf8")
22
+ .toLowerCase()
23
+ .includes("unable to get active developer directory");
24
+ return new RuntimeMetadataQueryError(`exit ${String(code)}`, noSelection);
25
+ }
26
+
27
+ function stopQuery(child: ChildProcess): void {
28
+ if (child.pid === undefined) return;
29
+ terminateOwnedProcessGroup(
30
+ { pid: child.pid, kill: (signal) => child.kill(signal) },
31
+ { alwaysGroup: true, fallbackError: "propagate" },
32
+ );
33
+ }
34
+
35
+ /** Bound both output streams and stop every descendant of this metadata query. */
36
+ export function runtimeMetadataQuery(
37
+ executable: string,
38
+ argument: string,
39
+ environment: Record<string, string>,
40
+ ): Promise<string> {
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(executable, [argument], {
43
+ detached: true,
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ env: {
46
+ ...environment,
47
+ HOME: homedir(),
48
+ HOMEBREW_NO_AUTO_UPDATE: "1",
49
+ LC_ALL: "C",
50
+ LANG: "C",
51
+ },
52
+ });
53
+ const chunks: Buffer[] = [];
54
+ const stderr: Buffer[] = [];
55
+ let bytes = 0;
56
+ const fail = (reason: string, cause?: unknown) => {
57
+ stopQuery(child);
58
+ reject(new Error(reason, { cause }));
59
+ };
60
+ const timer = setTimeout(() => {
61
+ fail("timeout exceeded");
62
+ }, 3000);
63
+ const consume = (chunk: Buffer) => {
64
+ bytes += chunk.length;
65
+ if (bytes > 8192) fail("output limit exceeded");
66
+ };
67
+ child.stdout.on("data", (chunk: Buffer) => {
68
+ consume(chunk);
69
+ if (bytes <= 8192) chunks.push(chunk);
70
+ });
71
+ child.stderr.on("data", (chunk: Buffer) => {
72
+ consume(chunk);
73
+ if (bytes <= 8192) stderr.push(chunk);
74
+ });
75
+ child.on("error", (cause: Error) => {
76
+ fail("provider unavailable", cause);
77
+ });
78
+ child.on("close", (code) => {
79
+ clearTimeout(timer);
80
+ stopQuery(child);
81
+ if (code !== 0) reject(exitError(code, stderr));
82
+ else resolve(Buffer.concat(chunks).toString("utf8"));
83
+ });
84
+ });
85
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ chmod,
3
+ mkdir,
4
+ readFile,
5
+ readdir,
6
+ realpath,
7
+ stat,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { hashJson, sha256 } from "../identity";
12
+ import type { RuntimePolicy } from "./config";
13
+ import { insideRuntimeRoot } from "./paths";
14
+
15
+ class SeedSnapshot {
16
+ private entries = 0;
17
+ private bytes = 0;
18
+ private readonly files: {
19
+ path: string;
20
+ sha256: string;
21
+ mode: number;
22
+ bytes: Buffer | null;
23
+ }[] = [];
24
+ constructor(
25
+ private readonly root: string,
26
+ private readonly retainContents = false,
27
+ ) {}
28
+
29
+ async digest(): Promise<string> {
30
+ await this.visit("", new Set());
31
+ return hashJson(
32
+ this.files.map(({ path, sha256, mode }) => ({ path, sha256, mode })),
33
+ );
34
+ }
35
+
36
+ private async visit(path: string, ancestors: Set<string>): Promise<void> {
37
+ const logical = join(this.root, path);
38
+ const canonical = await realpath(logical);
39
+ if (!insideRuntimeRoot(this.root, canonical))
40
+ throw new Error("runtime seed link escapes its source");
41
+ if (++this.entries > 16384)
42
+ throw new Error("runtime seed exceeds entry limit");
43
+ const info = await stat(canonical);
44
+ if (info.isDirectory()) {
45
+ this.files.push({
46
+ path,
47
+ sha256: "directory",
48
+ mode: info.mode & 0o777,
49
+ bytes: null,
50
+ });
51
+ await this.visitDirectory(path, canonical, ancestors);
52
+ return;
53
+ }
54
+ if (!info.isFile())
55
+ throw new Error("runtime seed contains an unsupported file");
56
+ await this.visitFile(path, canonical, info);
57
+ }
58
+
59
+ private async visitDirectory(
60
+ path: string,
61
+ canonical: string,
62
+ ancestors: Set<string>,
63
+ ): Promise<void> {
64
+ if (ancestors.has(canonical))
65
+ throw new Error("runtime seed contains a directory cycle");
66
+ const parents = new Set([...ancestors, canonical]);
67
+ for (const name of (await readdir(canonical)).sort())
68
+ await this.visit(join(path, name), parents);
69
+ }
70
+
71
+ private async visitFile(
72
+ path: string,
73
+ canonical: string,
74
+ info: { size: number; mode: number },
75
+ ): Promise<void> {
76
+ this.bytes += info.size;
77
+ if (this.bytes > 512 * 1024 * 1024)
78
+ throw new Error("runtime seed exceeds 512 MiB");
79
+ const bytes = await readFile(canonical);
80
+ this.files.push({
81
+ path,
82
+ sha256: sha256(bytes),
83
+ mode: info.mode & 0o777,
84
+ bytes: this.retainContents ? bytes : null,
85
+ });
86
+ }
87
+ async materialize(target: string): Promise<void> {
88
+ for (const file of this.files) {
89
+ const path = join(target, file.path);
90
+ if (file.bytes === null) await mkdir(path, { mode: 0o700 });
91
+ else
92
+ await writeFile(path, file.bytes, {
93
+ flag: "wx",
94
+ mode: file.mode,
95
+ });
96
+ }
97
+ for (const file of [...this.files].reverse())
98
+ await chmod(join(target, file.path), file.mode);
99
+ }
100
+ }
101
+
102
+ export async function runtimeSeedDigest(source: string): Promise<string> {
103
+ return new SeedSnapshot(await realpath(source)).digest();
104
+ }
105
+
106
+ type RuntimeSeed = NonNullable<RuntimePolicy["seeds"]>[number];
107
+ const verifiedSeeds = new WeakMap<
108
+ RuntimeSeed,
109
+ { source: string; digest: string; snapshot: Promise<SeedSnapshot> }
110
+ >();
111
+
112
+ /** Contents stay in runner memory; no writable role or filesystem alias owns them. */
113
+ export async function materializeRuntimeSeed(
114
+ seed: RuntimeSeed,
115
+ target: string,
116
+ ): Promise<void> {
117
+ let verified = verifiedSeeds.get(seed);
118
+ if (verified?.source !== seed.source || verified.digest !== seed.sha256) {
119
+ verified = {
120
+ source: seed.source,
121
+ digest: seed.sha256,
122
+ snapshot: verifiedSnapshot(seed),
123
+ };
124
+ verifiedSeeds.set(seed, verified);
125
+ }
126
+ await (await verified.snapshot).materialize(target);
127
+ }
128
+
129
+ async function verifiedSnapshot(seed: RuntimeSeed): Promise<SeedSnapshot> {
130
+ const snapshot = new SeedSnapshot(await realpath(seed.source), true);
131
+ if ((await snapshot.digest()) !== seed.sha256)
132
+ throw new Error("runtime seed changed after configuration snapshot");
133
+ return snapshot;
134
+ }
@@ -0,0 +1,143 @@
1
+ import { access, lstat, realpath, symlink } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { userInfo } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
5
+ import type { RuntimePolicy, RuntimeRole } from "./config";
6
+ import { declaredRuntimeRoots, requireRuntimeReadRoots } from "./paths";
7
+
8
+ export interface SharedCacheDirectory {
9
+ source: string;
10
+ target: string;
11
+ roles: RuntimeRole[];
12
+ }
13
+
14
+ export async function resolveSharedCaches(
15
+ caches: SharedCacheDirectory[],
16
+ environment: Record<string, string>,
17
+ base: string,
18
+ protectedRoots: string[],
19
+ ): Promise<SharedCacheDirectory[]> {
20
+ return Promise.all(
21
+ caches.map(async (cache) => {
22
+ const roots = await declaredRuntimeRoots(
23
+ [cache.source],
24
+ environment,
25
+ base,
26
+ );
27
+ requireRuntimeReadRoots(roots, protectedRoots);
28
+ const [declared] = roots;
29
+ if (!declared) throw new Error("runtime shared cache source is missing");
30
+ const source = await realpath(declared);
31
+ await requireSharedCache(source);
32
+ return { ...cache, source };
33
+ }),
34
+ );
35
+ }
36
+
37
+ export function selectedSharedCaches(
38
+ policy: RuntimePolicy | undefined,
39
+ role: RuntimeRole = "candidate",
40
+ ): SharedCacheDirectory[] {
41
+ return (policy?.sharedCacheDirectories ?? []).filter((cache) =>
42
+ cache.roles.includes(role),
43
+ );
44
+ }
45
+
46
+ export async function sharedCacheRoots(
47
+ policy: RuntimePolicy | undefined,
48
+ role: RuntimeRole = "candidate",
49
+ protectedRoots: string[],
50
+ ): Promise<string[]> {
51
+ requireSharedCachePolicy(policy);
52
+ const roots = selectedSharedCaches(policy, role).map((cache) => cache.source);
53
+ return requireWritableCacheRoots(roots, protectedRoots);
54
+ }
55
+
56
+ export function requireSharedCachePolicy(
57
+ policy: RuntimePolicy | undefined,
58
+ ): void {
59
+ if (!policy) return;
60
+ const caches = policy.sharedCacheDirectories ?? [];
61
+ const protectedRoots = [
62
+ ...policy.readOnlyRoots,
63
+ ...(policy.seeds ?? []).map((seed) => seed.source),
64
+ ];
65
+ for (const cache of caches) {
66
+ requireCacheRoles(cache.roles);
67
+ requireRuntimeReadRoots(
68
+ [cache.source],
69
+ [
70
+ ...protectedRoots,
71
+ ...caches
72
+ .filter((other) => other !== cache)
73
+ .map((other) => other.source),
74
+ ],
75
+ );
76
+ }
77
+ }
78
+
79
+ function requireCacheRoles(roles: RuntimeRole[]): void {
80
+ if (!Array.isArray(roles) || roles.length === 0)
81
+ throw new Error("runtime shared cache roles are invalid");
82
+ const allowed = ["candidate", "checks", "hooks", "semantic", "advisory"];
83
+ if (
84
+ new Set(roles).size !== roles.length ||
85
+ roles.some((role) => !allowed.includes(role))
86
+ )
87
+ throw new Error("runtime shared cache roles are invalid");
88
+ }
89
+
90
+ export function sharedCacheDenyRoots(
91
+ policy: RuntimePolicy | undefined,
92
+ role: RuntimeRole = "candidate",
93
+ ): string[] {
94
+ return (policy?.sharedCacheDirectories ?? [])
95
+ .filter((cache) => !cache.roles.includes(role))
96
+ .map((cache) => cache.source);
97
+ }
98
+
99
+ export async function requireWritableCacheRoots(
100
+ roots: string[],
101
+ protectedRoots: string[],
102
+ ): Promise<string[]> {
103
+ requireRuntimeReadRoots(roots, protectedRoots);
104
+ for (const root of roots) await requireSharedCache(root);
105
+ return roots;
106
+ }
107
+
108
+ async function requireSharedCache(source: string): Promise<void> {
109
+ if (!isAbsolute(source) || (await realpath(source)) !== source)
110
+ throw new Error("runtime shared cache must be a canonical directory");
111
+ const info = await lstat(source);
112
+ if (!info.isDirectory() || info.uid !== userInfo().uid)
113
+ throw new Error("runtime shared cache must be an owned directory");
114
+ await access(source, constants.R_OK | constants.W_OK | constants.X_OK);
115
+ }
116
+
117
+ /** A slot aliases the operator-selected cache; its contents are never copied. */
118
+ export async function linkSharedCaches(
119
+ policy: RuntimePolicy,
120
+ role: RuntimeRole,
121
+ root: string,
122
+ ): Promise<void> {
123
+ for (const cache of selectedSharedCaches(policy, role)) {
124
+ const target = join(root, cache.target);
125
+ try {
126
+ await symlink(cache.source, target, "dir");
127
+ } catch (cause) {
128
+ if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw cause;
129
+ await requireCacheSlot(target, cache.source, cause);
130
+ }
131
+ }
132
+ }
133
+
134
+ async function requireCacheSlot(
135
+ target: string,
136
+ source: string,
137
+ cause: unknown,
138
+ ): Promise<void> {
139
+ if (!(await lstat(target)).isSymbolicLink())
140
+ throw new Error("runtime shared cache slot was replaced", { cause });
141
+ if ((await realpath(target)) !== source)
142
+ throw new Error("runtime shared cache slot changed its source", { cause });
143
+ }