context-mode 1.0.162 → 1.0.164

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.
Files changed (149) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +149 -30
  7. package/bin/statusline.mjs +24 -4
  8. package/build/adapters/antigravity/index.d.ts +1 -1
  9. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  10. package/build/adapters/antigravity-cli/index.js +342 -0
  11. package/build/adapters/claude-code/hooks.d.ts +1 -0
  12. package/build/adapters/claude-code/hooks.js +3 -0
  13. package/build/adapters/claude-code/index.js +24 -5
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +5 -1
  16. package/build/adapters/codex/hooks.js +5 -1
  17. package/build/adapters/codex/index.d.ts +9 -1
  18. package/build/adapters/codex/index.js +87 -5
  19. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  20. package/build/adapters/copilot-cli/hooks.js +64 -0
  21. package/build/adapters/copilot-cli/index.d.ts +48 -0
  22. package/build/adapters/copilot-cli/index.js +341 -0
  23. package/build/adapters/detect.d.ts +1 -1
  24. package/build/adapters/detect.js +71 -3
  25. package/build/adapters/openclaw/mcp-tools.js +1 -1
  26. package/build/adapters/opencode/index.js +31 -17
  27. package/build/adapters/opencode/zod3tov4.js +27 -6
  28. package/build/adapters/pi/extension.d.ts +2 -12
  29. package/build/adapters/pi/extension.js +128 -109
  30. package/build/adapters/types.d.ts +5 -4
  31. package/build/adapters/types.js +4 -3
  32. package/build/cache-heal.d.ts +48 -0
  33. package/build/cache-heal.js +150 -0
  34. package/build/cli.js +37 -97
  35. package/build/executor.d.ts +25 -0
  36. package/build/executor.js +143 -22
  37. package/build/lifecycle.d.ts +48 -0
  38. package/build/lifecycle.js +111 -0
  39. package/build/opencode-plugin.js +5 -2
  40. package/build/routing-block.d.ts +8 -0
  41. package/build/routing-block.js +86 -0
  42. package/build/runtime.d.ts +0 -36
  43. package/build/runtime.js +107 -27
  44. package/build/search/flood-guard.d.ts +57 -0
  45. package/build/search/flood-guard.js +80 -0
  46. package/build/security.d.ts +73 -3
  47. package/build/security.js +293 -33
  48. package/build/server.d.ts +14 -0
  49. package/build/server.js +441 -354
  50. package/build/session/analytics.d.ts +1 -1
  51. package/build/session/analytics.js +5 -1
  52. package/build/session/db.js +23 -3
  53. package/build/session/extract.js +78 -0
  54. package/build/store.d.ts +1 -1
  55. package/build/store.js +139 -25
  56. package/build/tool-naming.d.ts +4 -0
  57. package/build/tool-naming.js +24 -0
  58. package/build/util/jsonc.d.ts +14 -0
  59. package/build/util/jsonc.js +104 -0
  60. package/cli.bundle.mjs +253 -250
  61. package/configs/antigravity/GEMINI.md +2 -2
  62. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  63. package/configs/antigravity-cli/hooks.json +37 -0
  64. package/configs/antigravity-cli/mcp_config.json +10 -0
  65. package/configs/antigravity-cli/plugin.json +14 -0
  66. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  67. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  68. package/configs/claude-code/CLAUDE.md +2 -2
  69. package/configs/codex/AGENTS.md +2 -2
  70. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  71. package/configs/copilot-cli/.mcp.json +12 -0
  72. package/configs/copilot-cli/README.md +47 -0
  73. package/configs/copilot-cli/hooks.json +41 -0
  74. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  75. package/configs/gemini-cli/GEMINI.md +2 -2
  76. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  77. package/configs/kilo/AGENTS.md +2 -2
  78. package/configs/kiro/KIRO.md +2 -2
  79. package/configs/omp/SYSTEM.md +2 -2
  80. package/configs/openclaw/AGENTS.md +2 -2
  81. package/configs/opencode/AGENTS.md +2 -2
  82. package/configs/qwen-code/QWEN.md +2 -2
  83. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  84. package/configs/zed/AGENTS.md +2 -2
  85. package/hooks/antigravity-cli/payload.mjs +98 -0
  86. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  87. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  88. package/hooks/antigravity-cli/stop.mjs +58 -0
  89. package/hooks/codex/pretooluse.mjs +14 -4
  90. package/hooks/codex/stop.mjs +12 -4
  91. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  92. package/hooks/copilot-cli/precompact.mjs +66 -0
  93. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  94. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  95. package/hooks/copilot-cli/stop.mjs +59 -0
  96. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  97. package/hooks/core/codex-caps.mjs +112 -0
  98. package/hooks/core/formatters.mjs +158 -7
  99. package/hooks/core/mcp-ready.mjs +37 -8
  100. package/hooks/core/routing.mjs +94 -8
  101. package/hooks/core/tool-naming.mjs +3 -0
  102. package/hooks/hooks.json +12 -1
  103. package/hooks/pretooluse.mjs +6 -2
  104. package/hooks/routing-block.mjs +3 -4
  105. package/hooks/security.bundle.mjs +2 -1
  106. package/hooks/session-db.bundle.mjs +5 -5
  107. package/hooks/session-directive.mjs +88 -20
  108. package/hooks/session-extract.bundle.mjs +2 -2
  109. package/hooks/session-helpers.mjs +21 -0
  110. package/hooks/sessionstart.mjs +37 -5
  111. package/hooks/stop.mjs +49 -0
  112. package/openclaw.plugin.json +1 -1
  113. package/package.json +2 -10
  114. package/server.bundle.mjs +206 -200
  115. package/skills/ctx-insight/SKILL.md +12 -17
  116. package/build/util/db-lock.d.ts +0 -65
  117. package/build/util/db-lock.js +0 -166
  118. package/insight/index.html +0 -13
  119. package/insight/package.json +0 -55
  120. package/insight/server.mjs +0 -1265
  121. package/insight/src/components/analytics.tsx +0 -112
  122. package/insight/src/components/ui/badge.tsx +0 -52
  123. package/insight/src/components/ui/button.tsx +0 -58
  124. package/insight/src/components/ui/card.tsx +0 -103
  125. package/insight/src/components/ui/chart.tsx +0 -371
  126. package/insight/src/components/ui/collapsible.tsx +0 -19
  127. package/insight/src/components/ui/input.tsx +0 -20
  128. package/insight/src/components/ui/progress.tsx +0 -83
  129. package/insight/src/components/ui/scroll-area.tsx +0 -55
  130. package/insight/src/components/ui/separator.tsx +0 -23
  131. package/insight/src/components/ui/table.tsx +0 -114
  132. package/insight/src/components/ui/tabs.tsx +0 -82
  133. package/insight/src/components/ui/tooltip.tsx +0 -64
  134. package/insight/src/lib/api.ts +0 -144
  135. package/insight/src/lib/utils.ts +0 -6
  136. package/insight/src/main.tsx +0 -22
  137. package/insight/src/routeTree.gen.ts +0 -189
  138. package/insight/src/router.tsx +0 -19
  139. package/insight/src/routes/__root.tsx +0 -55
  140. package/insight/src/routes/enterprise.tsx +0 -316
  141. package/insight/src/routes/index.tsx +0 -1482
  142. package/insight/src/routes/knowledge.tsx +0 -221
  143. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  144. package/insight/src/routes/search.tsx +0 -97
  145. package/insight/src/routes/sessions.tsx +0 -179
  146. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  147. package/insight/src/styles.css +0 -104
  148. package/insight/tsconfig.json +0 -29
  149. package/insight/vite.config.ts +0 -19
