@ruvnet/open-claude-code 2.1.2-nightly.20261011.203 → 2.1.3
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 +22 -0
- package/package.json +1 -1
- package/src/core/agent-loop.mjs +17 -3
- package/src/hooks/engine.mjs +118 -129
- package/src/tools/agent.mjs +75 -10
- package/src/tools/edit.mjs +2 -1
- package/src/tools/multi-edit.mjs +2 -1
- package/src/tools/notebook-edit.mjs +8 -3
- package/src/tools/read.mjs +37 -16
- package/src/tools/registry.mjs +2 -2
- package/src/tools/text-encoding.mjs +15 -0
package/README.md
CHANGED
|
@@ -91,6 +91,28 @@ node scripts/plugin-e2e.mjs ruvnet/open-claude-code
|
|
|
91
91
|
|
|
92
92
|
The suite installs and removes real skills and plugins in disposable profiles, checks all three Claude skill expansions with a local model fixture, and verifies OCC discovery. Live provider behavior is tested separately by `scripts/live-package-e2e.mjs`. Tested installer: skills 1.7.2; Claude Code: 2.1.283.
|
|
93
93
|
|
|
94
|
+
|
|
95
|
+
## Upstream behavior fixes in 2.1.3
|
|
96
|
+
|
|
97
|
+
Implemented and tested against the public [Claude Code 2.1.295 and 2.1.296 changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md):
|
|
98
|
+
|
|
99
|
+
| Behavior | OCC support |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| Hook failure policy | Command and HTTP hooks honor `onFailure: "block"`; exit 2 vetoes execution |
|
|
102
|
+
| PreToolUse veto | `continue: false` and structured deny block the call and return an error result to the next model turn |
|
|
103
|
+
| PostToolUse MCP output | `updatedMCPToolOutput` replaces results for MCP tool names |
|
|
104
|
+
| Large text reads | `Read.allow_large: true` bypasses the implicit 2,000 line limit; explicit offset/limit remain active |
|
|
105
|
+
| Encoding safety | Edit, MultiEdit and NotebookEdit reject invalid UTF8 before any write |
|
|
106
|
+
| Delegation safety | Child tools intersect parent tools and inherit permission checks and hooks |
|
|
107
|
+
| Model call budget | `--max-turns` limits model requests, including tool continuations |
|
|
108
|
+
|
|
109
|
+
Large text reads have hard ceilings of 16 MiB and 100,000 lines. HTTP hook responses have a 1 MiB limit. Typed hook timeouts use seconds; legacy flat hook timeouts retain milliseconds. Plain command stdout remains supported.
|
|
110
|
+
|
|
111
|
+
Subagents default to at most 10 model calls, delegation depth 2 (hard maximum 3), and at most 4 running background agents. An explicit empty child tool list grants no tools. Worktree isolation requests are rejected until isolation is implemented.
|
|
112
|
+
|
|
113
|
+
Prompt and agent hook evaluators, asynchronous hooks, complete session/checkpoint lifecycle wiring, and full provider parity remain outside this release. See [ADR-006](docs/adr/ADR-006-upstream-behavioral-contracts.md). The [lifecycle issue](https://github.com/ruvnet/open-claude-code/issues/17) remains open for those gaps.
|
|
114
|
+
|
|
115
|
+
|
|
94
116
|
## 🧠 What Is This?
|
|
95
117
|
|
|
96
118
|
**Open Claude Code** is a ground-up open source rebuild of Anthropic's [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), informed by [ruDevolution's](https://github.com/ruvnet/rudevolution) AI-powered decompilation of the published npm package.
|
package/package.json
CHANGED
package/src/core/agent-loop.mjs
CHANGED
|
@@ -18,9 +18,12 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
18
18
|
throw new TypeError(key + ' must be an array of nonempty tool names');
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
if (settings.maxTurns != null && (!Number.isSafeInteger(settings.maxTurns) || settings.maxTurns < 1)) {
|
|
22
|
+
throw new TypeError('maxTurns must be a positive safe integer');
|
|
23
|
+
}
|
|
21
24
|
const allowedNames = new Set(settings.allowedTools || []);
|
|
22
25
|
const deniedNames = new Set(settings.disallowedTools || []);
|
|
23
|
-
const toolAllowed = name => !deniedNames.has(name) &&
|
|
26
|
+
const toolAllowed = name => tools.has?.(name) !== false && !deniedNames.has(name) &&
|
|
24
27
|
(allowedNames.size === 0 || allowedNames.has(name));
|
|
25
28
|
const availableTools = () => (tools.list?.() || []).filter(tool => toolAllowed(tool.name));
|
|
26
29
|
|
|
@@ -66,6 +69,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
async function* run(userMessage, options = {}) {
|
|
72
|
+
if (!options.continuation) state.modelCallCount = 0;
|
|
69
73
|
const depth = (options._depth || 0);
|
|
70
74
|
|
|
71
75
|
// Guard against runaway tool-call recursion
|
|
@@ -101,7 +105,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
// Check max turns
|
|
104
|
-
if (settings.maxTurns && state.
|
|
108
|
+
if (settings.maxTurns && state.modelCallCount >= settings.maxTurns) {
|
|
105
109
|
yield { type: 'error', message: `Max turns (${settings.maxTurns}) reached.` };
|
|
106
110
|
yield { type: 'stop', reason: 'max_turns' };
|
|
107
111
|
return;
|
|
@@ -113,6 +117,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
113
117
|
state.messages = contextManager.compact(state.messages);
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
state.modelCallCount++;
|
|
116
121
|
yield { type: 'stream_request_start', turn: state.turnCount };
|
|
117
122
|
|
|
118
123
|
// Detect provider and call API. Use state.model so an opt-in cascade
|
|
@@ -207,6 +212,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
207
212
|
toolResults.push({
|
|
208
213
|
type: 'tool_result',
|
|
209
214
|
tool_use_id: block.id,
|
|
215
|
+
is_error: true,
|
|
210
216
|
content: `Blocked by hook: ${hookResult.message}`,
|
|
211
217
|
});
|
|
212
218
|
continue;
|
|
@@ -220,6 +226,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
220
226
|
toolResults.push({
|
|
221
227
|
type: 'tool_result',
|
|
222
228
|
tool_use_id: block.id,
|
|
229
|
+
is_error: true,
|
|
223
230
|
content: 'Permission denied',
|
|
224
231
|
});
|
|
225
232
|
continue;
|
|
@@ -229,9 +236,15 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
229
236
|
yield { type: 'tool_progress', tool: block.name, status: 'running' };
|
|
230
237
|
|
|
231
238
|
let result;
|
|
239
|
+
let toolFailed = false;
|
|
232
240
|
try {
|
|
233
|
-
result = await tools.call(block.name, block.input
|
|
241
|
+
result = await tools.call(block.name, block.input, {
|
|
242
|
+
permissions, settings, hooks, model: activeModel,
|
|
243
|
+
allowedToolNames: availableTools().map(tool => tool.name),
|
|
244
|
+
delegationDepth: settings.delegationDepth || 0,
|
|
245
|
+
});
|
|
234
246
|
} catch (err) {
|
|
247
|
+
toolFailed = true;
|
|
235
248
|
result = `Tool error: ${err.message}`;
|
|
236
249
|
}
|
|
237
250
|
|
|
@@ -245,6 +258,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks, ca
|
|
|
245
258
|
toolResults.push({
|
|
246
259
|
type: 'tool_result',
|
|
247
260
|
tool_use_id: block.id,
|
|
261
|
+
...(toolFailed ? { is_error: true } : {}),
|
|
248
262
|
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
249
263
|
});
|
|
250
264
|
}
|
package/src/hooks/engine.mjs
CHANGED
|
@@ -1,170 +1,159 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hook Engine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - PostToolUse: can modify results
|
|
7
|
-
* - Stop: can prevent the agent from stopping
|
|
8
|
-
* - Notification: inform external systems
|
|
9
|
-
* - PrePrompt: modify user input
|
|
10
|
-
* - PostResponse: modify assistant output
|
|
11
|
-
*
|
|
12
|
-
* Hooks are defined in settings.json under the "hooks" key.
|
|
2
|
+
* Hook Engine: command, HTTP and function hooks for pre/post tool use and stop.
|
|
3
|
+
* Legacy flat hooks retain millisecond timeouts and environment input.
|
|
4
|
+
* Claude-style typed hooks use second timeouts and JSON stdin/POST input.
|
|
5
|
+
* Prompt and agent evaluators are not supported.
|
|
13
6
|
*/
|
|
14
|
-
|
|
15
|
-
import { execSync } from 'child_process';
|
|
7
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
16
8
|
|
|
17
9
|
export class HookEngine {
|
|
18
|
-
|
|
19
|
-
* @param {object} hooksConfig - hooks configuration from settings
|
|
20
|
-
*/
|
|
21
|
-
constructor(hooksConfig = {}) {
|
|
22
|
-
this.hooks = hooksConfig;
|
|
23
|
-
}
|
|
10
|
+
constructor(hooksConfig = {}) { this.hooks = hooksConfig; }
|
|
24
11
|
|
|
25
|
-
/**
|
|
26
|
-
* Run pre-tool-use hooks. Returns { allow, message }.
|
|
27
|
-
* If any hook returns deny, the tool call is blocked.
|
|
28
|
-
*
|
|
29
|
-
* @param {string} toolName - name of the tool being called
|
|
30
|
-
* @param {object} input - tool input arguments
|
|
31
|
-
* @returns {Promise<{allow: boolean, message?: string}>}
|
|
32
|
-
*/
|
|
33
12
|
async runPreToolUse(toolName, input) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
toolName,
|
|
42
|
-
input,
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
if (result?.decision === 'deny' || result?.decision === 'block') {
|
|
46
|
-
return { allow: false, message: result.message || `Blocked by hook: ${hook.name || 'unnamed'}` };
|
|
13
|
+
for (const hook of this._getHooks('PreToolUse', toolName)) {
|
|
14
|
+
const result = await this._executeHook(hook, { event: 'PreToolUse', toolName, input });
|
|
15
|
+
const specific = result?.hookSpecificOutput;
|
|
16
|
+
if (result?.continue === false || ['deny', 'block'].includes(result?.decision)
|
|
17
|
+
|| specific?.permissionDecision === 'deny') {
|
|
18
|
+
return { allow: false, message: result.stopReason || result.message || result.reason
|
|
19
|
+
|| specific?.permissionDecisionReason || `Blocked by hook: ${hook.name || 'unnamed'}` };
|
|
47
20
|
}
|
|
48
21
|
}
|
|
49
22
|
return { allow: true };
|
|
50
23
|
}
|
|
51
24
|
|
|
52
|
-
/**
|
|
53
|
-
* Run post-tool-use hooks. Can modify the result.
|
|
54
|
-
*
|
|
55
|
-
* @param {string} toolName - name of the tool that was called
|
|
56
|
-
* @param {*} result - tool execution result
|
|
57
|
-
* @returns {Promise<*>} possibly modified result
|
|
58
|
-
*/
|
|
59
25
|
async runPostToolUse(toolName, result) {
|
|
60
|
-
const hooks = this._getHooks('PostToolUse');
|
|
61
26
|
let current = result;
|
|
62
|
-
for (const hook of
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
result: current,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
if (hookResult?.modifiedResult !== undefined) {
|
|
72
|
-
current = hookResult.modifiedResult;
|
|
73
|
-
}
|
|
27
|
+
for (const hook of this._getHooks('PostToolUse', toolName)) {
|
|
28
|
+
const output = await this._executeHook(hook, { event: 'PostToolUse', toolName, result: current });
|
|
29
|
+
const specific = output?.hookSpecificOutput;
|
|
30
|
+
if (output?.modifiedResult !== undefined) current = output.modifiedResult;
|
|
31
|
+
if (toolName.startsWith('mcp__') && specific?.updatedMCPToolOutput !== undefined)
|
|
32
|
+
current = specific.updatedMCPToolOutput;
|
|
74
33
|
}
|
|
75
34
|
return current;
|
|
76
35
|
}
|
|
77
36
|
|
|
78
|
-
/**
|
|
79
|
-
* Run stop hooks. Returns true if stop should proceed, false to continue.
|
|
80
|
-
*
|
|
81
|
-
* @returns {Promise<boolean>} whether to allow stopping
|
|
82
|
-
*/
|
|
83
37
|
async runStop() {
|
|
84
|
-
const
|
|
85
|
-
for (const hook of hooks) {
|
|
38
|
+
for (const hook of this._getHooks('Stop')) {
|
|
86
39
|
const result = await this._executeHook(hook, { event: 'Stop' });
|
|
87
|
-
if (result?.preventStop)
|
|
88
|
-
return false; // do not stop
|
|
89
|
-
}
|
|
40
|
+
if (result?.preventStop || result?.decision === 'block') return false;
|
|
90
41
|
}
|
|
91
|
-
return true;
|
|
42
|
+
return true;
|
|
92
43
|
}
|
|
93
44
|
|
|
94
|
-
/**
|
|
95
|
-
* Run notification hooks (fire-and-forget).
|
|
96
|
-
* @param {string} event - notification event name
|
|
97
|
-
* @param {object} data - event data
|
|
98
|
-
*/
|
|
99
45
|
async runNotification(event, data) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
46
|
+
for (const hook of this._getHooks('Notification')) {
|
|
47
|
+
try { await this._executeHook(hook, { event, ...data }); } catch { /* best effort */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_getHooks(eventType, toolName) {
|
|
52
|
+
const configured = this.hooks?.[eventType];
|
|
53
|
+
const entries = Array.isArray(configured) ? configured : configured ? [configured] : [];
|
|
54
|
+
return entries.flatMap(entry => {
|
|
55
|
+
if (entry.toolName && entry.toolName !== toolName) return [];
|
|
56
|
+
if (entry.matcher && toolName) {
|
|
57
|
+
try { if (!new RegExp(entry.matcher).test(toolName)) return []; }
|
|
58
|
+
catch {
|
|
59
|
+
return (Array.isArray(entry.hooks) ? entry.hooks : [entry]).map(hook => ({
|
|
60
|
+
onFailure: hook.onFailure, failOpen: hook.failOpen,
|
|
61
|
+
handler: () => { throw new Error('Invalid hook matcher'); },
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
106
64
|
}
|
|
65
|
+
return Array.isArray(entry.hooks) ? entry.hooks : [entry];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_timeout(hook) {
|
|
70
|
+
const value = hook.timeout === undefined ? 10000 : Number(hook.timeout) * (hook.type ? 1000 : 1);
|
|
71
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error('Invalid hook timeout');
|
|
72
|
+
return Math.ceil(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_parseOutput(text, http = false) {
|
|
76
|
+
const trimmed = text.trim();
|
|
77
|
+
if (!trimmed) return null;
|
|
78
|
+
let output;
|
|
79
|
+
try { output = JSON.parse(trimmed); }
|
|
80
|
+
catch {
|
|
81
|
+
if (http || /^[\[{]/.test(trimmed)) throw new Error('Invalid hook JSON');
|
|
82
|
+
return { output: trimmed };
|
|
107
83
|
}
|
|
84
|
+
if (!output || typeof output !== 'object' || Array.isArray(output)) throw new Error('Invalid hook output object');
|
|
85
|
+
for (const key of ['continue', 'preventStop']) {
|
|
86
|
+
if (output[key] !== undefined && typeof output[key] !== 'boolean') throw new Error('Invalid hook decision');
|
|
87
|
+
}
|
|
88
|
+
if (output.decision !== undefined && !['allow', 'deny', 'block', 'approve'].includes(output.decision))
|
|
89
|
+
throw new Error('Invalid hook decision');
|
|
90
|
+
const specific = output.hookSpecificOutput;
|
|
91
|
+
if (specific !== undefined && (!specific || typeof specific !== 'object' || Array.isArray(specific)))
|
|
92
|
+
throw new Error('Invalid hook-specific output');
|
|
93
|
+
if (specific?.permissionDecision !== undefined && !['allow', 'deny', 'ask'].includes(specific.permissionDecision))
|
|
94
|
+
throw new Error('Invalid hook permission decision');
|
|
95
|
+
return output;
|
|
108
96
|
}
|
|
109
97
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
98
|
+
async _readHTTPResponse(response) {
|
|
99
|
+
if (!response.body) return '';
|
|
100
|
+
const reader = response.body.getReader();
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
size += value.byteLength;
|
|
108
|
+
if (size > 1024 * 1024) {
|
|
109
|
+
await reader.cancel('Hook response exceeds 1 MiB');
|
|
110
|
+
throw new Error('Hook response exceeds 1 MiB');
|
|
111
|
+
}
|
|
112
|
+
chunks.push(value);
|
|
113
|
+
}
|
|
114
|
+
return Buffer.concat(chunks, size).toString('utf-8');
|
|
115
|
+
} finally {
|
|
116
|
+
reader.releaseLock();
|
|
117
|
+
}
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
/**
|
|
122
|
-
* Execute a single hook. Supports command (shell) and function hooks.
|
|
123
|
-
*
|
|
124
|
-
* Note: hook.command is an operator-configured shell string (defined in
|
|
125
|
-
* settings.json by the user who controls this machine) — it is intentionally
|
|
126
|
-
* executed as a shell command. The tool input/context is passed ONLY via
|
|
127
|
-
* environment variables (never interpolated into the command string) to
|
|
128
|
-
* prevent escalation from tool input into the hook command itself.
|
|
129
|
-
*
|
|
130
|
-
* @param {object} hook - hook definition
|
|
131
|
-
* @param {object} context - execution context
|
|
132
|
-
* @returns {Promise<object|null>}
|
|
133
|
-
*/
|
|
134
120
|
async _executeHook(hook, context) {
|
|
135
121
|
try {
|
|
122
|
+
const timeout = this._timeout(hook);
|
|
123
|
+
const payload = { ...context, hook_event_name: context.event, tool_name: context.toolName,
|
|
124
|
+
tool_input: context.input, tool_response: context.result };
|
|
125
|
+
if (hook.type === 'http') {
|
|
126
|
+
const allowed = new Set(hook.allowedEnvVars || []);
|
|
127
|
+
const headers = Object.fromEntries(Object.entries(hook.headers || {}).map(([key, value]) =>
|
|
128
|
+
[key, String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
129
|
+
(_, braced, plain) => allowed.has(braced || plain) ? process.env[braced || plain] || '' : '')]));
|
|
130
|
+
const response = await fetch(hook.url, { method: 'POST', redirect: 'error',
|
|
131
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify(payload), signal: AbortSignal.timeout(timeout) });
|
|
133
|
+
if (!response.ok) throw new Error(`HTTP hook status ${response.status}`);
|
|
134
|
+
return this._parseOutput(await this._readHTTPResponse(response), true);
|
|
135
|
+
}
|
|
136
136
|
if (hook.command) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
145
|
-
const output = execSync(hook.command, {
|
|
146
|
-
encoding: 'utf-8',
|
|
147
|
-
timeout: hook.timeout || 10000,
|
|
148
|
-
env,
|
|
149
|
-
});
|
|
150
|
-
try {
|
|
151
|
-
return JSON.parse(output.trim());
|
|
152
|
-
} catch {
|
|
153
|
-
return { output: output.trim() };
|
|
154
|
-
}
|
|
137
|
+
const options = { encoding: 'utf-8', timeout,
|
|
138
|
+
input: JSON.stringify(payload), stdio: ['pipe', 'pipe', 'pipe'],
|
|
139
|
+
env: { ...process.env, HOOK_EVENT: String(context.event || ''),
|
|
140
|
+
HOOK_TOOL: String(context.toolName || ''), HOOK_INPUT: JSON.stringify(context.input || {}) } };
|
|
141
|
+
const output = Array.isArray(hook.args) ? execFileSync(hook.command, hook.args, options)
|
|
142
|
+
: execSync(hook.command, options);
|
|
143
|
+
return this._parseOutput(output);
|
|
155
144
|
}
|
|
156
|
-
|
|
157
145
|
if (typeof hook.handler === 'function') {
|
|
158
146
|
return await hook.handler(context);
|
|
159
147
|
}
|
|
160
|
-
|
|
148
|
+
if (hook.type || hook.onFailure === 'block') throw new Error('Unsupported hook definition');
|
|
161
149
|
return null;
|
|
162
150
|
} catch (err) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
151
|
+
// Exit 2 is an explicit veto, regardless of the configured failure mode.
|
|
152
|
+
if (err.status === 2) return { decision: context.event === 'Stop' ? 'block' : 'deny', message: 'Hook blocked action (exit 2)' };
|
|
153
|
+
// Stop failure must not create an endless retry loop.
|
|
154
|
+
if (context.event !== 'Stop' && (hook.onFailure === 'block' || hook.failOpen === false))
|
|
155
|
+
return { decision: 'deny', message: 'Hook failed; action blocked by failure policy' };
|
|
156
|
+
return null;
|
|
168
157
|
}
|
|
169
158
|
}
|
|
170
159
|
}
|
package/src/tools/agent.mjs
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
5
|
* - subagent_type parameter
|
|
6
|
-
* - isolation
|
|
6
|
+
* - worktree isolation is rejected until implemented
|
|
7
7
|
* - run_in_background option
|
|
8
8
|
* - model override
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createAgentLoop } from '../core/agent-loop.mjs';
|
|
12
|
-
import { createToolRegistry } from './registry.mjs';
|
|
13
12
|
import { createPermissionChecker } from '../permissions/checker.mjs';
|
|
14
13
|
|
|
15
14
|
export const AgentTool = {
|
|
@@ -25,7 +24,7 @@ export const AgentTool = {
|
|
|
25
24
|
allowed_tools: {
|
|
26
25
|
type: 'array',
|
|
27
26
|
items: { type: 'string' },
|
|
28
|
-
description: '
|
|
27
|
+
description: 'Subset of parent tools the subagent can use (default: inherited tools)',
|
|
29
28
|
},
|
|
30
29
|
subagent_type: {
|
|
31
30
|
type: 'string',
|
|
@@ -34,7 +33,7 @@ export const AgentTool = {
|
|
|
34
33
|
isolation: {
|
|
35
34
|
type: 'string',
|
|
36
35
|
enum: ['default', 'worktree'],
|
|
37
|
-
description: 'Isolation mode.
|
|
36
|
+
description: 'Isolation mode. Worktree requests are currently rejected.',
|
|
38
37
|
},
|
|
39
38
|
run_in_background: {
|
|
40
39
|
type: 'boolean',
|
|
@@ -50,7 +49,8 @@ export const AgentTool = {
|
|
|
50
49
|
|
|
51
50
|
validateInput(input) {
|
|
52
51
|
const errors = [];
|
|
53
|
-
if (!input.prompt) errors.push('prompt is required');
|
|
52
|
+
if (typeof input?.prompt !== 'string' || !input.prompt.trim()) errors.push('prompt is required');
|
|
53
|
+
if (input?.allowed_tools !== undefined && (!Array.isArray(input.allowed_tools) || !input.allowed_tools.every(n => typeof n === 'string'))) errors.push('allowed_tools must be an array of tool names');
|
|
54
54
|
return errors;
|
|
55
55
|
},
|
|
56
56
|
|
|
@@ -58,10 +58,15 @@ export const AgentTool = {
|
|
|
58
58
|
_backgroundAgents: new Map(),
|
|
59
59
|
_nextBgId: 0,
|
|
60
60
|
|
|
61
|
-
async call(input) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
async call(input, context = {}) {
|
|
62
|
+
const errors = AgentTool.validateInput(input);
|
|
63
|
+
if (errors.length) throw new Error(errors.join(', '));
|
|
64
|
+
if (input.isolation && input.isolation !== 'default') throw new Error('Subagent worktree isolation is not implemented.');
|
|
65
|
+
const { createToolRegistry } = await import('./registry.mjs');
|
|
66
|
+
const policy = createSubagentPolicy(input, context, createToolRegistry());
|
|
67
|
+
const model = input.model || context.model || process.env.SUBAGENT_MODEL || 'claude-sonnet-4-6';
|
|
68
|
+
const { tools, permissions } = policy;
|
|
69
|
+
if (input.run_in_background && [...AgentTool._backgroundAgents.values()].filter(a => a.status === 'running').length >= 4) throw new Error('Background subagent concurrency limit reached.');
|
|
65
70
|
|
|
66
71
|
// Build type-specific system prompt prefix
|
|
67
72
|
let systemPrefix = '';
|
|
@@ -84,7 +89,8 @@ export const AgentTool = {
|
|
|
84
89
|
model,
|
|
85
90
|
tools,
|
|
86
91
|
permissions,
|
|
87
|
-
settings:
|
|
92
|
+
settings: policy.settings,
|
|
93
|
+
hooks: policy.hooks,
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
if (input.run_in_background) {
|
|
@@ -115,6 +121,7 @@ async function runSubagent(loop, prompt) {
|
|
|
115
121
|
if (event.type === 'assistant' && event.content) {
|
|
116
122
|
results.push(event.content);
|
|
117
123
|
}
|
|
124
|
+
if (event.type === 'error') throw new Error(event.message || 'Subagent failed');
|
|
118
125
|
if (event.type === 'result') {
|
|
119
126
|
results.push(`[tool:${event.tool}] ${String(event.result).slice(0, 500)}`);
|
|
120
127
|
}
|
|
@@ -125,3 +132,61 @@ async function runSubagent(loop, prompt) {
|
|
|
125
132
|
|
|
126
133
|
return results.join('\n') || 'Subagent completed with no output.';
|
|
127
134
|
}
|
|
135
|
+
|
|
136
|
+
const SAFE_STANDALONE_TOOLS = ['Read', 'Glob', 'Grep', 'LS'];
|
|
137
|
+
const MAX_CHILD_TURNS = 10;
|
|
138
|
+
const MAX_DELEGATION_DEPTH = 3;
|
|
139
|
+
|
|
140
|
+
/** Child capabilities are an intersection, never a fresh grant. */
|
|
141
|
+
export function createSubagentPolicy(input, context = {}, registry) {
|
|
142
|
+
const errors = AgentTool.validateInput(input);
|
|
143
|
+
if (errors.length) throw new Error(errors.join(', '));
|
|
144
|
+
if (input.isolation && input.isolation !== 'default') {
|
|
145
|
+
throw new Error('Subagent worktree isolation is not implemented; refusing to run without isolation.');
|
|
146
|
+
}
|
|
147
|
+
const depth = context.delegationDepth ?? 0;
|
|
148
|
+
const maxDepth = Math.min(context.settings?.maxDelegationDepth ?? 2, MAX_DELEGATION_DEPTH);
|
|
149
|
+
if (!Number.isInteger(depth) || depth < 0 || !Number.isInteger(maxDepth) || depth >= maxDepth) {
|
|
150
|
+
throw new Error('Subagent delegation depth limit reached.');
|
|
151
|
+
}
|
|
152
|
+
const inheritedNames = context.allowedToolNames ?? SAFE_STANDALONE_TOOLS;
|
|
153
|
+
if (!Array.isArray(inheritedNames) || !inheritedNames.every(n => typeof n === 'string')) {
|
|
154
|
+
throw new Error('Invalid parent tool policy.');
|
|
155
|
+
}
|
|
156
|
+
const requested = new Set(input.allowed_tools ?? inheritedNames);
|
|
157
|
+
const allowed = new Set(inheritedNames.filter(name => requested.has(name) && registry.has(name)));
|
|
158
|
+
const parentPermissions = context.permissions ?? createPermissionChecker({ defaultMode: 'plan' });
|
|
159
|
+
if (typeof parentPermissions.check !== 'function') throw new Error('Invalid parent permission checker.');
|
|
160
|
+
const childContext = {
|
|
161
|
+
...context,
|
|
162
|
+
allowedToolNames: [...allowed],
|
|
163
|
+
delegationDepth: depth + 1,
|
|
164
|
+
settings: {
|
|
165
|
+
...context.settings,
|
|
166
|
+
stream: false,
|
|
167
|
+
allowedTools: [...allowed],
|
|
168
|
+
maxTurns: Math.min(context.settings?.maxTurns || MAX_CHILD_TURNS, MAX_CHILD_TURNS),
|
|
169
|
+
maxDelegationDepth: maxDepth,
|
|
170
|
+
delegationDepth: depth + 1,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const permissions = {
|
|
174
|
+
mode: parentPermissions.mode,
|
|
175
|
+
async check(name, toolInput) {
|
|
176
|
+
return allowed.has(name) && await parentPermissions.check(name, toolInput) === true;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
childContext.permissions = permissions;
|
|
180
|
+
const tools = {
|
|
181
|
+
list: () => registry.list().filter(tool => allowed.has(tool.name)),
|
|
182
|
+
has: name => allowed.has(name),
|
|
183
|
+
get: name => allowed.has(name) ? registry.get(name) : undefined,
|
|
184
|
+
async call(name, toolInput) {
|
|
185
|
+
if (!await permissions.check(name, toolInput)) throw new Error('Subagent tool denied: ' + name);
|
|
186
|
+
// Explicit context propagation also works with older registry dispatchers.
|
|
187
|
+
if (name === 'Agent') return AgentTool.call(toolInput, childContext);
|
|
188
|
+
return registry.call(name, toolInput, childContext);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
return { ...childContext, tools, permissions };
|
|
192
|
+
}
|
package/src/tools/edit.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import path from 'path';
|
|
12
|
+
import { readStrictUtf8 } from './text-encoding.mjs';
|
|
12
13
|
import { hasBeenRead, markRead } from './read.mjs';
|
|
13
14
|
|
|
14
15
|
export const EditTool = {
|
|
@@ -46,7 +47,7 @@ export const EditTool = {
|
|
|
46
47
|
|
|
47
48
|
let content;
|
|
48
49
|
try {
|
|
49
|
-
content =
|
|
50
|
+
content = readStrictUtf8(filePath);
|
|
50
51
|
} catch (e) {
|
|
51
52
|
return `Error: ${e.message}`;
|
|
52
53
|
}
|
package/src/tools/multi-edit.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
+
import { readStrictUtf8 } from './text-encoding.mjs';
|
|
10
11
|
|
|
11
12
|
export const MultiEditTool = {
|
|
12
13
|
name: 'MultiEdit',
|
|
@@ -57,7 +58,7 @@ export const MultiEditTool = {
|
|
|
57
58
|
|
|
58
59
|
if (!fileContents.has(filePath)) {
|
|
59
60
|
try {
|
|
60
|
-
fileContents.set(filePath,
|
|
61
|
+
fileContents.set(filePath, readStrictUtf8(filePath));
|
|
61
62
|
} catch (err) {
|
|
62
63
|
errors.push(`edit[${i}]: cannot read ${filePath}: ${err.message}`);
|
|
63
64
|
continue;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import path from 'path';
|
|
12
|
+
import { readStrictUtf8 } from './text-encoding.mjs';
|
|
12
13
|
|
|
13
14
|
export const NotebookEditTool = {
|
|
14
15
|
name: 'NotebookEdit',
|
|
@@ -48,8 +49,9 @@ export const NotebookEditTool = {
|
|
|
48
49
|
const filePath = path.resolve(input.notebook_path);
|
|
49
50
|
|
|
50
51
|
try {
|
|
51
|
-
const raw =
|
|
52
|
-
const
|
|
52
|
+
const raw = readStrictUtf8(filePath);
|
|
53
|
+
const bom = raw.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
54
|
+
const notebook = JSON.parse(bom ? raw.slice(1) : raw);
|
|
53
55
|
|
|
54
56
|
if (!notebook.cells || !Array.isArray(notebook.cells)) {
|
|
55
57
|
return 'Error: invalid notebook format (no cells array)';
|
|
@@ -87,7 +89,10 @@ export const NotebookEditTool = {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
const newline = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
93
|
+
const trailing = raw.match(/(?:\r?\n)+$/)?.[0] || '';
|
|
94
|
+
const serialized = JSON.stringify(notebook, null, 1).replace(/\n/g, newline);
|
|
95
|
+
fs.writeFileSync(filePath, bom + serialized + trailing);
|
|
91
96
|
return `Notebook updated: ${input.operation} at cell ${input.cell_index} (${notebook.cells.length} cells total)`;
|
|
92
97
|
} catch (err) {
|
|
93
98
|
return `Error: ${err.message}`;
|
package/src/tools/read.mjs
CHANGED
|
@@ -14,6 +14,29 @@ import path from 'path';
|
|
|
14
14
|
import { execFileSync } from 'child_process';
|
|
15
15
|
|
|
16
16
|
const DEFAULT_LIMIT = 2000;
|
|
17
|
+
const MAX_TEXT_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
const MAX_TEXT_LINES = 100000;
|
|
19
|
+
|
|
20
|
+
// Read a bounded snapshot even if the file grows after the initial stat.
|
|
21
|
+
function readTextSnapshot(filePath) {
|
|
22
|
+
const fd = fs.openSync(filePath, 'r');
|
|
23
|
+
try {
|
|
24
|
+
const size = fs.fstatSync(fd).size;
|
|
25
|
+
if (size > MAX_TEXT_BYTES) throw new Error('Text file exceeds the 16 MiB hard limit.');
|
|
26
|
+
const buffer = Buffer.alloc(size + 1);
|
|
27
|
+
let total = 0;
|
|
28
|
+
while (total < buffer.length) {
|
|
29
|
+
const count = fs.readSync(fd, buffer, total, buffer.length - total, total);
|
|
30
|
+
if (!count) break;
|
|
31
|
+
total += count;
|
|
32
|
+
}
|
|
33
|
+
if (total > size) throw new Error('File changed while reading; retry the read.');
|
|
34
|
+
if (isBinary(buffer.subarray(0, total))) throw new Error('File appears to be binary. Cannot display binary content.');
|
|
35
|
+
return buffer.toString('utf-8', 0, total);
|
|
36
|
+
} finally {
|
|
37
|
+
fs.closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
17
40
|
|
|
18
41
|
// Track which files have been read (used by Edit and Write tools)
|
|
19
42
|
const readFiles = new Set();
|
|
@@ -44,16 +67,24 @@ export const ReadTool = {
|
|
|
44
67
|
file_path: { type: 'string', description: 'Absolute path to the file' },
|
|
45
68
|
offset: { type: 'number', description: 'Line number to start reading from (0-based)' },
|
|
46
69
|
limit: { type: 'number', description: 'Number of lines to read (default 2000)' },
|
|
70
|
+
allow_large: { type: 'boolean', description: 'Read beyond the default 2000 lines; explicit offset/limit still apply. Text is capped at 16 MiB and 100000 lines.' },
|
|
47
71
|
pages: { type: 'string', description: 'Page range for PDF files (e.g. "1-5")' },
|
|
48
72
|
},
|
|
49
73
|
required: ['file_path'],
|
|
50
74
|
},
|
|
51
75
|
validateInput(input) {
|
|
52
76
|
const errors = [];
|
|
53
|
-
if (!input.
|
|
77
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return ['input must be an object'];
|
|
78
|
+
if (typeof input.file_path !== 'string' || !input.file_path.trim() || input.file_path.includes('\0')) errors.push('file_path must be a nonempty string without null bytes');
|
|
79
|
+
if (input.offset !== undefined && (!Number.isSafeInteger(input.offset) || input.offset < 0)) errors.push('offset must be a nonnegative safe integer');
|
|
80
|
+
if (input.limit !== undefined && (!Number.isSafeInteger(input.limit) || input.limit < 1)) errors.push('limit must be a positive safe integer');
|
|
81
|
+
if (input.allow_large !== undefined && typeof input.allow_large !== 'boolean') errors.push('allow_large must be a boolean');
|
|
82
|
+
if (input.pages !== undefined && typeof input.pages !== 'string') errors.push('pages must be a string');
|
|
54
83
|
return errors;
|
|
55
84
|
},
|
|
56
85
|
async call(input) {
|
|
86
|
+
const errors = this.validateInput(input);
|
|
87
|
+
if (errors.length) return `Error: ${errors.join('; ')}`;
|
|
57
88
|
const filePath = path.resolve(input.file_path);
|
|
58
89
|
|
|
59
90
|
// Check existence
|
|
@@ -72,24 +103,14 @@ export const ReadTool = {
|
|
|
72
103
|
return readPdf(filePath, input.pages);
|
|
73
104
|
}
|
|
74
105
|
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const fd = fs.openSync(filePath, 'r');
|
|
78
|
-
const buf = Buffer.alloc(8192);
|
|
79
|
-
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
|
80
|
-
fs.closeSync(fd);
|
|
81
|
-
if (isBinary(buf.subarray(0, bytesRead))) {
|
|
82
|
-
return `Error: ${filePath} appears to be a binary file. Cannot display binary content.`;
|
|
83
|
-
}
|
|
84
|
-
} catch (e) {
|
|
85
|
-
return `Error: ${e.message}`;
|
|
86
|
-
}
|
|
106
|
+
if (!stat.isFile()) return 'Error: Read requires a regular file.';
|
|
87
107
|
|
|
88
108
|
try {
|
|
89
|
-
const content =
|
|
109
|
+
const content = readTextSnapshot(filePath);
|
|
110
|
+
if (content.split('\n', MAX_TEXT_LINES + 1).length > MAX_TEXT_LINES) return 'Error: Text file exceeds the 100000 line hard limit.';
|
|
90
111
|
const lines = content.split('\n');
|
|
91
|
-
const start = input.offset
|
|
92
|
-
const limit = input.limit
|
|
112
|
+
const start = input.offset ?? 0;
|
|
113
|
+
const limit = input.limit ?? (input.allow_large ? MAX_TEXT_LINES : DEFAULT_LIMIT);
|
|
93
114
|
const end = Math.min(start + limit, lines.length);
|
|
94
115
|
|
|
95
116
|
// Track as read
|
package/src/tools/registry.mjs
CHANGED
|
@@ -73,12 +73,12 @@ export function createToolRegistry() {
|
|
|
73
73
|
}));
|
|
74
74
|
},
|
|
75
75
|
|
|
76
|
-
async call(name, input) {
|
|
76
|
+
async call(name, input, context) {
|
|
77
77
|
const tool = tools.get(name);
|
|
78
78
|
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
79
79
|
const errors = tool.validateInput?.(input) || [];
|
|
80
80
|
if (errors.length > 0) return `Validation error: ${errors.join(', ')}`;
|
|
81
|
-
return tool.call(input);
|
|
81
|
+
return tool.call(input, context);
|
|
82
82
|
},
|
|
83
83
|
|
|
84
84
|
register(tool) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode edit targets without silently replacing malformed byte sequences.
|
|
3
|
+
* ignoreBOM retains a UTF-8 BOM so ordinary string edits preserve its bytes.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { TextDecoder } from 'node:util';
|
|
7
|
+
|
|
8
|
+
export function readStrictUtf8(filePath) {
|
|
9
|
+
const bytes = fs.readFileSync(filePath);
|
|
10
|
+
try {
|
|
11
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error('File is not valid UTF-8. Refusing to edit to avoid corrupting its encoding. Convert it to UTF-8 explicitly first.');
|
|
14
|
+
}
|
|
15
|
+
}
|