@prestyj/cli 5.32.0 → 5.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +3 -2
  2. package/assets/skills/task-list/SKILL.md +39 -0
  3. package/dist/app-sidecar/app-settings.js +11 -5
  4. package/dist/app-sidecar/app-settings.js.map +1 -1
  5. package/dist/app-sidecar/auth-routes.js +10 -3
  6. package/dist/app-sidecar/auth-routes.js.map +1 -1
  7. package/dist/app-sidecar/git-target.d.ts +15 -2
  8. package/dist/app-sidecar/git-target.js +24 -10
  9. package/dist/app-sidecar/git-target.js.map +1 -1
  10. package/dist/app-sidecar/goal-routes.js +8 -0
  11. package/dist/app-sidecar/goal-routes.js.map +1 -1
  12. package/dist/app-sidecar/history-routes.js +7 -0
  13. package/dist/app-sidecar/history-routes.js.map +1 -1
  14. package/dist/app-sidecar/history.d.ts +5 -0
  15. package/dist/app-sidecar/history.js.map +1 -1
  16. package/dist/app-sidecar/integration-routes.js +4 -0
  17. package/dist/app-sidecar/integration-routes.js.map +1 -1
  18. package/dist/app-sidecar/mcp-routes.js +1 -0
  19. package/dist/app-sidecar/mcp-routes.js.map +1 -1
  20. package/dist/app-sidecar/mcp-rows.d.ts +2 -0
  21. package/dist/app-sidecar/mcp-rows.js +4 -1
  22. package/dist/app-sidecar/mcp-rows.js.map +1 -1
  23. package/dist/app-sidecar/prompt-routes.js +67 -1
  24. package/dist/app-sidecar/prompt-routes.js.map +1 -1
  25. package/dist/app-sidecar/route-context.d.ts +2 -0
  26. package/dist/app-sidecar/run-control-routes.js +43 -3
  27. package/dist/app-sidecar/run-control-routes.js.map +1 -1
  28. package/dist/app-sidecar/session-routes.js +10 -0
  29. package/dist/app-sidecar/session-routes.js.map +1 -1
  30. package/dist/app-sidecar/task-routes.js +2 -0
  31. package/dist/app-sidecar/task-routes.js.map +1 -1
  32. package/dist/app-sidecar/trophy-manager.d.ts +15 -0
  33. package/dist/app-sidecar/trophy-manager.js +71 -0
  34. package/dist/app-sidecar/trophy-manager.js.map +1 -0
  35. package/dist/app-sidecar/workspace-routes.js +1 -0
  36. package/dist/app-sidecar/workspace-routes.js.map +1 -1
  37. package/dist/app-sidecar.js +157 -30
  38. package/dist/app-sidecar.js.map +1 -1
  39. package/dist/cli/auth.js +22 -1
  40. package/dist/cli/auth.js.map +1 -1
  41. package/dist/cli/mcp.js +6 -1
  42. package/dist/cli/mcp.js.map +1 -1
  43. package/dist/cli.js +13 -9
  44. package/dist/cli.js.map +1 -1
  45. package/dist/config.js +4 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/core/action-threats.d.ts +68 -0
  48. package/dist/core/action-threats.js +894 -0
  49. package/dist/core/action-threats.js.map +1 -0
  50. package/dist/core/agent-notifications.d.ts +18 -0
  51. package/dist/core/agent-notifications.js +35 -2
  52. package/dist/core/agent-notifications.js.map +1 -1
  53. package/dist/core/agent-session/verification-tracker.d.ts +21 -0
  54. package/dist/core/agent-session/verification-tracker.js +39 -1
  55. package/dist/core/agent-session/verification-tracker.js.map +1 -1
  56. package/dist/core/agent-session.d.ts +168 -2
  57. package/dist/core/agent-session.js +486 -23
  58. package/dist/core/agent-session.js.map +1 -1
  59. package/dist/core/blocked-packages-file.d.ts +33 -0
  60. package/dist/core/blocked-packages-file.js +274 -0
  61. package/dist/core/blocked-packages-file.js.map +1 -0
  62. package/dist/core/compaction/policy.d.ts +2 -2
  63. package/dist/core/compaction/policy.js +86 -43
  64. package/dist/core/compaction/policy.js.map +1 -1
  65. package/dist/core/compaction/token-estimator.d.ts +4 -1
  66. package/dist/core/compaction/token-estimator.js +5 -2
  67. package/dist/core/compaction/token-estimator.js.map +1 -1
  68. package/dist/core/event-bus.d.ts +10 -0
  69. package/dist/core/event-bus.js +2 -0
  70. package/dist/core/event-bus.js.map +1 -1
  71. package/dist/core/idle-wake.d.ts +62 -0
  72. package/dist/core/idle-wake.js +103 -0
  73. package/dist/core/idle-wake.js.map +1 -0
  74. package/dist/core/injection-detect.d.ts +2 -1
  75. package/dist/core/injection-detect.js +13 -4
  76. package/dist/core/injection-detect.js.map +1 -1
  77. package/dist/core/installed-threats.d.ts +23 -0
  78. package/dist/core/installed-threats.js +71 -0
  79. package/dist/core/installed-threats.js.map +1 -0
  80. package/dist/core/mcp/catalog-cache.d.ts +2 -0
  81. package/dist/core/mcp/catalog-cache.js +4 -0
  82. package/dist/core/mcp/catalog-cache.js.map +1 -1
  83. package/dist/core/mcp/client.d.ts +8 -0
  84. package/dist/core/mcp/client.js +37 -3
  85. package/dist/core/mcp/client.js.map +1 -1
  86. package/dist/core/mcp/deferred-catalog.d.ts +12 -1
  87. package/dist/core/mcp/deferred-catalog.js +37 -10
  88. package/dist/core/mcp/deferred-catalog.js.map +1 -1
  89. package/dist/core/mcp/index.d.ts +2 -0
  90. package/dist/core/mcp/index.js +1 -0
  91. package/dist/core/mcp/index.js.map +1 -1
  92. package/dist/core/mcp/tool-guard.d.ts +58 -0
  93. package/dist/core/mcp/tool-guard.js +215 -0
  94. package/dist/core/mcp/tool-guard.js.map +1 -0
  95. package/dist/core/network-guard.d.ts +0 -20
  96. package/dist/core/network-guard.js +6 -0
  97. package/dist/core/network-guard.js.map +1 -1
  98. package/dist/core/package-cooldown.d.ts +71 -0
  99. package/dist/core/package-cooldown.js +301 -0
  100. package/dist/core/package-cooldown.js.map +1 -0
  101. package/dist/core/package-threats.d.ts +77 -3
  102. package/dist/core/package-threats.js +1250 -78
  103. package/dist/core/package-threats.js.map +1 -1
  104. package/dist/core/process-manager.d.ts +54 -12
  105. package/dist/core/process-manager.js +233 -56
  106. package/dist/core/process-manager.js.map +1 -1
  107. package/dist/core/project-files.d.ts +15 -0
  108. package/dist/core/project-files.js +37 -0
  109. package/dist/core/project-files.js.map +1 -0
  110. package/dist/core/prompt-commands.js +1 -0
  111. package/dist/core/prompt-commands.js.map +1 -1
  112. package/dist/core/session-manager.d.ts +3 -1
  113. package/dist/core/session-manager.js +2 -1
  114. package/dist/core/session-manager.js.map +1 -1
  115. package/dist/core/settings-manager.d.ts +1 -0
  116. package/dist/core/settings-manager.js +4 -0
  117. package/dist/core/settings-manager.js.map +1 -1
  118. package/dist/core/shell-threats.d.ts +14 -1
  119. package/dist/core/shell-threats.js +73 -24
  120. package/dist/core/shell-threats.js.map +1 -1
  121. package/dist/core/skill-scan.d.ts +112 -0
  122. package/dist/core/skill-scan.js +781 -0
  123. package/dist/core/skill-scan.js.map +1 -0
  124. package/dist/core/skills.d.ts +33 -0
  125. package/dist/core/skills.js +87 -16
  126. package/dist/core/skills.js.map +1 -1
  127. package/dist/core/slash-commands.d.ts +5 -1
  128. package/dist/core/slash-commands.js +24 -9
  129. package/dist/core/slash-commands.js.map +1 -1
  130. package/dist/core/steering.d.ts +7 -0
  131. package/dist/core/steering.js +13 -0
  132. package/dist/core/steering.js.map +1 -1
  133. package/dist/core/style-packs/index.js +1 -1
  134. package/dist/core/style-packs/index.js.map +1 -1
  135. package/dist/core/style-packs/packs.js +2 -2
  136. package/dist/core/subagent-manager.js +5 -4
  137. package/dist/core/subagent-manager.js.map +1 -1
  138. package/dist/core/threat-feed.d.ts +65 -0
  139. package/dist/core/threat-feed.js +335 -0
  140. package/dist/core/threat-feed.js.map +1 -0
  141. package/dist/core/trophies/catalog-more.d.ts +2 -0
  142. package/dist/core/trophies/catalog-more.js +857 -0
  143. package/dist/core/trophies/catalog-more.js.map +1 -0
  144. package/dist/core/trophies/catalog.d.ts +4 -0
  145. package/dist/core/trophies/catalog.js +649 -0
  146. package/dist/core/trophies/catalog.js.map +1 -0
  147. package/dist/core/trophies/engine.d.ts +70 -0
  148. package/dist/core/trophies/engine.js +351 -0
  149. package/dist/core/trophies/engine.js.map +1 -0
  150. package/dist/core/trophies/store.d.ts +17 -0
  151. package/dist/core/trophies/store.js +113 -0
  152. package/dist/core/trophies/store.js.map +1 -0
  153. package/dist/core/trophies/types.d.ts +119 -0
  154. package/dist/core/trophies/types.js +78 -0
  155. package/dist/core/trophies/types.js.map +1 -0
  156. package/dist/core/verification-evidence.d.ts +10 -6
  157. package/dist/core/verification-evidence.js +190 -11
  158. package/dist/core/verification-evidence.js.map +1 -1
  159. package/dist/goals-agent/goals-agent.js +3 -2
  160. package/dist/goals-agent/goals-agent.js.map +1 -1
  161. package/dist/interactive.js +16 -5
  162. package/dist/interactive.js.map +1 -1
  163. package/dist/modes/acp-mode.js +8 -1
  164. package/dist/modes/acp-mode.js.map +1 -1
  165. package/dist/modes/agent-home-mode.js +13 -1
  166. package/dist/modes/agent-home-mode.js.map +1 -1
  167. package/dist/modes/rpc-mode.d.ts +2 -1
  168. package/dist/modes/rpc-mode.js +15 -3
  169. package/dist/modes/rpc-mode.js.map +1 -1
  170. package/dist/modes/serve-mode.js +13 -1
  171. package/dist/modes/serve-mode.js.map +1 -1
  172. package/dist/system-prompt.d.ts +8 -0
  173. package/dist/system-prompt.js +34 -13
  174. package/dist/system-prompt.js.map +1 -1
  175. package/dist/test-support/slow-git.d.ts +1 -0
  176. package/dist/test-support/slow-git.js +21 -0
  177. package/dist/test-support/slow-git.js.map +1 -0
  178. package/dist/tools/background-watch-lint.d.ts +18 -0
  179. package/dist/tools/background-watch-lint.js +102 -0
  180. package/dist/tools/background-watch-lint.js.map +1 -0
  181. package/dist/tools/bash.d.ts +15 -4
  182. package/dist/tools/bash.js +75 -47
  183. package/dist/tools/bash.js.map +1 -1
  184. package/dist/tools/checklist.d.ts +3 -3
  185. package/dist/tools/compress.js +45 -0
  186. package/dist/tools/compress.js.map +1 -1
  187. package/dist/tools/edit.js +5 -0
  188. package/dist/tools/edit.js.map +1 -1
  189. package/dist/tools/generate-image.d.ts +29 -6
  190. package/dist/tools/generate-image.js +325 -151
  191. package/dist/tools/generate-image.js.map +1 -1
  192. package/dist/tools/grep.d.ts +14 -0
  193. package/dist/tools/grep.js +36 -2
  194. package/dist/tools/grep.js.map +1 -1
  195. package/dist/tools/index.d.ts +5 -1
  196. package/dist/tools/index.js +7 -4
  197. package/dist/tools/index.js.map +1 -1
  198. package/dist/tools/prompt-hints.d.ts +8 -1
  199. package/dist/tools/prompt-hints.js +22 -2
  200. package/dist/tools/prompt-hints.js.map +1 -1
  201. package/dist/tools/routine-output.d.ts +30 -0
  202. package/dist/tools/routine-output.js +203 -0
  203. package/dist/tools/routine-output.js.map +1 -0
  204. package/dist/tools/skill.d.ts +2 -2
  205. package/dist/tools/skill.js +8 -1
  206. package/dist/tools/skill.js.map +1 -1
  207. package/dist/tools/subagent-shared.js +4 -1
  208. package/dist/tools/subagent-shared.js.map +1 -1
  209. package/dist/tools/task-output.js +11 -1
  210. package/dist/tools/task-output.js.map +1 -1
  211. package/dist/tools/write.js +8 -0
  212. package/dist/tools/write.js.map +1 -1
  213. package/dist/ui/components/ModelSelector.d.ts +10 -0
  214. package/dist/ui/components/ModelSelector.js +51 -5
  215. package/dist/ui/components/ModelSelector.js.map +1 -1
  216. package/dist/ui/components/ToolExecution.js +2 -2
  217. package/dist/ui/components/ToolExecution.js.map +1 -1
  218. package/dist/ui/mcp.d.ts +2 -0
  219. package/dist/ui/mcp.js.map +1 -1
  220. package/dist/ui/terminal-history.js +2 -1
  221. package/dist/ui/terminal-history.js.map +1 -1
  222. package/dist/ui/tool-line-summary.js +3 -1
  223. package/dist/ui/tool-line-summary.js.map +1 -1
  224. package/dist/utils/git.d.ts +12 -3
  225. package/dist/utils/git.js +21 -8
  226. package/dist/utils/git.js.map +1 -1
  227. package/dist/utils/image.d.ts +14 -0
  228. package/dist/utils/image.js +23 -0
  229. package/dist/utils/image.js.map +1 -1
  230. package/package.json +5 -5