@@ -41,14 +41,19 @@ export declare function fileGlobToRegex(glob: string, caseInsensitive?: boolean)
41
41
  */
42
42
  export declare function matchesAnyPattern(command: string, patterns: string[], caseInsensitive?: boolean): string | null;
43
43
  /**
44
- * Split a shell command on chain operators (&&, ||, ;, |) while
45
- * respecting single/double quotes and backticks.
44
+ * Split a shell command on chain operators (&&, ||, ;, |, \n, \r, &) while
45
+ * respecting single/double quotes, backticks, subshells, and escape backslashes.
46
46
  *
47
47
  * "echo hello && sudo rm -rf /" → ["echo hello", "sudo rm -rf /"]
48
48
  *
49
49
  * This prevents bypassing deny patterns by prepending innocent commands.
50
50
  */
51
51
  export declare function splitChainedCommands(command: string): string[];
52
+ /**
53
+ * Recursively extract all nested subshell commands from `$()` and `` `...` ``.
54
+ * Handles escaping and quote contexts to ensure correct command boundary detection.
55
+ */
56
+ export declare function extractSubshellCommands(command: string): string[];
52
57
  /**
53
58
  * Read Bash permission policies from up to 3 settings files.
54
59
  *
@@ -71,6 +76,19 @@ export declare function readBashPolicies(projectDir?: string, globalSettingsPath
71
76
  * Each inner array contains the extracted glob strings.
72
77
  */
