@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
package/src/identity.ts CHANGED
@@ -40,90 +40,137 @@ export interface IdentityDimensions {
40
40
  passThreshold: number;
41
41
  }
42
42
 
43
+ function surrogateKind(code: number): "high" | "low" | null {
44
+ if (code >= 0xd800 && code <= 0xdbff) return "high";
45
+ return code >= 0xdc00 && code <= 0xdfff ? "low" : null;
46
+ }
47
+
43
48
  function assertUnicode(value: string): void {
44
49
  for (let index = 0; index < value.length; index++) {
45
- const code = value.charCodeAt(index);
46
- if (code >= 0xd800 && code <= 0xdbff) {
47
- const next = value.charCodeAt(++index);
48
- if (!(next >= 0xdc00 && next <= 0xdfff))
50
+ const kind = surrogateKind(value.charCodeAt(index));
51
+ if (kind === "high") {
52
+ if (surrogateKind(value.charCodeAt(++index)) !== "low")
49
53
  throw new Error("JSON string contains an unpaired surrogate");
50
- } else if (code >= 0xdc00 && code <= 0xdfff) {
54
+ } else if (kind === "low") {
51
55
  throw new Error("JSON string contains an unpaired surrogate");
52
56
  }
53
57
  }
54
58
  }
55
59
 
56
- /** RFC 8785 JSON canonicalization for ordinary JSON values. */
57
- export function canonicalJson(value: unknown): string {
58
- const visiting = new WeakSet<object>();
59
- function write(item: unknown): string {
60
+ class CanonicalJsonWriter {
61
+ private readonly visiting = new WeakSet();
62
+
63
+ write(item: unknown): string {
60
64
  if (item === null) return "null";
61
- if (typeof item === "string") {
62
- assertUnicode(item);
63
- return JSON.stringify(item);
65
+ switch (typeof item) {
66
+ case "string":
67
+ assertUnicode(item);
68
+ return JSON.stringify(item);
69
+ case "number":
70
+ return this.number(item);
71
+ case "boolean":
72
+ return item ? "true" : "false";
73
+ case "object":
74
+ return this.object(item);
75
+ default:
76
+ throw new Error("value is not JSON data");
64
77
  }
65
- if (typeof item === "number") {
66
- if (!Number.isFinite(item)) throw new Error("JSON number must be finite");
67
- return JSON.stringify(item);
68
- }
69
- if (typeof item === "boolean") return item ? "true" : "false";
70
- if (typeof item !== "object") throw new Error("value is not JSON data");
71
- if (visiting.has(item)) throw new Error("JSON value contains a cycle");
72
- visiting.add(item);
78
+ }
79
+
80
+ private number(item: number): string {
81
+ if (!Number.isFinite(item)) throw new Error("JSON number must be finite");
82
+ return JSON.stringify(item);
83
+ }
84
+
85
+ private object(item: object): string {
86
+ if (this.visiting.has(item)) throw new Error("JSON value contains a cycle");
87
+ this.visiting.add(item);
73
88
  try {
74
- if (Array.isArray(item)) return `[${item.map(write).join(",")}]`;
75
- const prototype = Object.getPrototypeOf(item);
76
- if (prototype !== Object.prototype && prototype !== null)
77
- throw new Error("value is not a plain JSON object");
78
- if (Object.getOwnPropertySymbols(item).length)
79
- throw new Error("JSON object has symbol keys");
80
- const record = item as Record<string, unknown>;
81
- const entries = Object.keys(record)
89
+ if (Array.isArray(item))
90
+ return `[${item.map((value: unknown) => this.write(value)).join(",")}]`;
91
+ this.assertPlainObject(item);
92
+ const entries = Object.keys(item)
82
93
  .sort()
83
- .map((key) => {
84
- assertUnicode(key);
85
- const descriptor = Object.getOwnPropertyDescriptor(record, key);
86
- if (!descriptor || !Object.hasOwn(descriptor, "value"))
87
- throw new Error("JSON object has an accessor property");
88
- return `${JSON.stringify(key)}:${write(descriptor.value)}`;
89
- });
94
+ .map((key) => this.property(item, key));
90
95
  return `{${entries.join(",")}}`;
91
96
  } finally {
92
- visiting.delete(item);
97
+ this.visiting.delete(item);
93
98
  }
94
99
  }
95
- return write(value);
100
+
101
+ private assertPlainObject(item: object): void {
102
+ const prototype: unknown = Object.getPrototypeOf(item);
103
+ if (prototype !== Object.prototype && prototype !== null)
104
+ throw new Error("value is not a plain JSON object");
105
+ if (Object.getOwnPropertySymbols(item).length)
106
+ throw new Error("JSON object has symbol keys");
107
+ }
108
+
109
+ private property(item: object, key: string): string {
110
+ assertUnicode(key);
111
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
112
+ if (!descriptor || !Object.hasOwn(descriptor, "value"))
113
+ throw new Error("JSON object has an accessor property");
114
+ const value: unknown = descriptor.value;
115
+ return `${JSON.stringify(key)}:${this.write(value)}`;
116
+ }
117
+ }
118
+
119
+ /** RFC 8785 JSON canonicalization for ordinary JSON values. */
120
+ export function canonicalJson(value: unknown): string {
121
+ return new CanonicalJsonWriter().write(value);
122
+ }
123
+
124
+ /** Hash exact bytes, or a string's UTF-8 bytes, without JSON canonicalization. */
125
+ export function sha256(content: string | Uint8Array): string {
126
+ return createHash("sha256").update(content).digest("hex");
96
127
  }
97
128
 
98
129
  export function hashJson(value: unknown): string {
99
- return createHash("sha256")
100
- .update(canonicalJson(value), "utf8")
101
- .digest("hex");
130
+ return sha256(canonicalJson(value));
102
131
  }
103
132
 
104
133
  function validatedDimensions(value: unknown): IdentityDimensions {
105
134
  if (!value || typeof value !== "object" || Array.isArray(value))
106
135
  throw new Error("identity dimensions must be a JSON object");
107
136
  const record = value as Record<string, unknown>;
137
+ validateDimensionKeys(record);
138
+ validateDigestDimensions(record);
139
+ validateExtensionDimension(record);
140
+ validateTrialDimensions(record);
141
+ return record as unknown as IdentityDimensions;
142
+ }
143
+
144
+ function validateDimensionKeys(record: Record<string, unknown>): void {
108
145
  for (const key of DIMENSION_KEYS)
109
146
  if (!Object.hasOwn(record, key))
110
147
  throw new Error(`missing identity dimension: ${key}`);
111
148
  const unexpected = Object.keys(record).filter(
112
149
  (key) => !DIMENSION_KEYS.includes(key as (typeof DIMENSION_KEYS)[number]),
113
150
  );
114
- if (unexpected.length)
115
- throw new Error(`unexpected identity dimension: ${unexpected[0]}`);
151
+ const firstUnexpected = unexpected[0];
152
+ if (firstUnexpected !== undefined)
153
+ throw new Error(`unexpected identity dimension: ${firstUnexpected}`);
154
+ }
155
+
156
+ function validateDigestDimensions(record: Record<string, unknown>): void {
116
157
  for (const key of DIMENSION_KEYS.filter((name) => name.endsWith("Digest"))) {
117
158
  if (key === "extensionDigest" && record[key] === null) continue;
118
- if (typeof record[key] !== "string" || !DIGEST.test(record[key]))
159
+ if (!validDigest(record[key]))
119
160
  throw new Error(`invalid identity dimension: ${key}`);
120
161
  }
162
+ }
163
+
164
+ function validateExtensionDimension(record: Record<string, unknown>): void {
121
165
  if (
122
166
  (record.extensionDigest === null) !== (record.extensionProtocol === null) ||
123
167
  (record.extensionProtocol !== null &&
124
168
  record.extensionProtocol !== "sevro.extension.v1")
125
169
  )
126
170
  throw new Error("extension identity requires its matching protocol");
171
+ }
172
+
173
+ function validateTrialDimensions(record: Record<string, unknown>): void {
127
174
  if (record.condition !== "passive" && record.condition !== "enforced")
128
175
  throw new Error("invalid identity condition");
129
176
  if (
@@ -131,14 +178,17 @@ function validatedDimensions(value: unknown): IdentityDimensions {
131
178
  (record.trialCount as number) < 1
132
179
  )
133
180
  throw new Error("invalid identity trial count");
181
+ validatePassThreshold(record.passThreshold);
182
+ }
183
+
184
+ function validatePassThreshold(value: unknown): void {
134
185
  if (
135
- typeof record.passThreshold !== "number" ||
136
- !Number.isFinite(record.passThreshold) ||
137
- record.passThreshold <= 0 ||
138
- record.passThreshold > 1
186
+ typeof value !== "number" ||
187
+ !Number.isFinite(value) ||
188
+ value <= 0 ||
189
+ value > 1
139
190
  )
140
191
  throw new Error("invalid identity pass threshold");
141
- return record as unknown as IdentityDimensions;
142
192
  }
143
193
 
144
194
  /** Digest the exact comparison dimensions with a versioned domain prefix. */
@@ -154,3 +204,7 @@ export function createEvaluationIdentity(dimensions: unknown): {
154
204
  .digest("hex");
155
205
  return { algorithm: ALGORITHM, digest, dimensions: checked };
156
206
  }
207
+
208
+ function validDigest(value: unknown): value is string {
209
+ return typeof value === "string" && DIGEST.test(value);
210
+ }
@@ -1,3 +1,4 @@
1
+ import { isRecord, isUnknownArray } from "./value-guards";
1
2
  import { canonicalJson } from "./identity";
2
3
 
3
4
  export interface InstrumentationRequest {
@@ -19,52 +20,88 @@ export class InstrumentationEvidenceError extends Error {
19
20
 
20
21
  const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
21
22
 
22
- function validRequest(value: InstrumentationRequest): boolean {
23
+ function validInstrumentationId(value: unknown): value is string {
24
+ return typeof value === "string" && ID.test(value);
25
+ }
26
+
27
+ function validRequest(value: unknown): value is InstrumentationRequest {
23
28
  return (
24
- Boolean(value && ID.test(value.id)) &&
25
- Boolean(
26
- value.configuration &&
27
- typeof value.configuration === "object" &&
28
- !Array.isArray(value.configuration),
29
- )
29
+ isRecord(value) &&
30
+ validInstrumentationId(value.id) &&
31
+ isRecord(value.configuration)
30
32
  );
31
33
  }
32
34
 
33
- /** Validate an extension's request before the candidate host starts. */
34
- export function prepareInstrumentation(
35
- requested: InstrumentationRequest[],
35
+ function validateSupportedInstrumentation(
36
36
  supported: InstrumentationCapability[],
37
- negotiated: string[],
38
- condition: "passive" | "enforced",
39
- ): InstrumentationRequest[] {
40
- const supportedIds = new Set(supported.map((item) => item.id));
37
+ ): void {
38
+ const ids = new Set(supported.map((item) => item.id));
41
39
  if (
42
40
  supported.some(
43
41
  (item) =>
44
42
  !ID.test(item.id) || typeof item.executionChanging !== "boolean",
45
43
  ) ||
46
- supportedIds.size !== supported.length
44
+ ids.size !== supported.length
47
45
  )
48
46
  throw new Error("host instrumentation capabilities are invalid");
49
- const requestedIds = new Set(requested.map((item) => item.id));
47
+ }
48
+
49
+ function validateRequestedInstrumentation(
50
+ requested: InstrumentationRequest[],
51
+ ): void {
52
+ const ids = new Set(requested.map((item) => item.id));
50
53
  if (
51
54
  requested.some((item) => !validRequest(item)) ||
52
- requestedIds.size !== requested.length
55
+ ids.size !== requested.length
53
56
  )
54
57
  throw new Error("requested instrumentation is invalid or duplicated");
55
- for (const item of requested) {
56
- const capability = supported.find((entry) => entry.id === item.id);
57
- if (!capability || !negotiated.includes(item.id))
58
- throw new Error(
59
- "extension preparation requested unsupported instrumentation",
60
- );
61
- if (condition === "passive" && capability.executionChanging)
62
- throw new Error(
63
- "passive condition cannot apply execution-changing instrumentation",
64
- );
65
- }
66
- canonicalJson(requested);
67
- return requested;
58
+ }
59
+
60
+ function requestedCapability(
61
+ item: InstrumentationRequest,
62
+ supported: InstrumentationCapability[],
63
+ negotiated: string[],
64
+ ): InstrumentationCapability {
65
+ const capability = supported.find((entry) => entry.id === item.id);
66
+ if (!capability || !negotiated.includes(item.id))
67
+ throw new Error(
68
+ "extension preparation requested unsupported instrumentation",
69
+ );
70
+ return capability;
71
+ }
72
+
73
+ function validateInstrumentationCondition(
74
+ capability: InstrumentationCapability,
75
+ condition: "passive" | "enforced",
76
+ ): void {
77
+ if (condition === "passive" && capability.executionChanging)
78
+ throw new Error(
79
+ "passive condition cannot apply execution-changing instrumentation",
80
+ );
81
+ }
82
+
83
+ /** Validate an extension's request before the candidate host starts. */
84
+ export function prepareInstrumentation(
85
+ requested: InstrumentationRequest[],
86
+ supported: InstrumentationCapability[],
87
+ negotiated: string[],
88
+ condition: "passive" | "enforced",
89
+ ): InstrumentationRequest[] {
90
+ validateSupportedInstrumentation(supported);
91
+ validateRequestedInstrumentation(requested);
92
+ for (const item of requested)
93
+ validateInstrumentationCondition(
94
+ requestedCapability(item, supported, negotiated),
95
+ condition,
96
+ );
97
+ return snapshotInstrumentation(requested);
98
+ }
99
+
100
+ /** Own the canonical JSON parameters independently of adapter state. */
101
+ export function snapshotInstrumentation(
102
+ requested: InstrumentationRequest[],
103
+ ): InstrumentationRequest[] {
104
+ return JSON.parse(canonicalJson(requested)) as InstrumentationRequest[];
68
105
  }
69
106
 
70
107
  /** A host must report exactly the instrumentation it applied. */
@@ -75,32 +112,56 @@ export function verifyAppliedInstrumentation(
75
112
  actualCondition: "passive" | "enforced" | "unknown" | undefined,
76
113
  ): InstrumentationRequest[] {
77
114
  const actual = applied ?? [];
115
+ validateAppliedRequests(actual, requested);
116
+ validateActualCondition(actualCondition);
78
117
  if (
79
- !Array.isArray(actual) ||
80
- actual.some((item) => !validRequest(item)) ||
81
- new Set(actual.map((item) => item.id)).size !== actual.length ||
82
- canonicalJson(actual) !== canonicalJson(requested)
118
+ mismatchedCondition(actualCondition, requestedCondition) ||
119
+ (requested.length > 0 && actualCondition !== requestedCondition)
83
120
  )
84
121
  throw new InstrumentationEvidenceError(
85
- "host applied instrumentation does not match the request",
122
+ "host did not confirm the requested condition",
86
123
  );
124
+ return snapshotInstrumentation(actual);
125
+ }
126
+
127
+ function requestArray(value: unknown): value is InstrumentationRequest[] {
128
+ return isUnknownArray(value) && value.every(validRequest);
129
+ }
130
+
131
+ function validateAppliedRequests(
132
+ actual: unknown,
133
+ requested: InstrumentationRequest[],
134
+ ): asserts actual is InstrumentationRequest[] {
87
135
  if (
88
- actualCondition !== undefined &&
89
- actualCondition !== "unknown" &&
90
- actualCondition !== "passive" &&
91
- actualCondition !== "enforced"
136
+ !requestArray(actual) ||
137
+ new Set(actual.map((item) => item.id)).size !== actual.length ||
138
+ canonicalJson(actual) !== canonicalJson(requested)
92
139
  )
93
140
  throw new InstrumentationEvidenceError(
94
- "host condition evidence is invalid",
141
+ "host applied instrumentation does not match the request",
95
142
  );
96
- if (
97
- (actualCondition !== undefined &&
98
- actualCondition !== "unknown" &&
99
- actualCondition !== requestedCondition) ||
100
- (requested.length > 0 && actualCondition !== requestedCondition)
101
- )
143
+ }
144
+
145
+ function validActualCondition(value: unknown): boolean {
146
+ return (
147
+ value === undefined ||
148
+ value === "unknown" ||
149
+ value === "passive" ||
150
+ value === "enforced"
151
+ );
152
+ }
153
+
154
+ function validateActualCondition(value: unknown): void {
155
+ if (!validActualCondition(value))
102
156
  throw new InstrumentationEvidenceError(
103
- "host did not confirm the requested condition",
157
+ "host condition evidence is invalid",
104
158
  );
105
- return actual;
159
+ }
160
+
161
+ function mismatchedCondition(
162
+ actual: string | undefined,
163
+ requested: string,
164
+ ): boolean {
165
+ if (actual === undefined || actual === "unknown") return false;
166
+ return actual !== requested;
106
167
  }
@@ -0,0 +1,131 @@
1
+ import { lstat, mkdir, mkdtemp, realpath } from "node:fs/promises";
2
+ import type { Stats } from "node:fs";
3
+ import { realpathSync } from "node:fs";
4
+ import { tmpdir, userInfo } from "node:os";
5
+ import { basename, dirname, join, sep } from "node:path";
6
+
7
+ /** A permanent deny boundary also covers roles allocated after command launch. */
8
+ export async function nativeStateParent(): Promise<string> {
9
+ const root = nativeStatePath();
10
+ await mkdir(root, { recursive: true, mode: 0o700 });
11
+ const info = await lstat(root);
12
+ requirePrivateNamespace(info);
13
+ return realpath(root);
14
+ }
15
+
16
+ /** Canonical base also binds declaration symlinks before any private tree is read. */
17
+ export function nativeStatePath(): string {
18
+ return join(realpathSync(nativeStateBase()), "sevro-native-private");
19
+ }
20
+
21
+ export function nativeStateProtectedRoots(): string[] {
22
+ return [nativeStatePath(), join(nativeStateBase(), "sevro-native-private")];
23
+ }
24
+
25
+ function nativeStateBase(): string {
26
+ return process.platform === "linux" ? "/var/tmp" : tmpdir();
27
+ }
28
+
29
+ function requirePrivateNamespace(info: Stats): void {
30
+ if (
31
+ !info.isDirectory() ||
32
+ info.uid !== userInfo().uid ||
33
+ (info.mode & 0o077) !== 0
34
+ )
35
+ throw new Error(
36
+ "native state namespace must be an owned private directory",
37
+ );
38
+ }
39
+
40
+ export async function allocateNativeState(
41
+ prefix: string,
42
+ runtimeWorkspace?: string,
43
+ ): Promise<string> {
44
+ await requireExternalNativeNamespace(runtimeWorkspace);
45
+ return realpath(await mkdtemp(join(await nativeStateParent(), prefix)));
46
+ }
47
+
48
+ /** Workspace writes must never reach existing or future native peer state. */
49
+ async function requireExternalNativeNamespace(
50
+ workspace?: string,
51
+ ): Promise<void> {
52
+ if (workspace === undefined) return;
53
+ const fixtureRoot = join(await realpath(workspace), sep);
54
+ const namespaceRoot = join(nativeStatePath(), sep);
55
+ if (
56
+ fixtureRoot.startsWith(namespaceRoot) ||
57
+ namespaceRoot.startsWith(fixtureRoot)
58
+ )
59
+ throw new Error("native runtime namespace overlaps the fixture workspace");
60
+ }
61
+
62
+ export async function requireNativeTranscriptView(
63
+ root: string | undefined,
64
+ ): Promise<void> {
65
+ if (root === undefined) return;
66
+ const parent = await nativeStateParent();
67
+ if (
68
+ dirname(root) !== parent ||
69
+ !basename(root).startsWith("view-") ||
70
+ (await realpath(root)) !== root
71
+ )
72
+ throw new Error(
73
+ "native transcript view must be runner-owned and canonical",
74
+ );
75
+ requirePrivateNamespace(await lstat(root));
76
+ }
77
+
78
+ export async function requireNativeTranscriptViews(
79
+ roots: string[] | undefined,
80
+ ): Promise<void> {
81
+ for (const root of roots ?? []) await requireNativeTranscriptView(root);
82
+ }
83
+
84
+ /** Curated hook copies are a separate runner-owned exception, never policy roots. */
85
+ export async function requireNativeHookRoots(
86
+ roots: string[] | undefined,
87
+ privateRoot: string,
88
+ ): Promise<void> {
89
+ for (const root of roots ?? [])
90
+ await requireNativeHookRoot(root, privateRoot);
91
+ }
92
+
93
+ async function requireNativeHookRoot(
94
+ root: string,
95
+ privateRoot: string,
96
+ ): Promise<void> {
97
+ const parent = await nativeStateParent();
98
+ const state = dirname(dirname(root));
99
+ if (!nativeHookLayout(root, state, parent) || (await realpath(root)) !== root)
100
+ throw new Error("native hook read root must be a canonical curated mount");
101
+ await requireNativeHookOwner(state, privateRoot);
102
+ const info = await lstat(root);
103
+ if (!info.isDirectory() || info.uid !== userInfo().uid)
104
+ throw new Error("native hook read root must be owned");
105
+ }
106
+
107
+ async function requireNativeHookOwner(
108
+ state: string,
109
+ privateRoot: string,
110
+ ): Promise<void> {
111
+ if (
112
+ dirname(privateRoot) !== state ||
113
+ (await realpath(privateRoot)) !== privateRoot
114
+ )
115
+ throw new Error("native hook read root must belong to the current request");
116
+ requirePrivateNamespace(await lstat(state));
117
+ requirePrivateNamespace(await lstat(privateRoot));
118
+ }
119
+
120
+ function nativeHookLayout(
121
+ root: string,
122
+ state: string,
123
+ parent: string,
124
+ ): boolean {
125
+ return (
126
+ dirname(state) === parent &&
127
+ /^(?:claude|codex)-/.test(basename(state)) &&
128
+ basename(dirname(root)) === "runtime-plugins" &&
129
+ /^plugin-[0-9]+$/.test(basename(root))
130
+ );
131
+ }
@@ -0,0 +1,90 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { gunzipSync } from "node:zlib";
4
+ import { allocateNativeState } from "./native-transcript-state";
5
+ import { fixtureParts } from "./fixtures/preparation";
6
+ import { isRecord, isUnknownArray } from "./value-guards";
7
+ import { sha256 } from "./identity";
8
+
9
+ const MAX_NATIVE_BYTES = 5 * 1024 * 1024;
10
+ type NativeFormat =
11
+ "sevro.native-transcripts.v1" | "sevro.native-transcripts.v2";
12
+
13
+ export async function materializeNativeTranscriptView(
14
+ bytes: Uint8Array,
15
+ ): Promise<string> {
16
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
17
+ const bundle = nativeBundle(value);
18
+ const root = await allocateNativeState("view-");
19
+ await writeFile(join(root, "index.json"), bytes, { flag: "wx", mode: 0o600 });
20
+ const filesRoot = join(root, bundle.host);
21
+ let remainingBytes = MAX_NATIVE_BYTES;
22
+ for (const item of bundle.files)
23
+ remainingBytes -= await materializeNativeFile(
24
+ filesRoot,
25
+ item,
26
+ bundle.format,
27
+ remainingBytes,
28
+ );
29
+ return root;
30
+ }
31
+
32
+ function nativeBundle(value: unknown) {
33
+ if (!isRecord(value) || !nativeFormat(value.format))
34
+ throw new Error("invalid native transcript bundle");
35
+ if (!nativeHost(value.host))
36
+ throw new Error("invalid native transcript host");
37
+ if (!isUnknownArray(value.files))
38
+ throw new Error("invalid native transcript files");
39
+ return { host: value.host, files: value.files, format: value.format };
40
+ }
41
+
42
+ function nativeFormat(value: unknown): value is NativeFormat {
43
+ return (
44
+ value === "sevro.native-transcripts.v1" ||
45
+ value === "sevro.native-transcripts.v2"
46
+ );
47
+ }
48
+
49
+ function nativeHost(value: unknown): value is "codex" | "claude" {
50
+ return value === "codex" || value === "claude";
51
+ }
52
+
53
+ async function materializeNativeFile(
54
+ root: string,
55
+ value: unknown,
56
+ format: NativeFormat,
57
+ remainingBytes: number,
58
+ ): Promise<number> {
59
+ if (!isRecord(value) || typeof value.path !== "string")
60
+ throw new Error("invalid native transcript file");
61
+ const path = join(root, ...fixtureParts(value.path));
62
+ const bytes = nativeFileBytes(value, format, remainingBytes);
63
+ if (bytes.length > remainingBytes)
64
+ throw new Error("native transcript byte limit exceeded");
65
+ if (sha256(bytes) !== value.sha256)
66
+ throw new Error("native transcript hash mismatch");
67
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
68
+ await writeFile(path, bytes, { flag: "wx", mode: 0o600 });
69
+ return bytes.length;
70
+ }
71
+
72
+ function nativeFileBytes(
73
+ value: Record<string, unknown>,
74
+ format: NativeFormat,
75
+ remainingBytes: number,
76
+ ): Buffer {
77
+ const encoded =
78
+ format === "sevro.native-transcripts.v1"
79
+ ? value.bytesBase64
80
+ : value.gzipBase64;
81
+ if (typeof encoded !== "string")
82
+ throw new Error("invalid native transcript file");
83
+ const bytes = Buffer.from(encoded, "base64");
84
+ if (format === "sevro.native-transcripts.v1") return bytes;
85
+ try {
86
+ return gunzipSync(bytes, { maxOutputLength: Math.max(1, remainingBytes) });
87
+ } catch (cause) {
88
+ throw new Error("invalid native transcript gzip", { cause });
89
+ }
90
+ }