@oked/sdk 0.1.5 → 0.1.6
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/classify.d.ts +9 -5
- package/dist/classify.js +292 -56
- package/dist/describe.js +9 -4
- package/package.json +1 -1
package/dist/classify.d.ts
CHANGED
|
@@ -8,10 +8,14 @@ export interface ShellWriteOp {
|
|
|
8
8
|
content?: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Removes heredoc *bodies* that are being written to a file, so their contents
|
|
12
|
+
* aren't parsed as shell. `cat >> file <<'EOF' … EOF` is a common file-writing
|
|
13
|
+
* idiom; the body is literal data, not commands, and must not be scanned for
|
|
14
|
+
* operators, redirects, or risky tokens (a config/test file full of `;`, `&&`,
|
|
15
|
+
* or even the literal text "rm -rf" would otherwise wreck classification). The
|
|
16
|
+
* opener line — with its redirect and target — is preserved; only the body and
|
|
17
|
+
* closing delimiter are dropped. Heredocs fed to an interpreter (no file
|
|
18
|
+
* redirect on the opener line) are left intact so SQL/code detection still runs.
|
|
16
19
|
*/
|
|
20
|
+
export declare function stripHeredocBodies(command: string): string;
|
|
17
21
|
export declare function extractShellWriteOps(command: string): ShellWriteOp[];
|
package/dist/classify.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import { findSqlInCommand } from "./describe.js";
|
|
3
4
|
import { TIER_ORDER } from "./degraded.js";
|
|
4
5
|
// Tier 1 - safe: auto-allow, no notification (Read, Glob, ls, git status, etc.)
|
|
@@ -78,6 +79,27 @@ const SAFE_COMMANDS = [
|
|
|
78
79
|
// `message delete` / `folder delete|expunge|purge` matter; those land in
|
|
79
80
|
// the review/high-stakes paths.
|
|
80
81
|
/^himalaya\s+(account|folder|envelope|message\s+(?:read|export|search|copy|move)|attachment\s+(?:download|list)|template|search)\b/,
|
|
82
|
+
// cd just changes directory — no side effect of its own. Any dangerous
|
|
83
|
+
// command chained after it (`cd x && rm -rf`) is caught per-stage.
|
|
84
|
+
/^cd\b/,
|
|
85
|
+
// sed without -i / --in-place only prints to stdout (read-only). In-place
|
|
86
|
+
// edits are detected as a write op (below) before this is reached.
|
|
87
|
+
/^sed\b/,
|
|
88
|
+
// gh read-only subcommands — listing/viewing PRs, issues, runs, etc.
|
|
89
|
+
/^gh\s+(pr|issue|repo|run|workflow|release|api)\s+(list|view|status|diff|checks)\b/,
|
|
90
|
+
// Test runners — running the project's own tests is part of the dev loop.
|
|
91
|
+
// (Arbitrary node/npx/python execution is `warning`, see WARNING_COMMANDS.)
|
|
92
|
+
/^npm\s+(test|t)\b/,
|
|
93
|
+
/^npx\s+(tsx|ts-node|jest|vitest|mocha|ava|cypress|playwright|tsc)\b/,
|
|
94
|
+
/^(jest|vitest|mocha|pytest|ava)\b/,
|
|
95
|
+
/^python3?\s+-m\s+pytest\b/,
|
|
96
|
+
// Shell control-flow keywords. A compound like `for f in *; do cmd; done`
|
|
97
|
+
// is split on `;` into stages; these keyword stages carry no risk of their
|
|
98
|
+
// own, and any real command in the body is classified per-stage. Dangerous
|
|
99
|
+
// commands hidden in a $(...) on a keyword line are still caught by the
|
|
100
|
+
// high-stakes scan, which runs on the full command first.
|
|
101
|
+
/^(for|while|until|do|done|then|else|elif|fi|case|esac|if|select)\b/,
|
|
102
|
+
/^(done|fi|esac|\}|\{|:|true|false)\s*$/,
|
|
81
103
|
];
|
|
82
104
|
// Bash commands classified as high stakes (destructive, irreversible, external)
|
|
83
105
|
const HIGH_STAKES_COMMANDS = [
|
|
@@ -93,9 +115,11 @@ const HIGH_STAKES_COMMANDS = [
|
|
|
93
115
|
/\bgit\s+clean\s+-f/,
|
|
94
116
|
/\bgit\s+checkout\s+--\s+\./,
|
|
95
117
|
/\bgit\s+restore\s+--staged\s+\./,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
// NOTE: SQL severity (DROP/DELETE FROM/TRUNCATE/…) is intentionally NOT matched
|
|
119
|
+
// here. Raw word patterns fire on ordinary text — `grep truncate`, `echo "drop
|
|
120
|
+
// table"` — producing false high_stakes. SQL is handled by findSqlInCommand,
|
|
121
|
+
// which only extracts statements from real SQL contexts (psql/mysql/sqlite3
|
|
122
|
+
// -c/-e, interpreter -e/-c bodies, heredocs), then classifySqlSeverity.
|
|
99
123
|
/\bdocker\s+(rm|rmi|system\s+prune)\b/,
|
|
100
124
|
/\bdocker\s+compose\s+down\b/,
|
|
101
125
|
/\bkill\b/,
|
|
@@ -128,6 +152,33 @@ const HIGH_STAKES_COMMANDS = [
|
|
|
128
152
|
/\bhimalaya\s+folder\s+(delete|expunge|purge)\b/,
|
|
129
153
|
/\bhimalaya\s+account\s+delete\b/,
|
|
130
154
|
];
|
|
155
|
+
// Commands that auto-allow without a phone prompt but are logged (warning):
|
|
156
|
+
// reversible/local actions where an audit line is enough.
|
|
157
|
+
const WARNING_COMMANDS = [
|
|
158
|
+
// Local, reversible git ops — branch/stage/commit/stash/switch. They touch
|
|
159
|
+
// only the local repo and can be undone (amend, reset, checkout).
|
|
160
|
+
// Destructive/remote git (push, reset --hard, clean, checkout -- .) is matched
|
|
161
|
+
// by HIGH_STAKES_COMMANDS above and wins first. `git stash drop|clear` is
|
|
162
|
+
// excluded — those discard stashed work — so it stays `review`.
|
|
163
|
+
/^git\s+add\b/,
|
|
164
|
+
/^git\s+commit\b/,
|
|
165
|
+
/^git\s+checkout\s+-b\b/,
|
|
166
|
+
/^git\s+switch\b/,
|
|
167
|
+
/^git\s+stash\b(?!\s+(?:drop|clear))/,
|
|
168
|
+
// PR creation is reversible (a PR can be closed); the underlying branch push
|
|
169
|
+
// is separately high_stakes.
|
|
170
|
+
/^gh\s+pr\s+create\b/,
|
|
171
|
+
// Arbitrary code execution (node/npx/python/npm run/bun/deno). The spawned
|
|
172
|
+
// process can do anything and its syscalls don't pass back through OKed, so
|
|
173
|
+
// we don't prompt but keep a local trail. Known test runners and read-only
|
|
174
|
+
// version flags are handled as `safe` (SAFE_COMMANDS) before reaching here.
|
|
175
|
+
/^node\b/,
|
|
176
|
+
/^npx\b/,
|
|
177
|
+
/^python3?\b/,
|
|
178
|
+
/^npm\s+run\b/,
|
|
179
|
+
/^bun\b/,
|
|
180
|
+
/^deno\b/,
|
|
181
|
+
];
|
|
131
182
|
// Ephemeral filesystem locations. Writes here have no lasting effect on
|
|
132
183
|
// their own — what matters is whatever subsequent command CONSUMES the file
|
|
133
184
|
// (e.g. `himalaya message send < /tmp/draft.eml`). Without this carve-out,
|
|
@@ -139,6 +190,32 @@ function isEphemeralPath(filePath) {
|
|
|
139
190
|
return false;
|
|
140
191
|
return EPHEMERAL_PATH_RE.test(filePath);
|
|
141
192
|
}
|
|
193
|
+
// Claude Code's own plan-mode and todo scratch files live under
|
|
194
|
+
// ~/.claude/plans and ~/.claude/todos. They're agent bookkeeping, not project
|
|
195
|
+
// changes, and have no side effects of their own. Writes there downgrade to
|
|
196
|
+
// `warning`. This is deliberately narrow: the rest of ~/.claude (notably
|
|
197
|
+
// settings.json, which holds the OKed hook config) is NOT covered, so an agent
|
|
198
|
+
// can't silently rewrite its own guardrails without an approval.
|
|
199
|
+
const AGENT_SCRATCH_DIRS = [
|
|
200
|
+
path.join(".claude", "plans"),
|
|
201
|
+
path.join(".claude", "todos"),
|
|
202
|
+
];
|
|
203
|
+
function isAgentScratchPath(filePath) {
|
|
204
|
+
if (!filePath)
|
|
205
|
+
return false;
|
|
206
|
+
try {
|
|
207
|
+
const resolved = path.resolve(filePath);
|
|
208
|
+
const home = os.homedir();
|
|
209
|
+
return AGENT_SCRATCH_DIRS.some((dir) => {
|
|
210
|
+
const base = path.join(home, dir);
|
|
211
|
+
const relative = path.relative(base, resolved);
|
|
212
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
142
219
|
function isInsideProject(filePath) {
|
|
143
220
|
if (!filePath)
|
|
144
221
|
return false;
|
|
@@ -167,13 +244,17 @@ export function classify(toolName, toolInput) {
|
|
|
167
244
|
// can't do harm — only what consumes it can.
|
|
168
245
|
if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
|
|
169
246
|
const filePath = toolInput.file_path;
|
|
170
|
-
if (isEphemeralPath(filePath) || isInsideProject(filePath))
|
|
247
|
+
if (isEphemeralPath(filePath) || isInsideProject(filePath) || isAgentScratchPath(filePath))
|
|
171
248
|
return "warning";
|
|
172
249
|
return "review";
|
|
173
250
|
}
|
|
174
|
-
// Agent tool -
|
|
251
|
+
// Agent tool - safe. Launching a sub-agent is not itself a side effect, and
|
|
252
|
+
// the sub-agent's own tool calls (Bash/Write/Edit/MCP) each fire their own
|
|
253
|
+
// PreToolUse hook and get classified independently. Gating the launch on top
|
|
254
|
+
// of that just double-prompts — once for the spawn, again for every real
|
|
255
|
+
// action the sub-agent takes — so the launch auto-allows.
|
|
175
256
|
if (toolName === "Agent")
|
|
176
|
-
return "
|
|
257
|
+
return "safe";
|
|
177
258
|
// Bash commands need deeper analysis
|
|
178
259
|
if (toolName === "Bash") {
|
|
179
260
|
return classifyBashCommand(toolInput.command);
|
|
@@ -195,7 +276,7 @@ export function classify(toolName, toolInput) {
|
|
|
195
276
|
const writePath = (toolInput.file_path ?? toolInput.path);
|
|
196
277
|
const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
|
|
197
278
|
if (typeof writePath === "string" && typeof writeContent === "string") {
|
|
198
|
-
if (isEphemeralPath(writePath) || isInsideProject(writePath))
|
|
279
|
+
if (isEphemeralPath(writePath) || isInsideProject(writePath) || isAgentScratchPath(writePath))
|
|
199
280
|
return "warning";
|
|
200
281
|
return "review";
|
|
201
282
|
}
|
|
@@ -205,79 +286,136 @@ export function classify(toolName, toolInput) {
|
|
|
205
286
|
function maxTier(a, b) {
|
|
206
287
|
return TIER_ORDER[a] >= TIER_ORDER[b] ? a : b;
|
|
207
288
|
}
|
|
208
|
-
/** Split a shell command
|
|
209
|
-
*
|
|
210
|
-
|
|
289
|
+
/** Split a shell command into top-level segments on the operators that
|
|
290
|
+
* sequence separate commands: `|`, `||`, `&&`, `;`. Operators inside quoted
|
|
291
|
+
* strings — including the `"$(cat <<'EOF' … )"` heredoc form used for commit
|
|
292
|
+
* messages — are kept intact so message text isn't split. Returns trimmed,
|
|
293
|
+
* non-empty segments. */
|
|
294
|
+
function splitTopLevel(cmd) {
|
|
211
295
|
const out = [];
|
|
212
296
|
let cur = "";
|
|
213
297
|
let quote = null;
|
|
214
|
-
|
|
298
|
+
let i = 0;
|
|
299
|
+
while (i < cmd.length) {
|
|
215
300
|
const ch = cmd[i];
|
|
216
301
|
if (quote) {
|
|
217
302
|
cur += ch;
|
|
218
303
|
if (ch === quote)
|
|
219
304
|
quote = null;
|
|
305
|
+
i++;
|
|
306
|
+
continue;
|
|
220
307
|
}
|
|
221
|
-
|
|
308
|
+
if (ch === '"' || ch === "'") {
|
|
222
309
|
cur += ch;
|
|
223
310
|
quote = ch;
|
|
311
|
+
i++;
|
|
312
|
+
continue;
|
|
224
313
|
}
|
|
225
|
-
|
|
314
|
+
// Heredoc: consume the opener and the entire body (up to the closing
|
|
315
|
+
// delimiter line) as part of the current segment, so operators inside a
|
|
316
|
+
// heredoc fed to an interpreter (psql/node/…) aren't treated as separators.
|
|
317
|
+
const hd = cmd.slice(i).match(/^<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/);
|
|
318
|
+
if (hd) {
|
|
319
|
+
cur += hd[0];
|
|
320
|
+
i += hd[0].length;
|
|
321
|
+
const close = cmd.slice(i).match(new RegExp(`\\n[ \\t]*${hd[2]}\\b`));
|
|
322
|
+
if (close) {
|
|
323
|
+
const end = i + (close.index ?? 0) + close[0].length;
|
|
324
|
+
cur += cmd.slice(i, end);
|
|
325
|
+
i = end;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
cur += cmd.slice(i);
|
|
329
|
+
i = cmd.length;
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const next = cmd[i + 1];
|
|
334
|
+
if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
|
|
226
335
|
out.push(cur.trim());
|
|
227
336
|
cur = "";
|
|
337
|
+
i += 2; // consume both operator chars
|
|
338
|
+
continue;
|
|
228
339
|
}
|
|
229
|
-
|
|
230
|
-
cur
|
|
340
|
+
if (ch === "|" || ch === ";") {
|
|
341
|
+
out.push(cur.trim());
|
|
342
|
+
cur = "";
|
|
343
|
+
i++;
|
|
344
|
+
continue;
|
|
231
345
|
}
|
|
346
|
+
cur += ch;
|
|
347
|
+
i++;
|
|
232
348
|
}
|
|
233
349
|
if (cur.trim())
|
|
234
350
|
out.push(cur.trim());
|
|
235
|
-
return out;
|
|
351
|
+
return out.filter(Boolean);
|
|
352
|
+
}
|
|
353
|
+
// rm/rmdir/trash whose every target is an ephemeral temp path (/tmp, %TEMP%,
|
|
354
|
+
// …). Deleting throwaway temp files is low-risk, so it downgrades to warning.
|
|
355
|
+
// Any non-temp target (or deleting a temp ROOT like `/tmp` itself, which isn't
|
|
356
|
+
// an ephemeral *path*) means this returns false and the deletion stays
|
|
357
|
+
// high_stakes.
|
|
358
|
+
function isEphemeralOnlyDeletion(command) {
|
|
359
|
+
const m = command.match(/^(?:sudo\s+)?(?:rm|rmdir|trash|trash-put)\b\s+(.+)$/s);
|
|
360
|
+
if (!m)
|
|
361
|
+
return false;
|
|
362
|
+
const targets = splitArgs(m[1]).filter((a) => !a.startsWith("-"));
|
|
363
|
+
if (targets.length === 0)
|
|
364
|
+
return false;
|
|
365
|
+
return targets.every((a) => isEphemeralPath(unquote(a)));
|
|
236
366
|
}
|
|
237
367
|
function classifyBashCommand(command) {
|
|
238
368
|
if (!command)
|
|
239
369
|
return "safe";
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
370
|
+
// Strip heredoc bodies up front: their contents are literal data, not shell,
|
|
371
|
+
// and must not be scanned for high-stakes tokens, operators, or redirects.
|
|
372
|
+
const trimmed = stripHeredocBodies(command).trim();
|
|
373
|
+
// rm/trash of only ephemeral temp files → warning (before the high-stakes
|
|
374
|
+
// scan, which would otherwise match the bare `rm`).
|
|
375
|
+
if (isEphemeralOnlyDeletion(trimmed))
|
|
376
|
+
return "warning";
|
|
377
|
+
// High-stakes scan on the FULL command, before any splitting. These patterns
|
|
378
|
+
// use \b and several intentionally span an operator (e.g. `curl … | bash`,
|
|
379
|
+
// `wget … | sh` — download-and-execute), so they have to be matched against
|
|
380
|
+
// the whole string. Most-restrictive-wins: a high-stakes match anywhere in a
|
|
381
|
+
// compound command takes the whole command to high_stakes.
|
|
382
|
+
for (const pattern of HIGH_STAKES_COMMANDS) {
|
|
383
|
+
if (pattern.test(trimmed))
|
|
384
|
+
return "high_stakes";
|
|
385
|
+
}
|
|
386
|
+
// Compound commands: split on top-level `|`, `||`, `&&`, `;` and take the
|
|
387
|
+
// highest tier. Without this, `cat /tmp/draft.eml | himalaya message send`
|
|
388
|
+
// would match `^cat\b` and silently allow the send, and `git add … && git
|
|
389
|
+
// commit …` couldn't be recognized as the local git ops they are. Only
|
|
390
|
+
// recurse when there are 2+ stages so single commands don't loop.
|
|
391
|
+
const stages = splitTopLevel(trimmed);
|
|
246
392
|
if (stages.length > 1) {
|
|
247
393
|
return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
|
|
248
394
|
}
|
|
249
|
-
// sudo:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (pattern.test(inner))
|
|
254
|
-
return "high_stakes";
|
|
255
|
-
}
|
|
395
|
+
// sudo: privilege escalation. A high-stakes inner command was already caught
|
|
396
|
+
// by the full-string scan above (\b patterns match through the `sudo` prefix);
|
|
397
|
+
// anything else still warrants review.
|
|
398
|
+
if (/^sudo\s/.test(trimmed))
|
|
256
399
|
return "review";
|
|
257
|
-
}
|
|
258
400
|
// SQL hidden inside an interpreter wrapper (python -c, node -e, heredoc),
|
|
259
401
|
// a DB CLI (psql -c, sqlite3 db "...", mysql -e), or at the top of the
|
|
260
|
-
// command. Severity comes from the statement, not the wrapper.
|
|
402
|
+
// command. Severity comes from the statement, not the wrapper. (High-stakes
|
|
403
|
+
// SQL — DROP/TRUNCATE/DELETE FROM — is already covered by the scan above.)
|
|
261
404
|
const sql = findSqlInCommand(trimmed);
|
|
262
405
|
if (sql)
|
|
263
406
|
return classifySqlSeverity(sql);
|
|
264
|
-
// Check high stakes first (most restrictive wins)
|
|
265
|
-
for (const pattern of HIGH_STAKES_COMMANDS) {
|
|
266
|
-
if (pattern.test(trimmed))
|
|
267
|
-
return "high_stakes";
|
|
268
|
-
}
|
|
269
407
|
// File-mutating shell patterns. Content-creation idioms (echo > X, tee,
|
|
270
|
-
// dd of=, touch, sed -i
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
408
|
+
// dd of=, touch, sed -i) are the bypass route from a denied Write, so they're
|
|
409
|
+
// classified like the Write/Edit tool: writes inside the project, an
|
|
410
|
+
// ephemeral temp dir (/tmp, %TEMP%), or an agent scratch dir downgrade to
|
|
411
|
+
// warning; writes elsewhere stay review. cp/mv (rearranging existing bytes)
|
|
412
|
+
// stay review.
|
|
274
413
|
const ops = extractShellWriteOps(trimmed);
|
|
275
414
|
if (ops.length > 0) {
|
|
276
415
|
const creates = ops.filter((o) => o.kind !== "copy" && o.kind !== "move");
|
|
277
416
|
if (creates.length > 0) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return "review";
|
|
417
|
+
const allLocal = creates.every((o) => isEphemeralPath(o.target) || isInsideProject(o.target) || isAgentScratchPath(o.target));
|
|
418
|
+
return allLocal ? "warning" : "review";
|
|
281
419
|
}
|
|
282
420
|
return "review";
|
|
283
421
|
}
|
|
@@ -286,6 +424,13 @@ function classifyBashCommand(command) {
|
|
|
286
424
|
if (pattern.test(trimmed))
|
|
287
425
|
return "safe";
|
|
288
426
|
}
|
|
427
|
+
// Reversible/local commands (local git, gh pr create, code execution) →
|
|
428
|
+
// warning: logged, no phone approval. Checked after SAFE so read-only git
|
|
429
|
+
// (status/log/`stash list`) and known test runners stay fully silent.
|
|
430
|
+
for (const pattern of WARNING_COMMANDS) {
|
|
431
|
+
if (pattern.test(trimmed))
|
|
432
|
+
return "warning";
|
|
433
|
+
}
|
|
289
434
|
// Default: review (require approval for unknown commands)
|
|
290
435
|
return "review";
|
|
291
436
|
}
|
|
@@ -316,20 +461,111 @@ function classifySqlSeverity(sql) {
|
|
|
316
461
|
*
|
|
317
462
|
* Skips /dev/null and bare-digit FD duplicates (2>&1).
|
|
318
463
|
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
464
|
+
// True when the opener line sends the heredoc to a FILE (a `>`/`>>` redirect to
|
|
465
|
+
// a real path, or `tee`). Those bodies are literal data; bodies fed to an
|
|
466
|
+
// interpreter/DB instead (`psql <<EOF`, `node - <<EOF`, `bash <<EOF`) are
|
|
467
|
+
// executed and must NOT be stripped — they still need to be classified.
|
|
468
|
+
function openerWritesToFile(line) {
|
|
469
|
+
if (/\btee\b/.test(line))
|
|
470
|
+
return true;
|
|
471
|
+
for (const m of line.matchAll(/(?:^|[^>])([12]?>>?|&>>?)\s*([^\s>|&;]+)/g)) {
|
|
327
472
|
const target = unquote(m[2]);
|
|
328
|
-
if (
|
|
473
|
+
if (target && !/^\d+$/.test(target) && !isDevNullish(target))
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Removes heredoc *bodies* that are being written to a file, so their contents
|
|
480
|
+
* aren't parsed as shell. `cat >> file <<'EOF' … EOF` is a common file-writing
|
|
481
|
+
* idiom; the body is literal data, not commands, and must not be scanned for
|
|
482
|
+
* operators, redirects, or risky tokens (a config/test file full of `;`, `&&`,
|
|
483
|
+
* or even the literal text "rm -rf" would otherwise wreck classification). The
|
|
484
|
+
* opener line — with its redirect and target — is preserved; only the body and
|
|
485
|
+
* closing delimiter are dropped. Heredocs fed to an interpreter (no file
|
|
486
|
+
* redirect on the opener line) are left intact so SQL/code detection still runs.
|
|
487
|
+
*/
|
|
488
|
+
export function stripHeredocBodies(command) {
|
|
489
|
+
const lines = command.split("\n");
|
|
490
|
+
const out = [];
|
|
491
|
+
let i = 0;
|
|
492
|
+
while (i < lines.length) {
|
|
493
|
+
const line = lines[i];
|
|
494
|
+
out.push(line);
|
|
495
|
+
// Heredoc openers: <<DELIM, <<'DELIM', <<"DELIM", <<-DELIM (with optional
|
|
496
|
+
// space). Require a word-char delimiter so numeric left-shifts ($((1<<2)))
|
|
497
|
+
// don't match. Use the last opener on the line as the active delimiter.
|
|
498
|
+
const openers = [...line.matchAll(/<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/g)];
|
|
499
|
+
if (openers.length > 0 && openerWritesToFile(line)) {
|
|
500
|
+
const delim = openers[openers.length - 1][2];
|
|
501
|
+
i++;
|
|
502
|
+
while (i < lines.length && lines[i].trim() !== delim)
|
|
503
|
+
i++; // drop body
|
|
504
|
+
if (i < lines.length)
|
|
505
|
+
i++; // drop the closing delimiter line
|
|
329
506
|
continue;
|
|
330
|
-
|
|
507
|
+
}
|
|
508
|
+
i++;
|
|
509
|
+
}
|
|
510
|
+
return out.join("\n");
|
|
511
|
+
}
|
|
512
|
+
/** Find output redirects (`>`, `>>`, `&>`, `2>`, …) outside quoted strings.
|
|
513
|
+
* The target token may itself be quoted. Skips `2>&1`-style FD dups, bare
|
|
514
|
+
* digits, and /dev/null. Heredoc `<<` is ignored (only `>` is a write). */
|
|
515
|
+
function findRedirects(cmd) {
|
|
516
|
+
const res = [];
|
|
517
|
+
let quote = null;
|
|
518
|
+
let i = 0;
|
|
519
|
+
while (i < cmd.length) {
|
|
520
|
+
const ch = cmd[i];
|
|
521
|
+
if (quote) {
|
|
522
|
+
if (ch === quote)
|
|
523
|
+
quote = null;
|
|
524
|
+
i++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (ch === '"' || ch === "'") {
|
|
528
|
+
quote = ch;
|
|
529
|
+
i++;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const op = cmd.slice(i).match(/^([12]?>>?|&>>?)/);
|
|
533
|
+
if (op && cmd[i - 1] !== ">") {
|
|
534
|
+
let j = i + op[1].length;
|
|
535
|
+
while (j < cmd.length && /\s/.test(cmd[j]))
|
|
536
|
+
j++;
|
|
537
|
+
let target = "";
|
|
538
|
+
if (cmd[j] === '"' || cmd[j] === "'") {
|
|
539
|
+
const q = cmd[j];
|
|
540
|
+
j++;
|
|
541
|
+
while (j < cmd.length && cmd[j] !== q)
|
|
542
|
+
target += cmd[j++];
|
|
543
|
+
if (j < cmd.length)
|
|
544
|
+
j++; // closing quote
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
while (j < cmd.length && !/[\s>|&;]/.test(cmd[j]))
|
|
548
|
+
target += cmd[j++];
|
|
549
|
+
}
|
|
550
|
+
if (target && !/^\d+$/.test(target) && !isDevNullish(target)) {
|
|
551
|
+
res.push({ append: op[1] === ">>" || op[1] === "&>>", target });
|
|
552
|
+
}
|
|
553
|
+
i = j;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
i++;
|
|
557
|
+
}
|
|
558
|
+
return res;
|
|
559
|
+
}
|
|
560
|
+
export function extractShellWriteOps(command) {
|
|
561
|
+
const cmd = stripHeredocBodies(command).trim();
|
|
562
|
+
const ops = [];
|
|
563
|
+
// Output redirects: > path, >> path, &> path, 2> path. Quote-aware so a `>`
|
|
564
|
+
// inside a quoted argument (e.g. a grep pattern `"echo > x"`) isn't mistaken
|
|
565
|
+
// for a redirect. The target token itself may be quoted (`> "my file"`).
|
|
566
|
+
for (const r of findRedirects(cmd)) {
|
|
331
567
|
const content = extractEchoContent(cmd);
|
|
332
|
-
ops.push({ kind: append ? "append" : "create", target, content });
|
|
568
|
+
ops.push({ kind: r.append ? "append" : "create", target: r.target, content });
|
|
333
569
|
}
|
|
334
570
|
// tee [-a] path
|
|
335
571
|
const teeM = cmd.match(/\btee\b\s+(-[aA]\s+)?([^\s|;&]+)/);
|
|
@@ -355,8 +591,8 @@ export function extractShellWriteOps(command) {
|
|
|
355
591
|
ops.push({ kind: "move", target: unquote(args[args.length - 1]), source: unquote(args[0]) });
|
|
356
592
|
}
|
|
357
593
|
}
|
|
358
|
-
// sed -i
|
|
359
|
-
if (/\bsed\b/.test(cmd) && /-i(?:\.\w+)?\b/.test(cmd)) {
|
|
594
|
+
// sed -i / --in-place
|
|
595
|
+
if (/\bsed\b/.test(cmd) && (/-i(?:\.\w+)?\b/.test(cmd) || /--in-place\b/.test(cmd))) {
|
|
360
596
|
const sedM = cmd.match(/^\s*sed\b\s+(.+)$/);
|
|
361
597
|
if (sedM) {
|
|
362
598
|
const args = splitArgs(sedM[1]);
|
package/dist/describe.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* from the `fields` payload. `describe()` returns just the title for
|
|
8
8
|
* backwards-compatible single-line consumers (audit logs, SMS).
|
|
9
9
|
*/
|
|
10
|
-
import { extractShellWriteOps } from "./classify.js";
|
|
10
|
+
import { extractShellWriteOps, stripHeredocBodies } from "./classify.js";
|
|
11
11
|
const BODY_PREVIEW_MAX = 200;
|
|
12
12
|
const COMMAND_INLINE_MAX = 50;
|
|
13
13
|
const DIFF_HUNK_MAX_LINES = 10;
|
|
@@ -84,7 +84,9 @@ function summarize(toolName, toolInput) {
|
|
|
84
84
|
// Bash / shell — semantic rerendering
|
|
85
85
|
// ───────────────────────────────────────────────────────────────────────
|
|
86
86
|
function summarizeBash(command, sizeBytes) {
|
|
87
|
-
|
|
87
|
+
// Strip heredoc bodies so a file's literal contents aren't parsed as shell
|
|
88
|
+
// (and don't bloat the card) — the redirect/target on the opener line stays.
|
|
89
|
+
const cmd = stripHeredocBodies(command || "").trim();
|
|
88
90
|
if (!cmd)
|
|
89
91
|
return { title: "Run empty command", kind: "unknown_bash" };
|
|
90
92
|
// SQL detection runs before shell-write detection so that inline-interpreter
|
|
@@ -138,8 +140,11 @@ function summarizeBash(command, sizeBytes) {
|
|
|
138
140
|
if (/\bgit\s+restore\s+--staged\s+\./.test(cmd))
|
|
139
141
|
return { title: "Unstage all staged changes", kind: "git_restore" };
|
|
140
142
|
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
// Pull a simple quoted -m message. Bail (show plain "Git commit") when the
|
|
144
|
+
// message is a command substitution / heredoc — `-m "$(cat <<'EOF' … )"` —
|
|
145
|
+
// since that has no clean inline title to extract.
|
|
146
|
+
const m = cmd.match(/-m\s+["']([^"'$]+)["']/);
|
|
147
|
+
return m ? { title: `Git commit "${truncate(m[1], 60)}"`, kind: "git_commit" } : { title: "Git commit", kind: "git_commit" };
|
|
143
148
|
}
|
|
144
149
|
// gh pr create — reversible (PRs can be closed). Extract --title when present.
|
|
145
150
|
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
|