@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.
- package/CONTRIBUTING.md +170 -0
- package/LICENSE +63 -136
- package/README.md +83 -66
- package/docs/README.md +44 -0
- package/docs/acknowledgements.md +14 -0
- package/docs/architecture.md +146 -0
- package/docs/assets/README.md +27 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +1 -0
- package/docs/assets/sevro-wordmark.svg +1 -0
- package/docs/builtin-graders-v1.md +41 -13
- package/docs/codex-host.md +184 -52
- package/docs/contracts.md +19 -0
- package/docs/creating-cases.md +139 -0
- package/docs/development-cli.md +190 -30
- package/docs/documentation-quality.md +61 -0
- package/docs/evaluation-workflow.md +110 -0
- package/docs/evidence/code-mode-controls-71.md +64 -0
- package/docs/evidence/code-mode-programs-85.md +64 -0
- package/docs/evidence/codex-state-73/README.md +204 -0
- package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
- package/docs/evidence/codex-state-73/results.json +661 -0
- package/docs/evidence/local-http-74.md +104 -0
- package/docs/evidence/runtime-startup-30/README.md +185 -0
- package/docs/evidence/runtime-startup-30/after.json +319 -0
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
- package/docs/evidence/runtime-startup-30/before.json +403 -0
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
- package/docs/evidence/skill-reads-70/results.json +823 -0
- package/docs/evidence.md +21 -0
- package/docs/extension-protocol-v1.md +50 -16
- package/docs/fixture-cleanup-validation.md +3 -0
- package/docs/getting-started.md +102 -0
- package/docs/guide-evaluation.md +89 -0
- package/docs/guide-verification.json +239 -0
- package/docs/identity-v1.md +35 -1
- package/docs/installing.md +88 -0
- package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
- package/docs/licensing.md +42 -0
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +228 -0
- package/docs/reading-results.md +141 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +81 -40
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +180 -0
- package/docs/run-settings-v1.md +147 -0
- package/docs/running-evaluations.md +31 -0
- package/docs/runtime-v1.md +614 -0
- package/docs/specs/repository-guide.md +58 -0
- package/docs/troubleshooting.md +30 -0
- package/docs/typescript-quality.md +234 -0
- package/docs/usability-study.md +114 -0
- package/examples/basic/host.ts +2 -2
- package/examples/recipes/candidate.ts +26 -0
- package/examples/recipes/cases/coding-change.json +21 -0
- package/examples/recipes/cases/host-tools.json +21 -0
- package/examples/recipes/cases/mise.json +21 -0
- package/examples/recipes/cases/node.json +21 -0
- package/examples/recipes/cases/python-uv.json +21 -0
- package/examples/recipes/cases/semantic.json +17 -0
- package/examples/recipes/coding-change/check.ts +23 -0
- package/examples/recipes/extension-artifacts.ts +65 -0
- package/examples/recipes/extension-methods.ts +174 -0
- package/examples/recipes/extension.ts +37 -0
- package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/README.md +3 -0
- package/examples/recipes/run-inputs.ts +188 -0
- package/examples/recipes/run-options.ts +131 -0
- package/examples/recipes/run.ts +63 -0
- package/examples/recipes/runtime/empty.json +1 -0
- package/examples/recipes/runtime/goals.json +1 -0
- package/examples/recipes/runtime/mise.json +6 -0
- package/examples/recipes/runtime/path.json +1 -0
- package/examples/recipes/runtime/plugins.json +4 -0
- package/examples/recipes/runtime/python-uv.json +10 -0
- package/examples/recipes/semantic-judge.ts +29 -0
- package/examples/templates/coding/adapter.ts +17 -0
- package/examples/templates/coding/case.json +15 -0
- package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
- package/examples/templates/repository-skill/extension-data.ts +64 -0
- package/examples/templates/repository-skill/extension.ts +31 -0
- package/examples/templates/response/adapter.ts +12 -0
- package/examples/templates/response/case.json +13 -0
- package/package.json +39 -6
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +193 -0
- package/src/advisory.ts +10 -5
- package/src/candidate-artifacts.ts +208 -0
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +105 -0
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +81 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +59 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +222 -0
- package/src/cli/hosts.ts +59 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/cli/invocation.ts +627 -0
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +163 -0
- package/src/cli/presentation.ts +91 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +133 -0
- package/src/cli/run-options.ts +101 -0
- package/src/cli/run-overrides.ts +83 -0
- package/src/cli/settings.ts +160 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -843
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +19 -2373
- package/src/evaluation/advisory.ts +150 -0
- package/src/evaluation/check-plan.ts +510 -0
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/evaluation/grading-host.ts +39 -0
- package/src/evaluation/grading.ts +258 -0
- package/src/evaluation/invocation-plan.ts +548 -0
- package/src/evaluation/plan.ts +736 -0
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
- package/src/evaluation/semantic.ts +312 -0
- package/src/evaluation/trial.ts +866 -0
- package/src/evaluation/types.ts +207 -0
- package/src/evidence/host-results.ts +92 -0
- package/src/evidence/retained-artifacts.ts +18 -0
- package/src/evidence/trial-writer.ts +203 -0
- package/src/evidence/usage.ts +17 -0
- package/src/extension-client.ts +228 -148
- package/src/extension-session.ts +326 -195
- package/src/fixtures/advisory.ts +338 -0
- package/src/fixtures/bin.ts +57 -0
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +187 -0
- package/src/fixtures/generated.ts +300 -0
- package/src/fixtures/git-hooks.ts +24 -0
- package/src/fixtures/preparation.ts +270 -0
- package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
- package/src/fixtures/setup.ts +224 -0
- package/src/fixtures/tools.ts +88 -0
- package/src/fixtures/workspace-state.ts +212 -0
- package/src/generated/cli-result.cjs +2 -0
- package/src/generated/cli-result.d.cts +3 -0
- package/src/generated/extension.cjs +2 -0
- package/src/generated/extension.d.cts +3 -0
- package/src/generated/report.cjs +2 -0
- package/src/generated/report.d.cts +3 -0
- package/src/generated/run-evidence.cjs +2 -0
- package/src/generated/run-evidence.d.cts +3 -0
- package/src/generated/runtime.cjs +2 -0
- package/src/generated/runtime.d.cts +3 -0
- package/src/graders/artifact.ts +56 -23
- package/src/graders/git-head.ts +79 -75
- package/src/graders/output.ts +222 -140
- package/src/graders/semantic.ts +102 -43
- package/src/graders/shell.ts +379 -207
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +319 -0
- package/src/hosts/claude-continuation.ts +63 -18
- package/src/hosts/claude-credential.ts +43 -62
- package/src/hosts/claude-events.ts +86 -51
- package/src/hosts/claude-keychain.ts +59 -0
- package/src/hosts/claude-native-goal.ts +57 -33
- package/src/hosts/claude-process.ts +111 -0
- package/src/hosts/claude-repository-invocation.ts +208 -117
- package/src/hosts/claude-settings.ts +240 -21
- package/src/hosts/claude-skill-activations.ts +166 -0
- package/src/hosts/claude-state.ts +403 -0
- package/src/hosts/claude-tool-calls.ts +171 -106
- package/src/hosts/claude.ts +561 -451
- package/src/hosts/codex-app-server.ts +326 -178
- package/src/hosts/codex-configuration.ts +44 -18
- package/src/hosts/codex-events.ts +97 -60
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-invocation.ts +70 -0
- package/src/hosts/codex-marketplace.ts +283 -0
- package/src/hosts/codex-native-calls.ts +546 -500
- package/src/hosts/codex-process.ts +202 -0
- package/src/hosts/codex-profile.ts +257 -28
- package/src/hosts/codex-session-files.ts +106 -0
- package/src/hosts/codex-state.ts +492 -0
- package/src/hosts/codex.ts +783 -832
- package/src/hosts/isolation-common.ts +94 -0
- package/src/hosts/isolation-roots.ts +54 -27
- package/src/hosts/isolation.ts +10 -0
- package/src/hosts/linux-sandbox.ts +159 -0
- package/src/hosts/mac-sandbox.ts +187 -61
- package/src/hosts/native-controls.ts +152 -62
- package/src/hosts/process-lifecycle.ts +37 -0
- package/src/hosts/protected-worktrees.ts +51 -19
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +580 -0
- package/src/hosts/workspace-fingerprint.ts +55 -35
- package/src/identity.ts +102 -48
- package/src/instrumentation.ts +109 -48
- package/src/native-transcript-state.ts +131 -0
- package/src/native-transcript-view.ts +90 -0
- package/src/native-transcripts.ts +286 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +161 -81
- package/src/prune.ts +215 -0
- package/src/report.ts +172 -140
- package/src/resolved-case.ts +33 -0
- package/src/results-lock.ts +29 -0
- package/src/results.ts +133 -86
- package/src/run-owner.ts +154 -63
- package/src/runtime/config.ts +456 -0
- package/src/runtime/discovery-apple.ts +90 -0
- package/src/runtime/discovery-providers.ts +63 -0
- package/src/runtime/discovery.ts +70 -0
- package/src/runtime/hook-processes.ts +97 -0
- package/src/runtime/hooks.ts +476 -0
- package/src/runtime/paths.ts +313 -0
- package/src/runtime/query.ts +85 -0
- package/src/runtime/seeds.ts +134 -0
- package/src/runtime/shared-cache.ts +143 -0
- package/src/runtime/state.ts +143 -0
- package/src/schema-types.ts +198 -0
- package/src/schema.ts +15 -19
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +20 -0
- package/src/advisory-fixture.ts +0 -282
- package/src/fixture-bin.ts +0 -87
- package/src/fixture-setup.ts +0 -163
- package/src/generated-fixture.ts +0 -220
- package/src/git-hooks.ts +0 -63
- package/src/hosts/claude-nested-skills.ts +0 -218
- package/src/hosts/codex-command-output.ts +0 -399
- package/src/hosts/codex-executor-command.ts +0 -195
- package/src/hosts/codex-skill-reads.ts +0 -391
- 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
|
|
46
|
-
if (
|
|
47
|
-
|
|
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 (
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
class CanonicalJsonWriter {
|
|
61
|
+
private readonly visiting = new WeakSet();
|
|
62
|
+
|
|
63
|
+
write(item: unknown): string {
|
|
60
64
|
if (item === null) return "null";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
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
|
|
136
|
-
!Number.isFinite(
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
package/src/instrumentation.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
34
|
-
export function prepareInstrumentation(
|
|
35
|
-
requested: InstrumentationRequest[],
|
|
35
|
+
function validateSupportedInstrumentation(
|
|
36
36
|
supported: InstrumentationCapability[],
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
+
ids.size !== supported.length
|
|
47
45
|
)
|
|
48
46
|
throw new Error("host instrumentation capabilities are invalid");
|
|
49
|
-
|
|
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
|
-
|
|
55
|
+
ids.size !== requested.length
|
|
53
56
|
)
|
|
54
57
|
throw new Error("requested instrumentation is invalid or duplicated");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
141
|
+
"host applied instrumentation does not match the request",
|
|
95
142
|
);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
157
|
+
"host condition evidence is invalid",
|
|
104
158
|
);
|
|
105
|
-
|
|
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
|
+
}
|