howtofixcli 0.7.24 → 0.7.26
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/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shell commands are read-only, ported from Claude Code's bash classifier.
|
|
3
|
+
*
|
|
4
|
+
* The predecessor of this file was a single loose regex:
|
|
5
|
+
*
|
|
6
|
+
* /^(?:cd\s+\S+\s*&&\s*)?(?:ls|cat|…|git\s+(?:status|diff|log|show|branch|remote))\b/
|
|
7
|
+
*
|
|
8
|
+
* which was wrong in both directions, and both directions cost the user:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Too strict.** Any `|`, `;`, `&`, `<`, `>` or `$(` bailed out to "ask".
|
|
11
|
+
* `ls | head`, `git log --oneline | head -20`, `grep -rn x src | wc -l` —
|
|
12
|
+
* the bread and butter of reading a codebase — each raised a prompt. That is
|
|
13
|
+
* the "it asks for permission just to look at my code" complaint.
|
|
14
|
+
* 2. **Too loose.** `\b` after `git\s+(?:…|branch|remote)` matched the
|
|
15
|
+
* subcommand only, so `git branch -D main` and `git remote remove origin`
|
|
16
|
+
* were auto-allowed as read-only. Those are writes.
|
|
17
|
+
*
|
|
18
|
+
* The reference's model fixes both: a command is read-only only if **every**
|
|
19
|
+
* segment of the compound is read-only, and a segment is read-only only if its
|
|
20
|
+
* flags are all in a per-subcommand allowlist. That is what this file ports.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately not ported: the git-hook sandbox-escape guards
|
|
23
|
+
* (`cd X && git status` where X contains a malicious `hooks/pre-commit`). They
|
|
24
|
+
* exist because Claude Code runs git inside a sandbox it wants to escape-proof.
|
|
25
|
+
* The equivalent guard here is simpler and stricter — see `cdWithGit`.
|
|
26
|
+
*/
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Flag validation
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const FLAG_PATTERN = /^-[a-zA-Z0-9_-]/;
|
|
31
|
+
function flagArgumentIsValid(value, type) {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case "number":
|
|
34
|
+
return /^\d+$/.test(value);
|
|
35
|
+
case "string":
|
|
36
|
+
return true;
|
|
37
|
+
default:
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Expand a short-flag cluster or an attached value: `-d:`, `-f1`, `-nr`, `-U3`.
|
|
43
|
+
*
|
|
44
|
+
* The allowlist is written one flag per entry, but a user types the getopt
|
|
45
|
+
* forms: `cut -d: -f1` attaches the value to the flag, and `sort -nr` clusters
|
|
46
|
+
* two `none` flags. Without this, both read as an unknown flag and every one of
|
|
47
|
+
* them raises an approval prompt — a read-only command asking permission, which
|
|
48
|
+
* is the complaint this fixes.
|
|
49
|
+
*
|
|
50
|
+
* getopt semantics, and the reason it stays safe: a value-taking flag consumes
|
|
51
|
+
* the **rest of its own token** and never reaches into the next one, so a
|
|
52
|
+
* cluster cannot smuggle a value past the check.
|
|
53
|
+
*/
|
|
54
|
+
function expandShortCluster(token, safeFlags) {
|
|
55
|
+
if (!/^-[^-]/.test(token) || token.includes("="))
|
|
56
|
+
return false;
|
|
57
|
+
for (let index = 1; index < token.length; index++) {
|
|
58
|
+
const type = safeFlags[`-${token[index]}`];
|
|
59
|
+
if (!type)
|
|
60
|
+
return false;
|
|
61
|
+
if (type === "none")
|
|
62
|
+
continue;
|
|
63
|
+
return flagArgumentIsValid(token.slice(index + 1), type);
|
|
64
|
+
}
|
|
65
|
+
// Every character was a known flag that takes no value.
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Walk a tokenized command's flags against a subcommand's allowlist.
|
|
70
|
+
*
|
|
71
|
+
* Positional arguments are accepted (they are usually paths or refs). A flag
|
|
72
|
+
* that is not in `safeFlags` fails the whole check, which is what turns
|
|
73
|
+
* `git branch -D main` into a prompt while `git branch -a` stays silent.
|
|
74
|
+
*/
|
|
75
|
+
export function validateFlags(tokens, startIndex, config) {
|
|
76
|
+
let index = startIndex;
|
|
77
|
+
while (index < tokens.length) {
|
|
78
|
+
const token = tokens[index];
|
|
79
|
+
if (!token) {
|
|
80
|
+
index++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (token === "--")
|
|
84
|
+
break; // Everything after `--` is a positional argument.
|
|
85
|
+
if (FLAG_PATTERN.test(token)) {
|
|
86
|
+
// git's count shorthand: `git log -5` means `--max-count=5`. It reads as
|
|
87
|
+
// a flag to every other rule here, so it needs its own branch, and it is
|
|
88
|
+
// only meaningful where the command already accepts `-n`.
|
|
89
|
+
if (/^-\d+$/.test(token) && config.safeFlags["-n"]) {
|
|
90
|
+
index++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// `--flag=value` is validated against the attached value, never by
|
|
94
|
+
// consuming the next token: `-E=` with an empty value must not be able to
|
|
95
|
+
// reach into the following argument.
|
|
96
|
+
const hasEquals = token.includes("=");
|
|
97
|
+
const [flag = "", ...valueParts] = token.split("=");
|
|
98
|
+
const type = config.safeFlags[flag];
|
|
99
|
+
if (!type) {
|
|
100
|
+
// Not a flag on its own: try the getopt forms before refusing.
|
|
101
|
+
if (!expandShortCluster(token, config.safeFlags))
|
|
102
|
+
return false;
|
|
103
|
+
index++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (type === "none") {
|
|
107
|
+
// A `none` flag must not carry a value in either form.
|
|
108
|
+
if (hasEquals)
|
|
109
|
+
return false;
|
|
110
|
+
index++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// `optional` is git's PARSE_OPT_OPTARG: the value may be attached
|
|
114
|
+
// (`--porcelain=v2`, `--abbrev=10`) but is never taken from the next
|
|
115
|
+
// token, which would swallow a path or a ref.
|
|
116
|
+
if (type === "optional") {
|
|
117
|
+
index++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (hasEquals) {
|
|
121
|
+
if (!flagArgumentIsValid(valueParts.join("="), type))
|
|
122
|
+
return false;
|
|
123
|
+
index++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const next = tokens[index + 1];
|
|
127
|
+
if (next === undefined || !flagArgumentIsValid(next, type))
|
|
128
|
+
return false;
|
|
129
|
+
index += 2;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A bare argument. Usually a path or a ref, but for an interpreter or a
|
|
133
|
+
// package runner it names code to execute, so those reject it.
|
|
134
|
+
if (config.allowPositional === false)
|
|
135
|
+
return false;
|
|
136
|
+
index++;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// git
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
const GIT_REF_SELECTION = { "--all": "none", "--branches": "none", "--tags": "none", "--remotes": "none" };
|
|
144
|
+
const GIT_LOG_DISPLAY = { "--oneline": "none", "--graph": "none", "--decorate": "none", "--no-decorate": "none", "--date": "string", "--relative-date": "none" };
|
|
145
|
+
const GIT_COUNT = { "--max-count": "number", "-n": "number" };
|
|
146
|
+
const GIT_STAT = { "--stat": "none", "--numstat": "none", "--shortstat": "none", "--name-only": "none", "--name-status": "none" };
|
|
147
|
+
const GIT_COLOR = { "--color": "none", "--no-color": "none" };
|
|
148
|
+
const GIT_PATCH = { "--patch": "none", "-p": "none", "--no-patch": "none", "--no-ext-diff": "none", "-s": "none" };
|
|
149
|
+
const GIT_AUTHOR = { "--author": "string", "--committer": "string", "--grep": "string" };
|
|
150
|
+
/** Flags that only ever *list*, shared by `branch` and `tag`. */
|
|
151
|
+
const GIT_LIST_ONLY = { "-l": "none", "--list": "none", "-a": "none", "-r": "none", "-v": "none", "-vv": "none", "-i": "none" };
|
|
152
|
+
/**
|
|
153
|
+
* git subcommands that only read, with the flags each may take.
|
|
154
|
+
*
|
|
155
|
+
* `config` is deliberately `--get` only: `git config user.email x` writes the
|
|
156
|
+
* config file, and the reference splits it into its own entry for that reason.
|
|
157
|
+
*/
|
|
158
|
+
export const GIT_READ_ONLY = {
|
|
159
|
+
"git status": { safeFlags: { "-s": "none", "--short": "none", "-b": "none", "--branch": "none", "-u": "optional", "--untracked-files": "optional", "--porcelain": "optional", "--ignored": "none", "--long": "none", "--no-renames": "none", ...GIT_COLOR } },
|
|
160
|
+
"git diff": {
|
|
161
|
+
safeFlags: {
|
|
162
|
+
...GIT_STAT,
|
|
163
|
+
...GIT_COLOR,
|
|
164
|
+
...GIT_PATCH,
|
|
165
|
+
"--cached": "none",
|
|
166
|
+
"--staged": "none",
|
|
167
|
+
"--word-diff": "none",
|
|
168
|
+
"--check": "none",
|
|
169
|
+
"--quiet": "none",
|
|
170
|
+
"--exit-code": "none",
|
|
171
|
+
"--no-renames": "none",
|
|
172
|
+
"--diff-filter": "string",
|
|
173
|
+
"--relative": "string",
|
|
174
|
+
"--full-index": "none",
|
|
175
|
+
"--binary": "none",
|
|
176
|
+
"--abbrev": "number",
|
|
177
|
+
"--unified": "number",
|
|
178
|
+
"-U": "number",
|
|
179
|
+
"--ignore-all-space": "none",
|
|
180
|
+
"--ignore-space-change": "none",
|
|
181
|
+
"--ignore-blank-lines": "none",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"git log": {
|
|
185
|
+
safeFlags: {
|
|
186
|
+
...GIT_LOG_DISPLAY,
|
|
187
|
+
...GIT_COUNT,
|
|
188
|
+
...GIT_STAT,
|
|
189
|
+
...GIT_COLOR,
|
|
190
|
+
...GIT_PATCH,
|
|
191
|
+
...GIT_AUTHOR,
|
|
192
|
+
...GIT_REF_SELECTION,
|
|
193
|
+
"--since": "string",
|
|
194
|
+
"--after": "string",
|
|
195
|
+
"--until": "string",
|
|
196
|
+
"--before": "string",
|
|
197
|
+
"--follow": "none",
|
|
198
|
+
"--no-merges": "none",
|
|
199
|
+
"--merges": "none",
|
|
200
|
+
"--first-parent": "none",
|
|
201
|
+
"--reverse": "none",
|
|
202
|
+
"--skip": "number",
|
|
203
|
+
"--all": "none",
|
|
204
|
+
"--format": "string",
|
|
205
|
+
"--pretty": "string",
|
|
206
|
+
"--abbrev-commit": "none",
|
|
207
|
+
"--no-abbrev-commit": "none",
|
|
208
|
+
"--left-right": "none",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
"git show": { safeFlags: { ...GIT_STAT, ...GIT_COLOR, ...GIT_PATCH, ...GIT_LOG_DISPLAY, "--format": "string", "--pretty": "string", "--abbrev-commit": "none", "--no-patch": "none", "-q": "none" } },
|
|
212
|
+
"git shortlog": { safeFlags: { ...GIT_COUNT, "-s": "none", "--summary": "none", "-n": "none", "--numbered": "none", "-e": "none", "--email": "none", "-sn": "none", "-ns": "none", "--all": "none" } },
|
|
213
|
+
"git reflog": { safeFlags: { ...GIT_COUNT, ...GIT_LOG_DISPLAY, ...GIT_AUTHOR, "--all": "none", "--date": "string" } },
|
|
214
|
+
"git blame": { safeFlags: { "-L": "string", "-w": "none", "-c": "none", "-n": "none", "-s": "none", "-e": "none", "-p": "none", "--porcelain": "none", "--line-porcelain": "none", "--date": "string", "--since": "string", "-C": "none", "-M": "none" } },
|
|
215
|
+
"git ls-files": {
|
|
216
|
+
safeFlags: {
|
|
217
|
+
"-m": "none",
|
|
218
|
+
"--modified": "none",
|
|
219
|
+
"-o": "none",
|
|
220
|
+
"--others": "none",
|
|
221
|
+
"-i": "none",
|
|
222
|
+
"--ignored": "none",
|
|
223
|
+
"-s": "none",
|
|
224
|
+
"--stage": "none",
|
|
225
|
+
"-d": "none",
|
|
226
|
+
"--deleted": "none",
|
|
227
|
+
"-c": "none",
|
|
228
|
+
"--cached": "none",
|
|
229
|
+
"-u": "none",
|
|
230
|
+
"--unmerged": "none",
|
|
231
|
+
"--exclude-standard": "none",
|
|
232
|
+
"--error-unmatch": "none",
|
|
233
|
+
"-z": "none",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
"git ls-remote": { safeFlags: { "--heads": "none", "--tags": "none", "--refs": "none", "--symref": "none" } },
|
|
237
|
+
"git rev-parse": {
|
|
238
|
+
safeFlags: {
|
|
239
|
+
"--short": "optional",
|
|
240
|
+
"--abbrev-ref": "none",
|
|
241
|
+
"--verify": "none",
|
|
242
|
+
"--quiet": "none",
|
|
243
|
+
"--git-dir": "none",
|
|
244
|
+
"--show-toplevel": "none",
|
|
245
|
+
"--show-cdup": "none",
|
|
246
|
+
"--is-inside-work-tree": "none",
|
|
247
|
+
"--is-bare-repository": "none",
|
|
248
|
+
"--symbolic-full-name": "none",
|
|
249
|
+
"--abbrev": "optional",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
"git rev-list": { safeFlags: { ...GIT_COUNT, "--count": "none", "--all": "none", "--objects": "none", "--reverse": "none", "--max-parents": "number", "--min-parents": "number", "--no-merges": "none", "--first-parent": "none", "--abbrev-commit": "none" } },
|
|
253
|
+
"git describe": { safeFlags: { "--tags": "none", "--all": "none", "--long": "none", "--always": "none", "--dirty": "none", "--abbrev": "number", "--match": "string", "--exclude": "string", "--first-parent": "none" } },
|
|
254
|
+
"git cat-file": { safeFlags: { "-p": "none", "-t": "none", "-s": "none", "-e": "none", "--batch-check": "none" } },
|
|
255
|
+
"git for-each-ref": { safeFlags: { "--format": "string", "--count": "number", "--sort": "string", "--points-at": "string", "--merged": "string", "--no-merged": "string" } },
|
|
256
|
+
"git grep": {
|
|
257
|
+
safeFlags: {
|
|
258
|
+
"-n": "none",
|
|
259
|
+
"--line-number": "none",
|
|
260
|
+
"-i": "none",
|
|
261
|
+
"--ignore-case": "none",
|
|
262
|
+
"-l": "none",
|
|
263
|
+
"--files-with-matches": "none",
|
|
264
|
+
"-c": "none",
|
|
265
|
+
"--count": "none",
|
|
266
|
+
"-w": "none",
|
|
267
|
+
"--word-regexp": "none",
|
|
268
|
+
"-E": "none",
|
|
269
|
+
"--extended-regexp": "none",
|
|
270
|
+
"-F": "none",
|
|
271
|
+
"--fixed-strings": "none",
|
|
272
|
+
"--cached": "none",
|
|
273
|
+
"--untracked": "none",
|
|
274
|
+
"-e": "string",
|
|
275
|
+
"--and": "none",
|
|
276
|
+
"--or": "none",
|
|
277
|
+
"--not": "none",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
"git merge-base": { safeFlags: { "--all": "none", "--is-ancestor": "none", "--fork-point": "none", "--octopus": "none" } },
|
|
281
|
+
"git stash list": { safeFlags: { ...GIT_LOG_DISPLAY } },
|
|
282
|
+
"git worktree list": { safeFlags: { "--porcelain": "none", "-v": "none", "--verbose": "none", "-z": "none" } },
|
|
283
|
+
"git branch": {
|
|
284
|
+
safeFlags: { ...GIT_LIST_ONLY, "--merged": "string", "--no-merged": "string", "--contains": "string", "--no-contains": "string", "--sort": "string", "--format": "string", "--points-at": "string", "--column": "none", "--show-current": "none" },
|
|
285
|
+
// A positional argument to `git branch` is not a filter, it is a *new
|
|
286
|
+
// branch*: `git branch foo` writes `.git/refs/heads/foo`. Listing accepts
|
|
287
|
+
// only ref filters, and those take a value, so any bare word is a write.
|
|
288
|
+
dangerous: (args) => {
|
|
289
|
+
const takesValue = new Set(["--merged", "--no-merged", "--contains", "--no-contains", "--sort", "--format", "--points-at"]);
|
|
290
|
+
let expectValue = false;
|
|
291
|
+
for (const arg of args) {
|
|
292
|
+
if (expectValue) {
|
|
293
|
+
expectValue = false;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (takesValue.has(arg)) {
|
|
297
|
+
expectValue = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!arg.startsWith("-"))
|
|
301
|
+
return true; // A branch name being created.
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
"git tag": {
|
|
307
|
+
safeFlags: { "-l": "none", "--list": "none", "-n": "number", "--contains": "string", "--no-contains": "string", "--merged": "string", "--sort": "string", "--points-at": "string", "--column": "none" },
|
|
308
|
+
// Same shape as `branch`: `git tag v1` creates `.git/refs/tags/v1`.
|
|
309
|
+
dangerous: (args) => {
|
|
310
|
+
const takesValue = new Set(["--contains", "--no-contains", "--merged", "--sort", "--points-at"]);
|
|
311
|
+
let expectValue = false;
|
|
312
|
+
for (const arg of args) {
|
|
313
|
+
if (expectValue) {
|
|
314
|
+
expectValue = false;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (takesValue.has(arg)) {
|
|
318
|
+
expectValue = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!arg.startsWith("-"))
|
|
322
|
+
return true; // A tag name being created.
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
"git remote": {
|
|
328
|
+
safeFlags: { "-v": "none", "--verbose": "none" },
|
|
329
|
+
// Every other `git remote` verb (`add`, `remove`, `rename`, `set-url`,
|
|
330
|
+
// `prune`, `update`) changes the repository.
|
|
331
|
+
dangerous: (args) => args.some((arg) => !arg.startsWith("-")),
|
|
332
|
+
},
|
|
333
|
+
"git config --get": { safeFlags: { "--local": "none", "--global": "none", "--system": "none", "--get-all": "none", "--get-regexp": "none", "--list": "none", "-l": "none", "--show-origin": "none", "--null": "none", "-z": "none" } },
|
|
334
|
+
};
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Everything else
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
/**
|
|
339
|
+
* Commands whose whole flag surface is read-only, so no flag list is needed.
|
|
340
|
+
* `env` and `printenv` are deliberately absent: this process holds provider API
|
|
341
|
+
* keys, and the reference removed them for exactly that reason.
|
|
342
|
+
*/
|
|
343
|
+
export const READ_ONLY_COMMANDS = [
|
|
344
|
+
"ls",
|
|
345
|
+
"pwd",
|
|
346
|
+
"cat",
|
|
347
|
+
"head",
|
|
348
|
+
"tail",
|
|
349
|
+
"wc",
|
|
350
|
+
"which",
|
|
351
|
+
"whoami",
|
|
352
|
+
"date",
|
|
353
|
+
"echo",
|
|
354
|
+
"sleep",
|
|
355
|
+
"true",
|
|
356
|
+
"false",
|
|
357
|
+
"type",
|
|
358
|
+
"test",
|
|
359
|
+
"seq",
|
|
360
|
+
"basename",
|
|
361
|
+
"dirname",
|
|
362
|
+
"realpath",
|
|
363
|
+
"stat",
|
|
364
|
+
"file",
|
|
365
|
+
"du",
|
|
366
|
+
"df",
|
|
367
|
+
"tree",
|
|
368
|
+
"uname",
|
|
369
|
+
"hostname",
|
|
370
|
+
"id",
|
|
371
|
+
"groups",
|
|
372
|
+
"tty",
|
|
373
|
+
"cal",
|
|
374
|
+
"uptime",
|
|
375
|
+
"free",
|
|
376
|
+
"locale",
|
|
377
|
+
"printf",
|
|
378
|
+
"grep",
|
|
379
|
+
"rg",
|
|
380
|
+
"fd",
|
|
381
|
+
"jq",
|
|
382
|
+
"bat",
|
|
383
|
+
"column",
|
|
384
|
+
"paste",
|
|
385
|
+
"nl",
|
|
386
|
+
"expand",
|
|
387
|
+
"fold",
|
|
388
|
+
"fmt",
|
|
389
|
+
// Hashing and comparing read their input and write nothing to it: `md5sum`
|
|
390
|
+
// and its sha siblings only print a digest, and `diff`/`cmp` only report a
|
|
391
|
+
// difference. `xxd` is NOT here — its `-r` form writes a file — so it lives
|
|
392
|
+
// in FLAGGED_READ_ONLY where the flag surface is checked.
|
|
393
|
+
"md5sum",
|
|
394
|
+
"sha1sum",
|
|
395
|
+
"sha256sum",
|
|
396
|
+
"sha512sum",
|
|
397
|
+
"b2sum",
|
|
398
|
+
"cksum",
|
|
399
|
+
"diff",
|
|
400
|
+
"cmp",
|
|
401
|
+
];
|
|
402
|
+
/** Commands that read, but only with the right flags. */
|
|
403
|
+
export const FLAGGED_READ_ONLY = {
|
|
404
|
+
find: {
|
|
405
|
+
safeFlags: {
|
|
406
|
+
"-name": "string",
|
|
407
|
+
"-iname": "string",
|
|
408
|
+
"-type": "string",
|
|
409
|
+
"-maxdepth": "number",
|
|
410
|
+
"-mindepth": "number",
|
|
411
|
+
"-newer": "string",
|
|
412
|
+
"-path": "string",
|
|
413
|
+
"-not": "none",
|
|
414
|
+
"-print": "none",
|
|
415
|
+
"-print0": "none",
|
|
416
|
+
"-empty": "none",
|
|
417
|
+
"-size": "string",
|
|
418
|
+
"-mtime": "number",
|
|
419
|
+
"-atime": "number",
|
|
420
|
+
"-perm": "string",
|
|
421
|
+
"-L": "none",
|
|
422
|
+
"-H": "none",
|
|
423
|
+
"-P": "none",
|
|
424
|
+
},
|
|
425
|
+
// `-delete`, `-exec`, `-execdir`, `-ok`, `-fprint`, `-fls` and `-fprintf`
|
|
426
|
+
// all write or run code; the flag list above already rejects them, and this
|
|
427
|
+
// states the intent so a future addition cannot reintroduce one.
|
|
428
|
+
dangerous: (args) => args.some((arg) => /^-(delete|exec|execdir|ok|okdir|fprint0?|fls|fprintf)$/.test(arg)),
|
|
429
|
+
},
|
|
430
|
+
sort: { safeFlags: { "-n": "none", "-r": "none", "-u": "none", "-k": "string", "-t": "string", "-f": "none", "-h": "none", "-V": "none", "-s": "none", "-c": "none", "-m": "none", "-b": "none", "-z": "none" } },
|
|
431
|
+
uniq: { safeFlags: { "-c": "none", "-d": "none", "-u": "none", "-i": "none", "-f": "number", "-s": "number", "-w": "number" } },
|
|
432
|
+
cut: { safeFlags: { "-d": "string", "-f": "string", "-c": "string", "-b": "string", "-s": "none", "--complement": "none", "-z": "none" } },
|
|
433
|
+
tr: { safeFlags: { "-d": "none", "-s": "none", "-c": "none" } },
|
|
434
|
+
node: { safeFlags: { "-v": "none", "--version": "none" }, allowPositional: false },
|
|
435
|
+
python: { safeFlags: { "--version": "none" }, allowPositional: false },
|
|
436
|
+
python3: { safeFlags: { "--version": "none" }, allowPositional: false },
|
|
437
|
+
npm: { safeFlags: { "--version": "none", "-v": "none" }, allowPositional: false },
|
|
438
|
+
// `sed` and `awk` are programs, not filters: `sed -i` edits in place and a
|
|
439
|
+
// `sed` script can `w` a file. Only the pure-stream forms are allowed, and
|
|
440
|
+
// the script is checked for the commands that write.
|
|
441
|
+
sed: {
|
|
442
|
+
safeFlags: { "-n": "none", "--quiet": "none", "-e": "string", "-E": "none", "-r": "none", "--regexp-extended": "none", "-s": "none", "-u": "none", "-z": "none", "--posix": "none" },
|
|
443
|
+
dangerous: (args) => args.some((arg) => /^-i|^--in-place/.test(arg) || /\b[we]\s/.test(arg) || /^[we]$/.test(arg)),
|
|
444
|
+
},
|
|
445
|
+
awk: {
|
|
446
|
+
safeFlags: { "-F": "string", "-v": "string", "-f": "string" },
|
|
447
|
+
dangerous: (args) => args.some((arg) => /\bsystem\s*\(|\|\s*"|>\s*"/.test(arg)),
|
|
448
|
+
},
|
|
449
|
+
// `xxd file` dumps hex and only reads. `xxd -r` is the reverse: it writes a
|
|
450
|
+
// file from hex, so it must ask.
|
|
451
|
+
xxd: {
|
|
452
|
+
safeFlags: { "-l": "number", "-s": "string", "-c": "number", "-g": "number", "-p": "none", "-i": "none", "-u": "none", "-b": "none", "-e": "none", "-o": "number" },
|
|
453
|
+
dangerous: (args) => args.some((arg) => arg === "-r" || /^-[a-z]*r/.test(arg)),
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
/**
|
|
457
|
+
* Multi-word commands that read only while they take no further argument:
|
|
458
|
+
* `npm ls` lists, but `npm ls --json > f` and `docker ps --format …` are fine
|
|
459
|
+
* too, so these are matched as subcommands with the same flag walk.
|
|
460
|
+
*/
|
|
461
|
+
export const SUBCOMMAND_CONFIGS = {
|
|
462
|
+
"npm ls": { safeFlags: { "--depth": "number", "--json": "none", "--long": "none", "--parseable": "none", "--all": "none", "--prod": "none", "--dev": "none", "--omit": "string", "--link": "none", "--global": "none", "-g": "none", "--silent": "none" } },
|
|
463
|
+
"npm view": { safeFlags: { "--json": "none", "--registry": "string" } },
|
|
464
|
+
"docker ps": { safeFlags: { "-a": "none", "--all": "none", "-q": "none", "--quiet": "none", "--no-trunc": "none", "--format": "string", "-n": "number", "--last": "number", "-s": "none", "--size": "none", "--filter": "string" } },
|
|
465
|
+
"docker images": { safeFlags: { "-a": "none", "--all": "none", "-q": "none", "--quiet": "none", "--no-trunc": "none", "--format": "string", "--filter": "string", "--digests": "none" } },
|
|
466
|
+
"docker inspect": { safeFlags: { "--format": "string", "-f": "string", "-s": "none", "--size": "none", "--type": "string" } },
|
|
467
|
+
"kubectl get": {
|
|
468
|
+
safeFlags: {
|
|
469
|
+
"-o": "string",
|
|
470
|
+
"--output": "string",
|
|
471
|
+
"-n": "string",
|
|
472
|
+
"--namespace": "string",
|
|
473
|
+
"-A": "none",
|
|
474
|
+
"--all-namespaces": "none",
|
|
475
|
+
"--no-headers": "none",
|
|
476
|
+
"-l": "string",
|
|
477
|
+
"--selector": "string",
|
|
478
|
+
"--show-labels": "none",
|
|
479
|
+
"-w": "none",
|
|
480
|
+
"--watch": "none",
|
|
481
|
+
"--sort-by": "string",
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
"kubectl describe": { safeFlags: { "-n": "string", "--namespace": "string", "-l": "string", "--selector": "string" } },
|
|
485
|
+
"cargo tree": { safeFlags: { "--depth": "number", "-e": "string", "--edges": "string", "--no-dev-dependencies": "none", "--prefix": "string", "-i": "string", "--invert": "string", "-p": "string", "--package": "string" } },
|
|
486
|
+
"go version": { safeFlags: {} },
|
|
487
|
+
};
|
|
488
|
+
/**
|
|
489
|
+
* Commands that are read-only when run through `git`, but are shell built-ins
|
|
490
|
+
* or paths that need no validation.
|
|
491
|
+
*/
|
|
492
|
+
export const SHELL_BUILTINS = ["cd", "alias", "history", "arch", "ip addr", "ifconfig"];
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Compound commands
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
/** Split a compound command into its segments, or `undefined` if it cannot be
|
|
497
|
+
* split safely. A `$(…)`, backtick or redirect anywhere makes the whole thing
|
|
498
|
+
* non-read-only, because it can write or substitute a value. */
|
|
499
|
+
export function splitCompound(command) {
|
|
500
|
+
if (/\$\(|`|\$\{|>|</.test(command))
|
|
501
|
+
return undefined;
|
|
502
|
+
// `||` and `&&` and `;` and `|` all separate commands that each must be
|
|
503
|
+
// read-only. `&` alone backgrounds, which is not a read of anything.
|
|
504
|
+
if (/(?<!&)&(?!&)/.test(command))
|
|
505
|
+
return undefined;
|
|
506
|
+
return command
|
|
507
|
+
.split(/\|\||&&|;|\|/)
|
|
508
|
+
.map((segment) => segment.trim())
|
|
509
|
+
.filter((segment) => segment.length > 0);
|
|
510
|
+
}
|
|
511
|
+
/** `cd X && git …` is refused: the git invocation would run in a directory the
|
|
512
|
+
* caller chose, where a planted `hooks/pre-commit` executes. The reference
|
|
513
|
+
* guards this because it sandboxes git; here the simpler answer is to ask. */
|
|
514
|
+
export function cdWithGit(segments) {
|
|
515
|
+
const hasCd = segments.some((segment) => /^cd\b/.test(segment));
|
|
516
|
+
const hasGit = segments.some((segment) => /^git\b/.test(segment));
|
|
517
|
+
return hasCd && hasGit;
|
|
518
|
+
}
|
|
519
|
+
/** Tokenize on whitespace, keeping quoted runs together. */
|
|
520
|
+
export function tokenize(segment) {
|
|
521
|
+
const tokens = [];
|
|
522
|
+
const pattern = /'[^']*'|"[^"]*"|\S+/g;
|
|
523
|
+
for (const match of segment.match(pattern) ?? []) {
|
|
524
|
+
tokens.push(match.length >= 2 && /^['"]/.test(match) ? match.slice(1, -1) : match);
|
|
525
|
+
}
|
|
526
|
+
return tokens;
|
|
527
|
+
}
|
|
528
|
+
/** The longest command name a segment starts with, from a set of candidates. */
|
|
529
|
+
function matchCommand(tokens) {
|
|
530
|
+
if (tokens.length === 0)
|
|
531
|
+
return undefined;
|
|
532
|
+
const [first = "", second = ""] = tokens;
|
|
533
|
+
const twoWord = `${first} ${second}`;
|
|
534
|
+
// `git config --get` is three words, and is the only read-only config form.
|
|
535
|
+
// `-l`/`--list` are the same read: they print the config instead of writing it.
|
|
536
|
+
if (first === "git" && second === "config") {
|
|
537
|
+
const verb = tokens[2];
|
|
538
|
+
const reads = verb === "--get" || verb === "--get-all" || verb === "--get-regexp" || verb === "-l" || verb === "--list";
|
|
539
|
+
return reads ? { name: "git config --get", rest: tokens.slice(3) } : undefined;
|
|
540
|
+
}
|
|
541
|
+
if (first === "git" && second === "stash") {
|
|
542
|
+
return tokens[2] === "list" ? { name: "git stash list", rest: tokens.slice(3) } : undefined;
|
|
543
|
+
}
|
|
544
|
+
if (first === "git" && second === "worktree") {
|
|
545
|
+
return tokens[2] === "list" ? { name: "git worktree list", rest: tokens.slice(3) } : undefined;
|
|
546
|
+
}
|
|
547
|
+
if (first === "git" && GIT_READ_ONLY[`git ${second}`])
|
|
548
|
+
return { name: `git ${second}`, rest: tokens.slice(2) };
|
|
549
|
+
if (SUBCOMMAND_CONFIGS[twoWord])
|
|
550
|
+
return { name: twoWord, rest: tokens.slice(2) };
|
|
551
|
+
return { name: first, rest: tokens.slice(1) };
|
|
552
|
+
}
|
|
553
|
+
/** Commands whose output is exactly their own arguments, so they only read if
|
|
554
|
+
* nothing in them can be expanded. */
|
|
555
|
+
const ECHO_LIKE = new Set(["echo", "printf"]);
|
|
556
|
+
/**
|
|
557
|
+
* Is one segment read-only? The decision is per segment, so a pipe is fine as
|
|
558
|
+
* long as both sides only read.
|
|
559
|
+
*/
|
|
560
|
+
export function isReadOnlySegment(segment) {
|
|
561
|
+
const tokens = tokenize(segment);
|
|
562
|
+
const matched = matchCommand(tokens);
|
|
563
|
+
if (!matched)
|
|
564
|
+
return false;
|
|
565
|
+
const { name, rest } = matched;
|
|
566
|
+
// `echo $SECRET` prints whatever the variable holds — and this process holds
|
|
567
|
+
// provider API keys in its environment. Claude Code removed `env`/`printenv`
|
|
568
|
+
// and rejects `$` in `echo` for the same reason: printing is a read of the
|
|
569
|
+
// process, not of the workspace.
|
|
570
|
+
if (ECHO_LIKE.has(name) && rest.some((token) => token.includes("$") || token.includes("`")))
|
|
571
|
+
return false;
|
|
572
|
+
const config = GIT_READ_ONLY[name] ?? FLAGGED_READ_ONLY[name] ?? SUBCOMMAND_CONFIGS[name];
|
|
573
|
+
if (config) {
|
|
574
|
+
if (config.dangerous?.(rest))
|
|
575
|
+
return false;
|
|
576
|
+
return validateFlags(rest, 0, config);
|
|
577
|
+
}
|
|
578
|
+
if (READ_ONLY_COMMANDS.includes(name))
|
|
579
|
+
return true;
|
|
580
|
+
if (SHELL_BUILTINS.includes(name))
|
|
581
|
+
return true;
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The whole check: every segment of the compound must be read-only.
|
|
586
|
+
*
|
|
587
|
+
* `ls | head` → both segments read → true. `ls | tee out` → `tee` is not in any
|
|
588
|
+
* list → false, so it prompts.
|
|
589
|
+
*/
|
|
590
|
+
export function isReadOnlyCommand(command) {
|
|
591
|
+
const segments = splitCompound(command);
|
|
592
|
+
if (!segments || segments.length === 0)
|
|
593
|
+
return false;
|
|
594
|
+
if (cdWithGit(segments))
|
|
595
|
+
return false;
|
|
596
|
+
return segments.every(isReadOnlySegment);
|
|
597
|
+
}
|