mtech-ai-local-runner 0.1.65 → 0.1.67
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/README.md +3 -2
- package/dist/src/adapters/claude-code/adapter.js +1 -1
- package/dist/src/adapters/claude-code/prompt-compiler.js +16 -1
- package/dist/src/adapters/claude-code/runtime-options.js +8 -8
- package/dist/src/adapters/codex/prompt-compiler.js +2 -24
- package/dist/src/adapters/completion-report.js +31 -8
- package/dist/src/adapters/local-tool-prompt.js +25 -0
- package/dist/src/asks/hook-client.js +16 -13
- package/dist/src/guide/hook-client.js +8 -7
- package/dist/src/safety/claude-production-host.js +6 -5
- package/dist/src/safety/claude-review-campaign.js +5 -3
- package/dist/src/safety/claude-review-layout.js +37 -12
- package/dist/src/safety/claude-review-run.js +5 -3
- package/dist/src/safety/hook-policy-builder.js +3 -1
- package/dist/src/safety/job-tool-dispatcher.js +6 -2
- package/dist/src/safety/job-tool-session.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,8 +175,9 @@ Only a session that a person is attending is served. A Claude Code session count
|
|
|
175
175
|
is `1` or, where Claude Code does not export that variable, when `CLAUDE_CODE_ENTRYPOINT` names a terminal, the Claude
|
|
176
176
|
desktop app or the VS Code extension. A headless `claude -p` or Agent SDK run gets nothing and is never counted as
|
|
177
177
|
having seen an ask. Codex exports no such marker, so every Codex hook session counts. The runner's own jobs never
|
|
178
|
-
count: a hook
|
|
179
|
-
|
|
178
|
+
count: a hook that sees the runner's hook policy variable, or a `TMPDIR` under `<stateRoot>/claude-jobs/` (where every
|
|
179
|
+
Claude job keeps its controls), is refused. A Claude job keeps the person's `HOME`, so `HOME` alone does not tell it
|
|
180
|
+
apart. Codex jobs run with `features.hooks=false`, so they run no hook at all.
|
|
180
181
|
|
|
181
182
|
An ask is shown at a session start, at the next prompt (where it also counts as seen), at a turn end, or by waking an
|
|
182
183
|
idle Claude session through an `asyncRewake` Stop watcher. An ask shown at a session start, at a turn end or by a wake
|
|
@@ -243,7 +243,7 @@ export class ClaudeCodeAdapter {
|
|
|
243
243
|
continue;
|
|
244
244
|
if (parsed.eventType === "completed") {
|
|
245
245
|
const observedUsage = parsed.payload?.usage;
|
|
246
|
-
parsed = completionEvent(message.result, parsed.payload?.cost_usd);
|
|
246
|
+
parsed = completionEvent(message.result, parsed.payload?.cost_usd, { earlierCodeBlocks: true });
|
|
247
247
|
if (this.p1)
|
|
248
248
|
parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
|
|
249
249
|
}
|
|
@@ -1 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import { compilePrompt as compileBrief } from "../prompt-compiler.js";
|
|
2
|
+
import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
|
|
3
|
+
// Claude's working directory is the job's private control directory, so without these rules it sends that directory's
|
|
4
|
+
// absolute path or shell commands to the ai_local tools (P4 Investigate of Work Item 1c8026f1, 2026-10-28).
|
|
5
|
+
const CLAUDE_PATH_RULES = "## Local tool path rules\n" +
|
|
6
|
+
"Every path argument is one checkout-relative file path inside the approved scope, without a leading slash: write " +
|
|
7
|
+
"/Modules/Example.php as Modules/Example.php. Never pass your working directory or any absolute path. " +
|
|
8
|
+
"local_search searches only inside that one file; there is no directory listing or repository-wide search, so take " +
|
|
9
|
+
"file paths from the objective and from files you have read. local_test runs only a registered command ID, never a " +
|
|
10
|
+
"shell command. A DENIED_* result names the rule your call broke, such as DENIED_PATH for an absolute path, " +
|
|
11
|
+
"DENIED_FILE for a directory, DENIED_SCOPE for a path outside the approved scope or DENIED_COMMAND for an unregistered " +
|
|
12
|
+
"command: send a corrected call instead of concluding that source is unavailable. LOCAL_TOOL_FAILED means the call " +
|
|
13
|
+
"failed for another reason, such as a file that does not exist.";
|
|
14
|
+
export function compilePrompt(taskPackage, options) {
|
|
15
|
+
return `${compileBrief(taskPackage, options)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${CLAUDE_PATH_RULES}`;
|
|
16
|
+
}
|
|
@@ -54,16 +54,16 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
54
54
|
if (inside(checkout, state))
|
|
55
55
|
throw new Error('Claude job controls must be outside the checkout');
|
|
56
56
|
const control = path.join(state, 'claude-jobs', randomUUID());
|
|
57
|
-
const
|
|
57
|
+
const cwd = path.join(control, 'cwd'), config = path.join(control, 'mcp.json');
|
|
58
58
|
const env = {};
|
|
59
|
-
|
|
59
|
+
// Claude owns credential access, so it runs in the employee's own login context: their HOME, USER and LOGNAME, and
|
|
60
|
+
// CLAUDE_CONFIG_DIR only when they set it. macOS finds the login keychain through HOME, and a set CLAUDE_CONFIG_DIR
|
|
61
|
+
// moves Claude's Keychain item, so either change would hide the terminal's login (Work Decision b501d68d, design
|
|
62
|
+
// 2026-10-28-claude-job-login-context). Never read or copy its files or send this environment to scoped workers.
|
|
63
|
+
for (const key of ['PATH', 'LANG', 'LC_ALL', 'USER', 'LOGNAME', 'HOME', 'CLAUDE_CONFIG_DIR'])
|
|
60
64
|
if (input.env[key] !== undefined)
|
|
61
65
|
env[key] = input.env[key];
|
|
62
|
-
env.
|
|
63
|
-
env.TMPDIR = path.join(control, 'tmp'); // Claude owns credential access. Preserve the account location by path only;
|
|
64
|
-
// never read/copy its files or send this environment to scoped workers.
|
|
65
|
-
// https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts
|
|
66
|
-
env.CLAUDE_CONFIG_DIR = account;
|
|
66
|
+
env.TMPDIR = path.join(control, 'tmp');
|
|
67
67
|
if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
|
|
68
68
|
throw new Error('Claude account configuration path must be absolute');
|
|
69
69
|
return {
|
|
@@ -114,7 +114,7 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
114
114
|
throw Error('CLAUDE_CONTROL_ALIAS_REJECTED');
|
|
115
115
|
await mkdir(control, { mode: 0o700 });
|
|
116
116
|
owned = true;
|
|
117
|
-
for (const directory of [
|
|
117
|
+
for (const directory of [cwd, env.TMPDIR])
|
|
118
118
|
await mkdir(directory, { mode: 0o700 });
|
|
119
119
|
let active = true;
|
|
120
120
|
const tools = new JobToolSession({
|
|
@@ -1,29 +1,7 @@
|
|
|
1
1
|
import { compilePrompt as compileBrief } from "../prompt-compiler.js";
|
|
2
|
-
|
|
3
|
-
const repositories = taskPackage.repositories;
|
|
4
|
-
const repo = Array.isArray(repositories) && repositories.length === 1
|
|
5
|
-
&& repositories[0] && typeof repositories[0] === "object" && !Array.isArray(repositories[0])
|
|
6
|
-
? repositories[0] : undefined;
|
|
7
|
-
const paths = (value) => Array.isArray(value)
|
|
8
|
-
&& value.every(item => typeof item === "string" && item.length > 0);
|
|
9
|
-
if (!repo || !paths(repo.allowed_paths) || !repo.allowed_paths.length || !paths(repo.blocked_paths)
|
|
10
|
-
|| !["read", "write"].includes(String(repo.access))
|
|
11
|
-
|| typeof repo.repository_key !== "string" || typeof repo.base_commit_sha !== "string"
|
|
12
|
-
|| !/^[a-f0-9]{40}$/i.test(repo.base_commit_sha)) {
|
|
13
|
-
return "## Approved repository scope\nRepository scope is unavailable; do not guess source paths or commands. Stop and emit one final blocked report.";
|
|
14
|
-
}
|
|
15
|
-
// Project only source-scope fields from the binding bundle. Never stringify
|
|
16
|
-
// the whole repository: it may contain transport coordinates or write leases.
|
|
17
|
-
const scope = { repository_key: repo.repository_key, base_commit_sha: repo.base_commit_sha,
|
|
18
|
-
access: repo.access, allowed_paths: repo.allowed_paths, blocked_paths: repo.blocked_paths };
|
|
19
|
-
return `## Approved repository scope\n${JSON.stringify(scope, null, 2)}\n` +
|
|
20
|
-
"Use these checkout-relative paths exactly, including case. Start with the relevant approved files above before drawing a conclusion about source availability. " +
|
|
21
|
-
"Do not invent paths or commands from framework conventions. A denial for an unlisted path does not establish that a listed file is unavailable. " +
|
|
22
|
-
"Do not claim an approved file was denied unless its exact path was attempted and the tool returned a denial. " +
|
|
23
|
-
"These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
|
|
24
|
-
}
|
|
2
|
+
import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
|
|
25
3
|
export function compileCodexPrompt(taskPackage, label) {
|
|
26
|
-
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\
|
|
4
|
+
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}`;
|
|
27
5
|
}
|
|
28
6
|
export const CODEX_REPORT_SCHEMA = {
|
|
29
7
|
type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
|
|
@@ -75,8 +75,33 @@ export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the
|
|
|
75
75
|
function boundedText(value, max) {
|
|
76
76
|
return typeof value === "string" && value.trim().length > 0 && value.length <= max;
|
|
77
77
|
}
|
|
78
|
+
/** The body of the final fenced block when it can only be the report: it ends the text, and no other block may be one. */
|
|
79
|
+
function finalFencedReport(result, options) {
|
|
80
|
+
const text = result.trim(), blocks = [];
|
|
81
|
+
let open;
|
|
82
|
+
for (const line of text.split(/\r?\n/)) {
|
|
83
|
+
if (!open) {
|
|
84
|
+
const fence = line.match(/^```([^`\s]*)[ \t]*$/);
|
|
85
|
+
if (fence)
|
|
86
|
+
blocks.push(open = { info: fence[1], body: [] });
|
|
87
|
+
}
|
|
88
|
+
else if (line.trimEnd() === "```")
|
|
89
|
+
open = undefined;
|
|
90
|
+
else
|
|
91
|
+
open.body.push(line);
|
|
92
|
+
}
|
|
93
|
+
const last = blocks.at(-1);
|
|
94
|
+
if (!last || open || !text.endsWith("```") || (text.match(/```/g) ?? []).length !== blocks.length * 2)
|
|
95
|
+
return undefined;
|
|
96
|
+
const earlier = blocks.slice(0, -1);
|
|
97
|
+
if (earlier.length === 0)
|
|
98
|
+
return last.info === "json" || last.info === "" ? last.body.join("\n") : undefined;
|
|
99
|
+
if (!options.earlierCodeBlocks || last.info !== "json" || earlier.some(block => block.info === "json" || block.info === ""))
|
|
100
|
+
return undefined;
|
|
101
|
+
return last.body.join("\n");
|
|
102
|
+
}
|
|
78
103
|
/** Decode bounded framing only. Callers must still validate status and evidence. */
|
|
79
|
-
export function completionEnvelope(result) {
|
|
104
|
+
export function completionEnvelope(result, options = {}) {
|
|
80
105
|
if (typeof result !== "string" || result.length > MAX_REPORT_ENVELOPE_CHARS)
|
|
81
106
|
return undefined;
|
|
82
107
|
let report;
|
|
@@ -84,13 +109,11 @@ export function completionEnvelope(result) {
|
|
|
84
109
|
report = JSON.parse(result);
|
|
85
110
|
}
|
|
86
111
|
catch {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const framed = result.trim().match(/^(?:[\s\S]*?\n)?```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/);
|
|
90
|
-
if (!framed)
|
|
112
|
+
const framed = finalFencedReport(result, options);
|
|
113
|
+
if (framed === undefined)
|
|
91
114
|
return undefined;
|
|
92
115
|
try {
|
|
93
|
-
report = JSON.parse(framed
|
|
116
|
+
report = JSON.parse(framed);
|
|
94
117
|
}
|
|
95
118
|
catch {
|
|
96
119
|
return undefined;
|
|
@@ -100,7 +123,7 @@ export function completionEnvelope(result) {
|
|
|
100
123
|
? report : undefined;
|
|
101
124
|
}
|
|
102
125
|
/** Validate the full final result before truncation; never publish malformed/interim output. */
|
|
103
|
-
export function completionEvent(result, costUsd) {
|
|
126
|
+
export function completionEvent(result, costUsd, options = {}) {
|
|
104
127
|
const usage = typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0
|
|
105
128
|
? { cost_usd: costUsd }
|
|
106
129
|
: {};
|
|
@@ -109,7 +132,7 @@ export function completionEvent(result, costUsd) {
|
|
|
109
132
|
summary: "completion report is missing, incomplete or invalid",
|
|
110
133
|
payload: usage,
|
|
111
134
|
};
|
|
112
|
-
const value = completionEnvelope(result);
|
|
135
|
+
const value = completionEnvelope(result, options);
|
|
113
136
|
const reject = (code) => ({ ...failed, payload: { ...usage, completion_rejection: { version: 'completion-rejection-v1', code } } });
|
|
114
137
|
if (!value)
|
|
115
138
|
return reject('ENVELOPE_INVALID');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Approved source scope and ai_local tool guidance for CLI adapters whose model sees only the job MCP tools.
|
|
2
|
+
export function repositoryScope(taskPackage) {
|
|
3
|
+
const repositories = taskPackage.repositories;
|
|
4
|
+
const repo = Array.isArray(repositories) && repositories.length === 1
|
|
5
|
+
&& repositories[0] && typeof repositories[0] === "object" && !Array.isArray(repositories[0])
|
|
6
|
+
? repositories[0] : undefined;
|
|
7
|
+
const paths = (value) => Array.isArray(value)
|
|
8
|
+
&& value.every(item => typeof item === "string" && item.length > 0);
|
|
9
|
+
if (!repo || !paths(repo.allowed_paths) || !repo.allowed_paths.length || !paths(repo.blocked_paths)
|
|
10
|
+
|| !["read", "write"].includes(String(repo.access))
|
|
11
|
+
|| typeof repo.repository_key !== "string" || typeof repo.base_commit_sha !== "string"
|
|
12
|
+
|| !/^[a-f0-9]{40}$/i.test(repo.base_commit_sha)) {
|
|
13
|
+
return "## Approved repository scope\nRepository scope is unavailable; do not guess source paths or commands. Stop and emit one final blocked report.";
|
|
14
|
+
}
|
|
15
|
+
// Project only source-scope fields from the binding bundle. Never stringify
|
|
16
|
+
// the whole repository: it may contain transport coordinates or write leases.
|
|
17
|
+
const scope = { repository_key: repo.repository_key, base_commit_sha: repo.base_commit_sha,
|
|
18
|
+
access: repo.access, allowed_paths: repo.allowed_paths, blocked_paths: repo.blocked_paths };
|
|
19
|
+
return `## Approved repository scope\n${JSON.stringify(scope, null, 2)}\n` +
|
|
20
|
+
"Use these checkout-relative paths exactly, including case. Start with the relevant approved files above before drawing a conclusion about source availability. " +
|
|
21
|
+
"Do not invent paths or commands from framework conventions. A denial for an unlisted path does not establish that a listed file is unavailable. " +
|
|
22
|
+
"Do not claim an approved file was denied unless its exact path was attempted and the tool returned a denial. " +
|
|
23
|
+
"These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
|
|
24
|
+
}
|
|
25
|
+
export const LOCAL_TOOL_GUIDANCE = "Use only the ai_local job tools for repository reads, edits and approved tests. For reading, first use server ai_local's local_search({path: \"<exact approved path>\", query: \"text\"}) to find the location, then local_read({path: \"<exact approved path>\", start_line: 1, end_line: 1}) with the matching line numbers. Ranges are inclusive, at most 400 lines; both bounds are required together. Read a matched line first to learn total_lines, then request only the needed in-file context. Omit both bounds only when the entire file is necessary. Use approved checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.";
|
|
@@ -13,29 +13,32 @@ const STATE_ROOT_FLAG = "--state-root=";
|
|
|
13
13
|
* Agent SDK, mcp, local-agent, remote and the GitHub action for the rest), so any other value is not a person.
|
|
14
14
|
*/
|
|
15
15
|
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
16
|
-
/** Where the Claude adapter puts each job's
|
|
16
|
+
/** Where the Claude adapter puts each job's controls, its TMPDIR among them: <stateRoot>/claude-jobs/<id>/
|
|
17
|
+
* (src/adapters/claude-code/runtime-options.ts). Runners before 0.1.66 also put the job's HOME there. */
|
|
17
18
|
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/** HOOK_POLICY_ENV_VAR of src/safety/hook-policy-builder.ts, named here so this client loads no safety module; a test keeps the two in step. */
|
|
20
|
+
const RUNNER_HOOK_POLICY_ENV = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
|
|
21
|
+
/** True when the hook runs in a Claude job this runner started, never a person's session: the environment carries the
|
|
22
|
+
* runner's hook policy, which every job launch sets and the runner's own hook needs, or its TMPDIR or HOME resolves
|
|
23
|
+
* inside <stateRoot>/claude-jobs/. A job keeps the person's HOME, so HOME alone no longer tells it apart. */
|
|
24
|
+
function isRunnerJob(env, stateRoot) {
|
|
25
|
+
if (env[RUNNER_HOOK_POLICY_ENV] !== undefined)
|
|
26
|
+
return true;
|
|
23
27
|
const roots = new Set([resolve(stateRoot)]);
|
|
24
|
-
// The adapter builds job
|
|
28
|
+
// The adapter builds job controls from the real state root; the shim may name it through a symlink.
|
|
25
29
|
try {
|
|
26
30
|
roots.add(realpathSync(stateRoot));
|
|
27
31
|
}
|
|
28
32
|
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
-
|
|
30
|
-
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
33
|
+
return [env.TMPDIR, env.HOME].some(dir => !!dir && isAbsolute(dir) && [...roots].some(root => resolve(dir).startsWith(join(root, CLAUDE_JOBS_DIR) + sep)));
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
33
36
|
* True when a person is attending the session that runs this hook (owner decision 2026-10-23). The one place that
|
|
34
37
|
* decides it, so it can be adjusted after the pilot.
|
|
35
38
|
*
|
|
36
|
-
* The runner's own jobs never count. A Claude job
|
|
37
|
-
*
|
|
38
|
-
* adapter's isolation overrides), so they never run a hook at all.
|
|
39
|
+
* The runner's own jobs never count. A Claude job carries the runner's hook policy and a TMPDIR under
|
|
40
|
+
* <stateRoot>/claude-jobs/, so a hook that sees either is refused whatever else the environment says. Codex jobs run
|
|
41
|
+
* with features.hooks=false (the Codex adapter's isolation overrides), so they never run a hook at all.
|
|
39
42
|
*
|
|
40
43
|
* Claude Code: CLAUDE_CODE_SESSION_ATTENDED, where the Claude Code version exports it, is the answer: "1" is a
|
|
41
44
|
* person, any other value is not, whatever the entrypoint says (a scheduled desktop task has the desktop entrypoint
|
|
@@ -47,7 +50,7 @@ function isRunnerJobHome(env, stateRoot) {
|
|
|
47
50
|
* `input` is the parsed hook input, passed for the day Codex exposes such a marker.
|
|
48
51
|
*/
|
|
49
52
|
export function isHumanSession(app, env, _input, stateRoot) {
|
|
50
|
-
if (
|
|
53
|
+
if (isRunnerJob(env, stateRoot))
|
|
51
54
|
return false;
|
|
52
55
|
if (app !== "claude")
|
|
53
56
|
return true;
|
|
@@ -16,22 +16,23 @@ const START_SOURCES = new Set(["startup", "clear", "compact"]);
|
|
|
16
16
|
/** The same values as the asks hook client (src/asks/hook-client.ts): a test keeps the two in step. */
|
|
17
17
|
const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
|
|
18
18
|
const CLAUDE_JOBS_DIR = "claude-jobs";
|
|
19
|
+
/** HOOK_POLICY_ENV_VAR of src/safety/hook-policy-builder.ts, as in the asks hook client; a test keeps them in step. */
|
|
20
|
+
const RUNNER_HOOK_POLICY_ENV = "AI_GATEWAY_RUNNER_HOOK_POLICY_B64";
|
|
19
21
|
const TRUNCATED_SESSION_ID = /"session_id"\s*:\s*"([^"\\]{1,128})"/;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
22
|
+
/** The same rule as the asks hook client: the runner's hook policy, or a TMPDIR or HOME inside <stateRoot>/claude-jobs/. */
|
|
23
|
+
function isRunnerJob(env, stateRoot) {
|
|
24
|
+
if (env[RUNNER_HOOK_POLICY_ENV] !== undefined)
|
|
25
|
+
return true;
|
|
24
26
|
const roots = new Set([resolve(stateRoot)]);
|
|
25
27
|
try {
|
|
26
28
|
roots.add(realpathSync(stateRoot));
|
|
27
29
|
}
|
|
28
30
|
catch { /* A state root that does not exist holds no jobs. */ }
|
|
29
|
-
|
|
30
|
-
return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
|
|
31
|
+
return [env.TMPDIR, env.HOME].some(dir => !!dir && isAbsolute(dir) && [...roots].some(root => resolve(dir).startsWith(join(root, CLAUDE_JOBS_DIR) + sep)));
|
|
31
32
|
}
|
|
32
33
|
/** True when a person attends the session: the asks hook client's rule, repeated so this client loads nothing of asks. */
|
|
33
34
|
export function isHumanSession(app, env, stateRoot) {
|
|
34
|
-
if (
|
|
35
|
+
if (isRunnerJob(env, stateRoot))
|
|
35
36
|
return false;
|
|
36
37
|
if (app !== "claude")
|
|
37
38
|
return true;
|
|
@@ -47,15 +47,16 @@ const CLAUDE_LOGIN_PROBE_TIMEOUT_MS = 10_000;
|
|
|
47
47
|
/** Readiness only: after a signature failure, the same release and copy identity is not re-hashed or run through
|
|
48
48
|
* codesign again before this delay; a new identity, a new listing or the delay re-checks at once. */
|
|
49
49
|
export const CLAUDE_SIGNATURE_RECHECK_MS = 5 * 60_000;
|
|
50
|
-
/** `auth status --json` of the pinned copy, never the employee's own install, with a fixed environment
|
|
51
|
-
*
|
|
50
|
+
/** `auth status --json` of the pinned copy, never the employee's own install, with a fixed environment in the job's login
|
|
51
|
+
* context: the employee's HOME, USER and LOGNAME, and CLAUDE_CONFIG_DIR only when they set it (runtime-options.ts).
|
|
52
|
+
* The documented Claude Code opt-outs keep the probe from updating itself or sending other traffic.
|
|
52
53
|
* `timeoutMs` is a trusted in-process test seam only. */
|
|
53
54
|
export function pinnedClaudeLogin(home, env, timeoutMs = CLAUDE_LOGIN_PROBE_TIMEOUT_MS) {
|
|
54
55
|
return binary => new Promise(resolve => {
|
|
55
|
-
const account = env.CLAUDE_CONFIG_DIR
|
|
56
|
-
if (!path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
56
|
+
const account = env.CLAUDE_CONFIG_DIR;
|
|
57
|
+
if (account !== undefined && !path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
57
58
|
return resolve({ authenticated: false, loginStatus: 'unknown' });
|
|
58
|
-
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, CLAUDE_CONFIG_DIR: account,
|
|
59
|
+
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, ...(account === undefined ? {} : { CLAUDE_CONFIG_DIR: account }),
|
|
59
60
|
DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
60
61
|
for (const key of ['USER', 'LOGNAME']) {
|
|
61
62
|
const value = env[key];
|
|
@@ -14,7 +14,7 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, clau
|
|
|
14
14
|
import { CLAUDE_CODE_REQUIREMENT, verifyClaudeCodeSignature } from './claude-release-copy.js';
|
|
15
15
|
import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
|
|
16
16
|
import { CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, CLAUDE_REVIEW_PROFILE_VERSION } from './claude-review-sandbox.js';
|
|
17
|
-
import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_PLANTED_SOURCES, createClaudeReviewLayout } from './claude-review-layout.js';
|
|
17
|
+
import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_KEYCHAIN_SERVICES, CLAUDE_REVIEW_PLANTED_SOURCES, createClaudeReviewLayout } from './claude-review-layout.js';
|
|
18
18
|
import { CLAUDE_REVIEW_FAKE_KEY, CLAUDE_REVIEW_PROMPT, CLAUDE_REVIEW_RUN_TIMEOUT_MS, CLAUDE_REVIEW_SLASH_PROMPT, CLAUDE_REVIEW_TOOL_IDS, claudeReviewEnv, runClaudeReview, runClaudeVersion } from './claude-review-run.js';
|
|
19
19
|
import { CLAUDE_REVIEW_STAGES, buildClaudeReviewEvidence } from './claude-review-evidence.js';
|
|
20
20
|
const AI_LOCAL = Object.freeze([...CLAUDE_LOCAL_MCP_TOOLS].sort());
|
|
@@ -66,7 +66,9 @@ export function evaluateClaudeReview(input) {
|
|
|
66
66
|
scenario('fake_credentials', { fake_key_only: scripted.length > 0 && runs.every(r => messages(r).every(q => q.apiKey === CLAUDE_REVIEW_FAKE_KEY)),
|
|
67
67
|
no_authorization_header: runs.every(r => messages(r).every(q => !q.authorization)), no_credential_content: hits('credential') === 0,
|
|
68
68
|
fake_key_not_in_body: runs.every(r => r.fakeKeyInBody === 0), environment_exact: every(runs, r => isDeepStrictEqual(r.envKeys, [...input.expectedEnvKeys])),
|
|
69
|
-
keychain_reads_only: runs.every(r => r.
|
|
69
|
+
keychain_reads_only: runs.every(r => r.keychainLookups.every(k => CLAUDE_REVIEW_KEYCHAIN_READS.includes(k.operation))),
|
|
70
|
+
// Each run looks up the Keychain, and only under the terminal login's services: no config-dir hash suffix.
|
|
71
|
+
keychain_service_default: runs.every(r => r.keychainLookups.length > 0 && r.keychainLookups.every(k => CLAUDE_REVIEW_KEYCHAIN_SERVICES.includes(k.service))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
|
|
70
72
|
scenario('communication_boundary', { sandboxed: every(runs, r => r.sandboxed) && input.version.sandboxed, no_unlisted_destination: runs.every(r => r.decoyConnections === 0) }, { sandboxed_runs: runs.filter(r => r.sandboxed).length + (input.version.sandboxed ? 1 : 0) }),
|
|
71
73
|
scenario('cleanup', { runs_terminal: every(runs, r => r.terminal), owned_exit_confirmed: every(runs, r => r.exitConfirmed) && input.version.exitConfirmed,
|
|
72
74
|
temp_removed: input.tempRemoved }, { owned_exits: runs.length + 1 }),
|
|
@@ -119,7 +121,7 @@ export async function runClaudeReviewCampaign(input) {
|
|
|
119
121
|
...(input.authObservation ? { authObservation: { sha256: input.authObservation.sha256, decision_id: input.authObservation.observation.decision_id } } : {}),
|
|
120
122
|
observations: { started_at: startedAt, finished_at: now().toISOString(), root: layout.root, runner_package_version: input.runnerVersion ?? packageVersion(),
|
|
121
123
|
runs: runs.map(r => ({ kind: r.kind, model: r.model, duration_ms: r.durationMs, auxiliary_requests: r.requests.filter(q => q.kind === 'auxiliary').length,
|
|
122
|
-
unknown_requests: r.requests.filter(q => q.kind === 'unknown' || q.kind === 'invalid').length, proxy_attempts: [...r.proxyAttempts], keychain_lookups: r.
|
|
124
|
+
unknown_requests: r.requests.filter(q => q.kind === 'unknown' || q.kind === 'invalid').length, proxy_attempts: [...r.proxyAttempts], keychain_lookups: r.keychainLookups.length, home_writes: [...r.homeWrites],
|
|
123
125
|
requests: r.requests.filter(q => q.kind === 'primary' || q.kind === 'auxiliary').map(q => ({ kind: q.kind, step: q.step, model_matches: q.model === r.model, prompt_matched: q.promptMatched, reminders: q.reminders, tools: q.toolNames.length })) })) },
|
|
124
126
|
});
|
|
125
127
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
export const CLAUDE_REVIEW_SOURCE_FILE = 'src/review.txt';
|
|
6
6
|
/** Read-only lookups the absent-item helper may record; anything else is a credential write or export attempt. */
|
|
7
7
|
export const CLAUDE_REVIEW_KEYCHAIN_READS = Object.freeze(['find-generic-password', 'find-internet-password']);
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** The Keychain services of the employee's terminal login: Claude Code's OAuth item and its legacy API key item. A service with a
|
|
9
|
+
* config-dir hash suffix means the CLI would look past that login (design 2026-10-28-claude-job-login-context). */
|
|
10
|
+
export const CLAUDE_REVIEW_KEYCHAIN_SERVICES = Object.freeze(['Claude Code-credentials', 'Claude Code']);
|
|
11
|
+
export const CLAUDE_REVIEW_PLANTED_SOURCES = Object.freeze(['config_settings', 'cwd_settings', 'cwd_settings_local', 'source_settings',
|
|
12
|
+
'source_settings_local', 'config_memory', 'cwd_memory', 'source_memory', 'cwd_mcp', 'source_mcp', 'config_user_config', 'home_user_config',
|
|
10
13
|
'config_command', 'cwd_command', 'config_skill']);
|
|
11
14
|
const shq = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
15
|
const canary = (label) => `${label}-canary-${randomBytes(16).toString('hex')}`;
|
|
@@ -14,7 +17,7 @@ export async function createClaudeReviewLayout(base = os.tmpdir()) {
|
|
|
14
17
|
const root = await realpath(await mkdtemp(path.join(base, 'claude-release-review-')));
|
|
15
18
|
await chmod(root, 0o700);
|
|
16
19
|
const c = path.join(root, 'cli');
|
|
17
|
-
const cli = { root: c, bin: path.join(c, 'bin'), home: path.join(c, 'home'), config: path.join(c, '
|
|
20
|
+
const cli = { root: c, bin: path.join(c, 'bin'), home: path.join(c, 'home'), config: path.join(c, 'home', '.claude'), cwd: path.join(c, 'cwd'), tmp: path.join(c, 'tmp'),
|
|
18
21
|
sentinels: path.join(c, 'sentinels'), keychainLog: path.join(c, 'keychain.log') };
|
|
19
22
|
const source = path.join(root, 'source'), outside = path.join(root, 'outside');
|
|
20
23
|
for (const dir of [cli.root, cli.bin, cli.home, cli.config, cli.cwd, cli.tmp, cli.sentinels, path.join(cli.home, '.aws'), source, path.join(source, 'src'), outside])
|
|
@@ -28,22 +31,20 @@ export async function createClaudeReviewLayout(base = os.tmpdir()) {
|
|
|
28
31
|
await write(path.join(cli.config, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: canaries.credential, refreshToken: canaries.credential, expiresAt: 4102444800000, scopes: ['user:inference'] } }));
|
|
29
32
|
await write(path.join(cli.home, '.aws', 'credentials'), `[default]\naws_access_key_id=${canaries.credential}\naws_secret_access_key=${canaries.credential}\n`);
|
|
30
33
|
await write(path.join(cli.home, '.netrc'), `machine api.anthropic.com login review password ${canaries.credential}\n`);
|
|
31
|
-
// Absent-item Keychain helper, first on PATH: records the operation name only and answers "item not found"
|
|
32
|
-
// as the real helper does for a missing item. The review profile denies the real /usr/bin/security and the Keychain services.
|
|
33
|
-
await write(path.join(cli.bin, 'security'), `#!/bin/sh\nprintf '%s\\n' "$1" >> ${shq(cli.keychainLog)}\necho 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2\nexit 44\n`, 0o700);
|
|
34
|
-
return { root, source, outside, cli, canaries, env: { PATH: `${cli.bin}:/usr/bin:/bin`, HOME: cli.home, TMPDIR: cli.tmp, LANG: 'C', LC_ALL: 'C'
|
|
34
|
+
// Absent-item Keychain helper, first on PATH: records the operation and the -s service name only and answers "item not found"
|
|
35
|
+
// (exit 44), as the real helper does for a missing item. The review profile denies the real /usr/bin/security and the Keychain services.
|
|
36
|
+
await write(path.join(cli.bin, 'security'), `#!/bin/sh\nservice='';previous=''\nfor argument in "$@";do [ "$previous" = '-s' ] && service="$argument";previous="$argument";done\nprintf '%s\\t%s\\n' "$1" "$service" >> ${shq(cli.keychainLog)}\necho 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2\nexit 44\n`, 0o700);
|
|
37
|
+
return { root, source, outside, cli, canaries, env: { PATH: `${cli.bin}:/usr/bin:/bin`, HOME: cli.home, TMPDIR: cli.tmp, LANG: 'C', LC_ALL: 'C' } };
|
|
35
38
|
}
|
|
36
39
|
export function claudeReviewPlantedFiles(layout) {
|
|
37
40
|
const { cli, source } = layout;
|
|
38
41
|
return [
|
|
39
42
|
{ id: 'config_settings', kind: 'settings', file: path.join(cli.config, 'settings.json') },
|
|
40
|
-
{ id: 'home_settings', kind: 'settings', file: path.join(cli.home, '.claude', 'settings.json') },
|
|
41
43
|
{ id: 'cwd_settings', kind: 'settings', file: path.join(cli.cwd, '.claude', 'settings.json') },
|
|
42
44
|
{ id: 'cwd_settings_local', kind: 'settings', file: path.join(cli.cwd, '.claude', 'settings.local.json') },
|
|
43
45
|
{ id: 'source_settings', kind: 'settings', file: path.join(source, '.claude', 'settings.json') },
|
|
44
46
|
{ id: 'source_settings_local', kind: 'settings', file: path.join(source, '.claude', 'settings.local.json') },
|
|
45
47
|
{ id: 'config_memory', kind: 'memory', file: path.join(cli.config, 'CLAUDE.md') },
|
|
46
|
-
{ id: 'home_memory', kind: 'memory', file: path.join(cli.home, '.claude', 'CLAUDE.md') },
|
|
47
48
|
{ id: 'cwd_memory', kind: 'memory', file: path.join(cli.cwd, 'CLAUDE.md') },
|
|
48
49
|
{ id: 'source_memory', kind: 'memory', file: path.join(source, 'CLAUDE.md') },
|
|
49
50
|
{ id: 'cwd_mcp', kind: 'mcp', file: path.join(cli.cwd, '.mcp.json') },
|
|
@@ -85,8 +86,32 @@ export async function takeSentinels(layout) {
|
|
|
85
86
|
return names.map(name => /^[a-z-]{1,40}$/.test(name) ? name : 'unexpected');
|
|
86
87
|
}
|
|
87
88
|
/** Operation names that the absent-item helper recorded; cleared for the next run. */
|
|
88
|
-
export async function
|
|
89
|
+
export async function takeKeychainLookups(layout) {
|
|
89
90
|
const text = await readFile(layout.cli.keychainLog, 'utf8').catch(() => '');
|
|
90
91
|
await rm(layout.cli.keychainLog, { force: true });
|
|
91
|
-
return text.split('\n').filter(Boolean).slice(0, 256).map(
|
|
92
|
+
return text.split('\n').filter(Boolean).slice(0, 256).map(line => {
|
|
93
|
+
const [operation = '', service = '', ...rest] = line.split('\t');
|
|
94
|
+
return { operation: /^[a-z-]{1,40}$/.test(operation) ? operation : 'unexpected', service: rest.length === 0 && /^[A-Za-z0-9 ._-]{0,64}$/.test(service) ? service : 'unexpected' };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Every regular file under the fake HOME with its size and modification time, so a run can list what the CLI created or changed. */
|
|
98
|
+
export async function snapshotReviewHome(layout) {
|
|
99
|
+
const files = new Map();
|
|
100
|
+
const walk = async (dir) => {
|
|
101
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
102
|
+
const file = path.join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory())
|
|
104
|
+
await walk(file);
|
|
105
|
+
else if (entry.isFile() && files.size < 4096) {
|
|
106
|
+
const info = await lstat(file);
|
|
107
|
+
files.set(path.relative(layout.cli.home, file), `${info.size}:${info.mtimeMs}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
await walk(layout.cli.home);
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
/** Paths under the fake HOME, relative to it, that a run created or changed: names only, at most 64. */
|
|
115
|
+
export function reviewHomeWrites(before, after) {
|
|
116
|
+
return [...after].filter(([file, stamp]) => before.get(file) !== stamp).map(([file]) => file).sort().slice(0, 64);
|
|
92
117
|
}
|
|
@@ -11,7 +11,7 @@ import { jobToolPolicyOptions } from './job-tool-policy.js';
|
|
|
11
11
|
import { LocalToolServer, localEndpointReadObservations } from './local-tool-server.js';
|
|
12
12
|
import { claudeReviewProfile, claudeReviewSandboxArgs } from './claude-review-sandbox.js';
|
|
13
13
|
import { startClaudeReviewDecoy, startClaudeReviewProvider } from './claude-review-provider.js';
|
|
14
|
-
import { CLAUDE_REVIEW_SOURCE_FILE, plantHostileSources,
|
|
14
|
+
import { CLAUDE_REVIEW_SOURCE_FILE, plantHostileSources, reviewHomeWrites, snapshotReviewHome, takeKeychainLookups, takeSentinels } from './claude-review-layout.js';
|
|
15
15
|
export const CLAUDE_REVIEW_FAKE_KEY = 'sk-ant-claude-release-review-fake-key';
|
|
16
16
|
export const CLAUDE_REVIEW_PROMPT = 'Offline Claude release review. Follow the scripted provider. No real project, account or model.';
|
|
17
17
|
export const CLAUDE_REVIEW_SLASH_PROMPT = '/review-planted now';
|
|
@@ -87,7 +87,7 @@ export async function runClaudeReview(input) {
|
|
|
87
87
|
const layout = input.layout, started = performance.now(), discovery = [];
|
|
88
88
|
const prompt = input.kind === 'slash' ? CLAUDE_REVIEW_SLASH_PROMPT : CLAUDE_REVIEW_PROMPT;
|
|
89
89
|
let active = true, terminal = false, exitConfirmed = false, envKeys = [], reads = { successful: 0, failed: 0 };
|
|
90
|
-
let handle, provider, decoy;
|
|
90
|
+
let handle, provider, decoy, homeBefore;
|
|
91
91
|
const tools = new JobToolSession({ policyOptions: reviewToolPolicy(layout.source), binding: () => REVIEW_BINDING, active: () => active,
|
|
92
92
|
createServer: policy => new LocalToolServer({ policy, onDiscovery: event => { discovery.push(event); } }) });
|
|
93
93
|
try {
|
|
@@ -106,6 +106,7 @@ export async function runClaudeReview(input) {
|
|
|
106
106
|
envKeys = Object.keys(env).sort();
|
|
107
107
|
const profile = claudeReviewProfile({ root: layout.root, cliRoot: layout.cli.root, ports: [provider.port, Number(new URL(endpoint.url).port)], readRoots: input.readRoots, deniedHome: input.deniedHome });
|
|
108
108
|
const [command, argv] = input.sandbox ? claudeReviewSandboxArgs(profile, input.binary, args) : [input.binary, args];
|
|
109
|
+
homeBefore = await snapshotReviewHome(layout);
|
|
109
110
|
handle = input.processHost.spawn(command, argv, { cwd: layout.cli.cwd, env });
|
|
110
111
|
terminal = await drive(handle, claudeUserInput(prompt), input.timeoutMs);
|
|
111
112
|
exitConfirmed = await confirmExit(handle);
|
|
@@ -122,7 +123,8 @@ export async function runClaudeReview(input) {
|
|
|
122
123
|
const p = provider;
|
|
123
124
|
return { kind: input.kind, model: input.model, sandboxed: input.sandbox, terminal, exitConfirmed, requests: [...p.requests], toolResults: new Map(p.toolResults),
|
|
124
125
|
canaryHits: { ...p.canaryHits }, fakeKeyInBody: p.fakeKeyInBody, proxyAttempts: [...p.proxyAttempts], decoyConnections: decoy.connections(), discovery, reads, envKeys,
|
|
125
|
-
|
|
126
|
+
keychainLookups: await takeKeychainLookups(layout), homeWrites: homeBefore ? reviewHomeWrites(homeBefore, await snapshotReviewHome(layout)) : [],
|
|
127
|
+
sentinels: await takeSentinels(layout), durationMs: Math.round(performance.now() - started) };
|
|
126
128
|
}
|
|
127
129
|
/** Stage 1: `--version` of the exact copy, under the review profile with no network grant at all. */
|
|
128
130
|
export async function runClaudeVersion(input) {
|
|
@@ -6,7 +6,9 @@ function requiredTestsFromTaskPackage(taskPackage) {
|
|
|
6
6
|
const pkg = taskPackage;
|
|
7
7
|
const execution = pkg.agent_execution;
|
|
8
8
|
const brief = execution?.implementation_brief;
|
|
9
|
-
|
|
9
|
+
// A missing list means no commands, as in the prompt compiler: Gateways before the empty-list fix dropped an
|
|
10
|
+
// investigation's required_tests: []. A value that is not a list is still a malformed package.
|
|
11
|
+
const requiredTests = brief?.required_tests ?? [];
|
|
10
12
|
if (!Array.isArray(requiredTests)) {
|
|
11
13
|
throw new Error("task package is missing agent_execution.implementation_brief.required_tests");
|
|
12
14
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { validateLocalReadRange } from './local-read-range.js';
|
|
2
2
|
import { localToolInventory } from './local-tool-server.js';
|
|
3
3
|
const rejected = () => new Error('JOB_TOOL_DISPATCH_REJECTED');
|
|
4
|
+
/** The scoped policy's stable denial code, which LocalToolServer already lets a model see, so a model can correct its
|
|
5
|
+
* call. Only an exact code qualifies: any other failure, including binding and lease checks, stays one masked rejection. */
|
|
6
|
+
export const localToolDenialCode = (error) => error instanceof Error && /^(?:DENIED_[A-Z_]+|LOCAL_SANDBOX_UNAVAILABLE)$/.test(error.message) ? error.message : undefined;
|
|
4
7
|
function record(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
|
|
5
8
|
const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\r\n\0]/.test(value);
|
|
6
9
|
const keys = (value, expected) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort());
|
|
@@ -52,8 +55,9 @@ export class JobToolDispatcher {
|
|
|
52
55
|
throw rejected();
|
|
53
56
|
return result;
|
|
54
57
|
}
|
|
55
|
-
catch {
|
|
56
|
-
|
|
58
|
+
catch (error) {
|
|
59
|
+
const code = localToolDenialCode(error);
|
|
60
|
+
throw code ? new Error(code) : rejected();
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JobToolDispatcher } from './job-tool-dispatcher.js';
|
|
1
|
+
import { JobToolDispatcher, localToolDenialCode } from './job-tool-dispatcher.js';
|
|
2
2
|
import { LocalToolPolicy } from './local-tool-policy.js';
|
|
3
3
|
import { LocalToolServer } from './local-tool-server.js';
|
|
4
4
|
const sameBinding = (a, b) => b !== null && a.jobId === b.jobId && a.threadId === b.threadId && a.turnId === b.turnId && a.generation === b.generation;
|
|
@@ -32,8 +32,8 @@ export class JobToolSession {
|
|
|
32
32
|
throw Error();
|
|
33
33
|
return await this.dispatcher.dispatch({ ...this.bound, requestId: String(++this.sequence), tool, args });
|
|
34
34
|
}
|
|
35
|
-
catch {
|
|
36
|
-
throw new Error('JOB_TOOL_DISPATCH_REJECTED');
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new Error(localToolDenialCode(error) ?? 'JOB_TOOL_DISPATCH_REJECTED');
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
async start() {
|