kimi-plugin-cc 2.4.4 → 2.5.1
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +50 -34
- package/dist/commands/swarm.js +1 -1
- package/dist/dsh/companion.js +174 -0
- package/dist/dsh/index.js +156 -0
- package/dist/hooks/config-safety.js +3 -2
- package/dist/hooks/install-paths.js +2 -2
- package/dist/kimi-engine.js +1 -0
- package/dist/swarm-activation.js +1 -1
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -4
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +20 -17
- package/docs/safety.md +28 -15
- package/docs/swarm-recovery.md +1 -1
- package/docs/upstream-2.4.1-certification.md +54 -0
- package/package.json +41 -6
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/swarm.js +1 -1
- package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
- package/plugins/kimi-codex/dist/dsh/index.js +156 -0
- package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
- package/plugins/kimi-codex/dist/swarm-activation.js +1 -1
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +80 -0
- package/plugins/kimi-dsh/cordis.patch.yml +45 -0
- package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
- package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-dsh/dist/cli-client.js +876 -0
- package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
- package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
- package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
- package/plugins/kimi-dsh/dist/commands/models.js +140 -0
- package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
- package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
- package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
- package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
- package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
- package/plugins/kimi-dsh/dist/commands/result.js +66 -0
- package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
- package/plugins/kimi-dsh/dist/commands/review.js +220 -0
- package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
- package/plugins/kimi-dsh/dist/commands/status.js +27 -0
- package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
- package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
- package/plugins/kimi-dsh/dist/companion.js +97 -0
- package/plugins/kimi-dsh/dist/config.js +22 -0
- package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
- package/plugins/kimi-dsh/dist/errors.js +40 -0
- package/plugins/kimi-dsh/dist/git.js +224 -0
- package/plugins/kimi-dsh/dist/help.js +43 -0
- package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
- package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
- package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
- package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
- package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
- package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
- package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
- package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-dsh/dist/job-id.js +12 -0
- package/plugins/kimi-dsh/dist/job-store.js +659 -0
- package/plugins/kimi-dsh/dist/jobs.js +217 -0
- package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
- package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
- package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
- package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
- package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
- package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
- package/plugins/kimi-dsh/dist/logging.js +22 -0
- package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
- package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
- package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
- package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
- package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
- package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
- package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
- package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
- package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
- package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
- package/plugins/kimi-dsh/dist/model-command.js +32 -0
- package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-dsh/dist/package.json +1 -0
- package/plugins/kimi-dsh/dist/parsing.js +620 -0
- package/plugins/kimi-dsh/dist/paths.js +27 -0
- package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
- package/plugins/kimi-dsh/dist/process-tree.js +479 -0
- package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
- package/plugins/kimi-dsh/dist/render.js +177 -0
- package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
- package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
- package/plugins/kimi-dsh/dist/session-title.js +293 -0
- package/plugins/kimi-dsh/dist/stream-json.js +346 -0
- package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
- package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-dsh/dist/types.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
- package/plugins/kimi-dsh/dist/version.js +7 -0
- package/plugins/kimi-dsh/package.json +48 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { access, lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "./vendor/shell-quote/index.js";
|
|
4
|
+
// Tool-owned output flags write without shell redirection and therefore bypass
|
|
5
|
+
// path checks unless treated as mutating. The `-o` short form is not banned
|
|
6
|
+
// globally because tools such as rg and grep use it for read-only behavior;
|
|
7
|
+
// per-tool checks handle it. Exact `--output` also rejects the two-argument
|
|
8
|
+
// form because this scanner intentionally does not pair arguments.
|
|
9
|
+
const MUTATING_FLAGS_EXACT = new Set([
|
|
10
|
+
"--fix",
|
|
11
|
+
"--write",
|
|
12
|
+
"-w",
|
|
13
|
+
"--apply",
|
|
14
|
+
"--in-place",
|
|
15
|
+
"-i",
|
|
16
|
+
"--output",
|
|
17
|
+
"--output-file",
|
|
18
|
+
"--output-directory",
|
|
19
|
+
"--output-dir",
|
|
20
|
+
]);
|
|
21
|
+
const MUTATING_FLAG_PREFIXES = [
|
|
22
|
+
"--fix=",
|
|
23
|
+
"--write=",
|
|
24
|
+
"--apply=",
|
|
25
|
+
"--in-place=",
|
|
26
|
+
"--output=",
|
|
27
|
+
"--output-file=",
|
|
28
|
+
"--output-directory=",
|
|
29
|
+
"--output-dir=",
|
|
30
|
+
];
|
|
31
|
+
// Flags whose VALUE is executed as a command or external tool are rejected for
|
|
32
|
+
// every tool: Git pager overrides and Go -vettool/-exec/-toolexec are direct
|
|
33
|
+
// command-execution escapes. Git's -O short form is handled in its own branch
|
|
34
|
+
// because it collides with find's read-only -O<level> syntax.
|
|
35
|
+
const EXEC_DELEGATING_FLAGS_EXACT = new Set([
|
|
36
|
+
"--open-files-in-pager",
|
|
37
|
+
"-vettool",
|
|
38
|
+
"--vettool",
|
|
39
|
+
"-toolexec",
|
|
40
|
+
"--toolexec",
|
|
41
|
+
"-exec",
|
|
42
|
+
"-execdir",
|
|
43
|
+
// rg --pre/--hostname-bin and GNU sort --compress-program also execute their
|
|
44
|
+
// values as commands.
|
|
45
|
+
"--pre",
|
|
46
|
+
"--hostname-bin",
|
|
47
|
+
"--compress-program",
|
|
48
|
+
]);
|
|
49
|
+
const EXEC_DELEGATING_FLAG_PREFIXES = [
|
|
50
|
+
"--open-files-in-pager=",
|
|
51
|
+
"-vettool=",
|
|
52
|
+
"--vettool=",
|
|
53
|
+
"-toolexec=",
|
|
54
|
+
"--toolexec=",
|
|
55
|
+
"--pre=",
|
|
56
|
+
"--hostname-bin=",
|
|
57
|
+
"--compress-program=",
|
|
58
|
+
];
|
|
59
|
+
// Pytest report flags write to arbitrary paths. Test runners execute repo code
|
|
60
|
+
// by design, so these checks block known runner-owned report outputs rather
|
|
61
|
+
// than every possible write. Match argparse allow_abbrev prefixes as well.
|
|
62
|
+
const PYTEST_REPORT_FLAGS = [
|
|
63
|
+
"--junitxml",
|
|
64
|
+
"--junit-xml",
|
|
65
|
+
"--result-log",
|
|
66
|
+
"--report-log",
|
|
67
|
+
];
|
|
68
|
+
const PIPELINE_PLUMBING = new Set(["head", "tail", "wc", "sort", "uniq"]);
|
|
69
|
+
const GIT_READONLY_SUBCOMMANDS = new Set(["status", "diff", "show", "log", "grep", "blame"]);
|
|
70
|
+
const CARGO_SUBCOMMANDS = new Set(["check", "clippy", "test"]);
|
|
71
|
+
const GO_SUBCOMMANDS = new Set(["build", "vet", "test"]);
|
|
72
|
+
const BANNED_FIND_ACTIONS = new Set([
|
|
73
|
+
"-exec",
|
|
74
|
+
"-execdir",
|
|
75
|
+
"-ok",
|
|
76
|
+
"-okdir",
|
|
77
|
+
"-delete",
|
|
78
|
+
"-fprint",
|
|
79
|
+
"-fprintf",
|
|
80
|
+
"-fprint0",
|
|
81
|
+
"-fls",
|
|
82
|
+
]);
|
|
83
|
+
// Per-tool flags that write to an arbitrary path even in the tool's read/check
|
|
84
|
+
// mode. Each takes a path value (space- or =-separated);
|
|
85
|
+
// `hasFlagFromSet` matches both spellings.
|
|
86
|
+
const GO_WRITE_FLAGS = new Set([
|
|
87
|
+
"-coverprofile",
|
|
88
|
+
"-cpuprofile",
|
|
89
|
+
"-memprofile",
|
|
90
|
+
"-blockprofile",
|
|
91
|
+
"-mutexprofile",
|
|
92
|
+
"-trace",
|
|
93
|
+
"-outputdir",
|
|
94
|
+
]);
|
|
95
|
+
const CARGO_WRITE_FLAGS = new Set(["--target-dir", "--out-dir"]);
|
|
96
|
+
// tsc matches option names case-INSENSITIVELY (the compiler lowercases them via
|
|
97
|
+
// optionsNameMap), so `--outdir` == `--outDir`. Stored lowercase; matched
|
|
98
|
+
// against lowercased arg names.
|
|
99
|
+
const TSC_WRITE_FLAGS_LC = new Set([
|
|
100
|
+
"--generatecpuprofile",
|
|
101
|
+
"--generatetrace",
|
|
102
|
+
"--tsbuildinfofile",
|
|
103
|
+
"--outfile",
|
|
104
|
+
"--outdir",
|
|
105
|
+
"--out",
|
|
106
|
+
"--declarationdir",
|
|
107
|
+
]);
|
|
108
|
+
// ruff --config accepts an inline `key=value` override (`--config 'fix=true'`
|
|
109
|
+
// flips ruff into file-rewriting mode) or an arbitrary config path — reject it
|
|
110
|
+
// alongside the direct write flags.
|
|
111
|
+
const RUFF_WRITE_FLAGS = new Set(["--add-noqa", "--fix-only", "--cache-dir", "--output-file", "--config"]);
|
|
112
|
+
const ESLINT_WRITE_FLAGS = new Set(["--cache-location", "--output-file"]);
|
|
113
|
+
// ESLint loads and executes the module named by these flags, so an arbitrary
|
|
114
|
+
// parser/rules/plugin path is a code-execution escape. Normal linting uses the
|
|
115
|
+
// project's own config instead of these overrides.
|
|
116
|
+
const ESLINT_CODELOAD_FLAGS = new Set(["--parser", "--rulesdir", "--resolve-plugins-relative-to"]);
|
|
117
|
+
// By default mypy does not execute repo code, but config plugins are imported
|
|
118
|
+
// (the --config-file exception denied below). Reports/cache can target arbitrary
|
|
119
|
+
// paths and --install-types runs pip. Match argparse allow_abbrev prefixes.
|
|
120
|
+
const MYPY_WRITE_FLAGS_ABBREV = [
|
|
121
|
+
"--junit-xml",
|
|
122
|
+
"--cache-dir",
|
|
123
|
+
"--install-types",
|
|
124
|
+
// A mypy config file can import plugins, so an arbitrary --config-file is a
|
|
125
|
+
// code-execution escape; normal checks use the workspace mypy.ini.
|
|
126
|
+
"--config-file",
|
|
127
|
+
"--any-exprs-report",
|
|
128
|
+
"--cobertura-xml-report",
|
|
129
|
+
"--html-report",
|
|
130
|
+
"--linecount-report",
|
|
131
|
+
"--linecoverage-report",
|
|
132
|
+
"--lineprecision-report",
|
|
133
|
+
"--memory-xml-report",
|
|
134
|
+
"--txt-report",
|
|
135
|
+
"--xml-report",
|
|
136
|
+
"--xslt-html-report",
|
|
137
|
+
"--xslt-txt-report",
|
|
138
|
+
];
|
|
139
|
+
/**
|
|
140
|
+
* True if any arg is exactly a flag in `flags` or its `--flag=value` form.
|
|
141
|
+
* Handles both `--flag value` (arg === "--flag") and `--flag=value`.
|
|
142
|
+
*/
|
|
143
|
+
function hasFlagFromSet(args, flags) {
|
|
144
|
+
return args.some((arg) => {
|
|
145
|
+
const eq = arg.indexOf("=");
|
|
146
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
147
|
+
return flags.has(name);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* True if `arg` matches any flag in `flags` accounting for argparse-style prefix
|
|
152
|
+
* ABBREVIATION (`allow_abbrev`, the default for mypy/pytest): argparse accepts
|
|
153
|
+
* any unambiguous prefix of a registered option, so `--jun` resolves to
|
|
154
|
+
* `--junit-xml`. Matches when the arg name (before any `=`) is a prefix of a
|
|
155
|
+
* dangerous flag (abbreviation) OR a dangerous flag is a prefix of the name
|
|
156
|
+
* (exact + trailing). This is a SUPERSET of the argparse-acceptable forms —
|
|
157
|
+
* genuinely ambiguous prefixes that argparse would reject are harmlessly denied
|
|
158
|
+
* too. Min length 3 (`--` + 1 char) avoids matching a bare `--`.
|
|
159
|
+
*/
|
|
160
|
+
function matchesAbbreviatedFlag(args, flags) {
|
|
161
|
+
return args.some((arg) => {
|
|
162
|
+
const eq = arg.indexOf("=");
|
|
163
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
164
|
+
if (name.length < 3)
|
|
165
|
+
return false;
|
|
166
|
+
return flags.some((flag) => flag.startsWith(name) || name.startsWith(flag));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Direct-read bypass for rescue. Keep this set identical to READ_ONLY_TOOLS in
|
|
171
|
+
* approval-policy.ts; the deliberate duplication must be updated in both places.
|
|
172
|
+
*/
|
|
173
|
+
const RESCUE_READ_ONLY_TOOLS = new Set([
|
|
174
|
+
"Read",
|
|
175
|
+
"Grep",
|
|
176
|
+
"Glob",
|
|
177
|
+
"ReadMediaFile",
|
|
178
|
+
"TaskList",
|
|
179
|
+
"TaskOutput",
|
|
180
|
+
]);
|
|
181
|
+
/**
|
|
182
|
+
* Evaluate a PreToolUse hook request under the rescue policy.
|
|
183
|
+
*
|
|
184
|
+
* `workspaceRoot` is the plugin-owned KIMI_PLUGIN_CC_WORKSPACE_ROOT passed by
|
|
185
|
+
* the hook entry, never the payload cwd; the model cannot forge that per-spawn
|
|
186
|
+
* environment value. `toolName` and `toolInput` are the raw upstream tool call.
|
|
187
|
+
*/
|
|
188
|
+
export async function evaluateRescueHookRequest(workspaceRoot, toolName, toolInput) {
|
|
189
|
+
if (RESCUE_READ_ONLY_TOOLS.has(toolName)) {
|
|
190
|
+
return { decision: "allow" };
|
|
191
|
+
}
|
|
192
|
+
let root;
|
|
193
|
+
try {
|
|
194
|
+
root = await realpath(workspaceRoot);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
decision: "deny",
|
|
199
|
+
reason: `rescue cannot resolve workspace root: ${err.message}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (toolName === "Bash") {
|
|
203
|
+
const command = extractBashCommand(toolInput);
|
|
204
|
+
if (command === null) {
|
|
205
|
+
return {
|
|
206
|
+
decision: "deny",
|
|
207
|
+
reason: "rescue cannot evaluate Bash input with no command field",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const cwd = isObject(toolInput) ? toolInput.cwd : undefined;
|
|
211
|
+
if (cwd !== undefined) {
|
|
212
|
+
if (typeof cwd !== "string" || cwd.length === 0) {
|
|
213
|
+
return { decision: "deny", reason: "rescue requires Bash cwd to be a non-empty string" };
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const outcome = await checkApprovedDirectory(root, cwd);
|
|
217
|
+
if (outcome === "symlink") {
|
|
218
|
+
return { decision: "deny", reason: `rescue rejects Bash cwd symlinks: ${cwd}` };
|
|
219
|
+
}
|
|
220
|
+
if (outcome === "missing") {
|
|
221
|
+
return { decision: "deny", reason: `rescue rejects Bash cwd that is missing or not a directory: ${cwd}` };
|
|
222
|
+
}
|
|
223
|
+
if (outcome === "reject") {
|
|
224
|
+
return { decision: "deny", reason: `rescue rejects Bash cwd outside the workspace or inside .git: ${cwd}` };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return { decision: "deny", reason: `rescue cannot inspect Bash cwd ${cwd}: ${err.message}` };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const result = validateShellCommand(command);
|
|
232
|
+
return result.response === "approve"
|
|
233
|
+
? { decision: "allow" }
|
|
234
|
+
: { decision: "deny", reason: result.feedback ?? "rescue rejected the shell command" };
|
|
235
|
+
}
|
|
236
|
+
if (toolName === "Write" || toolName === "Edit") {
|
|
237
|
+
const filePath = extractFilePath(toolInput);
|
|
238
|
+
if (filePath === null) {
|
|
239
|
+
return {
|
|
240
|
+
decision: "deny",
|
|
241
|
+
reason: `rescue cannot evaluate ${toolName} input with no path field`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return pathDecision(await checkApprovedPath(root, filePath), filePath);
|
|
245
|
+
}
|
|
246
|
+
if (toolName === "MultiEdit") {
|
|
247
|
+
const filePath = extractFilePath(toolInput);
|
|
248
|
+
if (filePath === null) {
|
|
249
|
+
return {
|
|
250
|
+
decision: "deny",
|
|
251
|
+
reason: "rescue cannot evaluate MultiEdit input with no path field",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return pathDecision(await checkApprovedPath(root, filePath), filePath);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
decision: "deny",
|
|
258
|
+
reason: `rescue does not allow tool "${toolName}" — only Bash, Write, Edit, MultiEdit (with allowlist checks) and read-only tools are permitted.`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function pathDecision(outcome, filePath) {
|
|
262
|
+
if (outcome === "allow")
|
|
263
|
+
return { decision: "allow" };
|
|
264
|
+
if (outcome === "symlink") {
|
|
265
|
+
return {
|
|
266
|
+
decision: "deny",
|
|
267
|
+
reason: `rescue does not overwrite symlinks: ${filePath}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
decision: "deny",
|
|
272
|
+
reason: `rescue rejects file edits outside the workspace or inside .git: ${filePath}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function extractBashCommand(toolInput) {
|
|
276
|
+
if (!isObject(toolInput))
|
|
277
|
+
return null;
|
|
278
|
+
const command = toolInput.command;
|
|
279
|
+
return typeof command === "string" && command.length > 0 ? command : null;
|
|
280
|
+
}
|
|
281
|
+
function extractFilePath(toolInput) {
|
|
282
|
+
if (!isObject(toolInput))
|
|
283
|
+
return null;
|
|
284
|
+
// kimi-code Write/Edit/MultiEdit call this field `path`, not Claude Code's
|
|
285
|
+
// `file_path`. Prefer the native key and accept the alias for compatibility.
|
|
286
|
+
const candidate = toolInput.path ?? toolInput.file_path;
|
|
287
|
+
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
|
|
288
|
+
}
|
|
289
|
+
function isObject(value) {
|
|
290
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* TOCTOU residual: this check runs before kimi-code writes. An attacker with
|
|
294
|
+
* workspace write access could replace the approved path with an escaping
|
|
295
|
+
* symlink before that write. The plugin cannot open-by-fd or recheck inside the
|
|
296
|
+
* Write tool, so the explicit trust boundary is the workspace's existing write
|
|
297
|
+
* controls: such an attacker can already mutate workspace files directly, and
|
|
298
|
+
* rescue is intentionally write-capable.
|
|
299
|
+
*/
|
|
300
|
+
export async function checkApprovedPath(workspaceRoot, rawTargetPath) {
|
|
301
|
+
const absoluteTarget = path.isAbsolute(rawTargetPath)
|
|
302
|
+
? path.resolve(rawTargetPath)
|
|
303
|
+
: path.resolve(workspaceRoot, rawTargetPath);
|
|
304
|
+
const targetStats = await statIfExists(absoluteTarget);
|
|
305
|
+
if (targetStats?.isSymbolicLink()) {
|
|
306
|
+
return "symlink";
|
|
307
|
+
}
|
|
308
|
+
const nearestExistingAncestor = await findNearestExistingAncestor(path.dirname(absoluteTarget));
|
|
309
|
+
const ancestorRealPath = await realpath(nearestExistingAncestor);
|
|
310
|
+
const candidatePath = path.resolve(ancestorRealPath, path.relative(nearestExistingAncestor, absoluteTarget));
|
|
311
|
+
const relativeToRoot = path.relative(workspaceRoot, candidatePath);
|
|
312
|
+
return (isWithin(workspaceRoot, candidatePath) &&
|
|
313
|
+
!relativeToRoot.split(path.sep).includes(".git"))
|
|
314
|
+
? "allow"
|
|
315
|
+
: "reject";
|
|
316
|
+
}
|
|
317
|
+
async function checkApprovedDirectory(workspaceRoot, rawCwd) {
|
|
318
|
+
const candidate = path.resolve(workspaceRoot, rawCwd);
|
|
319
|
+
const stats = await statIfExists(candidate);
|
|
320
|
+
if (stats?.isSymbolicLink())
|
|
321
|
+
return "symlink";
|
|
322
|
+
if (!stats?.isDirectory())
|
|
323
|
+
return "missing";
|
|
324
|
+
const resolved = await realpath(candidate);
|
|
325
|
+
return isWithin(workspaceRoot, resolved) &&
|
|
326
|
+
!path.relative(workspaceRoot, resolved).split(path.sep).includes(".git")
|
|
327
|
+
? "allow"
|
|
328
|
+
: "reject";
|
|
329
|
+
}
|
|
330
|
+
async function statIfExists(targetPath) {
|
|
331
|
+
try {
|
|
332
|
+
return await lstat(targetPath);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error.code === "ENOENT") {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function findNearestExistingAncestor(targetDirectory) {
|
|
342
|
+
let candidate = targetDirectory;
|
|
343
|
+
while (true) {
|
|
344
|
+
try {
|
|
345
|
+
await access(candidate);
|
|
346
|
+
return candidate;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
const parent = path.dirname(candidate);
|
|
350
|
+
if (parent === candidate) {
|
|
351
|
+
return candidate;
|
|
352
|
+
}
|
|
353
|
+
candidate = parent;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export function validateShellCommand(command) {
|
|
358
|
+
// shell-quote collapses raw newline/carriage-return separators into adjacent tokens, which
|
|
359
|
+
// hides a second command from per-stage validation (`git status\nrm -rf /` parses as
|
|
360
|
+
// ['git','status','rm','-rf','/']). Reject any line-break characters up front so every
|
|
361
|
+
// approved command maps to a single shell invocation.
|
|
362
|
+
if (/[\r\n]/.test(command)) {
|
|
363
|
+
return rejectShell(`Rescue rejects multi-line shell commands: ${JSON.stringify(command)}`);
|
|
364
|
+
}
|
|
365
|
+
if (hasUnsafeShellSyntax(command)) {
|
|
366
|
+
return rejectShell(`Rescue rejects command substitution, process substitution, or backticks: ${command}`);
|
|
367
|
+
}
|
|
368
|
+
const parsed = parse(command);
|
|
369
|
+
const stages = [[]];
|
|
370
|
+
for (const token of parsed) {
|
|
371
|
+
if (typeof token === "string") {
|
|
372
|
+
stages.at(-1).push(token);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if ("op" in token && token.op === "|") {
|
|
376
|
+
stages.push([]);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
return rejectShell(`Rescue rejects shell syntax outside simple pipelines: ${command}`);
|
|
380
|
+
}
|
|
381
|
+
if (stages.some((stage) => stage.length === 0)) {
|
|
382
|
+
return rejectShell(`Rescue rejects malformed shell pipelines: ${command}`);
|
|
383
|
+
}
|
|
384
|
+
for (let index = 0; index < stages.length; index += 1) {
|
|
385
|
+
const stage = stages[index];
|
|
386
|
+
const allowed = validateShellStage(stage, index > 0 || stages.length > 1);
|
|
387
|
+
if (!allowed.ok) {
|
|
388
|
+
return rejectShell(allowed.reason);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { response: "approve" };
|
|
392
|
+
}
|
|
393
|
+
export function validateShellStage(tokens, pipelineMode) {
|
|
394
|
+
const [command, ...args] = tokens;
|
|
395
|
+
if (!command || path.isAbsolute(command) || command.includes(path.sep)) {
|
|
396
|
+
return { ok: false, reason: `Rescue rejects non-standard shell entrypoints: ${tokens.join(" ")}` };
|
|
397
|
+
}
|
|
398
|
+
if (hasMutatingFlag(args)) {
|
|
399
|
+
return { ok: false, reason: `Rescue rejects mutating shell flags: ${tokens.join(" ")}` };
|
|
400
|
+
}
|
|
401
|
+
if (hasExecDelegatingFlag(args)) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: `Rescue rejects flags that execute an external command/tool (e.g. --open-files-in-pager, -vettool, -exec, -toolexec): ${tokens.join(" ")}`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (command === "git") {
|
|
408
|
+
const firstArg = args[0] ?? "";
|
|
409
|
+
// Pre-subcommand git flags shift where the subcommand lives in argv. The `-c` form in
|
|
410
|
+
// particular can smuggle pager overrides: `git -c core.pager=bash show HEAD:exfil.sh`
|
|
411
|
+
// makes git pipe blob contents through bash without any shell metacharacters. Any
|
|
412
|
+
// legitimate "find first non-flag arg then check allowlist" refactor would accidentally
|
|
413
|
+
// admit that class of attack, so we preemptively reject every pre-subcommand flag here
|
|
414
|
+
// with an unambiguous error.
|
|
415
|
+
if (firstArg.startsWith("-")) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
reason: `Rescue rejects git pre-subcommand flags (e.g. -c/-C/-p/--no-pager/--exec-path): ${tokens.join(" ")}. Put the read-only subcommand immediately after \`git\`.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// git grep -O/--open-files-in-pager executes its pager value. The long form
|
|
422
|
+
// is caught globally; reject -O here because find uses that spelling for a
|
|
423
|
+
// read-only optimization level.
|
|
424
|
+
if (args.some((arg) => arg === "-O" || /^-O./.test(arg))) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
reason: `Rescue rejects git -O/--open-files-in-pager (executes the pager value as a command): ${tokens.join(" ")}.`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return GIT_READONLY_SUBCOMMANDS.has(firstArg)
|
|
431
|
+
? { ok: true }
|
|
432
|
+
: { ok: false, reason: `Rescue rejects git mutation commands: ${tokens.join(" ")}` };
|
|
433
|
+
}
|
|
434
|
+
if (command === "find") {
|
|
435
|
+
return args.some((arg) => BANNED_FIND_ACTIONS.has(arg))
|
|
436
|
+
? {
|
|
437
|
+
ok: false,
|
|
438
|
+
reason: `Rescue rejects find actions that execute or write files: ${tokens.join(" ")}`,
|
|
439
|
+
}
|
|
440
|
+
: { ok: true };
|
|
441
|
+
}
|
|
442
|
+
if (command === "tsc") {
|
|
443
|
+
// tsc option names are case-INSENSITIVE (compiler lowercases them), so match
|
|
444
|
+
// lowercased. `--noemit`/`--noEmit` both suppress JS emit; `--outdir` is the
|
|
445
|
+
// same write flag as `--outDir`.
|
|
446
|
+
const tscLowerNames = args.map((arg) => {
|
|
447
|
+
const eq = arg.indexOf("=");
|
|
448
|
+
return (eq === -1 ? arg : arg.slice(0, eq)).toLowerCase();
|
|
449
|
+
});
|
|
450
|
+
if (!tscLowerNames.includes("--noemit")) {
|
|
451
|
+
return { ok: false, reason: "Rescue allows tsc only with --noEmit." };
|
|
452
|
+
}
|
|
453
|
+
// --noEmit suppresses JS emit but NOT the profile/trace/buildinfo/declaration
|
|
454
|
+
// writers, which write to arbitrary paths.
|
|
455
|
+
if (tscLowerNames.some((name) => TSC_WRITE_FLAGS_LC.has(name))) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
reason: `Rescue rejects tsc write flags (--generateCpuProfile, --generateTrace, --tsBuildInfoFile, --outFile, --outDir, --out, --declarationDir — case-insensitive): ${tokens.join(" ")}.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return { ok: true };
|
|
462
|
+
}
|
|
463
|
+
if (command === "biome") {
|
|
464
|
+
if (args[0] !== "check") {
|
|
465
|
+
return { ok: false, reason: "Rescue allows biome only in check mode." };
|
|
466
|
+
}
|
|
467
|
+
// biome check applies fixes for every --apply/--write/--fix variant,
|
|
468
|
+
// including -unsafe/-only suffixes missed by the global mutating-flag set.
|
|
469
|
+
if (args.some((arg) => /^--(apply|write|fix)/.test(arg))) {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
reason: `Rescue rejects biome check write flags (--apply, --apply-unsafe, --write, --fix, and -unsafe/-only variants): ${tokens.join(" ")}.`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return { ok: true };
|
|
476
|
+
}
|
|
477
|
+
if (command === "ruff") {
|
|
478
|
+
if (args[0] === "check") {
|
|
479
|
+
if (hasWriteShortFlag(args)) {
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
reason: `Rescue rejects ruff -o (writes the report to a file): ${tokens.join(" ")}.`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
// --add-noqa/--fix-only rewrite source; --cache-dir/--output-file write
|
|
486
|
+
// outside the workspace (--fix-only dodges the global --fix check).
|
|
487
|
+
if (hasFlagFromSet(args, RUFF_WRITE_FLAGS)) {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
reason: `Rescue rejects ruff write flags (--add-noqa, --fix-only, --cache-dir, --output-file): ${tokens.join(" ")}.`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return { ok: true };
|
|
494
|
+
}
|
|
495
|
+
if (args[0] === "format") {
|
|
496
|
+
return args.some((arg) => arg === "--check" || arg === "--diff")
|
|
497
|
+
? { ok: true }
|
|
498
|
+
: { ok: false, reason: "Rescue allows ruff format only with --check or --diff." };
|
|
499
|
+
}
|
|
500
|
+
return { ok: false, reason: `Rescue rejects ruff subcommand: ${tokens.join(" ")}` };
|
|
501
|
+
}
|
|
502
|
+
if (command === "cargo") {
|
|
503
|
+
if (args[0] === "fmt") {
|
|
504
|
+
return args.includes("--check")
|
|
505
|
+
? { ok: true }
|
|
506
|
+
: { ok: false, reason: "Rescue allows cargo fmt only with --check." };
|
|
507
|
+
}
|
|
508
|
+
if (!CARGO_SUBCOMMANDS.has(args[0] ?? "")) {
|
|
509
|
+
return { ok: false, reason: `Rescue rejects cargo subcommand: ${tokens.join(" ")}` };
|
|
510
|
+
}
|
|
511
|
+
// --target-dir/--out-dir redirect the whole artifact tree to an arbitrary
|
|
512
|
+
// path outside the workspace.
|
|
513
|
+
if (hasFlagFromSet(args, CARGO_WRITE_FLAGS)) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
reason: `Rescue rejects cargo --target-dir/--out-dir (writes artifacts to an arbitrary path): ${tokens.join(" ")}.`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return { ok: true };
|
|
520
|
+
}
|
|
521
|
+
if (command === "go") {
|
|
522
|
+
if (!GO_SUBCOMMANDS.has(args[0] ?? "")) {
|
|
523
|
+
return { ok: false, reason: `Rescue rejects go subcommand: ${tokens.join(" ")}` };
|
|
524
|
+
}
|
|
525
|
+
// go build/test -o writes a binary to an arbitrary path. Exec-delegating
|
|
526
|
+
// -vettool/-exec/-toolexec values are already rejected globally.
|
|
527
|
+
if (hasWriteShortFlag(args)) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
reason: `Rescue rejects go -o (writes a binary to an arbitrary path): ${tokens.join(" ")}.`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
// Profile/trace/output-dir flags on `go test`/`go build` write to arbitrary
|
|
534
|
+
// paths without `-o`.
|
|
535
|
+
if (hasFlagFromSet(args, GO_WRITE_FLAGS)) {
|
|
536
|
+
return {
|
|
537
|
+
ok: false,
|
|
538
|
+
reason: `Rescue rejects go profile/output flags (-coverprofile, -cpuprofile, -memprofile, -blockprofile, -mutexprofile, -trace, -outputdir write to arbitrary paths): ${tokens.join(" ")}.`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return { ok: true };
|
|
542
|
+
}
|
|
543
|
+
if (command === "python" || command === "python3") {
|
|
544
|
+
if (args[0] !== "-m" || args[1] !== "pytest") {
|
|
545
|
+
return { ok: false, reason: `Rescue rejects ${command} shell command: ${tokens.join(" ")}` };
|
|
546
|
+
}
|
|
547
|
+
if (hasPytestReportFlag(args.slice(2))) {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
reason: `Rescue rejects pytest report-writing flags (--junitxml, --result-log, --report-log): ${tokens.join(" ")}.`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return { ok: true };
|
|
554
|
+
}
|
|
555
|
+
if (command === "npm" || command === "pnpm" || command === "yarn" || command === "bun" || command === "uv") {
|
|
556
|
+
return validatePackageManagerCommand(command, args);
|
|
557
|
+
}
|
|
558
|
+
if (command === "eslint") {
|
|
559
|
+
// Block ESLint's runner-owned report/cache paths. `-o` and joined
|
|
560
|
+
// `-o<path>` need a tool-specific check because rg/grep use `-o` read-only;
|
|
561
|
+
// the long --output-file form is already caught globally. These checks do
|
|
562
|
+
// not make linting read-only: project config and linter code still execute
|
|
563
|
+
// within rescue's repository trust boundary.
|
|
564
|
+
if (args.some((arg) => arg === "-o" || /^-o./.test(arg))) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
reason: `Rescue rejects eslint -o (writes report to a file): ${tokens.join(" ")}. Drop the flag or use a path-checked Write tool call.`,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
// --output-file (long form) is caught globally; --cache-location writes the
|
|
571
|
+
// cache tree to an arbitrary path.
|
|
572
|
+
if (hasFlagFromSet(args, ESLINT_WRITE_FLAGS)) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
reason: `Rescue rejects eslint --cache-location/--output-file (writes to an arbitrary path): ${tokens.join(" ")}.`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// --parser/--rulesdir/--resolve-plugins-relative-to LOAD AND EXECUTE a module
|
|
579
|
+
// from the given path — a code-exec escape.
|
|
580
|
+
if (hasFlagFromSet(args, ESLINT_CODELOAD_FLAGS)) {
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
reason: `Rescue rejects eslint --parser/--rulesdir/--resolve-plugins-relative-to (loads and executes a module from the given path): ${tokens.join(" ")}.`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return { ok: true };
|
|
587
|
+
}
|
|
588
|
+
if (command === "mypy") {
|
|
589
|
+
// By default mypy does not execute repo code, but --config-file can import
|
|
590
|
+
// plugins and is denied below. Block report/cache/install outputs and their
|
|
591
|
+
// argparse allow_abbrev prefixes.
|
|
592
|
+
if (matchesAbbreviatedFlag(args, MYPY_WRITE_FLAGS_ABBREV)) {
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
reason: `Rescue rejects mypy report/cache/install flags (--junit-xml, --*-report, --cache-dir, --install-types — incl. argparse abbreviations — write outside the workspace or run pip): ${tokens.join(" ")}.`,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return { ok: true };
|
|
599
|
+
}
|
|
600
|
+
if (command === "pytest") {
|
|
601
|
+
// pytest executes repo code by design. Block its known runner-owned report
|
|
602
|
+
// flags, not every write that test code itself can perform.
|
|
603
|
+
if (hasPytestReportFlag(args)) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
reason: `Rescue rejects pytest report-writing flags (--junitxml, --result-log, --report-log): ${tokens.join(" ")}.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return { ok: true };
|
|
610
|
+
}
|
|
611
|
+
if (["rg", "grep", "ls", "cat", "pwd", "pyright"].includes(command)) {
|
|
612
|
+
return { ok: true };
|
|
613
|
+
}
|
|
614
|
+
if (pipelineMode && PIPELINE_PLUMBING.has(command)) {
|
|
615
|
+
// sort -o and uniq's second positional are output writes, unnecessary for
|
|
616
|
+
// pipeline stages that read stdin.
|
|
617
|
+
if (hasWriteShortFlag(args) || args.includes("--output")) {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
reason: `Rescue rejects ${command} -o/--output (writes to an arbitrary file): ${tokens.join(" ")}.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
// `sort -o FILE` / `-T DIR` write to an arbitrary path — including BUNDLED
|
|
624
|
+
// short-flag clusters (`sort -rT /tmp`, `-ro file`) that the generic `-o`
|
|
625
|
+
// check above misses because the cluster starts with `-r` (Opus review
|
|
626
|
+
// 2026-07-17). `sortShortClusterWrites` scans the cluster with getopt
|
|
627
|
+
// semantics; the long `--temporary-directory` form is matched separately.
|
|
628
|
+
if (command === "sort" &&
|
|
629
|
+
(args.some((arg) => sortShortClusterWrites(arg)) ||
|
|
630
|
+
args.includes("--temporary-directory") ||
|
|
631
|
+
args.some((arg) => arg.startsWith("--temporary-directory=")))) {
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
reason: `Rescue rejects sort -o/-T (incl. bundled short clusters like -rT) and --temporary-directory (write to an arbitrary path): ${tokens.join(" ")}.`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
// `uniq IN OUT` writes OUT; `-` (stdin) counts as an operand, so
|
|
638
|
+
// `uniq - /tmp/out` is a write. Two+ operands ⇒ an output file is present.
|
|
639
|
+
if (command === "uniq" &&
|
|
640
|
+
args.filter((arg) => arg === "-" || !arg.startsWith("-")).length >= 2) {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
reason: `Rescue rejects uniq with an output-file argument (uniq IN OUT writes OUT): ${tokens.join(" ")}.`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return { ok: true };
|
|
647
|
+
}
|
|
648
|
+
return { ok: false, reason: `Rescue rejects shell command: ${tokens.join(" ")}` };
|
|
649
|
+
}
|
|
650
|
+
class ShellExpansionError extends Error {
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Env callback for `parse(command, env)`. shell-quote invokes it ONLY for a
|
|
654
|
+
* `$VAR`/`${…}` expansion that bash would actually perform — i.e. OUTSIDE single
|
|
655
|
+
* quotes (single-quoted `$` is literal). Throwing on any non-empty key rejects
|
|
656
|
+
* every real reference — including `$IFS`, `$HOME`, and double-quoted `"$PAT"` —
|
|
657
|
+
* quote-aware and for free. An EMPTY key is a trailing/literal `$` (`grep 'end$'`,
|
|
658
|
+
* `awk '{print $}'`), which bash does not expand, so it returns a literal `$`.
|
|
659
|
+
*/
|
|
660
|
+
function throwingExpansionEnv(key) {
|
|
661
|
+
if (key !== "")
|
|
662
|
+
throw new ShellExpansionError(key);
|
|
663
|
+
return "$";
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* True if the command performs any `$VAR`/`${…}` parameter expansion. Uses the
|
|
667
|
+
* vendored parser's own quote tracking (via the throwing env), so it is immune
|
|
668
|
+
* to the two ways the default empty-env parse DIVERGES from bash and hides a
|
|
669
|
+
* smuggled flag from every per-flag check:
|
|
670
|
+
* - `${VAR:-flag}` collapsed to an empty token (no `:-`-default modelling), and
|
|
671
|
+
* - `$IFS`-gluing (`rg x$IFS--pre$IFS'sh'` → one token here, but bash splits it
|
|
672
|
+
* into `rg x --pre sh` — an RCE).
|
|
673
|
+
* A malformed substitution (`${}`, unterminated) throws a plain parser Error;
|
|
674
|
+
* that is itself unsafe to pass through, so any throw ⇒ unsafe (fail closed).
|
|
675
|
+
*/
|
|
676
|
+
function hasShellExpansion(command) {
|
|
677
|
+
try {
|
|
678
|
+
parse(command, throwingExpansionEnv);
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* True if `s[open]` opens a bash BRACE EXPANSION (`{a,b}` / `{a..b}`): a matching
|
|
687
|
+
* `}` with a top-level `,` or `..` between. bash expands it into multiple words
|
|
688
|
+
* BEFORE the allowlist sees them (`find . {-delete,}` → `find . -delete`), while
|
|
689
|
+
* shell-quote keeps it as one literal token — so a smuggled flag/action hides in
|
|
690
|
+
* the group. `${…}` (param expansion, handled by `hasShellExpansion`) and a
|
|
691
|
+
* quoted group are not brace expansion.
|
|
692
|
+
*/
|
|
693
|
+
function isBraceExpansion(s, open) {
|
|
694
|
+
if (open > 0 && s[open - 1] === "$")
|
|
695
|
+
return false;
|
|
696
|
+
let depth = 0;
|
|
697
|
+
let sawSeparator = false;
|
|
698
|
+
for (let j = open; j < s.length; j += 1) {
|
|
699
|
+
const ch = s[j];
|
|
700
|
+
if (ch === "\\") {
|
|
701
|
+
j += 1;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (ch === "'" || ch === '"')
|
|
705
|
+
return false;
|
|
706
|
+
if (ch === "{") {
|
|
707
|
+
depth += 1;
|
|
708
|
+
}
|
|
709
|
+
else if (ch === "}") {
|
|
710
|
+
depth -= 1;
|
|
711
|
+
if (depth === 0)
|
|
712
|
+
return sawSeparator;
|
|
713
|
+
}
|
|
714
|
+
else if (depth === 1 && (ch === "," || (ch === "." && s[j + 1] === "."))) {
|
|
715
|
+
sawSeparator = true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Quote-aware scan for dangerous constructs the vendored parser does NOT surface
|
|
722
|
+
* as a `$VAR` expansion (so `hasShellExpansion` can't see them): backticks,
|
|
723
|
+
* command/process substitution, the `$'…'`/`$"…"` quoting openers (bash decodes
|
|
724
|
+
* `\xNN` etc. into bytes that can form a flag — shell-quote does not), and brace
|
|
725
|
+
* expansion. SINGLE-quoted spans are fully inert (bash suppresses everything), so
|
|
726
|
+
* a `` ` ``/`$`/`{` inside them is never flagged — that removes the blind-regex
|
|
727
|
+
* false positives (`grep 'foo $'`, `grep '$(x)'`, `find -name '*.{a,b}'`). DOUBLE
|
|
728
|
+
* quotes are NOT inert: `$(…)` and backticks stay ACTIVE inside them (only `$VAR`
|
|
729
|
+
* detection is delegated to `hasShellExpansion`), so the double-quote span is
|
|
730
|
+
* scanned for those two. A backslash escapes the next char in either context.
|
|
731
|
+
*/
|
|
732
|
+
function scanUnsafeUnquoted(command) {
|
|
733
|
+
const n = command.length;
|
|
734
|
+
let i = 0;
|
|
735
|
+
while (i < n) {
|
|
736
|
+
const c = command[i];
|
|
737
|
+
if (c === "\\") {
|
|
738
|
+
i += 2;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (c === "'") {
|
|
742
|
+
const end = command.indexOf("'", i + 1);
|
|
743
|
+
if (end === -1)
|
|
744
|
+
return true; // unterminated quote → unsafe
|
|
745
|
+
i = end + 1;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (c === '"') {
|
|
749
|
+
// Double quotes keep command substitution active (`"$(id)"` runs `id`;
|
|
750
|
+
// `` "`id`" `` too), so scan the span for them. `"\$(x)"` / `` "\`x`" `` are
|
|
751
|
+
// escaped → literal → allowed. `$VAR`/`${…}` are caught by hasShellExpansion.
|
|
752
|
+
i += 1;
|
|
753
|
+
while (i < n && command[i] !== '"') {
|
|
754
|
+
if (command[i] === "\\") {
|
|
755
|
+
i += 2;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (command[i] === "`")
|
|
759
|
+
return true; // backtick cmd-sub (active in "…")
|
|
760
|
+
if (command[i] === "$" && command[i + 1] === "(")
|
|
761
|
+
return true; // $(…) cmd-sub (active in "…")
|
|
762
|
+
i += 1;
|
|
763
|
+
}
|
|
764
|
+
if (i >= n)
|
|
765
|
+
return true; // unterminated quote → unsafe
|
|
766
|
+
i += 1;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (c === "`")
|
|
770
|
+
return true; // backtick command substitution
|
|
771
|
+
if (c === "$" && (command[i + 1] === "'" || command[i + 1] === '"'))
|
|
772
|
+
return true; // $'…' / $"…"
|
|
773
|
+
if (c === "$" && command[i + 1] === "(")
|
|
774
|
+
return true; // $(…) command substitution
|
|
775
|
+
if ((c === "<" || c === ">") && command[i + 1] === "(")
|
|
776
|
+
return true; // <(…) / >(…)
|
|
777
|
+
if (c === "{" && isBraceExpansion(command, i))
|
|
778
|
+
return true;
|
|
779
|
+
i += 1;
|
|
780
|
+
}
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Syntax gate before the per-flag allowlist. Reject the expansion/substitution
|
|
785
|
+
* classes shell-quote does not model: backticks, command/process substitution,
|
|
786
|
+
* `$'…'`/`$"…"` openers, brace expansion, and `$VAR`/`${…}` parameter expansion
|
|
787
|
+
* including `$IFS` gluing and default-value forms. Two complementary quote-aware
|
|
788
|
+
* passes prevent these forms from smuggling flags or commands downstream.
|
|
789
|
+
*/
|
|
790
|
+
export function hasUnsafeShellSyntax(command) {
|
|
791
|
+
return scanUnsafeUnquoted(command) || hasShellExpansion(command);
|
|
792
|
+
}
|
|
793
|
+
export function hasMutatingFlag(args) {
|
|
794
|
+
for (const arg of args) {
|
|
795
|
+
if (MUTATING_FLAGS_EXACT.has(arg)) {
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
if (MUTATING_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
// sed's -i accepts an optional suffix like -i.bak or --in-place=.bak. Match only the
|
|
802
|
+
// punctuation-suffix shapes so we don't collide with read-only short-flag clusters
|
|
803
|
+
// such as `find -iname`, `grep -il`, or `rg -iw`.
|
|
804
|
+
if (/^-i[.=]/.test(arg)) {
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
/** True if any argument delegates execution to a command or external tool. */
|
|
811
|
+
export function hasExecDelegatingFlag(args) {
|
|
812
|
+
return args.some((arg) => EXEC_DELEGATING_FLAGS_EXACT.has(arg) ||
|
|
813
|
+
EXEC_DELEGATING_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix)));
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Per-tool check for `-o`, joined `-ofile`, and `-o=file` where that flag writes
|
|
817
|
+
* output (go, sort, ruff, and ESLint). It is not global because rg/grep use `-o`
|
|
818
|
+
* for read-only matching. Long --output forms are covered by the mutating set.
|
|
819
|
+
*/
|
|
820
|
+
export function hasWriteShortFlag(args) {
|
|
821
|
+
return args.some((arg) => arg === "-o" || /^-o./.test(arg));
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* True if a GNU `sort` short-flag token writes a file — including a BUNDLED
|
|
825
|
+
* cluster (`-rT DIR`, `-ro FILE`) the plain `-o` check misses. getopt consumes a
|
|
826
|
+
* cluster left to right; the first argument-taking option grabs the rest as its
|
|
827
|
+
* value, so an `o`/`T` AFTER a `-t`/`-k`/`-S` (sort's other arg-taking options)
|
|
828
|
+
* is that option's value, not a write flag.
|
|
829
|
+
*/
|
|
830
|
+
function sortShortClusterWrites(token) {
|
|
831
|
+
if (token.length < 2 || token[0] !== "-" || token[1] === "-")
|
|
832
|
+
return false;
|
|
833
|
+
for (let i = 1; i < token.length; i += 1) {
|
|
834
|
+
const ch = token[i];
|
|
835
|
+
if (ch === "o" || ch === "T")
|
|
836
|
+
return true; // -o output / -T temp dir → write
|
|
837
|
+
if (ch === "t" || ch === "k" || ch === "S")
|
|
838
|
+
return false; // arg-taking: rest is its value
|
|
839
|
+
}
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
function hasPytestReportFlag(args) {
|
|
843
|
+
return matchesAbbreviatedFlag(args, PYTEST_REPORT_FLAGS);
|
|
844
|
+
}
|
|
845
|
+
export function validatePackageManagerCommand(command, args) {
|
|
846
|
+
// Direct <pm> test executes the repository's test script by design, like
|
|
847
|
+
// pytest/go test/cargo test, and remains inside the documented test-runner
|
|
848
|
+
// trust boundary. `run <arbitrary-script>` is instead a general script
|
|
849
|
+
// executor (dev/deploy/publish), so this deliberate test/run asymmetry stays.
|
|
850
|
+
if (args[0] === "test") {
|
|
851
|
+
return { ok: true };
|
|
852
|
+
}
|
|
853
|
+
// `<pm> run <script>` is opaque: a malicious package.json can redefine any script to do anything.
|
|
854
|
+
// Rescue requires users to invoke direct check/build tools (tsc, pyright, mypy, eslint, etc.).
|
|
855
|
+
return {
|
|
856
|
+
ok: false,
|
|
857
|
+
reason: `Rescue rejects ${command} ${args.join(" ")}. Use direct tools (tsc --noEmit, pyright, mypy, ruff check, eslint, pytest) instead of package.json scripts.`,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function isWithin(root, target) {
|
|
861
|
+
return target === root || target.startsWith(`${root}${path.sep}`);
|
|
862
|
+
}
|
|
863
|
+
function rejectShell(feedback) {
|
|
864
|
+
return { response: "reject", feedback };
|
|
865
|
+
}
|