@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
@@ -1,5 +1,7 @@
1
- import type { codexNativeCallObservation } from "./codex-native-calls";
1
+ import { isRecord, isUnknownArray } from "../value-guards";
2
2
  import { summarizeClaudeEvents } from "./claude-events";
3
+ import type { codexNativeCallObservation } from "./codex-native-calls";
4
+ import type { ExecCallReceipt } from "./codex-exec-controls";
3
5
 
4
6
  const MAX_CALLS = 128;
5
7
  type Call = { ordinal: number; namespace: string; name: string };
@@ -12,15 +14,18 @@ export interface NativeControlObservation {
12
14
  method: "native_control_calls";
13
15
  calls: Call[];
14
16
  acceptedAgentCount: number | null;
17
+ directAcceptedAgentCount?: number;
18
+ coverage?: {
19
+ nativeCalls: NativeControlObservation["completeness"];
20
+ execInspection: "complete" | "partial";
21
+ acceptedAgents: "direct_native_calls";
22
+ };
15
23
  submittedExecCalls: number | null;
24
+ execCalls?: ExecCallReceipt[];
16
25
  truncated: boolean | null;
17
26
  };
18
27
  }
19
28
 
20
- function record(value: unknown): value is Entry {
21
- return value !== null && typeof value === "object" && !Array.isArray(value);
22
- }
23
-
24
29
  function label(value: unknown, max: number): value is string {
25
30
  return (
26
31
  typeof value === "string" &&
@@ -33,10 +38,11 @@ function label(value: unknown, max: number): value is string {
33
38
  export function codexNativeControls(
34
39
  observation: Awaited<ReturnType<typeof codexNativeCallObservation>>,
35
40
  ): NativeControlObservation {
36
- const complete = observation.completeness === "complete";
41
+ const complete =
42
+ observation.completeness === "complete" && inspectedExecCalls(observation);
37
43
  return {
38
44
  id: "sevro.host.native-controls",
39
- completeness: observation.completeness,
45
+ completeness: controlCompleteness(observation.completeness, complete),
40
46
  data: {
41
47
  method: "native_control_calls",
42
48
  calls: observation.data.toolCalls.map(({ ordinal, namespace, name }) => ({
@@ -47,11 +53,137 @@ export function codexNativeControls(
47
53
  acceptedAgentCount: complete
48
54
  ? observation.data.acceptedSpawns.length
49
55
  : null,
50
- submittedExecCalls: complete ? observation.data.submittedExecCalls : null,
51
- truncated: complete ? false : null,
56
+ directAcceptedAgentCount: observation.data.acceptedSpawns.length,
57
+ coverage: {
58
+ nativeCalls: observation.completeness,
59
+ execInspection: inspectedExecCalls(observation)
60
+ ? "complete"
61
+ : "partial",
62
+ acceptedAgents: "direct_native_calls",
63
+ },
64
+ ...nativeCounts(observation),
65
+ ...execEvidence(observation.data.execCalls),
52
66
  },
53
67
  };
54
68
  }
69
+ function nativeCounts(
70
+ observation: Awaited<ReturnType<typeof codexNativeCallObservation>>,
71
+ ) {
72
+ const complete = observation.completeness === "complete";
73
+ return {
74
+ submittedExecCalls: complete ? observation.data.submittedExecCalls : null,
75
+ truncated: complete ? false : null,
76
+ };
77
+ }
78
+ function controlCompleteness(
79
+ source: NativeControlObservation["completeness"],
80
+ complete: boolean,
81
+ ): NativeControlObservation["completeness"] {
82
+ return source === "complete" && !complete ? "partial" : source;
83
+ }
84
+ function execEvidence(receipts: ExecCallReceipt[] | undefined) {
85
+ return receipts ? { execCalls: receipts } : {};
86
+ }
87
+
88
+ function inspectedExecCalls(
89
+ observation: Awaited<ReturnType<typeof codexNativeCallObservation>>,
90
+ ): boolean {
91
+ const receipts = observation.data.execCalls ?? [];
92
+ const submissions = observation.data.toolCalls.filter(
93
+ (call) => call.name === "exec",
94
+ );
95
+ if (
96
+ submissions.length !== observation.data.submittedExecCalls ||
97
+ receipts.length !== submissions.length
98
+ )
99
+ return false;
100
+ return receipts.every(
101
+ (receipt, index) =>
102
+ receipt.completeness === "complete" &&
103
+ receipt.ordinal === submissions[index]?.ordinal,
104
+ );
105
+ }
106
+
107
+ const MALFORMED = Symbol("malformed native control event");
108
+
109
+ function controlEvent(line: string): unknown {
110
+ try {
111
+ return JSON.parse(line) as unknown;
112
+ } catch {
113
+ return MALFORMED;
114
+ }
115
+ }
116
+
117
+ function assistantBlocks(event: unknown): unknown[] | null | typeof MALFORMED {
118
+ if (event === MALFORMED) return MALFORMED;
119
+ if (!assistantEvent(event)) return null;
120
+ const content = isRecord(event.message) ? event.message.content : null;
121
+ return isUnknownArray(content) ? content : MALFORMED;
122
+ }
123
+
124
+ function nativeBlock(value: unknown): Entry | null {
125
+ const types = ["text", "thinking", "redacted_thinking", "tool_use"];
126
+ return isRecord(value) && types.includes(String(value.type)) ? value : null;
127
+ }
128
+
129
+ function toolIdentity(
130
+ block: Entry,
131
+ ids: Set<string>,
132
+ ): { id: string; name: string } | null {
133
+ if (!label(block.id, 128) || !label(block.name, 256) || ids.has(block.id))
134
+ return null;
135
+ return { id: block.id, name: block.name };
136
+ }
137
+
138
+ class ClaudeNativeControls {
139
+ readonly calls: Call[] = [];
140
+ readonly ids = new Set<string>();
141
+ malformed = false;
142
+ truncated = false;
143
+
144
+ parse(stream: string): void {
145
+ for (const line of stream.split("\n")) {
146
+ if (!line.trim()) continue;
147
+ if (!this.observeLine(line)) break;
148
+ }
149
+ }
150
+
151
+ private observeLine(line: string): boolean {
152
+ const blocks = assistantBlocks(controlEvent(line));
153
+ if (blocks === null) return true;
154
+ if (blocks === MALFORMED) {
155
+ this.malformed = true;
156
+ return true;
157
+ }
158
+ for (const block of blocks) if (!this.observeBlock(block)) return false;
159
+ return true;
160
+ }
161
+
162
+ private observeBlock(value: unknown): boolean {
163
+ const block = nativeBlock(value);
164
+ if (!block) {
165
+ this.malformed = true;
166
+ return true;
167
+ }
168
+ if (block.type !== "tool_use") return true;
169
+ const identity = toolIdentity(block, this.ids);
170
+ if (!identity) {
171
+ this.malformed = true;
172
+ return true;
173
+ }
174
+ this.ids.add(identity.id);
175
+ if (this.calls.length >= MAX_CALLS) {
176
+ this.truncated = true;
177
+ return false;
178
+ }
179
+ this.calls.push({
180
+ ordinal: this.calls.length,
181
+ namespace: "claude",
182
+ name: identity.name,
183
+ });
184
+ return true;
185
+ }
186
+ }
55
187
 
56
188
  /** Observe all native labels without retaining arguments or interpreting policy. */
57
189
  export function claudeNativeControls(
@@ -59,66 +191,24 @@ export function claudeNativeControls(
59
191
  exitCode: number,
60
192
  ): NativeControlObservation {
61
193
  const summary = summarizeClaudeEvents(stream, exitCode);
62
- const calls: Call[] = [];
63
- const ids = new Set<string>();
64
- let malformed = false;
65
- let truncated = false;
66
- lines: for (const line of stream.split("\n")) {
67
- if (!line.trim()) continue;
68
- let event: unknown;
69
- try {
70
- event = JSON.parse(line);
71
- } catch {
72
- malformed = true;
73
- continue;
74
- }
75
- if (!record(event) || event.type !== "assistant") continue;
76
- const content = record(event.message) ? event.message.content : null;
77
- if (!Array.isArray(content)) {
78
- malformed = true;
79
- continue;
80
- }
81
- for (const block of content) {
82
- if (
83
- !record(block) ||
84
- !["text", "thinking", "redacted_thinking", "tool_use"].includes(
85
- String(block.type),
86
- )
87
- ) {
88
- malformed = true;
89
- continue;
90
- }
91
- if (block.type !== "tool_use") continue;
92
- if (
93
- !label(block.id, 128) ||
94
- !label(block.name, 256) ||
95
- ids.has(block.id)
96
- ) {
97
- malformed = true;
98
- continue;
99
- }
100
- ids.add(block.id);
101
- if (calls.length >= MAX_CALLS) {
102
- truncated = true;
103
- break lines;
104
- }
105
- calls.push({
106
- ordinal: calls.length,
107
- namespace: "claude",
108
- name: block.name,
109
- });
110
- }
111
- }
194
+ const controls = new ClaudeNativeControls();
195
+ controls.parse(stream);
112
196
  return {
113
197
  id: "sevro.host.native-controls",
114
198
  completeness:
115
- summary.complete && !malformed && !truncated ? "complete" : "partial",
199
+ summary.complete && !controls.malformed && !controls.truncated
200
+ ? "complete"
201
+ : "partial",
116
202
  data: {
117
203
  method: "native_control_calls",
118
- calls,
204
+ calls: controls.calls,
119
205
  acceptedAgentCount: null,
120
206
  submittedExecCalls: null,
121
- truncated,
207
+ truncated: controls.truncated,
122
208
  },
123
209
  };
124
210
  }
211
+
212
+ function assistantEvent(value: unknown): value is Entry {
213
+ return isRecord(value) && value.type === "assistant";
214
+ }
@@ -0,0 +1,37 @@
1
+ import { ProcessDeadline } from "../process-lifecycle";
2
+
3
+ export const MAX_EVENT_BYTES = 8 * 1024 * 1024;
4
+ export class HostProcessInterruption extends Error {
5
+ constructor(
6
+ host: string,
7
+ readonly reason: "timeout" | "cancelled",
8
+ ) {
9
+ super(`${host} run ${reason === "timeout" ? "timed out" : "cancelled"}`);
10
+ }
11
+ }
12
+
13
+ /** Race completion against the host deadline and cancellation, then release listeners. */
14
+ export async function awaitHostProcess<T>(
15
+ proc: Bun.Subprocess,
16
+ completed: Promise<T>,
17
+ options: {
18
+ host: "Codex" | "Claude";
19
+ timeoutMs: number;
20
+ signal?: AbortSignal;
21
+ stop: (proc: Bun.Subprocess) => void;
22
+ },
23
+ ): Promise<T> {
24
+ const deadline = new ProcessDeadline({
25
+ ...options,
26
+ interruption: (reason) => new HostProcessInterruption(options.host, reason),
27
+ });
28
+ try {
29
+ return await deadline.wait(completed);
30
+ } catch (error) {
31
+ options.stop(proc);
32
+ await proc.exited;
33
+ throw error;
34
+ } finally {
35
+ deadline.dispose();
36
+ }
37
+ }
@@ -1,26 +1,63 @@
1
- import { lstat, realpath, stat } from "node:fs/promises";
1
+ import { isMissingFile } from "../fixtures/tools";
2
+ import { lstat, readdir, realpath, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
2
4
  import { dirname, isAbsolute, join } from "node:path";
3
5
 
4
6
  async function containingRepository(root: string): Promise<string | null> {
5
7
  let directory = (await stat(root)).isDirectory() ? root : dirname(root);
6
- while (true) {
7
- try {
8
- await lstat(join(directory, ".git"));
9
- return directory;
10
- } catch (error) {
11
- if ((error as NodeJS.ErrnoException).code !== "ENOENT")
12
- throw new Error("protected repository metadata is unreadable");
13
- }
8
+ for (;;) {
9
+ if (await hasRepositoryMetadata(directory)) return directory;
14
10
  const parent = dirname(directory);
15
11
  if (parent === directory) return null;
16
12
  directory = parent;
17
13
  }
18
14
  }
19
15
 
16
+ async function hasRepositoryMetadata(directory: string): Promise<boolean> {
17
+ try {
18
+ const path = join(directory, ".git");
19
+ const metadata = await lstat(path);
20
+ return !(await emptyTemporaryGitPlaceholder(directory, path, metadata));
21
+ } catch (cause) {
22
+ if (isMissingFile(cause)) return false;
23
+ throw new Error("protected repository metadata is unreadable", { cause });
24
+ }
25
+ }
26
+
27
+ async function emptyTemporaryGitPlaceholder(
28
+ directory: string,
29
+ path: string,
30
+ metadata: Awaited<ReturnType<typeof lstat>>,
31
+ ): Promise<boolean> {
32
+ // Codex's Linux sandbox can leave an empty mount placeholder at /tmp/.git.
33
+ return (
34
+ process.platform === "linux" &&
35
+ (directory === "/tmp" || directory === tmpdir()) &&
36
+ metadata.isDirectory() &&
37
+ (await readdir(path)).length === 0
38
+ );
39
+ }
40
+
20
41
  /** Protect the primary checkout and linked worktrees without requiring Git. */
21
42
  export async function protectedWorktrees(root: string): Promise<string[]> {
22
43
  const repository = await containingRepository(root);
23
44
  if (!repository) return [];
45
+ const paths = listWorktreePaths(repository);
46
+ const existing: string[] = [];
47
+ for (const path of paths) {
48
+ try {
49
+ existing.push(await realpath(path));
50
+ } catch (error) {
51
+ if (!isMissingFile(error))
52
+ throw new Error("protected repository worktree is unreadable", {
53
+ cause: error,
54
+ });
55
+ }
56
+ }
57
+ return existing;
58
+ }
59
+
60
+ function listWorktreePaths(repository: string): string[] {
24
61
  const result = Bun.spawnSync(
25
62
  ["git", "-C", repository, "worktree", "list", "--porcelain", "-z"],
26
63
  {
@@ -41,16 +78,11 @@ export async function protectedWorktrees(root: string): Promise<string[]> {
41
78
  .split("\0")
42
79
  .filter((field) => field.startsWith("worktree "))
43
80
  .map((field) => field.slice(9));
81
+ validateWorktreePaths(paths);
82
+ return paths;
83
+ }
84
+
85
+ function validateWorktreePaths(paths: string[]): void {
44
86
  if (!paths.length || paths.some((path) => !isAbsolute(path)))
45
87
  throw new Error("protected repository worktree paths are invalid");
46
- const existing: string[] = [];
47
- for (const path of paths) {
48
- try {
49
- existing.push(await realpath(path));
50
- } catch (error) {
51
- if ((error as NodeJS.ErrnoException).code !== "ENOENT")
52
- throw new Error("protected repository worktree is unreadable");
53
- }
54
- }
55
- return existing;
56
88
  }
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import type { Dirent } from "node:fs";
5
+ import type { HookSkill } from "./skill-hook-handler";
6
+ import { isRecord } from "../value-guards";
7
+
8
+ const MAX_SKILLS = 128;
9
+ const MAX_SKILL_BYTES = 1024 * 1024;
10
+ const MAX_CATALOG_BYTES = 8 * 1024 * 1024;
11
+ interface Catalog {
12
+ skills: HookSkill[];
13
+ complete: boolean;
14
+ bytes: number;
15
+ }
16
+ export interface SkillCatalogRoot {
17
+ directory: string;
18
+ boundary: string;
19
+ namespace?: string;
20
+ }
21
+
22
+ export async function codexSkillCatalogRoots(
23
+ workspace: string,
24
+ pluginRoots: string[],
25
+ nativeSkillRoots: SkillCatalogRoot[],
26
+ ): Promise<SkillCatalogRoot[]> {
27
+ const roots: SkillCatalogRoot[] = [
28
+ { directory: join(workspace, ".agents/skills"), boundary: workspace },
29
+ ...nativeSkillRoots,
30
+ ];
31
+ for (const root of pluginRoots) {
32
+ const manifest: unknown = JSON.parse(
33
+ await readFile(join(root, ".claude-plugin/plugin.json"), "utf8"),
34
+ );
35
+ if (!isRecord(manifest) || typeof manifest.name !== "string")
36
+ throw new Error("invalid skill catalog plugin manifest");
37
+ roots.push({
38
+ directory: join(root, "skills"),
39
+ boundary: root,
40
+ namespace: manifest.name,
41
+ });
42
+ }
43
+ return roots;
44
+ }
45
+
46
+ /** Snapshot only known skill mounts; shell syntax does not enter discovery. */
47
+ export async function skillHookCatalog(roots: SkillCatalogRoot[]) {
48
+ const catalog: Catalog = { skills: [], complete: true, bytes: 0 };
49
+ for (const root of roots) await readRoot(root, catalog);
50
+ return { skills: catalog.skills, catalogComplete: catalog.complete };
51
+ }
52
+
53
+ async function readRoot(root: SkillCatalogRoot, catalog: Catalog) {
54
+ try {
55
+ await requireCatalogRoot(root);
56
+ const entries = await readdir(root.directory, { withFileTypes: true });
57
+ const directories = catalogDirectories(entries, catalog);
58
+ for (const entry of directories.sort((a, b) =>
59
+ a.name.localeCompare(b.name),
60
+ )) {
61
+ await readSkill(root, entry.name, catalog);
62
+ }
63
+ } catch (cause) {
64
+ if (!missing(cause)) catalog.complete = false;
65
+ }
66
+ }
67
+
68
+ function catalogDirectories(entries: Dirent[], catalog: Catalog): Dirent[] {
69
+ const directories: Dirent[] = [];
70
+ for (const entry of entries) {
71
+ if (entry.isSymbolicLink()) catalog.complete = false;
72
+ if (entry.isDirectory() && /^[A-Za-z0-9._-]+$/.test(entry.name))
73
+ directories.push(entry);
74
+ }
75
+ return directories;
76
+ }
77
+
78
+ async function requireCatalogRoot(root: SkillCatalogRoot) {
79
+ const expected = resolve(
80
+ await realpath(root.boundary),
81
+ relative(root.boundary, root.directory),
82
+ );
83
+ if ((await realpath(root.directory)) !== expected)
84
+ throw new Error("redirected skill catalog root");
85
+ }
86
+
87
+ function missing(cause: unknown): boolean {
88
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
89
+ }
90
+
91
+ async function readSkill(
92
+ root: SkillCatalogRoot,
93
+ name: string,
94
+ catalog: Catalog,
95
+ ) {
96
+ try {
97
+ const path = join(root.directory, name, "SKILL.md");
98
+ const file = await lstat(path);
99
+ if (!readableSkill(file)) {
100
+ catalog.complete = false;
101
+ return;
102
+ }
103
+ const bytes = await readFile(path);
104
+ if (bytes.length > MAX_SKILL_BYTES)
105
+ throw new Error("skill changed during catalog read");
106
+ addSkill(bytes, [root.namespace, name].filter(Boolean).join(":"), catalog);
107
+ } catch (cause) {
108
+ if (!missing(cause)) catalog.complete = false;
109
+ }
110
+ }
111
+
112
+ function readableSkill(file: Awaited<ReturnType<typeof lstat>>): boolean {
113
+ return file.isFile() && file.size <= MAX_SKILL_BYTES;
114
+ }
115
+
116
+ function addSkill(bytes: Buffer, invocation: string, catalog: Catalog) {
117
+ if (!bytes.length) return;
118
+ catalog.bytes += bytes.length;
119
+ if (
120
+ catalog.skills.length >= MAX_SKILLS ||
121
+ catalog.bytes > MAX_CATALOG_BYTES
122
+ ) {
123
+ catalog.complete = false;
124
+ return;
125
+ }
126
+ catalog.skills.push({
127
+ invocation,
128
+ sha256: createHash("sha256").update(bytes).digest("hex"),
129
+ body: bytes.toString("utf8"),
130
+ });
131
+ }