73
78
  export declare function readToolDenyPatterns(toolName: string, projectDir?: string, globalSettingsPath?: string): string[][];
79
+ /**
80
+ * Read `permissions.{deny|allow}` globs for a tool from every settings file in
81
+ * precedence order (project local → project shared → adapter globals).
82
+ *
83
+ * Generalizes the original deny-only reader so the project-boundary guard
84
+ * (#852) can consult the SAME `permissions.allow` rules the user already
85
+ * maintains for the host's `Read` tool — instead of inventing a context-mode-
86
+ * specific opt-out env that would rot into dead code. A user who legitimately
87
+ * needs an out-of-project read expresses it once, in the host config, e.g.
88
+ * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
89
+ * context-mode honor it.
90
+ */
91
+ export declare function readToolPermissionPatterns(toolName: string, kind: "deny" | "allow", projectDir?: string, globalSettingsPath?: string): string[][];
74
92
  interface CommandDecision {
75
93
  decision: PermissionDecision;
76
94
  matchedPattern?: string;
@@ -93,7 +111,7 @@ export declare function evaluateCommand(command: string, policies: SecurityPolic
93
111
  * The server has no UI for "ask" prompts, so allow/ask patterns are
94
112
  * irrelevant. Returns "deny" if any deny pattern matches, otherwise "allow".
95
113
  *
96
- * Also splits chained commands to prevent bypass.
114
+ * Also splits chained commands and nested subshells to prevent bypass.
97
115
  */
98
116
  export declare function evaluateCommandDenyOnly(command: string, policies: SecurityPolicy[], caseInsensitive?: boolean): {
99
117
  decision: "deny" | "allow";
@@ -125,6 +143,58 @@ export declare function evaluateFilePath(filePath: string, denyGlobs: string[][]
125
143
  denied: boolean;
126
144
  matchedPattern?: string;
127
145
  };
146
+ /**
147
+ * Pure, algorithmic (no-regex) test: does `filePath` resolve to a location
148
+ * inside `projectRoot`?
149
+ *
150
+ * Issue #852 — `ctx_execute_file` previously fed its `path` argument straight
151
+ * into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute*
152
+ * argument win outright, an agent could read any file on the host
153
+ * (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and
154
+ * `../` traversal escaped just as easily. Claude Code's harness sandbox cannot
155
+ * inspect MCP input params, so the user approving the MCP call could not see
156
+ * that the path escaped the workspace. This guard re-anchors the path to the
157
+ * project boundary.
158
+ *
159
+ * Containment is decided on the *resolved* form. When the file (or its parent
160
+ * chain) exists, the symlink-canonical form is ALSO required to stay inside —
161
+ * this closes the symlink-escape class (a project-local `safe.log` whose
162
+ * realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`.
163
+ *
164
+ * A path equal to the project root itself counts as inside. Comparison is
165
+ * case-insensitive on Windows/macOS to match those filesystems' semantics.
166
+ *
167
+ * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
168
+ * caller's fail-open posture is preserved when the root cannot be resolved.
169
+ */
170
+ export declare function isPathInsideProject(filePath: string, projectRoot: string | undefined, caseInsensitive?: boolean): boolean;
171
+ /**
172
+ * Decide whether `filePath` may be processed, given the project boundary AND
173
+ * the user's existing host `Read(...)` allow rules.
174
+ *
175
+ * Decision order:
176
+ * 1. Inside the project root → allowed (the common case; no config needed).
177
+ * 2. Outside the project, but matching a `permissions.allow` `Read(...)` glob
178
+ * the user already configured for the host → allowed. This is the
179
+ * principled escape hatch: a deliberate out-of-project read is expressed
180
+ * ONCE in the host config the user already maintains, reusing the same
181
+ * mechanism Claude Code itself uses to whitelist a path outside the
182
+ * sandbox — no context-mode-specific opt-out env that would rot into
183
+ * dead code.
184
+ * 3. Outside the project, no allow match → denied (closes the #852 escape).
185
+ *
186
+ * `allowGlobs` has the same per-settings-file shape as the deny globs returned
187
+ * by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses
188
+ * `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is
189
+ * identical to the deny path — one matcher, no divergence.
190
+ *
191
+ * Fail-open on an unknown project root (boundary cannot be computed) so the
192
+ * guard never blocks legitimate in-project work when resolution fails.
193
+ */
194
+ export declare function evaluateProjectContainment(filePath: string, projectRoot: string | undefined, allowGlobs?: string[][], caseInsensitive?: boolean): {
195
+ allowed: boolean;
196
+ reason: "inside" | "allow-rule" | "outside";
197
+ };
128
198
  /**
129
199
  * Scan non-shell code for shell-escape calls and extract the embedded
130
200
  * command strings.
package/build/security.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve, sep } from "node:path";
3
3
  import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js";
4
4
  // ==============================================================================
5
5
  // Pattern Parsing
@@ -120,11 +120,18 @@ export function matchesAnyPattern(command, patterns, caseInsensitive = false) {
120
120
  return null;
121
121
  }
122
122
  // ==============================================================================
123
- // Chained Command Splitting
123
+ // Chained Command Splitting & Subshell Extraction
124
124
  // ==============================================================================
125
+ function isEscaped(command, index) {
126
+ let backslashes = 0;
127
+ for (let i = index - 1; i >= 0 && command[i] === "\\"; i--) {
128
+ backslashes++;
129
+ }
130
+ return backslashes % 2 === 1;
131
+ }
125
132
  /**
126
- * Split a shell command on chain operators (&&, ||, ;, |) while
127
- * respecting single/double quotes and backticks.
133
+ * Split a shell command on chain operators (&&, ||, ;, |, \n, \r, &) while
134
+ * respecting single/double quotes, backticks, subshells, and escape backslashes.
128
135
  *
129
136
  * "echo hello && sudo rm -rf /" → ["echo hello", "sudo rm -rf /"]
130
137
  *
@@ -136,37 +143,57 @@ export function splitChainedCommands(command) {
136
143
  let inSingle = false;
137
144
  let inDouble = false;
138
145
  let inBacktick = false;
146
+ let dollarParenDepth = 0;
139
147
  for (let i = 0; i < command.length; i++) {
140
148
  const ch = command[i];
141
- const prev = i > 0 ? command[i - 1] : "";
142
- if (ch === "'" && !inDouble && !inBacktick && prev !== "\\") {
149
+ const escaped = isEscaped(command, i);
150
+ if (ch === "'" && !inDouble && !inBacktick && !escaped) {
143
151
  inSingle = !inSingle;
144
152
  current += ch;
145
153
  }
146
- else if (ch === '"' && !inSingle && !inBacktick && prev !== "\\") {
154
+ else if (ch === '"' && !inSingle && !inBacktick && !escaped) {
147
155
  inDouble = !inDouble;
148
156
  current += ch;
149
157
  }
150
- else if (ch === "`" && !inSingle && !inDouble && prev !== "\\") {
158
+ else if (ch === "`" && !inSingle && !inDouble && !escaped) {
151
159
  inBacktick = !inBacktick;
152
160
  current += ch;
153
161
  }
154
162
  else if (!inSingle && !inDouble && !inBacktick) {
155
- if (ch === ";") {
163
+ if (ch === "$" && command[i + 1] === "(" && !escaped) {
164
+ dollarParenDepth++;
165
+ current += ch + command[i + 1];
166
+ i++;
167
+ }
168
+ else if (dollarParenDepth > 0 && ch === "(" && !escaped) {
169
+ dollarParenDepth++;
170
+ current += ch;
171
+ }
172
+ else if (ch === ")" && dollarParenDepth > 0 && !escaped) {
173
+ dollarParenDepth--;
174
+ current += ch;
175
+ }
176
+ else if (dollarParenDepth === 0 &&
177
+ (ch === ";" || ch === "\n" || ch === "\r") &&
178
+ !escaped) {
156
179
  parts.push(current.trim());
157
180
  current = "";
158
181
  }
159
- else if (ch === "|" && command[i + 1] === "|") {
182
+ else if (dollarParenDepth === 0 && ch === "|" && command[i + 1] === "|") {
160
183
  parts.push(current.trim());
161
184
  current = "";
162
185
  i++; // skip second |
163
186
  }
164
- else if (ch === "&" && command[i + 1] === "&") {
187
+ else if (dollarParenDepth === 0 && ch === "&" && command[i + 1] === "&") {
165
188
  parts.push(current.trim());
166
189
  current = "";
167
190
  i++; // skip second &
168
191
  }
169
- else if (ch === "|") {
192
+ else if (dollarParenDepth === 0 && ch === "&" && !escaped) {
193
+ parts.push(current.trim());
194
+ current = "";
195
+ }
196
+ else if (dollarParenDepth === 0 && ch === "|") {
170
197
  // Single pipe — left side is a command too
171
198
  parts.push(current.trim());
172
199
  current = "";
@@ -183,6 +210,83 @@ export function splitChainedCommands(command) {
183
210
  parts.push(current.trim());
184
211
  return parts.filter((p) => p.length > 0);
185
212
  }
213
+ /**
214
+ * Recursively extract all nested subshell commands from `$()` and `` `...` ``.
215
+ * Handles escaping and quote contexts to ensure correct command boundary detection.
216
+ */
217
+ export function extractSubshellCommands(command) {
218
+ const subshells = [];
219
+ let inSingle = false;
220
+ let inDouble = false;
221
+ let backtickStart = -1;
222
+ const dollarParenStarts = [];
223
+ const dollarParenDepths = [];
224
+ let parenDepth = 0;
225
+ for (let i = 0; i < command.length; i++) {
226
+ const ch = command[i];
227
+ const escaped = isEscaped(command, i);
228
+ if (ch === "'" && !inDouble && backtickStart === -1 && !escaped) {
229
+ inSingle = !inSingle;
230
+ }
231
+ else if (ch === '"' && !inSingle && backtickStart === -1 && !escaped) {
232
+ inDouble = !inDouble;
233
+ }
234
+ else if (ch === "`" && !inSingle && !inDouble && !escaped) {
235
+ if (backtickStart === -1) {
236
+ backtickStart = i + 1;
237
+ }
238
+ else {
239
+ const sub = command.slice(backtickStart, i);
240
+ subshells.push(sub);
241
+ subshells.push(...extractSubshellCommands(sub));
242
+ backtickStart = -1;
243
+ }
244
+ }
245
+ else if (!inSingle && backtickStart === -1) {
246
+ if (ch === "$" && command[i + 1] === "(" && !escaped) {
247
+ if (command[i + 2] === "(") {
248
+ // Arithmetic expansion is not command execution, but nested command
249
+ // substitutions inside it still get discovered by the scanner.
250
+ parenDepth += 2;
251
+ i += 2; // skip '(('
252
+ }
253
+ else {
254
+ dollarParenStarts.push(i + 2);
255
+ dollarParenDepths.push(parenDepth);
256
+ parenDepth++;
257
+ i++; // skip '('
258
+ }
259
+ }
260
+ else if (ch === "(" && !escaped) {
261
+ parenDepth++;
262
+ }
263
+ else if (ch === ")" && !escaped) {
264
+ if (parenDepth > 0) {
265
+ parenDepth--;
266
+ }
267
+ if (dollarParenDepths.length > 0 &&
268
+ parenDepth === dollarParenDepths[dollarParenDepths.length - 1]) {
269
+ dollarParenDepths.pop();
270
+ const start = dollarParenStarts.pop();
271
+ const sub = command.slice(start, i);
272
+ subshells.push(sub);
273
+ }
274
+ }
275
+ }
276
+ }
277
+ return subshells;
278
+ }
279
+ function collectCommandElements(command) {
280
+ const elements = [];
281
+ const segments = splitChainedCommands(command);
282
+ for (const segment of segments) {
283
+ elements.push(segment);
284
+ for (const subshell of extractSubshellCommands(segment)) {
285
+ elements.push(...collectCommandElements(subshell));
286
+ }
287
+ }
288
+ return elements;
289
+ }
186
290
  // ==============================================================================
187
291
  // Settings Reader
188
292
  // ==============================================================================
@@ -263,6 +367,21 @@ export function readBashPolicies(projectDir, globalSettingsPath) {
263
367
  * Each inner array contains the extracted glob strings.
264
368
  */
265
369
  export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
370
+ return readToolPermissionPatterns(toolName, "deny", projectDir, globalSettingsPath);
371
+ }
372
+ /**
373
+ * Read `permissions.{deny|allow}` globs for a tool from every settings file in
374
+ * precedence order (project local → project shared → adapter globals).
375
+ *
376
+ * Generalizes the original deny-only reader so the project-boundary guard
377
+ * (#852) can consult the SAME `permissions.allow` rules the user already
378
+ * maintains for the host's `Read` tool — instead of inventing a context-mode-
379
+ * specific opt-out env that would rot into dead code. A user who legitimately
380
+ * needs an out-of-project read expresses it once, in the host config, e.g.
381
+ * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
382
+ * context-mode honor it.
383
+ */
384
+ export function readToolPermissionPatterns(toolName, kind, projectDir, globalSettingsPath) {
266
385
  const result = [];
267
386
  const extractGlobs = (path) => {
268
387
  let raw;
@@ -279,11 +398,11 @@ export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
279
398
  catch {
280
399
  return null;
281
400
  }
282
- const deny = parsed?.permissions?.deny;
283
- if (!Array.isArray(deny))
401
+ const entries = parsed?.permissions?.[kind];
402
+ if (!Array.isArray(entries))
284
403
  return [];
285
404
  const globs = [];
286
- for (const entry of deny) {
405
+ for (const entry of entries) {
287
406
  if (typeof entry !== "string")
288
407
  continue;
289
408
  const tp = parseToolPattern(entry);
@@ -326,24 +445,46 @@ export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
326
445
  * First definitive match across policies wins.
327
446
  * Default (no match in any policy): "ask".
328
447
  */
329
- export function evaluateCommand(command, policies, caseInsensitive = process.platform === "win32") {
330
- // Check each segment of chained commands against deny patterns
331
- const segments = splitChainedCommands(command);
332
- for (const segment of segments) {
448
+ export function evaluateCommand(command, policies, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
449
+ // Extract all main segments and nested subshell commands
450
+ const allCommands = collectCommandElements(command);
451
+ // 1. Deny check: If ANY segment or subshell command is denied, block the entire command
452
+ for (const cmdElement of allCommands) {
333
453
  for (const policy of policies) {
334
- const denyMatch = matchesAnyPattern(segment, policy.deny, caseInsensitive);
454
+ const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive);
335
455
  if (denyMatch)
336
456
  return { decision: "deny", matchedPattern: denyMatch };
337
457
  }
338
458
  }
339
- // Check ask/allow against the full command (original behavior)
459
+ // 2. Allow/Ask check: Evaluate segment-by-segment in precedence order.
460
+ // The command is allowed if and only if EVERY segment and subshell is explicitly allowed.
461
+ // If any element matches an ask pattern or matches no allow pattern, it defaults to ask.
340
462
  for (const policy of policies) {
341
- const askMatch = matchesAnyPattern(command, policy.ask, caseInsensitive);
342
- if (askMatch)
343
- return { decision: "ask", matchedPattern: askMatch };
344
- const allowMatch = matchesAnyPattern(command, policy.allow, caseInsensitive);
345
- if (allowMatch)
346
- return { decision: "allow", matchedPattern: allowMatch };
463
+ let allAllowed = true;
464
+ let anyAsk = false;
465
+ let matchedAskPattern;
466
+ let matchedAllowPattern;
467
+ for (const cmdElement of allCommands) {
468
+ const askMatch = matchesAnyPattern(cmdElement, policy.ask, caseInsensitive);
469
+ if (askMatch) {
470
+ anyAsk = true;
471
+ matchedAskPattern = askMatch;
472
+ break; // Ask wins immediately within this policy
473
+ }
474
+ const allowMatch = matchesAnyPattern(cmdElement, policy.allow, caseInsensitive);
475
+ if (!allowMatch) {
476
+ allAllowed = false;
477
+ }
478
+ else {
479
+ matchedAllowPattern = allowMatch;
480
+ }
481
+ }
482
+ if (anyAsk) {
483
+ return { decision: "ask", matchedPattern: matchedAskPattern };
484
+ }
485
+ if (allAllowed && allCommands.length > 0) {
486
+ return { decision: "allow", matchedPattern: matchedAllowPattern };
487
+ }
347
488
  }
348
489
  return { decision: "ask" };
349
490
  }
@@ -353,13 +494,13 @@ export function evaluateCommand(command, policies, caseInsensitive = process.pla
353
494
  * The server has no UI for "ask" prompts, so allow/ask patterns are
354
495
  * irrelevant. Returns "deny" if any deny pattern matches, otherwise "allow".
355
496
  *
356
- * Also splits chained commands to prevent bypass.
497
+ * Also splits chained commands and nested subshells to prevent bypass.
357
498
  */
358
- export function evaluateCommandDenyOnly(command, policies, caseInsensitive = process.platform === "win32") {
359
- const segments = splitChainedCommands(command);
360
- for (const segment of segments) {
499
+ export function evaluateCommandDenyOnly(command, policies, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
500
+ const allCommands = collectCommandElements(command);
501
+ for (const cmdElement of allCommands) {
361
502
  for (const policy of policies) {
362
- const denyMatch = matchesAnyPattern(segment, policy.deny, caseInsensitive);
503
+ const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive);
363
504
  if (denyMatch)
364
505
  return { decision: "deny", matchedPattern: denyMatch };
365
506
  }
@@ -391,7 +532,7 @@ export function evaluateCommandDenyOnly(command, policies, caseInsensitive = pro
391
532
  * still checked. This keeps the function usable for paths that will
392
533
  * be created during execution.
393
534
  */
394
- export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.platform === "win32", projectRoot) {
535
+ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.platform === "win32" || process.platform === "darwin", projectRoot) {
395
536
  const toForward = (path) => path.replace(/\\/g, "/");
396
537
  // Match against the raw input, the lexically-resolved absolute path,
397
538
  // and the canonical (symlink-resolved) path when the file exists.
@@ -426,6 +567,125 @@ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.
426
567
  return { denied: false };
427
568
  }
428
569
  // ==============================================================================
570
+ // Project-Boundary Containment (Issue #852)
571
+ // ==============================================================================
572
+ /**
573
+ * Pure, algorithmic (no-regex) test: does `filePath` resolve to a location
574
+ * inside `projectRoot`?
575
+ *
576
+ * Issue #852 — `ctx_execute_file` previously fed its `path` argument straight
577
+ * into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute*
578
+ * argument win outright, an agent could read any file on the host
579
+ * (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and
580
+ * `../` traversal escaped just as easily. Claude Code's harness sandbox cannot
581
+ * inspect MCP input params, so the user approving the MCP call could not see
582
+ * that the path escaped the workspace. This guard re-anchors the path to the
583
+ * project boundary.
584
+ *
585
+ * Containment is decided on the *resolved* form. When the file (or its parent
586
+ * chain) exists, the symlink-canonical form is ALSO required to stay inside —
587
+ * this closes the symlink-escape class (a project-local `safe.log` whose
588
+ * realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`.
589
+ *
590
+ * A path equal to the project root itself counts as inside. Comparison is
591
+ * case-insensitive on Windows/macOS to match those filesystems' semantics.
592
+ *
593
+ * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
594
+ * caller's fail-open posture is preserved when the root cannot be resolved.
595
+ */
596
+ export function isPathInsideProject(filePath, projectRoot, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
597
+ if (!projectRoot)
598
+ return true;
599
+ const root = resolve(projectRoot);
600
+ const lexical = resolve(projectRoot, filePath);
601
+ const within = (root, candidate) => {
602
+ let a = root;
603
+ let b = candidate;
604
+ if (caseInsensitive) {
605
+ a = a.toLowerCase();
606
+ b = b.toLowerCase();
607
+ }
608
+ if (a === b)
609
+ return true;
610
+ // `path.relative` is pure string arithmetic — no regex. A candidate inside
611
+ // the root yields a relative path that neither starts with `..` (escapes
612
+ // upward) nor is absolute (a different drive/root on Windows that cannot be
613
+ // expressed relatively).
614
+ const rel = relative(a, b);
615
+ if (rel === "")
616
+ return true;
617
+ if (rel === ".." || rel.startsWith(".." + sep))
618
+ return false;
619
+ if (isAbsoluteRel(rel))
620
+ return false;
621
+ return true;
622
+ };
623
+ // Lexical containment is the primary gate.
624
+ if (!within(root, lexical))
625
+ return false;
626
+ // Defense-in-depth: when the path (or a parent) is a symlink that points
627
+ // outside the project, the canonical form must ALSO stay inside. Best-effort
628
+ // — a not-yet-created file (ENOENT) falls back to the lexical decision above.
629
+ try {
630
+ const canonicalRoot = realpathSync(root);
631
+ const canonical = realpathSync(lexical);
632
+ if (!within(canonicalRoot, canonical))
633
+ return false;
634
+ }
635
+ catch {
636
+ /* file does not exist yet / realpath failed — lexical decision stands */
637
+ }
638
+ return true;
639
+ }
640
+ /** Pure helper: is a `path.relative` result an absolute path? (no regex) */
641
+ function isAbsoluteRel(rel) {
642
+ if (rel.startsWith("/"))
643
+ return true; // POSIX absolute
644
+ // Windows drive-absolute: "C:\..." or "C:/..."
645
+ if (rel.length >= 3 && rel[1] === ":" && (rel[2] === "\\" || rel[2] === "/")) {
646
+ const c = rel.charCodeAt(0);
647
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
648
+ }
649
+ return false;
650
+ }
651
+ /**
652
+ * Decide whether `filePath` may be processed, given the project boundary AND
653
+ * the user's existing host `Read(...)` allow rules.
654
+ *
655
+ * Decision order:
656
+ * 1. Inside the project root → allowed (the common case; no config needed).
657
+ * 2. Outside the project, but matching a `permissions.allow` `Read(...)` glob
658
+ * the user already configured for the host → allowed. This is the
659
+ * principled escape hatch: a deliberate out-of-project read is expressed
660
+ * ONCE in the host config the user already maintains, reusing the same
661
+ * mechanism Claude Code itself uses to whitelist a path outside the
662
+ * sandbox — no context-mode-specific opt-out env that would rot into
663
+ * dead code.
664
+ * 3. Outside the project, no allow match → denied (closes the #852 escape).
665
+ *
666
+ * `allowGlobs` has the same per-settings-file shape as the deny globs returned
667
+ * by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses
668
+ * `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is
669
+ * identical to the deny path — one matcher, no divergence.
670
+ *
671
+ * Fail-open on an unknown project root (boundary cannot be computed) so the
672
+ * guard never blocks legitimate in-project work when resolution fails.
673
+ */
674
+ export function evaluateProjectContainment(filePath, projectRoot, allowGlobs = [], caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
675
+ if (isPathInsideProject(filePath, projectRoot, caseInsensitive)) {
676
+ return { allowed: true, reason: "inside" };
677
+ }
678
+ // Outside the project — permit only if the user explicitly allowed this path
679
+ // for the host Read tool. `evaluateFilePath` returns `denied:true` when a glob
680
+ // MATCHES, so a match here means "explicitly allowed".
681
+ if (allowGlobs.some((g) => g.length > 0)) {
682
+ const matched = evaluateFilePath(filePath, allowGlobs, caseInsensitive, projectRoot);
683
+ if (matched.denied)
684
+ return { allowed: true, reason: "allow-rule" };
685
+ }
686
+ return { allowed: false, reason: "outside" };
687
+ }
688
+ // ==============================================================================
429
689
  // Shell-Escape Scanner
430
690
  // ==============================================================================
431
691
  // Regex patterns that detect shell-escape calls in non-shell languages.
package/build/server.d.ts CHANGED
@@ -61,6 +61,8 @@ type ToolContextOverride = {
61
61
  sessionId?: string;
62
62
  };
63
63
  export declare function withProjectDirOverride<T>(projectDir: string | ToolContextOverride, fn: () => Promise<T>): Promise<T>;
64
+ export declare function sanitizeSchemaForStrictClients(node: unknown): unknown;
65
+ export declare function installStrictClientSchemaCompat(target?: McpServer): void;
64
66
  /**
65
67
  * Build the FK-attribution object passed to every ContentStore.index*() call
66
68
  * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
@@ -116,6 +118,7 @@ export interface BatchRunOptions {
116
118
  timeout: number | undefined;
117
119
  concurrency: number;
118
120
  nodeOptsPrefix: string;
121
+ cwd?: string;
119
122
  onFsBytes?: (bytes: number) => void;
120
123
  }
121
124
  interface BatchExecutor {
@@ -123,12 +126,23 @@ interface BatchExecutor {
123
126
  language: "shell";
124
127
  code: string;
125
128
  timeout: number | undefined;
129
+ cwd?: string;
126
130
  }): Promise<{
127
131
  stdout: string;
128
132
  timedOut?: boolean;
129
133
  }>;
130
134
  }
131
135
  export declare function buildBatchNodeOptionsPrefix(shellPath: string, preloadPath: string): string;
136
+ /**
137
+ * Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
138
+ * agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
139
+ * blocking script hangs forever — the host never kills it and the user must
140
+ * interrupt. Every other host enforces its own RPC timeout, so we keep the
141
+ * no-server-timer behavior there (Issue #406 — long builds need an unbounded
142
+ * run). A caller can still pass an explicit `timeout` to override on any host.
143
+ */
144
+ export declare const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120000;
145
+ export declare function resolveExecTimeout(timeout: number | undefined): number | undefined;
132
146
  /**
133
147
  * Execute batch commands. concurrency=1 preserves the legacy serial path
134
148
  * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs