@bjoernrochel/sevro 0.1.0-rc.1
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/LICENSE +159 -0
- package/README.md +71 -0
- package/docs/advisory-fixture.md +23 -0
- package/docs/builtin-graders-v1.md +77 -0
- package/docs/cache-ownership-validation.md +49 -0
- package/docs/claude-continuation-validation.md +47 -0
- package/docs/codex-host.md +134 -0
- package/docs/concurrency-validation.md +86 -0
- package/docs/configuration-root-validation.md +70 -0
- package/docs/development-cli.md +327 -0
- package/docs/engine-slice.md +114 -0
- package/docs/extension-protocol-v1.md +292 -0
- package/docs/fixture-cleanup-validation.md +70 -0
- package/docs/identity-v1.md +65 -0
- package/docs/native-controls.md +29 -0
- package/docs/releases.md +88 -0
- package/docs/report-v1.md +32 -0
- package/docs/results-v1.md +118 -0
- package/examples/basic/graded.json +13 -0
- package/examples/basic/host.ts +8 -0
- package/examples/basic/prompt-only.json +7 -0
- package/package.json +45 -0
- package/schemas/cli-result-v1.schema.json +184 -0
- package/schemas/extension-v1.schema.json +659 -0
- package/schemas/report-v1.schema.json +164 -0
- package/schemas/run-evidence-v1.schema.json +510 -0
- package/src/advisory-fixture.ts +282 -0
- package/src/advisory.ts +105 -0
- package/src/cli.ts +837 -0
- package/src/engine.ts +2341 -0
- package/src/extension-client.ts +257 -0
- package/src/extension-session.ts +335 -0
- package/src/fixture-bin.ts +87 -0
- package/src/fixture-cleanup.ts +38 -0
- package/src/fixture-setup.ts +163 -0
- package/src/generated-fixture.ts +220 -0
- package/src/git-hooks.ts +63 -0
- package/src/graders/git-head.ts +176 -0
- package/src/graders/output.ts +359 -0
- package/src/graders/semantic.ts +120 -0
- package/src/graders/shell.ts +277 -0
- package/src/hosts/claude-continuation.ts +94 -0
- package/src/hosts/claude-credential.ts +73 -0
- package/src/hosts/claude-events.ts +128 -0
- package/src/hosts/claude-nested-skills.ts +218 -0
- package/src/hosts/claude-repository-invocation.ts +239 -0
- package/src/hosts/claude-settings.ts +50 -0
- package/src/hosts/claude-tool-calls.ts +159 -0
- package/src/hosts/claude.ts +511 -0
- package/src/hosts/codex-configuration.ts +85 -0
- package/src/hosts/codex-events.ts +102 -0
- package/src/hosts/codex-native-calls.ts +702 -0
- package/src/hosts/codex-profile.ts +78 -0
- package/src/hosts/codex-skill-reads.ts +345 -0
- package/src/hosts/codex.ts +769 -0
- package/src/hosts/isolation-roots.ts +60 -0
- package/src/hosts/mac-sandbox.ts +108 -0
- package/src/hosts/native-controls.ts +124 -0
- package/src/hosts/protected-worktrees.ts +56 -0
- package/src/hosts/workspace-fingerprint.ts +55 -0
- package/src/identity.ts +156 -0
- package/src/instrumentation.ts +106 -0
- package/src/preparation.ts +211 -0
- package/src/provenance.ts +244 -0
- package/src/report.ts +351 -0
- package/src/repository-fixture.ts +286 -0
- package/src/results.ts +160 -0
- package/src/run-owner.ts +276 -0
- package/src/schema.ts +28 -0
- package/src/storage.ts +23 -0
- package/src/trial-scheduler.ts +27 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { protectedWorktrees } from "./protected-worktrees";
|
|
5
|
+
|
|
6
|
+
/** Include source, results, host configuration, and every active peer fixture. */
|
|
7
|
+
export async function evaluationProtectedRoots(options: {
|
|
8
|
+
workspace: string;
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
resultsRoot: string;
|
|
11
|
+
additionalRoots: string[];
|
|
12
|
+
}): Promise<string[]> {
|
|
13
|
+
const workspace = await realpath(options.workspace);
|
|
14
|
+
const optionalConfigRoots = [
|
|
15
|
+
process.env.CODEX_HOME,
|
|
16
|
+
process.env.CLAUDE_CONFIG_DIR,
|
|
17
|
+
].filter((value): value is string => Boolean(value));
|
|
18
|
+
const candidates = [
|
|
19
|
+
resolve(import.meta.dir, "../.."),
|
|
20
|
+
options.projectRoot,
|
|
21
|
+
options.resultsRoot,
|
|
22
|
+
homedir(),
|
|
23
|
+
userInfo().homedir,
|
|
24
|
+
...options.additionalRoots,
|
|
25
|
+
...optionalConfigRoots,
|
|
26
|
+
];
|
|
27
|
+
if (candidates.some((root) => !isAbsolute(root)))
|
|
28
|
+
throw new Error("evaluation protected roots must be absolute");
|
|
29
|
+
const peers = (await readdir(tmpdir(), { withFileTypes: true }))
|
|
30
|
+
.filter(
|
|
31
|
+
(entry) => entry.isDirectory() && entry.name.startsWith("sevro-case-"),
|
|
32
|
+
)
|
|
33
|
+
.map((entry) => join(tmpdir(), entry.name));
|
|
34
|
+
const roots: string[] = [];
|
|
35
|
+
for (const candidate of [...candidates, ...peers]) {
|
|
36
|
+
let canonical: string;
|
|
37
|
+
try {
|
|
38
|
+
canonical = await realpath(candidate);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (
|
|
41
|
+
(peers.includes(candidate) ||
|
|
42
|
+
optionalConfigRoots.includes(candidate)) &&
|
|
43
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
44
|
+
)
|
|
45
|
+
continue;
|
|
46
|
+
throw new Error("evaluation protected root is unreadable");
|
|
47
|
+
}
|
|
48
|
+
if (canonical !== workspace) {
|
|
49
|
+
roots.push(canonical);
|
|
50
|
+
// Peers are denied directly; their Git preparation may still be in progress.
|
|
51
|
+
if (!peers.includes(candidate))
|
|
52
|
+
roots.push(
|
|
53
|
+
...(await protectedWorktrees(canonical)).filter(
|
|
54
|
+
(root) => root !== workspace,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...new Set(roots)];
|
|
60
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
7
|
+
|
|
8
|
+
export class HostIsolationError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "HostIsolationError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function inside(root: string, path: string): boolean {
|
|
16
|
+
const child = relative(root, path);
|
|
17
|
+
return (
|
|
18
|
+
child === "" ||
|
|
19
|
+
(child !== ".." && !child.startsWith(`..${sep}`) && !child.startsWith(sep))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function quoted(path: string): string {
|
|
24
|
+
if (/[\x00-\x1f\x7f]/.test(path))
|
|
25
|
+
throw new HostIsolationError("sandbox path contains a control character");
|
|
26
|
+
return path.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deny both observation and mutation of every protected canonical root. */
|
|
30
|
+
export function macSandboxProfile(
|
|
31
|
+
deniedRoots: string[],
|
|
32
|
+
denyNetwork = false,
|
|
33
|
+
): string {
|
|
34
|
+
if (!deniedRoots.length || deniedRoots.some((path) => !path.startsWith("/")))
|
|
35
|
+
throw new HostIsolationError("sandbox roots must be absolute and nonempty");
|
|
36
|
+
return [
|
|
37
|
+
"(version 1)",
|
|
38
|
+
"(allow default)",
|
|
39
|
+
...(denyNetwork ? ["(deny network*)"] : []),
|
|
40
|
+
...[...new Set(deniedRoots)]
|
|
41
|
+
.sort()
|
|
42
|
+
.flatMap((path) => [
|
|
43
|
+
`(deny file-read* (subpath "${quoted(path)}"))`,
|
|
44
|
+
`(deny file-write* (subpath "${quoted(path)}"))`,
|
|
45
|
+
]),
|
|
46
|
+
"",
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IsolatedCommand {
|
|
51
|
+
argv: string[];
|
|
52
|
+
release(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the outer boundary before launching an agent or candidate-controlled code. */
|
|
56
|
+
export async function prepareMacSandboxCommand(options: {
|
|
57
|
+
argv: string[];
|
|
58
|
+
workspace: string;
|
|
59
|
+
protectedRoots: string[];
|
|
60
|
+
protectedRootsCanonical?: boolean;
|
|
61
|
+
privateStateRoot: string;
|
|
62
|
+
denyNetwork?: boolean;
|
|
63
|
+
}): Promise<IsolatedCommand> {
|
|
64
|
+
if (process.platform !== "darwin" || !existsSync(SANDBOX_EXEC))
|
|
65
|
+
throw new HostIsolationError("macOS sandbox-exec isolation is unavailable");
|
|
66
|
+
if (!options.argv.length || options.argv.some((part) => !part))
|
|
67
|
+
throw new HostIsolationError("isolated command must be nonempty");
|
|
68
|
+
if (!options.protectedRoots.length)
|
|
69
|
+
throw new HostIsolationError("isolated command needs protected roots");
|
|
70
|
+
if (
|
|
71
|
+
!isAbsolute(options.workspace) ||
|
|
72
|
+
!isAbsolute(options.privateStateRoot) ||
|
|
73
|
+
options.protectedRoots.some((path) => !isAbsolute(path))
|
|
74
|
+
)
|
|
75
|
+
throw new HostIsolationError("isolation paths must be absolute");
|
|
76
|
+
const workspace = await realpath(options.workspace);
|
|
77
|
+
const roots = options.protectedRootsCanonical
|
|
78
|
+
? options.protectedRoots
|
|
79
|
+
: await Promise.all(options.protectedRoots.map((path) => realpath(path)));
|
|
80
|
+
await mkdir(options.privateStateRoot, { recursive: true, mode: 0o700 });
|
|
81
|
+
const stateRoot = await realpath(options.privateStateRoot);
|
|
82
|
+
if (
|
|
83
|
+
roots.some((root) => inside(root, workspace)) ||
|
|
84
|
+
inside(stateRoot, workspace)
|
|
85
|
+
)
|
|
86
|
+
throw new HostIsolationError(
|
|
87
|
+
"protected root includes the candidate workspace",
|
|
88
|
+
);
|
|
89
|
+
if (inside(workspace, stateRoot))
|
|
90
|
+
throw new HostIsolationError(
|
|
91
|
+
"sandbox state is inside the candidate workspace",
|
|
92
|
+
);
|
|
93
|
+
const profilePath = resolve(stateRoot, `host-${randomUUID()}.sb`);
|
|
94
|
+
await writeFile(
|
|
95
|
+
profilePath,
|
|
96
|
+
macSandboxProfile([...roots, stateRoot], options.denyNetwork),
|
|
97
|
+
{
|
|
98
|
+
flag: "wx",
|
|
99
|
+
mode: 0o600,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
return {
|
|
103
|
+
argv: [SANDBOX_EXEC, "-f", profilePath, ...options.argv],
|
|
104
|
+
release: async () => {
|
|
105
|
+
await rm(profilePath, { force: true });
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { codexNativeCallObservation } from "./codex-native-calls";
|
|
2
|
+
import { summarizeClaudeEvents } from "./claude-events";
|
|
3
|
+
|
|
4
|
+
const MAX_CALLS = 128;
|
|
5
|
+
type Call = { ordinal: number; namespace: string; name: string };
|
|
6
|
+
type Entry = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface NativeControlObservation {
|
|
9
|
+
id: "sevro.host.native-controls";
|
|
10
|
+
completeness: "complete" | "partial" | "unavailable";
|
|
11
|
+
data: {
|
|
12
|
+
method: "native_control_calls";
|
|
13
|
+
calls: Call[];
|
|
14
|
+
acceptedAgentCount: number | null;
|
|
15
|
+
submittedExecCalls: number | null;
|
|
16
|
+
truncated: boolean | null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function record(value: unknown): value is Entry {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function label(value: unknown, max: number): value is string {
|
|
25
|
+
return (
|
|
26
|
+
typeof value === "string" &&
|
|
27
|
+
value.length > 0 &&
|
|
28
|
+
value.length <= max &&
|
|
29
|
+
/^[A-Za-z0-9_.:-]+$/.test(value)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function codexNativeControls(
|
|
34
|
+
observation: Awaited<ReturnType<typeof codexNativeCallObservation>>,
|
|
35
|
+
): NativeControlObservation {
|
|
36
|
+
const complete = observation.completeness === "complete";
|
|
37
|
+
return {
|
|
38
|
+
id: "sevro.host.native-controls",
|
|
39
|
+
completeness: observation.completeness,
|
|
40
|
+
data: {
|
|
41
|
+
method: "native_control_calls",
|
|
42
|
+
calls: observation.data.toolCalls.map(({ ordinal, namespace, name }) => ({
|
|
43
|
+
ordinal,
|
|
44
|
+
namespace,
|
|
45
|
+
name,
|
|
46
|
+
})),
|
|
47
|
+
acceptedAgentCount: complete
|
|
48
|
+
? observation.data.acceptedSpawns.length
|
|
49
|
+
: null,
|
|
50
|
+
submittedExecCalls: complete ? observation.data.submittedExecCalls : null,
|
|
51
|
+
truncated: complete ? false : null,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Observe all native labels without retaining arguments or interpreting policy. */
|
|
57
|
+
export function claudeNativeControls(
|
|
58
|
+
stream: string,
|
|
59
|
+
exitCode: number,
|
|
60
|
+
): NativeControlObservation {
|
|
61
|
+
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
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: "sevro.host.native-controls",
|
|
114
|
+
completeness:
|
|
115
|
+
summary.complete && !malformed && !truncated ? "complete" : "partial",
|
|
116
|
+
data: {
|
|
117
|
+
method: "native_control_calls",
|
|
118
|
+
calls,
|
|
119
|
+
acceptedAgentCount: null,
|
|
120
|
+
submittedExecCalls: null,
|
|
121
|
+
truncated,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
async function containingRepository(root: string): Promise<string | null> {
|
|
5
|
+
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
|
+
}
|
|
14
|
+
const parent = dirname(directory);
|
|
15
|
+
if (parent === directory) return null;
|
|
16
|
+
directory = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Protect the primary checkout and linked worktrees without requiring Git. */
|
|
21
|
+
export async function protectedWorktrees(root: string): Promise<string[]> {
|
|
22
|
+
const repository = await containingRepository(root);
|
|
23
|
+
if (!repository) return [];
|
|
24
|
+
const result = Bun.spawnSync(
|
|
25
|
+
["git", "-C", repository, "worktree", "list", "--porcelain", "-z"],
|
|
26
|
+
{
|
|
27
|
+
env: {
|
|
28
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
29
|
+
LC_ALL: "C",
|
|
30
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
31
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
32
|
+
},
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "ignore",
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
if (result.exitCode !== 0 || result.stdout.byteLength > 1024 * 1024)
|
|
38
|
+
throw new Error("protected repository worktrees are unavailable");
|
|
39
|
+
const paths = result.stdout
|
|
40
|
+
.toString()
|
|
41
|
+
.split("\0")
|
|
42
|
+
.filter((field) => field.startsWith("worktree "))
|
|
43
|
+
.map((field) => field.slice(9));
|
|
44
|
+
if (!paths.length || paths.some((path) => !isAbsolute(path)))
|
|
45
|
+
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, readdir, readlink } from "node:fs/promises";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX_ENTRIES = 10_000;
|
|
7
|
+
const MAX_FILE_BYTES = 128 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Hash visible worktree contents without retaining paths or file data. */
|
|
10
|
+
export async function workspaceFingerprint(
|
|
11
|
+
root: string,
|
|
12
|
+
): Promise<string | null> {
|
|
13
|
+
const identity = createHash("sha256");
|
|
14
|
+
let entries = 0;
|
|
15
|
+
let fileBytes = 0;
|
|
16
|
+
async function visit(path: string): Promise<void> {
|
|
17
|
+
entries++;
|
|
18
|
+
if (entries > MAX_ENTRIES) throw new Error("workspace entry limit");
|
|
19
|
+
const name = relative(root, path).split(sep).join("/");
|
|
20
|
+
const info = await lstat(path);
|
|
21
|
+
if (info.isDirectory()) {
|
|
22
|
+
identity.update(`dir\0${name}\0`);
|
|
23
|
+
for (const child of (await readdir(path)).sort()) {
|
|
24
|
+
await visit(join(path, child));
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (info.isSymbolicLink()) {
|
|
29
|
+
identity.update(`link\0${name}\0${await readlink(path)}\0`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!info.isFile() || !Number.isSafeInteger(info.size))
|
|
33
|
+
throw new Error("unsupported workspace entry");
|
|
34
|
+
if (fileBytes + info.size > MAX_FILE_BYTES)
|
|
35
|
+
throw new Error("workspace byte limit");
|
|
36
|
+
const content = createHash("sha256");
|
|
37
|
+
for await (const chunk of createReadStream(path)) {
|
|
38
|
+
fileBytes += chunk.byteLength;
|
|
39
|
+
if (fileBytes > MAX_FILE_BYTES) throw new Error("workspace byte limit");
|
|
40
|
+
content.update(chunk);
|
|
41
|
+
}
|
|
42
|
+
identity.update(
|
|
43
|
+
`file\0${name}\0${info.mode & 0o111 ? "x" : "-"}\0${content.digest("hex")}\0`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
for (const entry of (await readdir(root)).sort()) {
|
|
48
|
+
if (entry === ".git") continue;
|
|
49
|
+
await visit(join(root, entry));
|
|
50
|
+
}
|
|
51
|
+
return identity.digest("hex");
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = "sevro.identity.v1";
|
|
4
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
5
|
+
const DIMENSION_KEYS = [
|
|
6
|
+
"runnerBuildDigest",
|
|
7
|
+
"projectDigest",
|
|
8
|
+
"configurationDigest",
|
|
9
|
+
"extensionDigest",
|
|
10
|
+
"extensionProtocol",
|
|
11
|
+
"caseDigest",
|
|
12
|
+
"fixtureDigest",
|
|
13
|
+
"checksDigest",
|
|
14
|
+
"requiredEvidenceDigest",
|
|
15
|
+
"evaluatorDigest",
|
|
16
|
+
"graderDigest",
|
|
17
|
+
"instrumentationDigest",
|
|
18
|
+
"routeDigest",
|
|
19
|
+
"condition",
|
|
20
|
+
"trialCount",
|
|
21
|
+
"passThreshold",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export interface IdentityDimensions {
|
|
25
|
+
runnerBuildDigest: string;
|
|
26
|
+
projectDigest: string;
|
|
27
|
+
configurationDigest: string;
|
|
28
|
+
extensionDigest: string | null;
|
|
29
|
+
extensionProtocol: "sevro.extension.v1" | null;
|
|
30
|
+
caseDigest: string;
|
|
31
|
+
fixtureDigest: string;
|
|
32
|
+
checksDigest: string;
|
|
33
|
+
requiredEvidenceDigest: string;
|
|
34
|
+
evaluatorDigest: string;
|
|
35
|
+
graderDigest: string;
|
|
36
|
+
instrumentationDigest: string;
|
|
37
|
+
routeDigest: string;
|
|
38
|
+
condition: "passive" | "enforced";
|
|
39
|
+
trialCount: number;
|
|
40
|
+
passThreshold: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertUnicode(value: string): void {
|
|
44
|
+
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))
|
|
49
|
+
throw new Error("JSON string contains an unpaired surrogate");
|
|
50
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
51
|
+
throw new Error("JSON string contains an unpaired surrogate");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
if (item === null) return "null";
|
|
61
|
+
if (typeof item === "string") {
|
|
62
|
+
assertUnicode(item);
|
|
63
|
+
return JSON.stringify(item);
|
|
64
|
+
}
|
|
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);
|
|
73
|
+
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)
|
|
82
|
+
.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
|
+
});
|
|
90
|
+
return `{${entries.join(",")}}`;
|
|
91
|
+
} finally {
|
|
92
|
+
visiting.delete(item);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return write(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hashJson(value: unknown): string {
|
|
99
|
+
return createHash("sha256")
|
|
100
|
+
.update(canonicalJson(value), "utf8")
|
|
101
|
+
.digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validatedDimensions(value: unknown): IdentityDimensions {
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
106
|
+
throw new Error("identity dimensions must be a JSON object");
|
|
107
|
+
const record = value as Record<string, unknown>;
|
|
108
|
+
for (const key of DIMENSION_KEYS)
|
|
109
|
+
if (!Object.hasOwn(record, key))
|
|
110
|
+
throw new Error(`missing identity dimension: ${key}`);
|
|
111
|
+
const unexpected = Object.keys(record).filter(
|
|
112
|
+
(key) => !DIMENSION_KEYS.includes(key as (typeof DIMENSION_KEYS)[number]),
|
|
113
|
+
);
|
|
114
|
+
if (unexpected.length)
|
|
115
|
+
throw new Error(`unexpected identity dimension: ${unexpected[0]}`);
|
|
116
|
+
for (const key of DIMENSION_KEYS.filter((name) => name.endsWith("Digest"))) {
|
|
117
|
+
if (key === "extensionDigest" && record[key] === null) continue;
|
|
118
|
+
if (typeof record[key] !== "string" || !DIGEST.test(record[key]))
|
|
119
|
+
throw new Error(`invalid identity dimension: ${key}`);
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
(record.extensionDigest === null) !== (record.extensionProtocol === null) ||
|
|
123
|
+
(record.extensionProtocol !== null &&
|
|
124
|
+
record.extensionProtocol !== "sevro.extension.v1")
|
|
125
|
+
)
|
|
126
|
+
throw new Error("extension identity requires its matching protocol");
|
|
127
|
+
if (record.condition !== "passive" && record.condition !== "enforced")
|
|
128
|
+
throw new Error("invalid identity condition");
|
|
129
|
+
if (
|
|
130
|
+
!Number.isSafeInteger(record.trialCount) ||
|
|
131
|
+
(record.trialCount as number) < 1
|
|
132
|
+
)
|
|
133
|
+
throw new Error("invalid identity trial count");
|
|
134
|
+
if (
|
|
135
|
+
typeof record.passThreshold !== "number" ||
|
|
136
|
+
!Number.isFinite(record.passThreshold) ||
|
|
137
|
+
record.passThreshold <= 0 ||
|
|
138
|
+
record.passThreshold > 1
|
|
139
|
+
)
|
|
140
|
+
throw new Error("invalid identity pass threshold");
|
|
141
|
+
return record as unknown as IdentityDimensions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Digest the exact comparison dimensions with a versioned domain prefix. */
|
|
145
|
+
export function createEvaluationIdentity(dimensions: unknown): {
|
|
146
|
+
algorithm: typeof ALGORITHM;
|
|
147
|
+
digest: string;
|
|
148
|
+
dimensions: IdentityDimensions;
|
|
149
|
+
} {
|
|
150
|
+
const checked = validatedDimensions(dimensions);
|
|
151
|
+
const digest = createHash("sha256")
|
|
152
|
+
.update(`${ALGORITHM}\n`, "utf8")
|
|
153
|
+
.update(canonicalJson(checked), "utf8")
|
|
154
|
+
.digest("hex");
|
|
155
|
+
return { algorithm: ALGORITHM, digest, dimensions: checked };
|
|
156
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { canonicalJson } from "./identity";
|
|
2
|
+
|
|
3
|
+
export interface InstrumentationRequest {
|
|
4
|
+
id: string;
|
|
5
|
+
configuration: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InstrumentationCapability {
|
|
9
|
+
id: string;
|
|
10
|
+
executionChanging: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class InstrumentationEvidenceError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "InstrumentationEvidenceError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
|
|
21
|
+
|
|
22
|
+
function validRequest(value: InstrumentationRequest): boolean {
|
|
23
|
+
return (
|
|
24
|
+
Boolean(value && ID.test(value.id)) &&
|
|
25
|
+
Boolean(
|
|
26
|
+
value.configuration &&
|
|
27
|
+
typeof value.configuration === "object" &&
|
|
28
|
+
!Array.isArray(value.configuration),
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate an extension's request before the candidate host starts. */
|
|
34
|
+
export function prepareInstrumentation(
|
|
35
|
+
requested: InstrumentationRequest[],
|
|
36
|
+
supported: InstrumentationCapability[],
|
|
37
|
+
negotiated: string[],
|
|
38
|
+
condition: "passive" | "enforced",
|
|
39
|
+
): InstrumentationRequest[] {
|
|
40
|
+
const supportedIds = new Set(supported.map((item) => item.id));
|
|
41
|
+
if (
|
|
42
|
+
supported.some(
|
|
43
|
+
(item) =>
|
|
44
|
+
!ID.test(item.id) || typeof item.executionChanging !== "boolean",
|
|
45
|
+
) ||
|
|
46
|
+
supportedIds.size !== supported.length
|
|
47
|
+
)
|
|
48
|
+
throw new Error("host instrumentation capabilities are invalid");
|
|
49
|
+
const requestedIds = new Set(requested.map((item) => item.id));
|
|
50
|
+
if (
|
|
51
|
+
requested.some((item) => !validRequest(item)) ||
|
|
52
|
+
requestedIds.size !== requested.length
|
|
53
|
+
)
|
|
54
|
+
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;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A host must report exactly the instrumentation it applied. */
|
|
71
|
+
export function verifyAppliedInstrumentation(
|
|
72
|
+
requested: InstrumentationRequest[],
|
|
73
|
+
applied: InstrumentationRequest[] | undefined,
|
|
74
|
+
requestedCondition: "passive" | "enforced",
|
|
75
|
+
actualCondition: "passive" | "enforced" | "unknown" | undefined,
|
|
76
|
+
): InstrumentationRequest[] {
|
|
77
|
+
const actual = applied ?? [];
|
|
78
|
+
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)
|
|
83
|
+
)
|
|
84
|
+
throw new InstrumentationEvidenceError(
|
|
85
|
+
"host applied instrumentation does not match the request",
|
|
86
|
+
);
|
|
87
|
+
if (
|
|
88
|
+
actualCondition !== undefined &&
|
|
89
|
+
actualCondition !== "unknown" &&
|
|
90
|
+
actualCondition !== "passive" &&
|
|
91
|
+
actualCondition !== "enforced"
|
|
92
|
+
)
|
|
93
|
+
throw new InstrumentationEvidenceError(
|
|
94
|
+
"host condition evidence is invalid",
|
|
95
|
+
);
|
|
96
|
+
if (
|
|
97
|
+
(actualCondition !== undefined &&
|
|
98
|
+
actualCondition !== "unknown" &&
|
|
99
|
+
actualCondition !== requestedCondition) ||
|
|
100
|
+
(requested.length > 0 && actualCondition !== requestedCondition)
|
|
101
|
+
)
|
|
102
|
+
throw new InstrumentationEvidenceError(
|
|
103
|
+
"host did not confirm the requested condition",
|
|
104
|
+
);
|
|
105
|
+
return actual;
|
|
106
|
+
}
|