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