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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +149 -30
- package/bin/statusline.mjs +24 -4
- package/build/adapters/antigravity/index.d.ts +1 -1
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/claude-code/hooks.d.ts +1 -0
- package/build/adapters/claude-code/hooks.js +3 -0
- package/build/adapters/claude-code/index.js +24 -5
- package/build/adapters/client-map.js +5 -0
- package/build/adapters/codex/hooks.d.ts +5 -1
- package/build/adapters/codex/hooks.js +5 -1
- package/build/adapters/codex/index.d.ts +9 -1
- package/build/adapters/codex/index.js +87 -5
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/detect.d.ts +1 -1
- package/build/adapters/detect.js +71 -3
- package/build/adapters/openclaw/mcp-tools.js +1 -1
- package/build/adapters/opencode/index.js +31 -17
- package/build/adapters/opencode/zod3tov4.js +27 -6
- package/build/adapters/pi/extension.d.ts +2 -12
- package/build/adapters/pi/extension.js +128 -109
- package/build/adapters/types.d.ts +5 -4
- package/build/adapters/types.js +4 -3
- package/build/cache-heal.d.ts +48 -0
- package/build/cache-heal.js +150 -0
- package/build/cli.js +37 -97
- package/build/executor.d.ts +25 -0
- package/build/executor.js +143 -22
- package/build/lifecycle.d.ts +48 -0
- package/build/lifecycle.js +111 -0
- package/build/opencode-plugin.js +5 -2
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/runtime.d.ts +0 -36
- package/build/runtime.js +107 -27
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/security.d.ts +73 -3
- package/build/security.js +293 -33
- package/build/server.d.ts +14 -0
- package/build/server.js +441 -354
- package/build/session/analytics.d.ts +1 -1
- package/build/session/analytics.js +5 -1
- package/build/session/db.js +23 -3
- package/build/session/extract.js +78 -0
- package/build/store.d.ts +1 -1
- package/build/store.js +139 -25
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/cli.bundle.mjs +253 -250
- package/configs/antigravity/GEMINI.md +2 -2
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +2 -2
- package/configs/codex/AGENTS.md +2 -2
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/gemini-cli/GEMINI.md +2 -2
- package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
- package/configs/kilo/AGENTS.md +2 -2
- package/configs/kiro/KIRO.md +2 -2
- package/configs/omp/SYSTEM.md +2 -2
- package/configs/openclaw/AGENTS.md +2 -2
- package/configs/opencode/AGENTS.md +2 -2
- package/configs/qwen-code/QWEN.md +2 -2
- package/configs/vscode-copilot/copilot-instructions.md +2 -2
- package/configs/zed/AGENTS.md +2 -2
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/codex/pretooluse.mjs +14 -4
- package/hooks/codex/stop.mjs +12 -4
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +158 -7
- package/hooks/core/mcp-ready.mjs +37 -8
- package/hooks/core/routing.mjs +94 -8
- package/hooks/core/tool-naming.mjs +3 -0
- package/hooks/hooks.json +12 -1
- package/hooks/pretooluse.mjs +6 -2
- package/hooks/routing-block.mjs +3 -4
- package/hooks/security.bundle.mjs +2 -1
- package/hooks/session-db.bundle.mjs +5 -5
- package/hooks/session-directive.mjs +88 -20
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +21 -0
- package/hooks/sessionstart.mjs +37 -5
- package/hooks/stop.mjs +49 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -10
- package/server.bundle.mjs +206 -200
- package/skills/ctx-insight/SKILL.md +12 -17
- package/build/util/db-lock.d.ts +0 -65
- package/build/util/db-lock.js +0 -166
- package/insight/index.html +0 -13
- package/insight/package.json +0 -55
- package/insight/server.mjs +0 -1265
- package/insight/src/components/analytics.tsx +0 -112
- package/insight/src/components/ui/badge.tsx +0 -52
- package/insight/src/components/ui/button.tsx +0 -58
- package/insight/src/components/ui/card.tsx +0 -103
- package/insight/src/components/ui/chart.tsx +0 -371
- package/insight/src/components/ui/collapsible.tsx +0 -19
- package/insight/src/components/ui/input.tsx +0 -20
- package/insight/src/components/ui/progress.tsx +0 -83
- package/insight/src/components/ui/scroll-area.tsx +0 -55
- package/insight/src/components/ui/separator.tsx +0 -23
- package/insight/src/components/ui/table.tsx +0 -114
- package/insight/src/components/ui/tabs.tsx +0 -82
- package/insight/src/components/ui/tooltip.tsx +0 -64
- package/insight/src/lib/api.ts +0 -144
- package/insight/src/lib/utils.ts +0 -6
- package/insight/src/main.tsx +0 -22
- package/insight/src/routeTree.gen.ts +0 -189
- package/insight/src/router.tsx +0 -19
- package/insight/src/routes/__root.tsx +0 -55
- package/insight/src/routes/enterprise.tsx +0 -316
- package/insight/src/routes/index.tsx +0 -1482
- package/insight/src/routes/knowledge.tsx +0 -221
- package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
- package/insight/src/routes/search.tsx +0 -97
- package/insight/src/routes/sessions.tsx +0 -179
- package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
- package/insight/src/styles.css +0 -104
- package/insight/tsconfig.json +0 -29
- package/insight/vite.config.ts +0 -19
package/build/security.d.ts
CHANGED
|
@@ -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 (&&, ||, ;,
|
|
45
|
-
* respecting single/double quotes and
|
|
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 (&&, ||, ;,
|
|
127
|
-
* respecting single/double quotes and
|
|
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
|
|
142
|
-
if (ch === "'" && !inDouble && !inBacktick &&
|
|
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 &&
|
|
154
|
+
else if (ch === '"' && !inSingle && !inBacktick && !escaped) {
|
|
147
155
|
inDouble = !inDouble;
|
|
148
156
|
current += ch;
|
|
149
157
|
}
|
|
150
|
-
else if (ch === "`" && !inSingle && !inDouble &&
|
|
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
|
|
283
|
-
if (!Array.isArray(
|
|
401
|
+
const entries = parsed?.permissions?.[kind];
|
|
402
|
+
if (!Array.isArray(entries))
|
|
284
403
|
return [];
|
|
285
404
|
const globs = [];
|
|
286
|
-
for (const entry of
|
|
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
|
-
//
|
|
331
|
-
const
|
|
332
|
-
|
|
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(
|
|
454
|
+
const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive);
|
|
335
455
|
if (denyMatch)
|
|
336
456
|
return { decision: "deny", matchedPattern: denyMatch };
|
|
337
457
|
}
|
|
338
458
|
}
|
|
339
|
-
//
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
360
|
-
for (const
|
|
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(
|
|
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
|