@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,218 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
type Entry = Record<string, unknown>;
|
|
5
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const MAX_AGENTS = 64;
|
|
7
|
+
const MAX_SKILLS = 128;
|
|
8
|
+
|
|
9
|
+
function record(value: unknown): value is Entry {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function label(value: unknown): value is string {
|
|
14
|
+
return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function identity(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function blocks(entry: Entry): Entry[] {
|
|
22
|
+
const content = record(entry.message) ? entry.message.content : null;
|
|
23
|
+
return Array.isArray(content) ? content.filter(record) : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function entries(text: string): Entry[] {
|
|
27
|
+
return text
|
|
28
|
+
.split(/\r?\n/)
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((line) => {
|
|
31
|
+
const parsed: unknown = JSON.parse(line);
|
|
32
|
+
if (!record(parsed)) throw new Error("invalid Claude session entry");
|
|
33
|
+
return parsed;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readSession(path: string): Promise<string> {
|
|
38
|
+
const file = await lstat(path);
|
|
39
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_BYTES)
|
|
40
|
+
throw new Error("Claude session file is unavailable");
|
|
41
|
+
return readFile(path, "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type NestedSkill = {
|
|
45
|
+
ancestorToolUseId: string;
|
|
46
|
+
skill: string;
|
|
47
|
+
invocation: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type State = {
|
|
51
|
+
sessionId: string;
|
|
52
|
+
projectDir: string;
|
|
53
|
+
seen: Set<string>;
|
|
54
|
+
calls: NestedSkill[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function sidecarChild(
|
|
58
|
+
state: State,
|
|
59
|
+
toolUseId: string,
|
|
60
|
+
parentAgentId: string | null,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
const directory = join(state.projectDir, state.sessionId, "subagents");
|
|
63
|
+
const names = (await readdir(directory)).filter((name) =>
|
|
64
|
+
/^agent-[A-Za-z0-9_-]+\.meta\.json$/.test(name),
|
|
65
|
+
);
|
|
66
|
+
if (names.length > MAX_AGENTS) throw new Error("excessive Claude agents");
|
|
67
|
+
const matches: string[] = [];
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const metadata: unknown = JSON.parse(
|
|
70
|
+
await readSession(join(directory, name)),
|
|
71
|
+
);
|
|
72
|
+
if (
|
|
73
|
+
record(metadata) &&
|
|
74
|
+
metadata.toolUseId === toolUseId &&
|
|
75
|
+
(metadata.parentAgentId ?? null) === parentAgentId
|
|
76
|
+
)
|
|
77
|
+
matches.push(name.slice(6, -10));
|
|
78
|
+
}
|
|
79
|
+
if (matches.length !== 1 || !identity(matches[0]))
|
|
80
|
+
throw new Error("Claude child identity is unavailable");
|
|
81
|
+
return matches[0]!;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function childId(
|
|
85
|
+
entry: Entry,
|
|
86
|
+
state: State,
|
|
87
|
+
toolUseId: string,
|
|
88
|
+
parentAgentId: string | null,
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
const result = record(entry.toolUseResult) ? entry.toolUseResult : null;
|
|
91
|
+
if (result) {
|
|
92
|
+
if (result.status !== "completed" || !identity(result.agentId))
|
|
93
|
+
throw new Error("Claude child did not complete");
|
|
94
|
+
return result.agentId;
|
|
95
|
+
}
|
|
96
|
+
return sidecarChild(state, toolUseId, parentAgentId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function childSession(state: State, agentId: string) {
|
|
100
|
+
const directory = join(state.projectDir, state.sessionId, "subagents");
|
|
101
|
+
const path = join(directory, `agent-${agentId}.jsonl`);
|
|
102
|
+
if ((await realpath(path)) !== path)
|
|
103
|
+
throw new Error("Claude child session is redirected");
|
|
104
|
+
return readSession(path);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function skillCall(block: Entry, ancestorToolUseId: string | null) {
|
|
108
|
+
const invocation = record(block.input) ? block.input.skill : null;
|
|
109
|
+
if (!label(invocation)) throw new Error("invalid Claude Skill call");
|
|
110
|
+
return ancestorToolUseId
|
|
111
|
+
? {
|
|
112
|
+
ancestorToolUseId,
|
|
113
|
+
skill: invocation.split(":").at(-1)!,
|
|
114
|
+
invocation,
|
|
115
|
+
}
|
|
116
|
+
: null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function visit(
|
|
120
|
+
text: string,
|
|
121
|
+
state: State,
|
|
122
|
+
agentId: string | null,
|
|
123
|
+
ancestorToolUseId: string | null,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const pending = new Set<string>();
|
|
126
|
+
for (const entry of entries(text)) {
|
|
127
|
+
if (entry.type !== "assistant" && entry.type !== "user") continue;
|
|
128
|
+
if (
|
|
129
|
+
entry.sessionId !== state.sessionId ||
|
|
130
|
+
(entry.agentId ?? null) !== agentId
|
|
131
|
+
)
|
|
132
|
+
throw new Error("Claude session identity mismatch");
|
|
133
|
+
for (const block of blocks(entry)) {
|
|
134
|
+
if (entry.type === "assistant" && block.type === "tool_use") {
|
|
135
|
+
if (block.name === "Skill") {
|
|
136
|
+
const call = skillCall(block, ancestorToolUseId);
|
|
137
|
+
if (call) state.calls.push(call);
|
|
138
|
+
if (state.calls.length > MAX_SKILLS)
|
|
139
|
+
throw new Error("excessive Claude Skill calls");
|
|
140
|
+
}
|
|
141
|
+
if (block.name === "Agent" || block.name === "Task") {
|
|
142
|
+
if (!identity(block.id) || pending.has(block.id))
|
|
143
|
+
throw new Error("invalid Claude Agent call");
|
|
144
|
+
pending.add(block.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
entry.type === "user" &&
|
|
149
|
+
block.type === "tool_result" &&
|
|
150
|
+
identity(block.tool_use_id) &&
|
|
151
|
+
pending.has(block.tool_use_id)
|
|
152
|
+
) {
|
|
153
|
+
if (block.is_error === true)
|
|
154
|
+
throw new Error("Claude child call failed");
|
|
155
|
+
const child = await childId(entry, state, block.tool_use_id, agentId);
|
|
156
|
+
if (state.seen.has(child) || state.seen.size >= MAX_AGENTS)
|
|
157
|
+
throw new Error("repeated Claude child");
|
|
158
|
+
state.seen.add(child);
|
|
159
|
+
await visit(
|
|
160
|
+
await childSession(state, child),
|
|
161
|
+
state,
|
|
162
|
+
child,
|
|
163
|
+
ancestorToolUseId ?? block.tool_use_id,
|
|
164
|
+
);
|
|
165
|
+
pending.delete(block.tool_use_id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (pending.size) throw new Error("incomplete Claude Agent graph");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function claudeNestedSkillsObservation(
|
|
173
|
+
stream: string,
|
|
174
|
+
configRoot: string,
|
|
175
|
+
workspace: string,
|
|
176
|
+
) {
|
|
177
|
+
const unavailable = {
|
|
178
|
+
id: "sevro.claude.nested-skills" as const,
|
|
179
|
+
completeness: "partial" as const,
|
|
180
|
+
data: { method: "native_session_graph", calls: [] as NestedSkill[] },
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
const sessions = [
|
|
184
|
+
...new Set(
|
|
185
|
+
entries(stream)
|
|
186
|
+
.filter((entry) => entry.type === "result")
|
|
187
|
+
.map((entry) => entry.session_id),
|
|
188
|
+
),
|
|
189
|
+
];
|
|
190
|
+
if (sessions.length !== 1 || !identity(sessions[0])) return unavailable;
|
|
191
|
+
const canonicalWorkspace = await realpath(workspace);
|
|
192
|
+
const canonicalConfigRoot = await realpath(configRoot);
|
|
193
|
+
const projectDir = join(
|
|
194
|
+
canonicalConfigRoot,
|
|
195
|
+
"projects",
|
|
196
|
+
canonicalWorkspace.replace(/[^A-Za-z0-9]/g, "-"),
|
|
197
|
+
);
|
|
198
|
+
const state: State = {
|
|
199
|
+
sessionId: sessions[0],
|
|
200
|
+
projectDir,
|
|
201
|
+
seen: new Set(),
|
|
202
|
+
calls: [],
|
|
203
|
+
};
|
|
204
|
+
await visit(
|
|
205
|
+
await readSession(join(projectDir, `${state.sessionId}.jsonl`)),
|
|
206
|
+
state,
|
|
207
|
+
null,
|
|
208
|
+
null,
|
|
209
|
+
);
|
|
210
|
+
return {
|
|
211
|
+
id: "sevro.claude.nested-skills" as const,
|
|
212
|
+
completeness: "complete" as const,
|
|
213
|
+
data: { method: "native_session_graph", calls: state.calls },
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
return unavailable;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { HostAdapter } from "../engine";
|
|
4
|
+
import type { ClaudeToolCallsObservation } from "./claude-tool-calls";
|
|
5
|
+
|
|
6
|
+
type Entry = Record<string, unknown>;
|
|
7
|
+
export interface ClaudeRepositoryInvocation {
|
|
8
|
+
skillName: string;
|
|
9
|
+
skillDir: string;
|
|
10
|
+
skillText: string;
|
|
11
|
+
prompt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function record(value: unknown): value is Entry {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function entries(text: string): Entry[] {
|
|
19
|
+
return text
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.filter((line) => line.trim())
|
|
22
|
+
.map((line) => {
|
|
23
|
+
const value: unknown = JSON.parse(line);
|
|
24
|
+
if (!record(value)) throw new Error("invalid Claude session entry");
|
|
25
|
+
return value;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function content(entry: Entry): string {
|
|
30
|
+
const blocks = record(entry.message) ? entry.message.content : null;
|
|
31
|
+
if (typeof blocks === "string") return blocks;
|
|
32
|
+
if (!Array.isArray(blocks)) return "";
|
|
33
|
+
return blocks
|
|
34
|
+
.filter(record)
|
|
35
|
+
.filter((block) => block.type === "text")
|
|
36
|
+
.map((block) => (typeof block.text === "string" ? block.text : ""))
|
|
37
|
+
.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Validate the native project mount before candidate execution. */
|
|
41
|
+
export async function verifyClaudeRepositoryInvocation(
|
|
42
|
+
request: Parameters<HostAdapter["run"]>[0],
|
|
43
|
+
): Promise<ClaudeRepositoryInvocation> {
|
|
44
|
+
const selected = request.explicitSkillInvocation;
|
|
45
|
+
if (!selected || selected.scope !== "repository")
|
|
46
|
+
throw new Error("Claude repository invocation is not declared");
|
|
47
|
+
const { skillName, token } = selected;
|
|
48
|
+
if (
|
|
49
|
+
!/^[A-Za-z0-9._-]+$/.test(skillName) ||
|
|
50
|
+
[".", ".."].includes(skillName) ||
|
|
51
|
+
token !== `/${skillName}` ||
|
|
52
|
+
request.prompt.split(token).length - 1 !== 1 ||
|
|
53
|
+
(request.prompt !== token &&
|
|
54
|
+
!request.prompt.startsWith(token + " ") &&
|
|
55
|
+
!request.prompt.startsWith(token + "\n"))
|
|
56
|
+
)
|
|
57
|
+
throw new Error("invalid Claude repository skill invocation");
|
|
58
|
+
let path = await realpath(request.workspace);
|
|
59
|
+
for (const [index, part] of [
|
|
60
|
+
".claude",
|
|
61
|
+
"skills",
|
|
62
|
+
skillName,
|
|
63
|
+
"SKILL.md",
|
|
64
|
+
].entries()) {
|
|
65
|
+
path = join(path, part);
|
|
66
|
+
const entry = await lstat(path).catch(() => null);
|
|
67
|
+
if (
|
|
68
|
+
!entry ||
|
|
69
|
+
entry.isSymbolicLink() ||
|
|
70
|
+
(index === 3
|
|
71
|
+
? !entry.isFile() || entry.size > 1024 * 1024
|
|
72
|
+
: !entry.isDirectory())
|
|
73
|
+
)
|
|
74
|
+
throw new Error(
|
|
75
|
+
"invoked Claude repository skill is unavailable or unsafe",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
skillName,
|
|
80
|
+
skillDir: join(path, ".."),
|
|
81
|
+
skillText: await readFile(path, "utf8"),
|
|
82
|
+
prompt: request.prompt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Match one exact command and complete body before the assistant starts. */
|
|
87
|
+
export function matchClaudeRepositoryInvocation(
|
|
88
|
+
input: ClaudeRepositoryInvocation & {
|
|
89
|
+
transcript: string;
|
|
90
|
+
sessionId: string;
|
|
91
|
+
},
|
|
92
|
+
): { accepted: boolean | null; reason: string } {
|
|
93
|
+
let native: Entry[];
|
|
94
|
+
try {
|
|
95
|
+
native = entries(input.transcript);
|
|
96
|
+
} catch {
|
|
97
|
+
return { accepted: null, reason: "malformed native session" };
|
|
98
|
+
}
|
|
99
|
+
const firstAssistant = native.findIndex(
|
|
100
|
+
(entry) => entry.type === "assistant",
|
|
101
|
+
);
|
|
102
|
+
if (firstAssistant < 0)
|
|
103
|
+
return { accepted: null, reason: "missing assistant turn" };
|
|
104
|
+
const assistant = native[firstAssistant]!;
|
|
105
|
+
if (assistant.sessionId !== input.sessionId || assistant.isSidechain === true)
|
|
106
|
+
return { accepted: false, reason: "assistant session differs" };
|
|
107
|
+
const before = native.slice(0, firstAssistant);
|
|
108
|
+
const token = `/${input.skillName}`;
|
|
109
|
+
if (
|
|
110
|
+
input.prompt !== token &&
|
|
111
|
+
!input.prompt.startsWith(token + " ") &&
|
|
112
|
+
!input.prompt.startsWith(token + "\n")
|
|
113
|
+
)
|
|
114
|
+
return { accepted: false, reason: "prompt differs from mounted command" };
|
|
115
|
+
const args = input.prompt.slice(token.length).trim();
|
|
116
|
+
const command =
|
|
117
|
+
`<command-message>${input.skillName}</command-message>\n<command-name>${token}</command-name>` +
|
|
118
|
+
(args ? `\n<command-args>${args}</command-args>` : "");
|
|
119
|
+
const commands = before.filter(
|
|
120
|
+
(entry) =>
|
|
121
|
+
entry.type === "user" &&
|
|
122
|
+
content(entry).includes(`<command-name>${token}</command-name>`),
|
|
123
|
+
);
|
|
124
|
+
if (commands.length !== 1)
|
|
125
|
+
return { accepted: false, reason: "missing or duplicate native command" };
|
|
126
|
+
const called = commands[0]!;
|
|
127
|
+
if (
|
|
128
|
+
called.sessionId !== input.sessionId ||
|
|
129
|
+
called.isMeta === true ||
|
|
130
|
+
called.isSidechain === true ||
|
|
131
|
+
content(called) !== command
|
|
132
|
+
)
|
|
133
|
+
return { accepted: false, reason: "command session or arguments differ" };
|
|
134
|
+
const body = input.skillText
|
|
135
|
+
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
|
|
136
|
+
.trim();
|
|
137
|
+
if (!body) return { accepted: null, reason: "empty mounted body" };
|
|
138
|
+
const prefix = `Base directory for this skill: ${input.skillDir}\n\n${body}`;
|
|
139
|
+
const expanded = before.filter(
|
|
140
|
+
(entry) =>
|
|
141
|
+
entry.type === "user" &&
|
|
142
|
+
(content(entry) === prefix || content(entry).startsWith(prefix + "\n")),
|
|
143
|
+
);
|
|
144
|
+
if (expanded.length !== 1)
|
|
145
|
+
return {
|
|
146
|
+
accepted: false,
|
|
147
|
+
reason: "missing, partial, or duplicate mounted body",
|
|
148
|
+
};
|
|
149
|
+
const loaded = expanded[0]!;
|
|
150
|
+
if (
|
|
151
|
+
loaded.sessionId !== input.sessionId ||
|
|
152
|
+
loaded.isMeta !== true ||
|
|
153
|
+
loaded.isSidechain === true ||
|
|
154
|
+
before.indexOf(loaded) <= before.indexOf(called)
|
|
155
|
+
)
|
|
156
|
+
return { accepted: false, reason: "mounted body is uncorrelated" };
|
|
157
|
+
return {
|
|
158
|
+
accepted: true,
|
|
159
|
+
reason: "native command and complete mounted body matched",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read only the completed bound native session; retain no arguments or body. */
|
|
164
|
+
export async function claudeRepositoryInvocationObservation(input: {
|
|
165
|
+
invocation: ClaudeRepositoryInvocation;
|
|
166
|
+
stream: string;
|
|
167
|
+
configRoot: string;
|
|
168
|
+
workspace: string;
|
|
169
|
+
tools: ClaudeToolCallsObservation;
|
|
170
|
+
}) {
|
|
171
|
+
let receipt: { accepted: boolean | null; reason: string } = {
|
|
172
|
+
accepted: null,
|
|
173
|
+
reason: "native session evidence unavailable",
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const sessions = [
|
|
177
|
+
...new Set(
|
|
178
|
+
entries(input.stream)
|
|
179
|
+
.filter(
|
|
180
|
+
(entry) =>
|
|
181
|
+
entry.type === "result" ||
|
|
182
|
+
(entry.type === "system" && entry.subtype === "init"),
|
|
183
|
+
)
|
|
184
|
+
.map((entry) => entry.session_id),
|
|
185
|
+
),
|
|
186
|
+
];
|
|
187
|
+
if (
|
|
188
|
+
sessions.length === 1 &&
|
|
189
|
+
typeof sessions[0] === "string" &&
|
|
190
|
+
/^[A-Za-z0-9_-]{1,128}$/.test(sessions[0])
|
|
191
|
+
) {
|
|
192
|
+
const workspace = await realpath(input.workspace);
|
|
193
|
+
const path = join(
|
|
194
|
+
await realpath(input.configRoot),
|
|
195
|
+
"projects",
|
|
196
|
+
workspace.replace(/[^A-Za-z0-9]/g, "-"),
|
|
197
|
+
`${sessions[0]}.jsonl`,
|
|
198
|
+
);
|
|
199
|
+
const entry = await lstat(path);
|
|
200
|
+
if (
|
|
201
|
+
entry.isFile() &&
|
|
202
|
+
!entry.isSymbolicLink() &&
|
|
203
|
+
entry.size <= 8 * 1024 * 1024 &&
|
|
204
|
+
(await realpath(path)) === path
|
|
205
|
+
) {
|
|
206
|
+
receipt = matchClaudeRepositoryInvocation({
|
|
207
|
+
...input.invocation,
|
|
208
|
+
sessionId: sessions[0],
|
|
209
|
+
transcript: await readFile(path, "utf8"),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* Unavailable or malformed native evidence stays partial. */
|
|
215
|
+
}
|
|
216
|
+
const observed = input.tools.data.calls
|
|
217
|
+
.filter((call) => call.name === "Skill")
|
|
218
|
+
.map((call) => call.skill);
|
|
219
|
+
const observedSkills = [
|
|
220
|
+
...new Set([
|
|
221
|
+
...(receipt.accepted === true ? [input.invocation.skillName] : []),
|
|
222
|
+
...observed,
|
|
223
|
+
]),
|
|
224
|
+
];
|
|
225
|
+
return {
|
|
226
|
+
id: "sevro.claude.repository-invocation" as const,
|
|
227
|
+
completeness:
|
|
228
|
+
receipt.accepted === true && input.tools.completeness === "complete"
|
|
229
|
+
? ("complete" as const)
|
|
230
|
+
: ("partial" as const),
|
|
231
|
+
data: {
|
|
232
|
+
method: "native_repository_command",
|
|
233
|
+
...receipt,
|
|
234
|
+
skill: input.invocation.skillName,
|
|
235
|
+
primarySkill: observedSkills[0] ?? null,
|
|
236
|
+
observedSkills,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
|
|
3
|
+
function absoluteRule(root: string, tool: "Read" | "Edit"): string {
|
|
4
|
+
if (!isAbsolute(root) || /[\r\n()]/.test(root))
|
|
5
|
+
throw new Error("Claude protected path is invalid");
|
|
6
|
+
return `${tool}(/${root}/**)`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Keep agent tools away from the CLI's private state while core auth works. */
|
|
10
|
+
export function claudeHostSettings(
|
|
11
|
+
privateRoot: string,
|
|
12
|
+
credentialFile: string,
|
|
13
|
+
pluginRoots: string[] = [],
|
|
14
|
+
protectedRoots: string[] = [],
|
|
15
|
+
): Record<string, unknown> {
|
|
16
|
+
if (
|
|
17
|
+
!isAbsolute(privateRoot) ||
|
|
18
|
+
!isAbsolute(credentialFile) ||
|
|
19
|
+
!credentialFile.startsWith(`${privateRoot}/`) ||
|
|
20
|
+
[...pluginRoots, ...protectedRoots].some(
|
|
21
|
+
(root) => !isAbsolute(root) || /[\r\n()]/.test(root),
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
throw new Error("Claude private state paths are invalid");
|
|
25
|
+
return {
|
|
26
|
+
disableAllHooks: true,
|
|
27
|
+
sandbox: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
allowUnsandboxedCommands: false,
|
|
30
|
+
failIfUnavailable: true,
|
|
31
|
+
filesystem: {
|
|
32
|
+
denyRead: [privateRoot, ...protectedRoots],
|
|
33
|
+
denyWrite: [privateRoot, ...pluginRoots, ...protectedRoots],
|
|
34
|
+
},
|
|
35
|
+
credentials: {
|
|
36
|
+
files: [{ path: credentialFile, mode: "deny" }],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
permissions: {
|
|
40
|
+
allow: ["Bash", "Read", "Edit", "Skill", "Agent"],
|
|
41
|
+
deny: [
|
|
42
|
+
...[privateRoot, ...protectedRoots].flatMap((root) => [
|
|
43
|
+
absoluteRule(root, "Read"),
|
|
44
|
+
absoluteRule(root, "Edit"),
|
|
45
|
+
]),
|
|
46
|
+
...pluginRoots.map((root) => absoluteRule(root, "Edit")),
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { summarizeClaudeEvents } from "./claude-events";
|
|
3
|
+
|
|
4
|
+
const MAX_RETAINED_CALLS = 128;
|
|
5
|
+
type Entry = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function record(value: unknown): value is Entry {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function label(value: unknown, max = 256): string | null {
|
|
12
|
+
return typeof value === "string" &&
|
|
13
|
+
value.length >= 1 &&
|
|
14
|
+
value.length <= max &&
|
|
15
|
+
/^[A-Za-z0-9_.:-]+$/.test(value)
|
|
16
|
+
? value
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SkillCall {
|
|
21
|
+
ordinal: number;
|
|
22
|
+
actor: "parent" | "nested";
|
|
23
|
+
parentToolUseId: string | null;
|
|
24
|
+
name: "Skill";
|
|
25
|
+
skill: string;
|
|
26
|
+
invocation: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AgentCall {
|
|
30
|
+
ordinal: number;
|
|
31
|
+
actor: "parent" | "nested";
|
|
32
|
+
parentToolUseId: string | null;
|
|
33
|
+
name: "Agent";
|
|
34
|
+
toolUseId: string | null;
|
|
35
|
+
subagentType: string | null;
|
|
36
|
+
runInBackground: boolean | null;
|
|
37
|
+
model: string | null;
|
|
38
|
+
promptSha256: string | null;
|
|
39
|
+
promptFirstLineSha256: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ClaudeToolCallsObservation {
|
|
43
|
+
id: "sevro.claude.tool-calls";
|
|
44
|
+
completeness: "complete" | "partial";
|
|
45
|
+
data: {
|
|
46
|
+
method: "stream_tool_calls";
|
|
47
|
+
calls: Array<SkillCall | AgentCall>;
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Retain ordered host call metadata, never tool prompts or results. */
|
|
53
|
+
export function claudeToolCallsObservation(
|
|
54
|
+
stream: string,
|
|
55
|
+
exitCode: number,
|
|
56
|
+
): ClaudeToolCallsObservation {
|
|
57
|
+
const summary = summarizeClaudeEvents(stream, exitCode);
|
|
58
|
+
const calls: Array<SkillCall | AgentCall> = [];
|
|
59
|
+
let malformed = false;
|
|
60
|
+
let truncated = false;
|
|
61
|
+
let ordinal = 0;
|
|
62
|
+
for (const line of stream.split("\n")) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
let event: unknown;
|
|
65
|
+
try {
|
|
66
|
+
event = JSON.parse(line);
|
|
67
|
+
} catch {
|
|
68
|
+
malformed = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!record(event) || event.type !== "assistant") continue;
|
|
72
|
+
const blocks = record(event.message) ? event.message.content : null;
|
|
73
|
+
if (!Array.isArray(blocks)) {
|
|
74
|
+
malformed = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const parentToolUseId = label(event.parent_tool_use_id, 128);
|
|
78
|
+
const actor = parentToolUseId ? "nested" : "parent";
|
|
79
|
+
if (
|
|
80
|
+
event.parent_tool_use_id !== undefined &&
|
|
81
|
+
event.parent_tool_use_id !== null &&
|
|
82
|
+
actor === "parent"
|
|
83
|
+
)
|
|
84
|
+
malformed = true;
|
|
85
|
+
for (const value of blocks) {
|
|
86
|
+
if (!record(value) || value.type !== "tool_use") continue;
|
|
87
|
+
ordinal++;
|
|
88
|
+
if (!["Skill", "Agent", "Task"].includes(String(value.name))) continue;
|
|
89
|
+
if (calls.length >= MAX_RETAINED_CALLS) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const input = record(value.input) ? value.input : null;
|
|
94
|
+
if (value.name === "Skill") {
|
|
95
|
+
const invocation = label(input?.skill);
|
|
96
|
+
if (!invocation) {
|
|
97
|
+
malformed = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
calls.push({
|
|
101
|
+
ordinal,
|
|
102
|
+
actor,
|
|
103
|
+
parentToolUseId,
|
|
104
|
+
name: "Skill",
|
|
105
|
+
skill: invocation.split(":").at(-1)!,
|
|
106
|
+
invocation,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const toolUseId = label(value.id, 128);
|
|
111
|
+
const subagentType = label(input?.subagent_type ?? input?.subagentType);
|
|
112
|
+
const runInBackground =
|
|
113
|
+
typeof input?.run_in_background === "boolean"
|
|
114
|
+
? input.run_in_background
|
|
115
|
+
: null;
|
|
116
|
+
const model = input?.model === undefined ? null : label(input.model, 128);
|
|
117
|
+
const boundedPrompt =
|
|
118
|
+
typeof input?.prompt === "string" &&
|
|
119
|
+
Buffer.byteLength(input.prompt, "utf8") <= 1024 * 1024
|
|
120
|
+
? input.prompt
|
|
121
|
+
: null;
|
|
122
|
+
const promptSha256 = boundedPrompt
|
|
123
|
+
? createHash("sha256").update(boundedPrompt).digest("hex")
|
|
124
|
+
: null;
|
|
125
|
+
const promptFirstLineSha256 = boundedPrompt
|
|
126
|
+
? createHash("sha256")
|
|
127
|
+
.update(boundedPrompt.split(/\r?\n/, 1)[0]!)
|
|
128
|
+
.digest("hex")
|
|
129
|
+
: null;
|
|
130
|
+
if (
|
|
131
|
+
!toolUseId ||
|
|
132
|
+
!subagentType ||
|
|
133
|
+
runInBackground === null ||
|
|
134
|
+
(input?.model !== undefined && !model) ||
|
|
135
|
+
!promptSha256
|
|
136
|
+
)
|
|
137
|
+
malformed = true;
|
|
138
|
+
calls.push({
|
|
139
|
+
ordinal,
|
|
140
|
+
actor,
|
|
141
|
+
parentToolUseId,
|
|
142
|
+
name: "Agent",
|
|
143
|
+
toolUseId,
|
|
144
|
+
subagentType,
|
|
145
|
+
runInBackground,
|
|
146
|
+
model,
|
|
147
|
+
promptSha256,
|
|
148
|
+
promptFirstLineSha256,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (truncated) break;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
id: "sevro.claude.tool-calls",
|
|
155
|
+
completeness:
|
|
156
|
+
summary.complete && !malformed && !truncated ? "complete" : "partial",
|
|
157
|
+
data: { method: "stream_tool_calls", calls, truncated },
|
|
158
|
+
};
|
|
159
|
+
}
|