@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruvnet/open-claude-code",
3
- "version": "2.1.2-nightly.20261011.203",
3
+ "version": "2.1.3",
4
4
  "description": "Open source agentic coding CLI with verified nightly upstream analysis",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.turnCount > settings.maxTurns) {
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
  }
@@ -1,170 +1,159 @@
1
1
  /**
2
- * Hook Engine — pre/post tool use and stop hooks.
3
- *
4
- * Based on Claude Code's hooks system (6 event types):
5
- * - PreToolUse: can block tool execution
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 hooks = this._getHooks('PreToolUse');
35
- for (const hook of hooks) {
36
- // Check if hook applies to this tool
37
- if (hook.toolName && hook.toolName !== toolName) continue;
38
-
39
- const result = await this._executeHook(hook, {
40
- event: 'PreToolUse',
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 hooks) {
63
- if (hook.toolName && hook.toolName !== toolName) continue;
64
-
65
- const hookResult = await this._executeHook(hook, {
66
- event: 'PostToolUse',
67
- toolName,
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 hooks = this._getHooks('Stop');
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; // allow stop
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 hooks = this._getHooks('Notification');
101
- for (const hook of hooks) {
102
- try {
103
- await this._executeHook(hook, { event, ...data });
104
- } catch {
105
- // Notifications are best-effort
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
- * Get hooks for a given event type.
112
- * @param {string} eventType
113
- * @returns {Array}
114
- */
115
- _getHooks(eventType) {
116
- if (!this.hooks || !this.hooks[eventType]) return [];
117
- const hooks = this.hooks[eventType];
118
- return Array.isArray(hooks) ? hooks : [hooks];
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
- // Ensure env values are strings (guards against prototype pollution
138
- // or non-string values from context objects breaking child_process).
139
- const env = {
140
- ...process.env,
141
- HOOK_EVENT: String(context.event || ''),
142
- HOOK_TOOL: String(context.toolName || ''),
143
- HOOK_INPUT: JSON.stringify(context.input || {}),
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
- if (hook.failOpen !== false) {
164
- // Default: fail open (allow)
165
- return null;
166
- }
167
- return { decision: 'deny', message: `Hook error: ${err.message}` };
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
  }
@@ -3,13 +3,12 @@
3
3
  *
4
4
  * Features:
5
5
  * - subagent_type parameter
6
- * - isolation: "worktree" option
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: 'List of tool names the subagent can use (default: all)',
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. "worktree" uses a git worktree.',
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 model = input.model || process.env.SUBAGENT_MODEL || 'claude-sonnet-4-6';
63
- const tools = createToolRegistry();
64
- const permissions = createPermissionChecker({ defaultMode: 'bypassPermissions' });
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: { stream: false },
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
+ }
@@ -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 = fs.readFileSync(filePath, 'utf-8');
50
+ content = readStrictUtf8(filePath);
50
51
  } catch (e) {
51
52
  return `Error: ${e.message}`;
52
53
  }
@@ -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, fs.readFileSync(filePath, 'utf-8'));
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 = fs.readFileSync(filePath, 'utf-8');
52
- const notebook = JSON.parse(raw);
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
- fs.writeFileSync(filePath, JSON.stringify(notebook, null, 1));
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}`;
@@ -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.file_path) errors.push('file_path is required');
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
- // Binary detection
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 = fs.readFileSync(filePath, 'utf-8');
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 || 0;
92
- const limit = input.limit || DEFAULT_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
@@ -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
+ }