talentos 1.1.5 → 1.2.0

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.
@@ -0,0 +1,474 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { writePrivateFile } from "../../agent-config.js";
9
+ import { providerPathEnv } from "../../provider-binary.js";
10
+ import { ApprovalBridge } from "./approvals.js";
11
+ import { HOOK_ENTRY, ROOM_SERVER } from "./launch.js";
12
+ // The behavioural proof the owner-native profile rests on (#10987, R12 R3/R4).
13
+ // A real `claude` runs against a loopback Messages API that scripts tool calls,
14
+ // in an isolated HOME and config, with our hook installed exactly as in a job.
15
+ // A flag or version check proves nothing: the hook must be seen to fire, and its
16
+ // refusal to hold, in every permission mode and for subagent calls too.
17
+ // Cached per binary identity, version, probe revision and hook code; a changed
18
+ // rule or provider re-proves it. Never run per job.
19
+ export const PROBE_REVISION = 1;
20
+ const PROBE_MS = 45_000;
21
+ const SUB_MARK = "TALENTOS_SUBAGENT_PROBE";
22
+ const MODEL = "claude-sonnet-4-5";
23
+ export class OwnerNativeProbeError extends Error {
24
+ exitCode = 78;
25
+ constructor(version, detail) {
26
+ super(`Claude Code ${version} cannot safely run your full setup in rooms (${detail}). Update Claude Code, then run talentos agent native enable again. Rooms keep working without it.`);
27
+ }
28
+ }
29
+ const sse = (events) => events
30
+ .map(([name, data]) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)
31
+ .join("");
32
+ function message(model, id) {
33
+ return [
34
+ "message_start",
35
+ {
36
+ type: "message_start",
37
+ message: {
38
+ id,
39
+ type: "message",
40
+ role: "assistant",
41
+ model,
42
+ content: [],
43
+ stop_reason: null,
44
+ usage: { input_tokens: 1, output_tokens: 1 },
45
+ },
46
+ },
47
+ ];
48
+ }
49
+ function toolReply(model, id, tool) {
50
+ return sse([
51
+ message(model, id),
52
+ [
53
+ "content_block_start",
54
+ {
55
+ type: "content_block_start",
56
+ index: 0,
57
+ content_block: {
58
+ type: "tool_use",
59
+ id: `toolu_${id}`,
60
+ name: tool.name,
61
+ input: {},
62
+ },
63
+ },
64
+ ],
65
+ [
66
+ "content_block_delta",
67
+ {
68
+ type: "content_block_delta",
69
+ index: 0,
70
+ delta: {
71
+ type: "input_json_delta",
72
+ partial_json: JSON.stringify(tool.input),
73
+ },
74
+ },
75
+ ],
76
+ ["content_block_stop", { type: "content_block_stop", index: 0 }],
77
+ [
78
+ "message_delta",
79
+ {
80
+ type: "message_delta",
81
+ delta: { stop_reason: "tool_use" },
82
+ usage: { output_tokens: 5 },
83
+ },
84
+ ],
85
+ ["message_stop", { type: "message_stop" }],
86
+ ]);
87
+ }
88
+ function textReply(model, id, text) {
89
+ return sse([
90
+ message(model, id),
91
+ [
92
+ "content_block_start",
93
+ {
94
+ type: "content_block_start",
95
+ index: 0,
96
+ content_block: { type: "text", text: "" },
97
+ },
98
+ ],
99
+ [
100
+ "content_block_delta",
101
+ {
102
+ type: "content_block_delta",
103
+ index: 0,
104
+ delta: { type: "text_delta", text },
105
+ },
106
+ ],
107
+ ["content_block_stop", { type: "content_block_stop", index: 0 }],
108
+ [
109
+ "message_delta",
110
+ {
111
+ type: "message_delta",
112
+ delta: { stop_reason: "end_turn" },
113
+ usage: { output_tokens: 2 },
114
+ },
115
+ ],
116
+ ["message_stop", { type: "message_stop" }],
117
+ ]);
118
+ }
119
+ export async function runScenario(binary, scenario, hookEntry) {
120
+ const root = await mkdtemp(join(tmpdir(), "talentos-owner-probe-"));
121
+ const home = join(root, "home");
122
+ const cfg = join(root, "cfg");
123
+ const work = join(root, "work");
124
+ const jobDir = join(root, "job");
125
+ const created = join(work, "probe-created");
126
+ const userMarker = join(root, "user-hook-ran");
127
+ const results = [];
128
+ try {
129
+ await Promise.all([home, cfg, work, jobDir].map((dir) => mkdir(dir, { recursive: true })));
130
+ // A stand-in owner behind the real supervisor bridge: the ask is pending for
131
+ // `answerAfterMs`, then the server "says" the scenario's answer.
132
+ const startedAt = Date.now();
133
+ const owner = scenario.answer
134
+ ? await new ApprovalBridge({
135
+ gateway: {
136
+ request: () => Promise.resolve({ status: "pending" }),
137
+ status: (keys) => Promise.resolve(keys.map((actionKey) => ({
138
+ actionKey,
139
+ status: Date.now() - startedAt < (scenario.answerAfterMs ?? 0)
140
+ ? "pending"
141
+ : scenario.answer,
142
+ }))),
143
+ },
144
+ statusEveryMs: 50,
145
+ }).start()
146
+ : undefined;
147
+ await writeFile(join(jobDir, "context.json"), JSON.stringify({
148
+ ...(owner ? { askSocket: owner.socketPath } : {}),
149
+ home,
150
+ writeRoots: [realpathSync.native(work)],
151
+ }));
152
+ await writeFile(join(jobDir, "settings.json"), JSON.stringify({
153
+ hooks: {
154
+ PreToolUse: [
155
+ {
156
+ matcher: "*",
157
+ hooks: [
158
+ {
159
+ type: "command",
160
+ command: `"${process.execPath}" "${hookEntry}" "${jobDir}"`,
161
+ timeout: scenario.hookTimeoutSeconds ?? 10,
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ },
167
+ }));
168
+ await writeFile(join(jobDir, "mcp.json"), JSON.stringify({
169
+ mcpServers: {
170
+ [ROOM_SERVER]: {
171
+ command: process.execPath,
172
+ args: [join(root, "none.js")],
173
+ },
174
+ },
175
+ }));
176
+ // A stand-in for the owner's own setup: a user-level hook and MCP server.
177
+ await writeFile(join(cfg, "settings.json"), JSON.stringify({
178
+ permissions: scenario.ownerPermissions ?? {},
179
+ hooks: {
180
+ PreToolUse: [
181
+ {
182
+ matcher: "*",
183
+ hooks: [
184
+ {
185
+ type: "command",
186
+ command: `"${process.execPath}" -e "require('fs').writeFileSync(process.argv[1],'1')" "${userMarker}"`,
187
+ },
188
+ ],
189
+ },
190
+ ],
191
+ },
192
+ }));
193
+ const userServers = JSON.stringify({
194
+ mcpServers: {
195
+ owners_own: {
196
+ command: process.execPath,
197
+ args: [join(root, "none2.js")],
198
+ },
199
+ },
200
+ });
201
+ await writeFile(join(home, ".claude.json"), userServers);
202
+ await writeFile(join(cfg, ".claude.json"), userServers);
203
+ let counter = 0;
204
+ // Each scripted call is made once, so a provider that re-prompts cannot loop.
205
+ let mainSent = false;
206
+ let subSent = false;
207
+ const server = createServer((request, response) => {
208
+ let raw = "";
209
+ request.on("data", (chunk) => (raw += chunk));
210
+ request.on("end", () => {
211
+ if (!request.url?.startsWith("/v1/messages")) {
212
+ response.writeHead(200, { "content-type": "application/json" });
213
+ response.end("{}");
214
+ return;
215
+ }
216
+ const body = JSON.parse(raw || "{}");
217
+ const model = body.model ?? MODEL;
218
+ const id = `msg_${++counter}`;
219
+ const last = body.messages?.at(-1)?.content;
220
+ const result = Array.isArray(last)
221
+ ? last.find((part) => part.type === "tool_result")
222
+ : undefined;
223
+ response.writeHead(200, { "content-type": "text/event-stream" });
224
+ if (result) {
225
+ results.push(JSON.stringify(result.content));
226
+ response.end(textReply(model, id, "done"));
227
+ return;
228
+ }
229
+ if (!body.tools?.length) {
230
+ response.end(textReply(model, id, "ok"));
231
+ return;
232
+ }
233
+ const first = JSON.stringify(body.messages?.[0] ?? "");
234
+ if (scenario.sub && first.includes(SUB_MARK)) {
235
+ response.end(subSent
236
+ ? textReply(model, id, "done")
237
+ : toolReply(model, id, scenario.sub));
238
+ subSent = true;
239
+ return;
240
+ }
241
+ response.end(mainSent
242
+ ? textReply(model, id, "done")
243
+ : toolReply(model, id, scenario.main));
244
+ mainSent = true;
245
+ });
246
+ });
247
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
248
+ const address = server.address();
249
+ if (!address || typeof address === "string") {
250
+ throw new Error("No probe port");
251
+ }
252
+ const args = [
253
+ "-p",
254
+ "run the probe",
255
+ "--output-format",
256
+ "stream-json",
257
+ "--verbose",
258
+ "--settings",
259
+ join(jobDir, "settings.json"),
260
+ "--mcp-config",
261
+ join(jobDir, "mcp.json"),
262
+ "--model",
263
+ MODEL,
264
+ ...(scenario.mode ? ["--permission-mode", scenario.mode] : []),
265
+ ];
266
+ const child = spawn(binary, args, {
267
+ cwd: work,
268
+ env: {
269
+ PATH: providerPathEnv(binary, home),
270
+ HOME: home,
271
+ CLAUDE_CONFIG_DIR: cfg,
272
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
273
+ ANTHROPIC_API_KEY: `talentos-probe-${randomUUID()}`,
274
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
275
+ DISABLE_AUTOUPDATER: "1",
276
+ TMPDIR: tmpdir(),
277
+ },
278
+ stdio: ["ignore", "pipe", "ignore"],
279
+ });
280
+ let stdout = "";
281
+ child.stdout.on("data", (chunk) => (stdout += chunk));
282
+ const timer = setTimeout(() => child.kill("SIGKILL"), scenario.runMs ?? PROBE_MS);
283
+ await new Promise((resolve) => child.once("close", () => resolve()));
284
+ clearTimeout(timer);
285
+ owner?.stop();
286
+ server.closeAllConnections();
287
+ await new Promise((resolve) => server.close(() => resolve()));
288
+ const initServers = [];
289
+ for (const line of stdout.split("\n")) {
290
+ try {
291
+ const event = JSON.parse(line);
292
+ if (event.type === "system" && event.subtype === "init") {
293
+ initServers.push(...(event.mcp_servers ?? []).map((s) => s.name));
294
+ }
295
+ }
296
+ catch {
297
+ // Not JSON.
298
+ }
299
+ }
300
+ const calls = (await readFile(join(jobDir, "calls.jsonl"), "utf8").catch(() => ""))
301
+ .split("\n")
302
+ .filter(Boolean)
303
+ .map((line) => JSON.parse(line));
304
+ return {
305
+ calls,
306
+ created: existsSync(created) || existsSync(join(work, "probe-created-sub")),
307
+ initServers,
308
+ results,
309
+ userHookRan: existsSync(userMarker),
310
+ ran: existsSync(join(work, "probe-asked")),
311
+ wrote: existsSync(join(work, "probe-written")),
312
+ };
313
+ }
314
+ finally {
315
+ await rm(root, { force: true, recursive: true });
316
+ }
317
+ }
318
+ const writeTool = () => ({
319
+ name: "Write",
320
+ input: { file_path: "probe-written", content: "x" },
321
+ });
322
+ const mkdirAsk = () => ({
323
+ name: "Bash",
324
+ input: { command: "mkdir probe-asked" },
325
+ });
326
+ const touch = (name) => ({
327
+ name: "Bash",
328
+ input: { command: `touch ${name}` },
329
+ });
330
+ /** Throws OwnerNativeProbeError unless every guarantee holds. */
331
+ export async function probeOwnerNative(input) {
332
+ const hookEntry = input.hookEntry ?? HOOK_ENTRY;
333
+ const fail = (detail) => {
334
+ throw new OwnerNativeProbeError(input.version, detail);
335
+ };
336
+ const scenarios = [
337
+ ...["default", "acceptEdits", "bypassPermissions"].map((mode) => ({
338
+ main: touch("probe-created"),
339
+ mode,
340
+ name: `hook holds in ${mode}`,
341
+ })),
342
+ {
343
+ main: { name: "Bash", input: { command: "ls" } },
344
+ name: "vetted read is allowed",
345
+ },
346
+ // The hook may only tighten: a vetted edit runs, and the very same edit does
347
+ // not when the owner's own settings say to ask or to deny (headless, an ask
348
+ // is a refusal).
349
+ { main: writeTool(), mode: "default", name: "vetted edit is allowed" },
350
+ {
351
+ main: writeTool(),
352
+ mode: "default",
353
+ name: "owner ask rule holds",
354
+ ownerPermissions: { ask: ["Write"] },
355
+ },
356
+ // PR 2: the hook holds the call while the owner decides.
357
+ {
358
+ answer: "approved",
359
+ main: mkdirAsk(),
360
+ mode: "default",
361
+ name: "approved ask runs",
362
+ },
363
+ {
364
+ answer: "denied",
365
+ main: mkdirAsk(),
366
+ mode: "default",
367
+ name: "declined ask holds",
368
+ },
369
+ {
370
+ main: writeTool(),
371
+ mode: "default",
372
+ name: "owner deny rule holds",
373
+ ownerPermissions: { deny: ["Write"] },
374
+ },
375
+ {
376
+ main: {
377
+ name: "Agent",
378
+ input: {
379
+ description: "probe",
380
+ prompt: `${SUB_MARK} run it`,
381
+ subagent_type: "general-purpose",
382
+ },
383
+ },
384
+ name: "hook fires for subagents",
385
+ sub: touch("probe-created-sub"),
386
+ },
387
+ ];
388
+ let outcomes;
389
+ try {
390
+ outcomes = await Promise.all(scenarios.map((scenario) => runScenario(input.binary, scenario, hookEntry)));
391
+ }
392
+ catch {
393
+ return fail("the probe could not run");
394
+ }
395
+ for (const [index, scenario] of scenarios.entries()) {
396
+ const outcome = outcomes[index];
397
+ const denied = outcome.calls.filter((call) => call.tool === "Bash" && call.decision === "deny");
398
+ if (scenario.name.startsWith("hook holds")) {
399
+ if (outcome.created || denied.length === 0) {
400
+ fail(`the safety hook did not hold in ${scenario.mode} mode`);
401
+ }
402
+ }
403
+ else if (scenario.name === "vetted read is allowed") {
404
+ if (!outcome.calls.some((call) => call.tool === "Bash" && call.decision === "allow") ||
405
+ outcome.results.some((r) => r.includes("approval"))) {
406
+ fail("a vetted read was blocked");
407
+ }
408
+ if (!outcome.userHookRan) {
409
+ fail("your own hooks stopped running");
410
+ }
411
+ if (!(outcome.initServers.includes("owners_own") &&
412
+ outcome.initServers.includes(ROOM_SERVER))) {
413
+ fail("your own tool servers did not load next to the room tools");
414
+ }
415
+ }
416
+ else if (scenario.name === "approved ask runs") {
417
+ if (!outcome.ran) {
418
+ fail("an approved action did not run");
419
+ }
420
+ }
421
+ else if (scenario.name === "declined ask holds") {
422
+ if (outcome.ran) {
423
+ fail("a declined action ran");
424
+ }
425
+ }
426
+ else if (scenario.name === "vetted edit is allowed") {
427
+ if (!outcome.wrote) {
428
+ fail("a vetted edit was blocked");
429
+ }
430
+ }
431
+ else if (scenario.name === "owner ask rule holds" ||
432
+ scenario.name === "owner deny rule holds") {
433
+ if (outcome.wrote) {
434
+ fail("the hook overrode one of your own permission rules");
435
+ }
436
+ }
437
+ else if (!outcome.calls.some((call) => call.subagent && call.decision === "deny") ||
438
+ outcome.created) {
439
+ fail("the safety hook did not cover subagent calls");
440
+ }
441
+ }
442
+ }
443
+ function cacheKey(binary, version, hookEntry) {
444
+ const real = realpathSync.native(binary);
445
+ return createHash("sha256")
446
+ .update(JSON.stringify([
447
+ real,
448
+ statSync(real).mtimeMs,
449
+ version,
450
+ PROBE_REVISION,
451
+ readFileSync(hookEntry, "utf8"),
452
+ ]))
453
+ .digest("hex");
454
+ }
455
+ /** Cached probe: re-proves only when the binary, its version, the probe or the hook code changed. */
456
+ export async function ensureOwnerNativeProbe(input) {
457
+ const hookEntry = input.hookEntry ?? HOOK_ENTRY;
458
+ const key = cacheKey(input.binary, input.version, hookEntry);
459
+ try {
460
+ const cached = JSON.parse(await readFile(input.cachePath, "utf8"));
461
+ if (cached.key === key && cached.ok === true) {
462
+ return;
463
+ }
464
+ }
465
+ catch {
466
+ // No usable cache: prove it.
467
+ }
468
+ await probeOwnerNative({
469
+ binary: input.binary,
470
+ hookEntry,
471
+ version: input.version,
472
+ });
473
+ await writePrivateFile(input.cachePath, `${JSON.stringify({ key, ok: true, at: Date.now() })}\n`);
474
+ }
@@ -0,0 +1,174 @@
1
+ // A deliberately small shell reader for the owner-native hook (#10987). It
2
+ // understands quotes, `;`, `&&`, `||`, `|` and simple redirections, and it
3
+ // REFUSES everything else it cannot reason about (substitution, expansion,
4
+ // subshells, heredocs, comments, background jobs, env assignments). A refusal
5
+ // is "unparseable", which the policy treats as the strictest ask.
6
+ const DIGIT = /\d/u;
7
+ const ONE_DIGIT = /^\d$/u;
8
+ const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
9
+ const SEPARATORS = new Set([";", "|", "&", "\n"]);
10
+ export function parseShell(source) {
11
+ const commands = [];
12
+ let argv = [];
13
+ let redirects = [];
14
+ let word = "";
15
+ let started = false;
16
+ let glob = false;
17
+ let pending;
18
+ const fail = (reason) => ({ ok: false, reason });
19
+ const endWord = () => {
20
+ if (!started) {
21
+ return;
22
+ }
23
+ const finished = { glob, text: word };
24
+ if (pending) {
25
+ redirects.push({ mode: pending, target: finished });
26
+ pending = undefined;
27
+ }
28
+ else {
29
+ argv.push(finished);
30
+ }
31
+ word = "";
32
+ started = false;
33
+ glob = false;
34
+ };
35
+ const endCommand = () => {
36
+ endWord();
37
+ if (pending) {
38
+ return false;
39
+ }
40
+ if (argv.length > 0) {
41
+ commands.push({ argv, redirects });
42
+ }
43
+ else if (redirects.length > 0) {
44
+ return false;
45
+ }
46
+ argv = [];
47
+ redirects = [];
48
+ return true;
49
+ };
50
+ for (let i = 0; i < source.length; i++) {
51
+ const c = source[i];
52
+ if (c === "'") {
53
+ const close = source.indexOf("'", i + 1);
54
+ if (close < 0) {
55
+ return fail("unterminated quote");
56
+ }
57
+ word += source.slice(i + 1, close);
58
+ started = true;
59
+ i = close;
60
+ continue;
61
+ }
62
+ if (c === '"') {
63
+ let j = i + 1;
64
+ for (; j < source.length && source[j] !== '"'; j++) {
65
+ const d = source[j];
66
+ if (d === "$" || d === "`") {
67
+ return fail("expansion inside quotes");
68
+ }
69
+ if (d === "\\") {
70
+ j++;
71
+ word += source[j] ?? "";
72
+ continue;
73
+ }
74
+ word += d;
75
+ }
76
+ if (j >= source.length) {
77
+ return fail("unterminated quote");
78
+ }
79
+ started = true;
80
+ i = j;
81
+ continue;
82
+ }
83
+ if (c === "\\") {
84
+ const next = source[i + 1];
85
+ if (next === undefined || next === "\n") {
86
+ return fail("line continuation");
87
+ }
88
+ word += next;
89
+ started = true;
90
+ i++;
91
+ continue;
92
+ }
93
+ if (c === "$" || c === "`" || c === "(" || c === ")" || c === "#") {
94
+ return fail("expansion, subshell or comment");
95
+ }
96
+ if (c === "{" || c === "}" || c === "!") {
97
+ return fail("shell grouping");
98
+ }
99
+ if (c === " " || c === "\t" || c === "\r") {
100
+ endWord();
101
+ continue;
102
+ }
103
+ if (c === ">" || c === "<") {
104
+ // `2>&1` and `>&2` duplicate descriptors; anything else after `&` is background/`&>`.
105
+ if (c === ">" && source[i + 1] === "&") {
106
+ const fd = source[i + 2];
107
+ if (fd !== undefined && DIGIT.test(fd)) {
108
+ if (started && !ONE_DIGIT.test(word)) {
109
+ return fail("odd redirection");
110
+ }
111
+ word = "";
112
+ started = false;
113
+ i += 2;
114
+ continue;
115
+ }
116
+ return fail("odd redirection");
117
+ }
118
+ if (c === "<" && (source[i + 1] === "<" || source[i + 1] === "(")) {
119
+ return fail("heredoc or process substitution");
120
+ }
121
+ if (c === ">" && source[i + 1] === "(") {
122
+ return fail("process substitution");
123
+ }
124
+ // A leading file descriptor (`2>`) belongs to the redirection.
125
+ if (started && ONE_DIGIT.test(word)) {
126
+ word = "";
127
+ started = false;
128
+ }
129
+ else {
130
+ endWord();
131
+ }
132
+ const append = c === ">" && source[i + 1] === ">";
133
+ if (append) {
134
+ i++;
135
+ }
136
+ if (pending) {
137
+ return fail("odd redirection");
138
+ }
139
+ pending = c === "<" ? "read" : "write";
140
+ continue;
141
+ }
142
+ if (SEPARATORS.has(c)) {
143
+ if (c === "&" && source[i + 1] !== "&") {
144
+ return fail("background job");
145
+ }
146
+ if (!endCommand()) {
147
+ return fail("empty command");
148
+ }
149
+ if ((c === "&" || c === "|") && source[i + 1] === c) {
150
+ i++;
151
+ }
152
+ continue;
153
+ }
154
+ if (c === "*" || c === "?" || c === "[") {
155
+ glob = true;
156
+ }
157
+ word += c;
158
+ started = true;
159
+ }
160
+ if (!endCommand()) {
161
+ return fail("empty command");
162
+ }
163
+ if (commands.length === 0) {
164
+ return fail("empty command");
165
+ }
166
+ // `FOO=bar cmd` changes what the command does (PATH, LD_PRELOAD, GIT_*).
167
+ for (const command of commands) {
168
+ const first = command.argv[0];
169
+ if (first && ASSIGNMENT.test(first.text)) {
170
+ return fail("environment assignment");
171
+ }
172
+ }
173
+ return { ok: true, commands };
174
+ }