@@ -0,0 +1,894 @@
1
+ /**
2
+ * Appended to every safety block the agent receives. The user may be away and
3
+ * the step line is gone when the run ends, so the agent's reply is the lasting
4
+ * record: it carries on without the blocked step and says so in plain words.
5
+ */
6
+ const SAFETY_BLOCK_RESULT = /^(?:Error: )?Blocked (?:install|by (?:shell|action) safety check) \(([a-z-]+)\)/;
7
+ /** The rule of a guard's block when `result` is one (tool results start with it), else null. */
8
+ export function parseSafetyBlock(result) {
9
+ return SAFETY_BLOCK_RESULT.exec(result)?.[1] ?? null;
10
+ }
11
+ export const SAFETY_BLOCK_NOTE = "EZ Coder blocked this for the user's safety. Carry on without it, and tell the user in one plain sentence in your final reply what was blocked and what you did instead.";
12
+ // ─── Shell helpers ──────────────────────────────────────────────────────────
13
+ /** Quote-aware split of one shell segment into words (quotes removed). */
14
+ function shellWords(segment) {
15
+ const words = [];
16
+ let cur = "";
17
+ let quote = null;
18
+ let has = false;
19
+ let escaped = false;
20
+ for (const ch of segment) {
21
+ if (escaped) {
22
+ cur += ch;
23
+ has = true;
24
+ escaped = false;
25
+ }
26
+ else if (ch === "\\" && quote !== "'") {
27
+ escaped = true;
28
+ }
29
+ else if (quote) {
30
+ if (ch === quote)
31
+ quote = null;
32
+ else
33
+ cur += ch;
34
+ }
35
+ else if (ch === "'" || ch === '"') {
36
+ quote = ch;
37
+ has = true;
38
+ }
39
+ else if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
40
+ if (has)
41
+ words.push(cur);
42
+ cur = "";
43
+ has = false;
44
+ }
45
+ else {
46
+ cur += ch;
47
+ has = true;
48
+ }
49
+ }
50
+ if (has)
51
+ words.push(cur);
52
+ return words;
53
+ }
54
+ /**
55
+ * Split a command into top-level simple commands on `; & | && || newline`,
56
+ * keeping `$(…)`, backticks and quoted text intact.
57
+ */
58
+ function shellSegments(command) {
59
+ const out = [];
60
+ let cur = "";
61
+ let depth = 0;
62
+ let quote = null;
63
+ let backtick = false;
64
+ let escaped = false;
65
+ for (const ch of command) {
66
+ if (escaped) {
67
+ escaped = false;
68
+ cur += ch;
69
+ continue;
70
+ }
71
+ if (ch === "\\" && quote !== "'") {
72
+ escaped = true;
73
+ cur += ch;
74
+ continue;
75
+ }
76
+ if (quote) {
77
+ if (ch === quote)
78
+ quote = null;
79
+ cur += ch;
80
+ continue;
81
+ }
82
+ if (ch === "'" || ch === '"') {
83
+ quote = ch;
84
+ }
85
+ else if (ch === "`") {
86
+ backtick = !backtick;
87
+ }
88
+ else if (ch === "(") {
89
+ depth++;
90
+ }
91
+ else if (ch === ")" && depth > 0) {
92
+ depth--;
93
+ }
94
+ else if (depth === 0 &&
95
+ !backtick &&
96
+ (ch === ";" || ch === "&" || ch === "|" || ch === "\n")) {
97
+ if (cur.trim())
98
+ out.push(cur.trim());
99
+ cur = "";
100
+ continue;
101
+ }
102
+ cur += ch;
103
+ }
104
+ if (cur.trim())
105
+ out.push(cur.trim());
106
+ return out;
107
+ }
108
+ const WRAPPERS = new Set(["sudo", "env", "command", "exec", "nohup", "time", "doas"]);
109
+ /** Shell keywords that precede the real program (`for …; do curl …`). */
110
+ const KEYWORDS = new Set(["do", "then", "else", "elif", "!", "{", "("]);
111
+ const RUNNERS = new Set(["npx", "bunx", "pnpx"]);
112
+ const PACKAGE_BINARIES = {
113
+ "@anthropic-ai/claude-code": "claude",
114
+ "@google/gemini-cli": "gemini",
115
+ "@openai/codex": "codex",
116
+ };
117
+ /** The program a segment runs, and its arguments (wrappers/env stripped). */
118
+ function programOf(words) {
119
+ let i = 0;
120
+ for (;;) {
121
+ const w = words[i];
122
+ if (w === undefined)
123
+ return { program: "", args: [] };
124
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) ||
125
+ WRAPPERS.has(w) ||
126
+ KEYWORDS.has(w) ||
127
+ /^[\w-]+\(\)$/.test(w)) {
128
+ i++;
129
+ continue;
130
+ }
131
+ if (RUNNERS.has(w) ||
132
+ ((w === "pnpm" || w === "yarn" || w === "bun") && /^(?:dlx|exec|x)$/.test(words[i + 1] ?? ""))) {
133
+ i += RUNNERS.has(w) ? 1 : 2;
134
+ while ((words[i] ?? "").startsWith("-"))
135
+ i++;
136
+ continue;
137
+ }
138
+ break;
139
+ }
140
+ const raw = words[i] ?? "";
141
+ const pkg = raw.replace(/(?!^)@[^/]*$/, "");
142
+ const base = PACKAGE_BINARIES[pkg] ?? (raw.split(/[\\/]/).pop() ?? "").replace(/\.(?:exe|cmd)$/i, "");
143
+ return { program: base.toLowerCase(), args: words.slice(i + 1) };
144
+ }
145
+ /** Every simple command in `command`, including scripts passed to `sh -c`. */
146
+ function simpleCommands(command, depth = 0) {
147
+ const out = [];
148
+ for (const segment of shellSegments(command)) {
149
+ const parsed = programOf(shellWords(segment));
150
+ out.push(parsed);
151
+ if (depth < 2 && /^(?:sh|bash|zsh|dash|ksh|fish|pwsh|powershell)$/.test(parsed.program)) {
152
+ const c = parsed.args.findIndex((a) => /^-\w*c$/.test(a) || /^-command$/i.test(a));
153
+ const script = c >= 0 ? parsed.args[c + 1] : undefined;
154
+ if (script)
155
+ out.push(...simpleCommands(script, depth + 1));
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+ /** Every simple command (program + args) in `command`; exported for shell-threats. */
161
+ export function commandPrograms(command) {
162
+ return simpleCommands(command);
163
+ }
164
+ // ─── Heredoc bodies that never run ─────────────────────────────────────────
165
+ /** Code that can run commands or reach the network from a script body. */
166
+ const EXEC_CAPABLE = /\b(?:os\.(?:system|popen|exec\w*|spawn\w*|fork)|subprocess|import\s+pty|from\s+pty|pty\.spawn|commands\.getoutput|child_process|(?<![.\w])exec(?:Sync|File\w*)?\s*\(|\.exec(?:Sync|File\w*)\s*\(|spawn(?:Sync)?\s*\(|eval\s*\(|Function\s*\(|system\s*\(|Bun\.(?:spawn|\$)|Deno\.(?:run|Command)|socket|urllib|requests\.|httpx|aiohttp|http\.client|fetch\s*\(|https?\.(?:request|get)\s*\(|XMLHttpRequest|net\.connect|import\s*\(|require\s*\(\s*["'](?:node:)?(?:net|http|https|dgram|tls))/;
167
+ /** Interpreters whose stdin script is checked for exec capability. Shells/perl/ruby always scan. */
168
+ const SCRIPT_INTERPRETERS = /^(?:python[\d.]*|node|tsx|ts-node|deno|bun)$/;
169
+ const DATA_WRITERS = /^(?:cat|tee)$/;
170
+ const SHELL_SCRIPT_EXT = /(?:\.(?:sh|bash|zsh|ksh|command|ps1|bat|cmd|pl|rb)|\/[^./]*)$/i;
171
+ const HEREDOC = /<<(-?)[ \t]*(?:(['"])([A-Za-z_][\w-]*)\2|\\?([A-Za-z_][\w-]*))/g;
172
+ /**
173
+ * `command` with the bodies of heredocs that can't run anything replaced by
174
+ * blank lines: data written by `cat`/`tee` to a file that is not run later,
175
+ * and scripts fed to python/node/… that have no way to spawn a process or use
176
+ * the network (the shell text inside them is string data). Bodies fed to a
177
+ * shell, piped onward, executed afterwards, or (unquoted delimiter) holding
178
+ * `$(…)`/backticks are kept, so nothing that runs escapes the checks.
179
+ */
180
+ export function stripInertHeredocs(command) {
181
+ if (!command.includes("<<"))
182
+ return command;
183
+ let out = "";
184
+ let pos = 0;
185
+ let lastUse;
186
+ HEREDOC.lastIndex = 0;
187
+ for (let m = HEREDOC.exec(command); m; m = HEREDOC.exec(command)) {
188
+ if (m.index < pos)
189
+ continue;
190
+ const delim = m[3] ?? m[4] ?? "";
191
+ const quoted = m[3] !== undefined || command[m.index + 2 + m[1].length] === "\\";
192
+ const lineEnd = command.indexOf("\n", m.index);
193
+ if (lineEnd < 0)
194
+ break;
195
+ const tabs = m[1] === "-";
196
+ // Find the terminating line (one linear regex scan, no per-line slicing).
197
+ const term = new RegExp(`^${tabs ? "\\t*" : ""}${delim}$`, "gm");
198
+ term.lastIndex = lineEnd + 1;
199
+ const t = term.exec(command);
200
+ const bodyEnd = t ? t.index : -1;
201
+ const after = t ? t.index + t[0].length : -1;
202
+ if (bodyEnd < 0)
203
+ break;
204
+ const body = command.slice(lineEnd + 1, bodyEnd);
205
+ const lineStart = command.lastIndexOf("\n", m.index) + 1;
206
+ const head = command.slice(lineStart, m.index);
207
+ const tail = command.slice(m.index + m[0].length, lineEnd);
208
+ const usedLater = (needle) => ((lastUse ??= wordLastUse(command)).get(needle) ?? -1) > after;
209
+ const fnBody = (name) => {
210
+ // `name() { … }` defined earlier in the command (the `mk(){ cat > "$1"; }` idiom).
211
+ const at = name ? command.lastIndexOf(`${name}()`, lineStart) : -1;
212
+ if (at < 0)
213
+ return null;
214
+ const open = command.indexOf("{", at);
215
+ const close = open < 0 ? -1 : command.indexOf("}", open);
216
+ return close < 0 || close > lineStart ? null : command.slice(open + 1, close);
217
+ };
218
+ if (heredocIsInert(head, tail, body, quoted, usedLater, fnBody)) {
219
+ out += command.slice(pos, lineEnd + 1) + body.replace(/[^\n]/g, "");
220
+ pos = bodyEnd;
221
+ }
222
+ HEREDOC.lastIndex = Math.max(after, m.index + 1);
223
+ }
224
+ return pos === 0 ? command : out + command.slice(pos);
225
+ }
226
+ /** Last offset of each word (split on shell punctuation and path slashes); one pass. */
227
+ function wordLastUse(command) {
228
+ const out = new Map();
229
+ for (const m of command.matchAll(/[^\s"'`;&|()<>=\\/]+/g))
230
+ out.set(m[0], m.index);
231
+ return out;
232
+ }
233
+ function heredocIsInert(head, tail, body, quoted, usedLater, fnBody) {
234
+ if (!quoted && /\$\(|`/.test(body))
235
+ return false;
236
+ // Piped or chained onward on the same line (`cat <<EOF | sh`, `<<EOF && bash`).
237
+ if (/[|&;]|<</.test(tail.replace(/>{1,2}\s*\S+/g, "")))
238
+ return false;
239
+ const segs = shellSegments(head);
240
+ const seg = segs[segs.length - 1] ?? "";
241
+ const { program, args } = programOf(shellWords(seg));
242
+ const fn = DATA_WRITERS.test(program) || !/^[\w-]+$/.test(program) ? null : fnBody(program);
243
+ const writer = DATA_WRITERS.test(program) ||
244
+ (fn !== null &&
245
+ simpleCommands(fn).every((c) => /^(?:cat|tee|mkdir|dirname|echo|printf|:|)$/.test(c.program)));
246
+ if (writer) {
247
+ const target = /(?:^|\s)>{1,2}\s*["']?([^\s"';|&<>]+)/.exec(`${seg} ${tail}`)?.[1] ??
248
+ (program === "tee" ? args.find((a) => !a.startsWith("-")) : undefined);
249
+ const base = target?.split(/[\\/]/).pop();
250
+ if (!target || !base)
251
+ return !EXEC_CAPABLE.test(body);
252
+ if (!usedLater(base))
253
+ return true;
254
+ // The written file is used later: treat it as the script it is.
255
+ return !SHELL_SCRIPT_EXT.test(target) && !/^\.?[^.]*$/.test(base) && !EXEC_CAPABLE.test(body);
256
+ }
257
+ if (SCRIPT_INTERPRETERS.test(program))
258
+ return !EXEC_CAPABLE.test(body);
259
+ return false;
260
+ }
261
+ // ─── Destinations that are the user's own or a provider API ────────────────
262
+ /** LLM/API provider hosts that take a key in a header (the key's own service). */
263
+ const PROVIDER_API_HOSTS = [
264
+ "openrouter.ai",
265
+ "api.openai.com",
266
+ "api.anthropic.com",
267
+ "generativelanguage.googleapis.com",
268
+ "api.mistral.ai",
269
+ "api.groq.com",
270
+ "api.together.xyz",
271
+ "api.deepseek.com",
272
+ "api.x.ai",
273
+ "api.z.ai",
274
+ "open.bigmodel.cn",
275
+ "api.moonshot.ai",
276
+ "api.moonshot.cn",
277
+ "api.fireworks.ai",
278
+ "api.cohere.com",
279
+ "api.perplexity.ai",
280
+ "api.minimax.io",
281
+ "api.cerebras.ai",
282
+ "api.elevenlabs.io",
283
+ "api.replicate.com",
284
+ "api-inference.huggingface.co",
285
+ ];
286
+ function urlHost(u) {
287
+ try {
288
+ return new URL(u).hostname.toLowerCase() || undefined;
289
+ }
290
+ catch {
291
+ return undefined;
292
+ }
293
+ }
294
+ function isLocalHost(host) {
295
+ const h = host.replace(/^\[|\]$/g, "");
296
+ return (h === "localhost" ||
297
+ h.endsWith(".localhost") ||
298
+ h === "::1" ||
299
+ h === "0.0.0.0" ||
300
+ h === "host.docker.internal" ||
301
+ (IPV4.test(h) && isPrivateIpv4(h)));
302
+ }
303
+ /**
304
+ * True when every program in `command` that sends data (curl/wget with a body
305
+ * or upload, xh/http) targets only the local machine / private network or a
306
+ * known provider API, and nothing ships data any other way (scp/rsync/nc…).
307
+ * Used to let `.env`-keyed calls to the key's own API or a local dev server
308
+ * through. `$VAR` URLs resolve through `VAR=…` assignments in the command;
309
+ * an unresolved destination is never trusted.
310
+ */
311
+ export function sendsOnlyToTrustedHosts(command) {
312
+ const vars = new Map();
313
+ const addVar = (name, value) => vars.set(name, [...(vars.get(name) ?? []), value]);
314
+ for (const m of command.matchAll(/(?:^|[\s;&(])([A-Za-z_]\w*)=(["']?)(https?:\/\/[^\s"';&|)]+)\2/g))
315
+ if (m[1] && m[3])
316
+ addVar(m[1], m[3]);
317
+ for (const m of command.matchAll(/\bfor\s+([A-Za-z_]\w*)\s+in\s+([^;\n]*)/g))
318
+ for (const v of (m[2] ?? "").split(/\s+/))
319
+ if (/^["']?https?:\/\//.test(v) && m[1])
320
+ addVar(m[1], v.replace(/^["']|["']$/g, ""));
321
+ let fetchers = 0;
322
+ for (const { program, args } of commandPrograms(command)) {
323
+ if (/^(?:scp|rsync|sftp|nc|ncat|netcat|socat|telnet|ftp)$/.test(program))
324
+ return false;
325
+ if (!/^(?:curl|wget|xh|http|https)$/.test(program))
326
+ continue;
327
+ fetchers++;
328
+ const urls = args
329
+ .filter((a) => !a.startsWith("-"))
330
+ .flatMap((a) => {
331
+ const v = /^\$\{?([A-Za-z_]\w*)\}?/.exec(a);
332
+ if (!v)
333
+ return [a];
334
+ const values = vars.get(v[1] ?? "");
335
+ // A bare `"$body"` is data (after -d); `$BASE/path` is a destination.
336
+ if (!values && v[0].length === a.length)
337
+ return [];
338
+ // An unresolved $VAR destination is unknown, so never trusted.
339
+ return values ? values.map((x) => x + a.slice(v[0].length)) : ["unresolved://"];
340
+ })
341
+ .filter((a) => /^[a-z]+:\/\//i.test(a) || /^[\w.-]+\.[a-z]{2,}(?::\d+)?\//i.test(a));
342
+ if (urls.length === 0)
343
+ return false;
344
+ for (const u of urls) {
345
+ const host = urlHost(/^https?:/i.test(u) ? u : /:\/\//.test(u) ? "" : `http://${u}`);
346
+ if (!host)
347
+ return false;
348
+ if (!isLocalHost(host) && !PROVIDER_API_HOSTS.some((d) => host === d))
349
+ return false;
350
+ }
351
+ }
352
+ return fetchers > 0;
353
+ }
354
+ function block(rule, detail) {
355
+ return { rule, severity: "block", detail };
356
+ }
357
+ const GAP = /\[\^([^\]]*\\n)\]\*\??/;
358
+ /**
359
+ * Compile `HEAD[^…\n]*TAIL` so it runs in linear time. As one regex it tries
360
+ * every HEAD occurrence and scans to the line end from each (100 KB of
361
+ * `curl curl …` took 2.5 s). The first HEAD on a line covers every later one,
362
+ * so only that one is tried: same verdict and same match count. Regexes
363
+ * without such a gap are used as-is.
364
+ */
365
+ export function lineRule(re) {
366
+ const flags = re.flags.replace(/[gy]/g, "");
367
+ const gap = GAP.exec(re.source);
368
+ if (!gap || gap.index === 0) {
369
+ const g = new RegExp(re.source, `${flags}g`);
370
+ return {
371
+ test: (t) => {
372
+ g.lastIndex = 0;
373
+ return g.test(t);
374
+ },
375
+ count: (t) => {
376
+ g.lastIndex = 0;
377
+ return t.match(g)?.length ?? 0;
378
+ },
379
+ };
380
+ }
381
+ const head = new RegExp(re.source.slice(0, gap.index), `${flags}g`);
382
+ const rest = new RegExp(re.source.slice(gap.index), `${flags}y`);
383
+ const stop = new RegExp(`[${(gap[1] ?? "").replace(/^\^?/, "")}]`, "g");
384
+ const count = (text, max) => {
385
+ let n = 0;
386
+ head.lastIndex = 0;
387
+ for (let m = head.exec(text); m && n < max; m = head.exec(text)) {
388
+ const end = m.index + m[0].length;
389
+ rest.lastIndex = end;
390
+ if (rest.test(text)) {
391
+ n++;
392
+ head.lastIndex = Math.max(rest.lastIndex, end + (m[0].length === 0 ? 1 : 0));
393
+ continue;
394
+ }
395
+ stop.lastIndex = end;
396
+ const s = stop.exec(text);
397
+ if (!s)
398
+ break;
399
+ head.lastIndex = s.index + 1;
400
+ }
401
+ return n;
402
+ };
403
+ return { test: (t) => count(t, 1) > 0, count: (t) => count(t, Number.MAX_SAFE_INTEGER) };
404
+ }
405
+ // ─── A1: exfil hosts ────────────────────────────────────────────────────────
406
+ /** Request-collection / tunnel / paste services used as exfil drops. */
407
+ const EXFIL_DOMAINS = [
408
+ "webhook.site",
409
+ "pipedream.net",
410
+ "requestbin.com",
411
+ "requestbin.net",
412
+ "requestcatcher.com",
413
+ "hookbin.com",
414
+ "beeceptor.com",
415
+ "ngrok.io",
416
+ "ngrok.app",
417
+ "ngrok-free.app",
418
+ "ngrok-free.dev",
419
+ "interact.sh",
420
+ "interactsh.com",
421
+ "oast.pro",
422
+ "oast.live",
423
+ "oast.site",
424
+ "oast.online",
425
+ "oast.fun",
426
+ "oast.me",
427
+ "oastify.com",
428
+ "burpcollaborator.net",
429
+ "canarytokens.com",
430
+ "transfer.sh",
431
+ ];
432
+ /** Exfil sinks identified by host + path (the host itself is legitimate). */
433
+ const EXFIL_PATHS = [
434
+ { host: /^(?:www\.)?pastebin\.com$/, path: /^\/api\b/i, label: "pastebin.com API" },
435
+ {
436
+ host: /^(?:[\w-]+\.)?discord(?:app)?\.com$/,
437
+ path: /^\/api\/(?:v\d+\/)?webhooks\b/i,
438
+ label: "Discord webhook",
439
+ },
440
+ ];
441
+ function matchesDomain(host, domains) {
442
+ return domains.find((d) => host === d || host.endsWith(`.${d}`));
443
+ }
444
+ function isPrivateIpv4(ip) {
445
+ const p = ip.split(".").map(Number);
446
+ const [a = 0, b = 0] = p;
447
+ return (a === 10 ||
448
+ a === 127 ||
449
+ a === 0 ||
450
+ (a === 169 && b === 254) ||
451
+ (a === 172 && b >= 16 && b <= 31) ||
452
+ (a === 192 && b === 168) ||
453
+ (a === 100 && b >= 64 && b <= 127));
454
+ }
455
+ const IPV4 = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
456
+ /** Bare `name.ext` operands that are files, not hosts. */
457
+ const FILE_EXTENSIONS = new Set("json txt js mjs cjs ts tsx md yml yaml xml csv log tar gz tgz zip html htm py rb go rs toml lock env pem key crt bin out tmp png jpg jpeg gif svg pdf".split(" "));
458
+ /** URL-ish targets in a command: scheme URLs anywhere + bare host/path args of fetchers. */
459
+ function commandTargets(command) {
460
+ const out = [];
461
+ for (const m of command.matchAll(/(?<![\w+.-])[a-z][a-z0-9+.-]*:\/\/(?:[^@\s/'"]*@)?([^\s/:'"?#]+)(?::\d+)?([^\s'"]*)/gi)) {
462
+ if (m[1])
463
+ out.push({ host: m[1].toLowerCase(), path: m[2] ?? "" });
464
+ }
465
+ for (const { program, args } of simpleCommands(command)) {
466
+ if (!/^(?:curl|wget|http|https|xh|nc|ncat|netcat|telnet)$/.test(program))
467
+ continue;
468
+ for (const a of args) {
469
+ if (a.startsWith("-") || a.startsWith("@") || a.includes("://"))
470
+ continue;
471
+ const m = /^(?:[\w.-]+@)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?::\d+)?(\/\S*)?$/i.exec(a);
472
+ const host = m?.[1]?.toLowerCase();
473
+ if (!host)
474
+ continue;
475
+ const tld = host.split(".").pop() ?? "";
476
+ if (IPV4.test(host) || (/^[a-z]{2,}$/.test(tld) && !FILE_EXTENSIONS.has(tld))) {
477
+ out.push({ host, path: m?.[2] ?? "" });
478
+ }
479
+ }
480
+ }
481
+ return out;
482
+ }
483
+ /** Why `target` is an exfil drop, or null. */
484
+ function exfilSink(t) {
485
+ const domain = matchesDomain(t.host, EXFIL_DOMAINS);
486
+ if (domain)
487
+ return domain;
488
+ for (const p of EXFIL_PATHS)
489
+ if (p.host.test(t.host) && p.path.test(t.path))
490
+ return p.label;
491
+ if (IPV4.test(t.host) && !isPrivateIpv4(t.host))
492
+ return `raw IP ${t.host}`;
493
+ return null;
494
+ }
495
+ /** Data leaves with the request: body/upload flags, or substitution/$VAR in the command. */
496
+ const DATA_SEND = [
497
+ /\s(?:-[a-zA-Z]*[dFT]|--data(?:-\w+)?|--form(?:-string)?|--upload-file|--json)(?=[\s=@'"]|$)/,
498
+ /\s@[^\s]/,
499
+ /--(?:post|body)-(?:data|file)\b/i,
500
+ /\s-X\s*(?:POST|PUT|PATCH)\b/i,
501
+ /\$\(|`|\$\{?[A-Za-z_]/,
502
+ ];
503
+ function exfilHostThreats(command) {
504
+ if (!/\b(?:curl|wget|http|https|xh|nc|ncat|netcat|telnet|iwr|irm|invoke-webrequest|invoke-restmethod)\b/i.test(command))
505
+ return [];
506
+ if (!DATA_SEND.some((re) => re.test(command)))
507
+ return [];
508
+ for (const t of commandTargets(command)) {
509
+ const sink = exfilSink(t);
510
+ if (sink) {
511
+ return [
512
+ block("exfil-host", `command sends data to ${sink}, a request-collection/tunnel/paste service used to steal data. Don't upload there; if the user needs to share output, write it to a local file for them.`),
513
+ ];
514
+ }
515
+ }
516
+ return [];
517
+ }
518
+ // ─── A1: credential literals ────────────────────────────────────────────────
519
+ const CREDENTIALS = [
520
+ {
521
+ name: "Anthropic API key",
522
+ re: /\bsk-ant-[A-Za-z0-9_-]{20,}/,
523
+ hosts: ["anthropic.com", "claude.ai"],
524
+ },
525
+ { name: "OpenAI API key", re: /\bsk-proj-[A-Za-z0-9_-]{20,}/, hosts: ["openai.com"] },
526
+ {
527
+ name: "GitHub token",
528
+ re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/,
529
+ hosts: ["github.com", "githubusercontent.com", "ghcr.io"],
530
+ },
531
+ { name: "GitLab token", re: /\bglpat-[A-Za-z0-9_-]{20,}/, hosts: ["gitlab.com", "gitlab"] },
532
+ { name: "Slack token", re: /\bxox[bpoas]-[A-Za-z0-9-]{10,}/, hosts: ["slack.com"] },
533
+ {
534
+ name: "AWS access key",
535
+ re: /\bAKIA[0-9A-Z]{16}\b/,
536
+ hosts: ["amazonaws.com", "aws.amazon.com"],
537
+ },
538
+ {
539
+ name: "Google API key",
540
+ re: /\bAIza[0-9A-Za-z_-]{35}\b/,
541
+ hosts: ["googleapis.com", "google.com"],
542
+ },
543
+ { name: "npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/, hosts: ["npmjs.org", "npmjs.com"] },
544
+ ];
545
+ function isProviderHost(host, hosts) {
546
+ if (host === "localhost" || (IPV4.test(host) && isPrivateIpv4(host)))
547
+ return true;
548
+ // "gitlab" = any self-hosted GitLab (gitlab.example.com).
549
+ return hosts.some((d) => d.includes(".") ? host === d || host.endsWith(`.${d}`) : host.split(".").includes(d));
550
+ }
551
+ function foreignCredentialHost(text, targets) {
552
+ for (const cred of CREDENTIALS) {
553
+ if (!cred.re.test(text))
554
+ continue;
555
+ const foreign = targets.find((t) => !isProviderHost(t.host, cred.hosts));
556
+ if (foreign)
557
+ return `a literal ${cred.name} is sent to ${foreign.host}, which is not that key's provider`;
558
+ }
559
+ return null;
560
+ }
561
+ function credentialThreats(command) {
562
+ if (!CREDENTIALS.some((c) => c.re.test(command)))
563
+ return [];
564
+ const why = foreignCredentialHost(command, commandTargets(command));
565
+ return why
566
+ ? [
567
+ block("secret-literal-exfil", `${why}. Never send credentials to other hosts; reference the key by its env var and only call the provider's own API.`),
568
+ ]
569
+ : [];
570
+ }
571
+ // ─── A1: DNS exfil ──────────────────────────────────────────────────────────
572
+ const DNS_TOOLS = /^(?:ping6?|dig|nslookup|host|drill|getent|traceroute6?|tracepath|tracert|mtr|resolve-dnsname)$/;
573
+ function entropy(s) {
574
+ const counts = new Map();
575
+ for (const ch of s)
576
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
577
+ let h = 0;
578
+ for (const n of counts.values())
579
+ h -= (n / s.length) * Math.log2(n / s.length);
580
+ return h;
581
+ }
582
+ /** A DNS label that looks like it carries encoded data. */
583
+ function suspiciousLabel(label) {
584
+ const l = label.toLowerCase();
585
+ if (l.length >= 30 && !/^[a-z]+(?:-[a-z]+)*$/.test(l))
586
+ return true;
587
+ if (l.length >= 40)
588
+ return true;
589
+ if (/^[0-9a-f]{16,}$/.test(l) && /\d/.test(l) && /[a-f]/.test(l))
590
+ return true;
591
+ return l.length >= 20 && /\d/.test(l) && /[a-z]/.test(l) && entropy(l) >= 3.6;
592
+ }
593
+ function dnsThreats(command) {
594
+ for (const segment of shellSegments(command)) {
595
+ const words = shellWords(segment);
596
+ const { program, args } = programOf(words);
597
+ if (!DNS_TOOLS.test(program))
598
+ continue;
599
+ const argText = args.join(" ");
600
+ const sub = /\$\(|`/.test(argText);
601
+ const varInName = /\$\{?\w+\}?\.[A-Za-z0-9]|[A-Za-z0-9-]\.\$\{?\w/.test(argText);
602
+ const encoded = args.some((a) => !a.startsWith("-") &&
603
+ !a.startsWith("+") &&
604
+ a.includes(".") &&
605
+ a.replace(/^@/, "").split(".").some(suspiciousLabel));
606
+ if (sub || varInName || encoded) {
607
+ return [
608
+ block("dns-exfil", `${program} is given a hostname built from ${sub ? "command output" : varInName ? "a variable" : "an encoded-looking label"}; DNS lookups can smuggle data out to the attacker's nameserver. Use a plain, known hostname.`),
609
+ ];
610
+ }
611
+ }
612
+ return [];
613
+ }
614
+ // ─── P6: AI CLIs with safety disabled ───────────────────────────────────────
615
+ const AI_CLI_BYPASS = {
616
+ claude: (a) => a.includes("--dangerously-skip-permissions")
617
+ ? "--dangerously-skip-permissions"
618
+ : permissionModeBypass(a)
619
+ ? "--permission-mode bypassPermissions"
620
+ : null,
621
+ agy: (a) => a.includes("--dangerously-skip-permissions") ? "--dangerously-skip-permissions" : null,
622
+ gemini: (a) => a.find((x) => x === "--yolo" || x === "-y") ??
623
+ (a.some((x, i) => x === "--approval-mode=yolo" || (x === "--approval-mode" && a[i + 1] === "yolo"))
624
+ ? "--approval-mode yolo"
625
+ : null),
626
+ q: (a) => (a.includes("--trust-all-tools") ? "--trust-all-tools" : null),
627
+ qchat: (a) => (a.includes("--trust-all-tools") ? "--trust-all-tools" : null),
628
+ codex: (a) => a.find((x) => x === "--dangerously-bypass-approvals-and-sandbox" || x === "--yolo") ?? null,
629
+ "cursor-agent": (a) => a.find((x) => x === "--force" || x === "-f" || x === "--yolo") ?? null,
630
+ agent: (a) => a.find((x) => x === "--force" || x === "--yolo") ?? null,
631
+ };
632
+ function permissionModeBypass(a) {
633
+ return a.some((x, i) => /^--permission-mode=bypasspermissions$/i.test(x) ||
634
+ (x === "--permission-mode" && /^bypasspermissions$/i.test(a[i + 1] ?? "")));
635
+ }
636
+ function aiCliThreats(command) {
637
+ for (const { program, args } of simpleCommands(command)) {
638
+ const flag = AI_CLI_BYPASS[program]?.(args);
639
+ if (flag) {
640
+ return [
641
+ block("ai-cli-bypass", `launching ${program} with ${flag} runs another AI agent with its safety checks off. Do the work yourself, or run ${program} without that flag.`),
642
+ ];
643
+ }
644
+ }
645
+ return [];
646
+ }
647
+ const SHELL_STARTUP = new Set([
648
+ ".zshrc",
649
+ ".zshenv",
650
+ ".zprofile",
651
+ ".zlogin",
652
+ ".bashrc",
653
+ ".bash_profile",
654
+ ".bash_login",
655
+ ".profile",
656
+ ".kshrc",
657
+ "microsoft.powershell_profile.ps1",
658
+ "profile.ps1",
659
+ ]);
660
+ /** Which protected config file `filePath` is (case-insensitive, Windows paths too). */
661
+ export function classifyConfigPath(filePath) {
662
+ const p = filePath.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
663
+ const base = p.split("/").pop() ?? "";
664
+ if (/(?:^|\/)\.ezcoder\/auth\.json$/.test(p))
665
+ return "ez-auth";
666
+ if (/(?:^|\/)\.gg\/settings\.json$/.test(p))
667
+ return "ez-settings";
668
+ if (/(?:^|\/)\.claude\/settings(?:\.[\w-]+)?\.json$/.test(p))
669
+ return "claude-settings";
670
+ if (/(?:^|\/)\.vscode\/tasks\.json$/.test(p))
671
+ return "vscode-tasks";
672
+ if (/(?:^|\/)\.cursor\/[^/]+\.json$/.test(p) || /\/cursor\/user\/settings\.json$/.test(p))
673
+ return "cursor-config";
674
+ if (/(?:^|\/)\.vscode\/settings\.json$/.test(p) ||
675
+ /\/(?:code|code - insiders|vscodium|windsurf)\/user\/settings\.json$/.test(p) ||
676
+ base.endsWith(".code-workspace"))
677
+ return "vscode-settings";
678
+ if (SHELL_STARTUP.has(base) || /(?:^|\/)fish\/(?:config\.fish|conf\.d\/[^/]+\.fish)$/.test(p))
679
+ return "shell-startup";
680
+ if (/(?:^|\/)\.git\/hooks\/[^/]+$/.test(p) && !base.endsWith(".sample"))
681
+ return "git-hook";
682
+ return null;
683
+ }
684
+ /** Separators between a JSON key and value, also when shell-escaped (`\"`). */
685
+ const S = String.raw `[\\"'\s]*`;
686
+ const ON = String.raw `(?:true|"?on"?|"?always"?)`;
687
+ /**
688
+ * Any `key: on` pair; the key is filtered by YOLO_NAME in code. One regex with
689
+ * `[\w.]*yolo[\w.]*` backtracks cubically on long dotted runs (100 KB of
690
+ * `yolo.` took seconds); a single anchored `[\w.]+` stays linear.
691
+ */
692
+ const YOLO_KEY = new RegExp(String.raw `(?<![\w.])([\w.]+)${S}:${S}${ON}`, "gi");
693
+ const YOLO_NAME = /yolo|auto-?run/i;
694
+ const FETCH_EXEC_RES = [
695
+ /\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]*\|\s*(?:sudo\s+)?(?:[\w./-]*\/)?(?:sh|bash|zsh|dash|ksh|fish|python[\d.]*|node|perl|ruby|iex|invoke-expression)\b/i,
696
+ /\bbase64\s+(?:-d|--decode|-D)\b[^\n]*\|\s*(?:sudo\s+)?(?:[\w./-]*\/)?(?:sh|bash|zsh|dash|python[\d.]*|node|perl|ruby)\b/i,
697
+ /\b(?:sh|bash|zsh|eval|source|\.)\s+[^\n]*?(?:<\(|\$\(|`)\s*(?:curl|wget)\b/i,
698
+ /\bwget\b[^\n]*-O\s*-[^\n]*\|/i,
699
+ ];
700
+ const FETCH_EXEC = FETCH_EXEC_RES.map((re) => lineRule(new RegExp(re.source, "i")));
701
+ const CONFIG_RULES = {
702
+ "vscode-settings": [
703
+ {
704
+ re: new RegExp(String.raw `chat\.tools\.(?:global\.)?auto-?approve${S}:${S}true`, "gi"),
705
+ what: "chat.tools.autoApprove (agent runs tools without review)",
706
+ },
707
+ {
708
+ re: new RegExp(String.raw `task\.allowautomatictasks${S}:${S}${ON}`, "gi"),
709
+ what: "task.allowAutomaticTasks (tasks auto-run on folder open)",
710
+ },
711
+ {
712
+ re: new RegExp(String.raw `security\.workspace\.trust\.enabled${S}:${S}false`, "gi"),
713
+ what: "security.workspace.trust.enabled: false",
714
+ },
715
+ {
716
+ re: YOLO_KEY,
717
+ key: YOLO_NAME,
718
+ what: "an auto-run/YOLO setting",
719
+ },
720
+ ],
721
+ "vscode-tasks": [
722
+ {
723
+ re: new RegExp(String.raw `runon${S}:${S}folderopen`, "gi"),
724
+ what: `"runOn": "folderOpen" (task runs when the folder opens)`,
725
+ },
726
+ ],
727
+ "claude-settings": [
728
+ {
729
+ re: new RegExp(String.raw `defaultmode${S}:${S}bypasspermissions`, "gi"),
730
+ what: "defaultMode: bypassPermissions",
731
+ },
732
+ {
733
+ re: new RegExp(String.raw `skipdangerousmodepermissionprompt${S}:${S}true`, "gi"),
734
+ what: "skipDangerousModePermissionPrompt",
735
+ },
736
+ {
737
+ re: new RegExp(String.raw `[\\"']hooks${S}:\s*\{`, "gi"),
738
+ what: "hooks (commands Claude Code runs automatically)",
739
+ },
740
+ ],
741
+ "cursor-config": [
742
+ {
743
+ re: YOLO_KEY,
744
+ key: YOLO_NAME,
745
+ what: "an auto-run/YOLO setting",
746
+ },
747
+ { re: /shell\(\s*\*\s*\)/gi, what: "Shell(*) allow rule (every command auto-approved)" },
748
+ ],
749
+ "shell-startup": FETCH_EXEC.map((re) => ({
750
+ re,
751
+ what: "a download-and-execute line",
752
+ })),
753
+ "git-hook": FETCH_EXEC.map((re) => ({
754
+ re,
755
+ what: "a download-and-execute line",
756
+ })),
757
+ "ez-settings": [
758
+ {
759
+ re: new RegExp(String.raw `packagecooldowndays${S}:${S}0(?![\d.])`, "gi"),
760
+ what: "packageCooldownDays: 0 (package age check off)",
761
+ },
762
+ {
763
+ re: new RegExp(String.raw `networkmode${S}:${S}off`, "gi"),
764
+ what: `networkMode: "off" (network allowlist off)`,
765
+ },
766
+ {
767
+ re: new RegExp(String.raw `sandboxmode${S}:${S}off`, "gi"),
768
+ what: `sandboxMode: "off" (sandbox off)`,
769
+ },
770
+ ],
771
+ };
772
+ function count(re, text, key) {
773
+ if (!(re instanceof RegExp))
774
+ return re.count(text);
775
+ re.lastIndex = 0;
776
+ if (!key)
777
+ return text.match(re)?.length ?? 0;
778
+ let n = 0;
779
+ for (const m of text.matchAll(re))
780
+ if (key.test(m[1] ?? ""))
781
+ n++;
782
+ return n;
783
+ }
784
+ function describe(kind) {
785
+ switch (kind) {
786
+ case "ez-auth":
787
+ return "EZ Coder's credential store";
788
+ case "ez-settings":
789
+ return "EZ Coder's own safety settings";
790
+ case "shell-startup":
791
+ return "a shell startup file (runs in every new terminal)";
792
+ case "git-hook":
793
+ return "a git hook (runs on every git command)";
794
+ default:
795
+ return "another tool's config";
796
+ }
797
+ }
798
+ /**
799
+ * Block message when writing `next` to `filePath` (previously `previous`, or
800
+ * undefined for a new file) would switch on auto-execution or switch a guard
801
+ * off. Only NEW occurrences count, so editing a file that already has such a
802
+ * setting (a theme change next to an existing hook) still passes.
803
+ */
804
+ export function configWriteBlockMessage(filePath, next, previous = "") {
805
+ const kind = classifyConfigPath(filePath);
806
+ if (!kind)
807
+ return null;
808
+ if (kind === "ez-auth") {
809
+ return `Blocked by action safety check (ez-auth-write): agents may not write ${filePath}, ${describe(kind)}. Leave it alone; the user signs in through the app. ${SAFETY_BLOCK_NOTE}`;
810
+ }
811
+ for (const { re, key, what } of CONFIG_RULES[kind]) {
812
+ if (count(re, next, key) > count(re, previous, key)) {
813
+ return `Blocked by action safety check (auto-run-config): this write adds ${what} to ${filePath}, ${describe(kind)}, which would let code run without review. Make the rest of the change without it; the user can turn it on themselves. ${SAFETY_BLOCK_NOTE}`;
814
+ }
815
+ }
816
+ return null;
817
+ }
818
+ /** Files a shell command writes to: `>`/`>>`, tee, sed -i, cp/mv/install destination. */
819
+ function shellWriteTargets(command) {
820
+ const out = [];
821
+ for (const m of command.matchAll(/(?<![<>&\d])>>?\s*(["']?)([^\s"';|&<>]+)\1/g))
822
+ if (m[2])
823
+ out.push(m[2]);
824
+ for (const { program, args } of simpleCommands(command)) {
825
+ const operands = args.filter((a) => !a.startsWith("-"));
826
+ if (program === "tee" ||
827
+ (program === "sed" && args.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a)))) {
828
+ out.push(...operands);
829
+ }
830
+ else if (/^(?:cp|mv|install|ln|copy|copy-item|set-content|add-content|out-file)$/.test(program)) {
831
+ const last = operands[operands.length - 1];
832
+ if (last)
833
+ out.push(last);
834
+ }
835
+ }
836
+ return out;
837
+ }
838
+ function configShellThreats(command) {
839
+ for (const target of shellWriteTargets(command)) {
840
+ const kind = classifyConfigPath(target);
841
+ if (!kind)
842
+ continue;
843
+ const msg = configWriteBlockMessage(target, command);
844
+ if (msg)
845
+ return [
846
+ block(kind === "ez-auth" ? "ez-auth-write" : "auto-run-config", msg.replace(/^Blocked by action safety check \([\w-]+\): /, "")),
847
+ ];
848
+ }
849
+ return [];
850
+ }
851
+ // ─── Entry points ───────────────────────────────────────────────────────────
852
+ /** All action-level threats in a shell command (called from checkShellThreats). */
853
+ export function checkActionThreats(command) {
854
+ return [
855
+ ...exfilHostThreats(command),
856
+ ...credentialThreats(command),
857
+ ...dnsThreats(command),
858
+ ...aiCliThreats(command),
859
+ ...configShellThreats(command),
860
+ ];
861
+ }
862
+ /** web_fetch: block message for a URL that is an exfil channel, else null. */
863
+ export function urlExfilBlockMessage(url) {
864
+ let parsed;
865
+ try {
866
+ parsed = new URL(url);
867
+ }
868
+ catch {
869
+ return null;
870
+ }
871
+ const target = {
872
+ host: parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""),
873
+ path: parsed.pathname,
874
+ };
875
+ const sink = exfilSink(target);
876
+ const carriesData = parsed.search.length > 1 || parsed.pathname.length > 1;
877
+ const rawIp = sink?.startsWith("raw IP");
878
+ if (sink && (rawIp ? parsed.search.length > 1 : carriesData)) {
879
+ return `Blocked by action safety check (exfil-host): ${url} is ${sink}, a request-collection/tunnel/paste endpoint where the URL itself carries data out. Don't fetch it; use the real documentation or API host instead. ${SAFETY_BLOCK_NOTE}`;
880
+ }
881
+ const why = foreignCredentialHost(decodeURIComponentSafe(url), [target]);
882
+ return why
883
+ ? `Blocked by action safety check (secret-literal-exfil): ${why}. Never put credentials in URLs to other hosts. ${SAFETY_BLOCK_NOTE}`
884
+ : null;
885
+ }
886
+ function decodeURIComponentSafe(s) {
887
+ try {
888
+ return decodeURIComponent(s);
889
+ }
890
+ catch {
891
+ return s;
892
+ }
893
+ }
894
+ //# sourceMappingURL=action-threats.js.map