@oked/sdk 0.1.6 → 0.1.8
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 +8 -8
- package/dist/classify.js +216 -82
- package/dist/describe.js +31 -21
- package/package.json +1 -1
package/dist/classify.d.ts
CHANGED
|
@@ -8,14 +8,14 @@ export interface ShellWriteOp {
|
|
|
8
8
|
content?: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Removes heredoc *bodies*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* or
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* Removes heredoc *bodies* unless they're fed to an interpreter/DB/shell that
|
|
12
|
+
* executes them. The default is to strip: `cat >> file <<'EOF'`, `git commit -F
|
|
13
|
+
* - <<'MSG'`, `gh pr create --body "$(cat <<'BODY')"`, `mail <<'EOF'` and the
|
|
14
|
+
* like all treat the body as literal DATA, which must not be parsed as shell
|
|
15
|
+
* (a commit message with `->` or a PR body mentioning "TRUNCATE"/"rm -rf" would
|
|
16
|
+
* otherwise wreck classification). Only heredocs whose opener line invokes an
|
|
17
|
+
* interpreter (`psql <<EOF`, `node - <<EOF`, `cat <<EOF | bash`) keep their
|
|
18
|
+
* body, so SQL/code detection still runs. The opener line is always preserved.
|
|
19
19
|
*/
|
|
20
20
|
export declare function stripHeredocBodies(command: string): string;
|
|
21
21
|
export declare function extractShellWriteOps(command: string): ShellWriteOp[];
|
package/dist/classify.js
CHANGED
|
@@ -63,7 +63,8 @@ const SAFE_COMMANDS = [
|
|
|
63
63
|
/^node\s+(-v|--version)/,
|
|
64
64
|
/^npm\s+(list|ls|--version|-v|view|info|outdated|audit)\b/,
|
|
65
65
|
/^npx\s+-v/,
|
|
66
|
-
/^git\s+(status|log|diff|branch|remote|show|tag|stash
|
|
66
|
+
/^git\s+(status|log|diff|branch|remote|show|tag|stash\s+list|ls-files|ls-tree|ls-remote|check-ignore|check-attr|rev-parse|rev-list|describe|cat-file|blame|shortlog|reflog|name-rev|whatchanged|for-each-ref|symbolic-ref|merge-base|var|grep|count-objects|show-ref|cherry|verify-commit|fsck)\b/,
|
|
67
|
+
/^git\s+config\s+(--get|--get-all|--get-regexp|--list|-l)\b/,
|
|
67
68
|
/^docker\s+(ps|images|inspect|logs)\b/,
|
|
68
69
|
/^docker\s+compose\s+(ps|logs)\b/,
|
|
69
70
|
/^tree\b/,
|
|
@@ -100,16 +101,34 @@ const SAFE_COMMANDS = [
|
|
|
100
101
|
// high-stakes scan, which runs on the full command first.
|
|
101
102
|
/^(for|while|until|do|done|then|else|elif|fi|case|esac|if|select)\b/,
|
|
102
103
|
/^(done|fi|esac|\}|\{|:|true|false)\s*$/,
|
|
104
|
+
// Inert shell builtins / flow helpers with no side effects.
|
|
105
|
+
/^sleep\b/,
|
|
106
|
+
/^exit\b/,
|
|
107
|
+
/^return\b/,
|
|
108
|
+
/^seq\b/,
|
|
109
|
+
/^test\b/,
|
|
110
|
+
/^\[\[?\s/, // [ ... ] and [[ ... ]] conditionals
|
|
111
|
+
// `<cmd> --help` / `--version` just prints usage. (Dangerous verbs like
|
|
112
|
+
// `git push` are matched by HIGH_STAKES on the full command first.)
|
|
113
|
+
/\s--(help|version)\b/,
|
|
114
|
+
// Read-only npm subcommands beyond the install/version ones above.
|
|
115
|
+
/^npm\s+(prefix|root|bin|whoami|ping|docs|repo|why|explain|pkg\s+get|config\s+get)\b/,
|
|
103
116
|
];
|
|
104
|
-
//
|
|
105
|
-
|
|
117
|
+
// File deletion (rm/rmdir/trash). Checked PER-STAGE (not on the full command)
|
|
118
|
+
// so the ephemeral-temp downgrade can run first: `something; rm /tmp/x` must be
|
|
119
|
+
// warning, but `rm /tmp/x` mixed with a non-temp delete in another stage stays
|
|
120
|
+
// high_stakes. Matching the bare word also catches it inside a loop body or a
|
|
121
|
+
// $(...) substitution stage.
|
|
122
|
+
const DELETE_PATTERNS = [
|
|
106
123
|
/\brm\b/,
|
|
107
|
-
/\brm\b\s+(?:-[^\s]*[rf][^\s]*\s+)*-[^\s]*[rf][^\s]*\b/,
|
|
108
|
-
/\brm\s+--recursive\b/,
|
|
109
|
-
/\brm\b.*\s+\//, // rm with absolute path
|
|
110
124
|
/\brmdir\b/,
|
|
111
125
|
/\btrash\b/,
|
|
112
126
|
/\btrash-put\b/,
|
|
127
|
+
];
|
|
128
|
+
// Bash commands classified as high stakes (destructive, irreversible, external).
|
|
129
|
+
// Scanned on the FULL command (some patterns, e.g. download|shell, span a pipe).
|
|
130
|
+
// File deletion lives in DELETE_PATTERNS (per-stage) instead, see above.
|
|
131
|
+
const HIGH_STAKES_COMMANDS = [
|
|
113
132
|
/\bgit\s+push\b/,
|
|
114
133
|
/\bgit\s+reset\s+--hard\b/,
|
|
115
134
|
/\bgit\s+clean\s+-f/,
|
|
@@ -178,56 +197,73 @@ const WARNING_COMMANDS = [
|
|
|
178
197
|
/^npm\s+run\b/,
|
|
179
198
|
/^bun\b/,
|
|
180
199
|
/^deno\b/,
|
|
200
|
+
// Package installs run dependency postinstall scripts (code execution), so
|
|
201
|
+
// they're not "safe" — but they're a constant part of the dev loop, so log
|
|
202
|
+
// (warning) rather than prompt.
|
|
203
|
+
/^npm\s+(install|ci|i|update|upgrade|rebuild|prune|dedupe)\b/,
|
|
204
|
+
/^(pnpm|yarn)\s+(install|add|ci|up|upgrade)\b/,
|
|
181
205
|
];
|
|
182
206
|
// Ephemeral filesystem locations. Writes here have no lasting effect on
|
|
183
207
|
// their own — what matters is whatever subsequent command CONSUMES the file
|
|
184
208
|
// (e.g. `himalaya message send < /tmp/draft.eml`). Without this carve-out,
|
|
185
209
|
// every multi-step skill that drafts a temp file generates two approval
|
|
186
210
|
// prompts (the temp write + the real send) instead of one.
|
|
187
|
-
const EPHEMERAL_PATH_RE = /^(?:\/tmp\/|\/var\/tmp\/|\/private\/tmp\/|[A-Za-z]:[\\/](?:Windows[\\/]Temp|Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp)[\\/])/i;
|
|
211
|
+
const EPHEMERAL_PATH_RE = /^(?:\/tmp\/|\/var\/tmp\/|\/var\/folders\/|\/private\/tmp\/|\/private\/var\/folders\/|[A-Za-z]:[\\/](?:Windows[\\/]Temp|Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp)[\\/])/i;
|
|
212
|
+
// A temp-dir env var (the conventional output of `mktemp -d` etc.): $TMPDIR,
|
|
213
|
+
// $TMP, $TEMP, ${TMPDIR}, and paths beneath them. Treated as ephemeral since
|
|
214
|
+
// we can't resolve the value but the intent is unambiguous.
|
|
215
|
+
const TEMP_VAR_RE = /^\$\{?(?:TMPDIR|TMP|TEMP)\}?(?:\/|$)/;
|
|
188
216
|
function isEphemeralPath(filePath) {
|
|
189
217
|
if (!filePath)
|
|
190
218
|
return false;
|
|
191
|
-
return EPHEMERAL_PATH_RE.test(filePath);
|
|
219
|
+
return TEMP_VAR_RE.test(filePath) || EPHEMERAL_PATH_RE.test(filePath);
|
|
192
220
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
path.join(".claude", "plans"),
|
|
201
|
-
path.join(".claude", "todos"),
|
|
202
|
-
];
|
|
203
|
-
function isAgentScratchPath(filePath) {
|
|
221
|
+
// Paths where a write/edit is genuinely dangerous and must stay `review`:
|
|
222
|
+
// system directories, credential/secret stores, shell startup files (a
|
|
223
|
+
// persistence vector), and OKed's own config (so an agent can't disable its
|
|
224
|
+
// guardrails). Everything else — project files, sibling repos, scratch — is
|
|
225
|
+
// treated as `warning` (a file write can't act on its own; whatever later
|
|
226
|
+
// executes it is classified separately).
|
|
227
|
+
function isSensitiveWritePath(filePath) {
|
|
204
228
|
if (!filePath)
|
|
205
|
-
return
|
|
229
|
+
return true; // unknown target → err toward review
|
|
230
|
+
let resolved;
|
|
206
231
|
try {
|
|
207
|
-
|
|
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
|
-
});
|
|
232
|
+
resolved = path.resolve(filePath);
|
|
214
233
|
}
|
|
215
234
|
catch {
|
|
216
|
-
return
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
function isInsideProject(filePath) {
|
|
220
|
-
if (!filePath)
|
|
221
|
-
return false;
|
|
222
|
-
try {
|
|
223
|
-
const projectRoot = path.resolve(process.cwd());
|
|
224
|
-
const resolved = path.resolve(filePath);
|
|
225
|
-
const relative = path.relative(projectRoot, resolved);
|
|
226
|
-
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
235
|
+
return true;
|
|
227
236
|
}
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
const home = os.homedir();
|
|
238
|
+
const underHome = (rel) => {
|
|
239
|
+
const base = path.join(home, rel);
|
|
240
|
+
return resolved === base || resolved.startsWith(base + path.sep);
|
|
241
|
+
};
|
|
242
|
+
// OKed self-config — never let an agent edit its own hook config silently.
|
|
243
|
+
if (resolved === path.join(home, ".claude", "settings.json") ||
|
|
244
|
+
resolved === path.join(home, ".claude", "settings.local.json"))
|
|
245
|
+
return true;
|
|
246
|
+
// Credential / secret stores.
|
|
247
|
+
for (const d of [".ssh", ".aws", ".gnupg", ".kube", ".docker", path.join(".config", "gcloud")]) {
|
|
248
|
+
if (underHome(d))
|
|
249
|
+
return true;
|
|
230
250
|
}
|
|
251
|
+
// Sensitive dotfiles directly in $HOME (creds + shell startup persistence).
|
|
252
|
+
const sensitiveHomeFiles = new Set([
|
|
253
|
+
".netrc", ".npmrc", ".pypirc", ".git-credentials", ".bash_history", ".zsh_history",
|
|
254
|
+
".bashrc", ".zshrc", ".bash_profile", ".zprofile", ".profile", ".zshenv", ".zlogin",
|
|
255
|
+
]);
|
|
256
|
+
if (path.dirname(resolved) === home && sensitiveHomeFiles.has(path.basename(resolved)))
|
|
257
|
+
return true;
|
|
258
|
+
// System directories.
|
|
259
|
+
if (/^\/(etc|usr|bin|sbin|boot|sys|proc|opt|Library|System)(\/|$)/.test(resolved))
|
|
260
|
+
return true;
|
|
261
|
+
if (/^\/private\/etc(\/|$)/.test(resolved))
|
|
262
|
+
return true;
|
|
263
|
+
// /var, except the ephemeral temp subtrees.
|
|
264
|
+
if (/^\/var(\/|$)/.test(resolved) && !/^\/var\/(tmp|folders)(\/|$)/.test(resolved))
|
|
265
|
+
return true;
|
|
266
|
+
return false;
|
|
231
267
|
}
|
|
232
268
|
export function classify(toolName, toolInput) {
|
|
233
269
|
// Check tool-level classification first
|
|
@@ -239,14 +275,13 @@ export function classify(toolName, toolInput) {
|
|
|
239
275
|
return "high_stakes";
|
|
240
276
|
if (REVIEW_TOOLS.has(toolName))
|
|
241
277
|
return "review";
|
|
242
|
-
// File-editing tools:
|
|
243
|
-
//
|
|
244
|
-
//
|
|
278
|
+
// File-editing tools: a write/edit can't act on its own — whatever later
|
|
279
|
+
// executes it is classified separately — so it's `warning` (logged, no
|
|
280
|
+
// prompt) everywhere EXCEPT sensitive targets (system dirs, secret stores,
|
|
281
|
+
// shell startup files, OKed's own config), which stay `review`.
|
|
245
282
|
if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
|
|
246
283
|
const filePath = toolInput.file_path;
|
|
247
|
-
|
|
248
|
-
return "warning";
|
|
249
|
-
return "review";
|
|
284
|
+
return isSensitiveWritePath(filePath) ? "review" : "warning";
|
|
250
285
|
}
|
|
251
286
|
// Agent tool - safe. Launching a sub-agent is not itself a side effect, and
|
|
252
287
|
// the sub-agent's own tool calls (Bash/Write/Edit/MCP) each fire their own
|
|
@@ -276,9 +311,7 @@ export function classify(toolName, toolInput) {
|
|
|
276
311
|
const writePath = (toolInput.file_path ?? toolInput.path);
|
|
277
312
|
const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
|
|
278
313
|
if (typeof writePath === "string" && typeof writeContent === "string") {
|
|
279
|
-
|
|
280
|
-
return "warning";
|
|
281
|
-
return "review";
|
|
314
|
+
return isSensitiveWritePath(writePath) ? "review" : "warning";
|
|
282
315
|
}
|
|
283
316
|
// Unknown tool - default to review (require approval)
|
|
284
317
|
return "review";
|
|
@@ -295,6 +328,7 @@ function splitTopLevel(cmd) {
|
|
|
295
328
|
const out = [];
|
|
296
329
|
let cur = "";
|
|
297
330
|
let quote = null;
|
|
331
|
+
let subDepth = 0; // depth inside $( ... ) / `...` command substitutions
|
|
298
332
|
let i = 0;
|
|
299
333
|
while (i < cmd.length) {
|
|
300
334
|
const ch = cmd[i];
|
|
@@ -311,6 +345,31 @@ function splitTopLevel(cmd) {
|
|
|
311
345
|
i++;
|
|
312
346
|
continue;
|
|
313
347
|
}
|
|
348
|
+
// Command substitution: keep `$( ... )` / backticks intact so a pipe or `;`
|
|
349
|
+
// inside (e.g. `V=$(curl … | sed …)`) isn't treated as a top-level operator.
|
|
350
|
+
if (ch === "$" && cmd[i + 1] === "(") {
|
|
351
|
+
subDepth++;
|
|
352
|
+
cur += "$(";
|
|
353
|
+
i += 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (subDepth > 0 && ch === "(") {
|
|
357
|
+
subDepth++;
|
|
358
|
+
cur += ch;
|
|
359
|
+
i++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (subDepth > 0 && ch === ")") {
|
|
363
|
+
subDepth--;
|
|
364
|
+
cur += ch;
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (subDepth > 0) {
|
|
369
|
+
cur += ch;
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
314
373
|
// Heredoc: consume the opener and the entire body (up to the closing
|
|
315
374
|
// delimiter line) as part of the current segment, so operators inside a
|
|
316
375
|
// heredoc fed to an interpreter (psql/node/…) aren't treated as separators.
|
|
@@ -364,21 +423,86 @@ function isEphemeralOnlyDeletion(command) {
|
|
|
364
423
|
return false;
|
|
365
424
|
return targets.every((a) => isEphemeralPath(unquote(a)));
|
|
366
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Classifies a single stage that begins with one or more `NAME=value`
|
|
428
|
+
* assignments (a pure assignment like `TARGET=abc`, an env prefix like
|
|
429
|
+
* `FOO=bar cmd`, or a capture like `V=$(cmd)`). Strips the leading assignments,
|
|
430
|
+
* then takes the worst tier of: each command-substitution inner found in the
|
|
431
|
+
* values, plus any trailing command. A purely literal assignment is `safe`.
|
|
432
|
+
* Returns null if the stage is not assignment-prefixed.
|
|
433
|
+
*/
|
|
434
|
+
function classifyAssignmentStage(stage) {
|
|
435
|
+
if (!/^\w+=/.test(stage))
|
|
436
|
+
return null;
|
|
437
|
+
const inners = [];
|
|
438
|
+
let i = 0;
|
|
439
|
+
while (i < stage.length) {
|
|
440
|
+
const m = stage.slice(i).match(/^(\w+)=/);
|
|
441
|
+
if (!m)
|
|
442
|
+
break; // next token isn't an assignment → it's the command
|
|
443
|
+
i += m[0].length;
|
|
444
|
+
let quote = null;
|
|
445
|
+
while (i < stage.length) {
|
|
446
|
+
const ch = stage[i];
|
|
447
|
+
if (quote) {
|
|
448
|
+
if (ch === quote)
|
|
449
|
+
quote = null;
|
|
450
|
+
i++;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (ch === '"' || ch === "'") {
|
|
454
|
+
quote = ch;
|
|
455
|
+
i++;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (ch === "$" && stage[i + 1] === "(") {
|
|
459
|
+
let d = 1, j = i + 2;
|
|
460
|
+
while (j < stage.length && d > 0) {
|
|
461
|
+
if (stage[j] === "(")
|
|
462
|
+
d++;
|
|
463
|
+
else if (stage[j] === ")")
|
|
464
|
+
d--;
|
|
465
|
+
j++;
|
|
466
|
+
}
|
|
467
|
+
inners.push(stage.slice(i + 2, j - 1));
|
|
468
|
+
i = j;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (ch === "`") {
|
|
472
|
+
let j = i + 1;
|
|
473
|
+
while (j < stage.length && stage[j] !== "`")
|
|
474
|
+
j++;
|
|
475
|
+
inners.push(stage.slice(i + 1, j));
|
|
476
|
+
i = j + 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (/\s/.test(ch))
|
|
480
|
+
break; // end of this value
|
|
481
|
+
i++;
|
|
482
|
+
}
|
|
483
|
+
while (i < stage.length && /\s/.test(stage[i]))
|
|
484
|
+
i++;
|
|
485
|
+
}
|
|
486
|
+
const rest = stage.slice(i).trim();
|
|
487
|
+
let tier = "safe";
|
|
488
|
+
for (const inner of inners)
|
|
489
|
+
tier = maxTier(tier, classifyBashCommand(inner));
|
|
490
|
+
if (rest)
|
|
491
|
+
tier = maxTier(tier, classifyBashCommand(rest));
|
|
492
|
+
return tier;
|
|
493
|
+
}
|
|
367
494
|
function classifyBashCommand(command) {
|
|
368
495
|
if (!command)
|
|
369
496
|
return "safe";
|
|
370
497
|
// Strip heredoc bodies up front: their contents are literal data, not shell,
|
|
371
498
|
// and must not be scanned for high-stakes tokens, operators, or redirects.
|
|
372
499
|
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
500
|
// High-stakes scan on the FULL command, before any splitting. These patterns
|
|
378
501
|
// use \b and several intentionally span an operator (e.g. `curl … | bash`,
|
|
379
502
|
// `wget … | sh` — download-and-execute), so they have to be matched against
|
|
380
503
|
// the whole string. Most-restrictive-wins: a high-stakes match anywhere in a
|
|
381
|
-
// compound command takes the whole command to high_stakes.
|
|
504
|
+
// compound command takes the whole command to high_stakes. (File deletion is
|
|
505
|
+
// NOT here — it's per-stage below so the ephemeral-temp downgrade can run.)
|
|
382
506
|
for (const pattern of HIGH_STAKES_COMMANDS) {
|
|
383
507
|
if (pattern.test(trimmed))
|
|
384
508
|
return "high_stakes";
|
|
@@ -392,6 +516,23 @@ function classifyBashCommand(command) {
|
|
|
392
516
|
if (stages.length > 1) {
|
|
393
517
|
return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
|
|
394
518
|
}
|
|
519
|
+
// ---- single stage from here ----
|
|
520
|
+
// File deletion, per-stage so the ephemeral-temp carve-out can win: deleting
|
|
521
|
+
// only throwaway temp files (/tmp, $TMPDIR, /var/folders, …) → warning; any
|
|
522
|
+
// non-temp target (or the temp root itself) → high_stakes.
|
|
523
|
+
if (isEphemeralOnlyDeletion(trimmed))
|
|
524
|
+
return "warning";
|
|
525
|
+
for (const pattern of DELETE_PATTERNS) {
|
|
526
|
+
if (pattern.test(trimmed))
|
|
527
|
+
return "high_stakes";
|
|
528
|
+
}
|
|
529
|
+
// Variable assignments: `NAME=value` (pure) or `NAME=$(cmd) rest` (env prefix
|
|
530
|
+
// / capture). Classify the command-substitution inners and any trailing
|
|
531
|
+
// command; a purely literal assignment is safe. Dangerous substitutions were
|
|
532
|
+
// already caught by the high-stakes/delete scans above.
|
|
533
|
+
const assignTier = classifyAssignmentStage(trimmed);
|
|
534
|
+
if (assignTier !== null)
|
|
535
|
+
return assignTier;
|
|
395
536
|
// sudo: privilege escalation. A high-stakes inner command was already caught
|
|
396
537
|
// by the full-string scan above (\b patterns match through the `sudo` prefix);
|
|
397
538
|
// anything else still warrants review.
|
|
@@ -406,16 +547,15 @@ function classifyBashCommand(command) {
|
|
|
406
547
|
return classifySqlSeverity(sql);
|
|
407
548
|
// File-mutating shell patterns. Content-creation idioms (echo > X, tee,
|
|
408
549
|
// dd of=, touch, sed -i) are the bypass route from a denied Write, so they're
|
|
409
|
-
// classified like the Write/Edit tool:
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
550
|
+
// classified exactly like the Write/Edit tool: `warning` unless a target is a
|
|
551
|
+
// sensitive path (system dir, secret store, shell rc, OKed config), which
|
|
552
|
+
// stays `review`. cp/mv (which can clobber/relocate existing files) stay
|
|
553
|
+
// review.
|
|
413
554
|
const ops = extractShellWriteOps(trimmed);
|
|
414
555
|
if (ops.length > 0) {
|
|
415
556
|
const creates = ops.filter((o) => o.kind !== "copy" && o.kind !== "move");
|
|
416
557
|
if (creates.length > 0) {
|
|
417
|
-
|
|
418
|
-
return allLocal ? "warning" : "review";
|
|
558
|
+
return creates.some((o) => isSensitiveWritePath(o.target)) ? "review" : "warning";
|
|
419
559
|
}
|
|
420
560
|
return "review";
|
|
421
561
|
}
|
|
@@ -461,29 +601,23 @@ function classifySqlSeverity(sql) {
|
|
|
461
601
|
*
|
|
462
602
|
* Skips /dev/null and bare-digit FD duplicates (2>&1).
|
|
463
603
|
*/
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
for (const m of line.matchAll(/(?:^|[^>])([12]?>>?|&>>?)\s*([^\s>|&;]+)/g)) {
|
|
472
|
-
const target = unquote(m[2]);
|
|
473
|
-
if (target && !/^\d+$/.test(target) && !isDevNullish(target))
|
|
474
|
-
return true;
|
|
475
|
-
}
|
|
476
|
-
return false;
|
|
604
|
+
// Commands that EXECUTE a heredoc body fed to their stdin — a SQL CLI, a code
|
|
605
|
+
// interpreter, or a shell. Their heredoc bodies are code/SQL and must stay
|
|
606
|
+
// scannable. Detected as a command word at the start of the opener line or
|
|
607
|
+
// after a pipe / `&&` / `;` / `$(` / backtick.
|
|
608
|
+
const HEREDOC_INTERPRETER_RE = /(?:^|\||&&|;|\$\(|`)\s*(?:\w+=\S+\s+)*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlite3?|node|python\d?|ruby|perl|deno|bun|bash|sh|zsh|ksh|fish)\b/;
|
|
609
|
+
function openerFeedsInterpreter(line) {
|
|
610
|
+
return HEREDOC_INTERPRETER_RE.test(line);
|
|
477
611
|
}
|
|
478
612
|
/**
|
|
479
|
-
* Removes heredoc *bodies*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* or
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
613
|
+
* Removes heredoc *bodies* unless they're fed to an interpreter/DB/shell that
|
|
614
|
+
* executes them. The default is to strip: `cat >> file <<'EOF'`, `git commit -F
|
|
615
|
+
* - <<'MSG'`, `gh pr create --body "$(cat <<'BODY')"`, `mail <<'EOF'` and the
|
|
616
|
+
* like all treat the body as literal DATA, which must not be parsed as shell
|
|
617
|
+
* (a commit message with `->` or a PR body mentioning "TRUNCATE"/"rm -rf" would
|
|
618
|
+
* otherwise wreck classification). Only heredocs whose opener line invokes an
|
|
619
|
+
* interpreter (`psql <<EOF`, `node - <<EOF`, `cat <<EOF | bash`) keep their
|
|
620
|
+
* body, so SQL/code detection still runs. The opener line is always preserved.
|
|
487
621
|
*/
|
|
488
622
|
export function stripHeredocBodies(command) {
|
|
489
623
|
const lines = command.split("\n");
|
|
@@ -496,7 +630,7 @@ export function stripHeredocBodies(command) {
|
|
|
496
630
|
// space). Require a word-char delimiter so numeric left-shifts ($((1<<2)))
|
|
497
631
|
// don't match. Use the last opener on the line as the active delimiter.
|
|
498
632
|
const openers = [...line.matchAll(/<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/g)];
|
|
499
|
-
if (openers.length > 0 &&
|
|
633
|
+
if (openers.length > 0 && !openerFeedsInterpreter(line)) {
|
|
500
634
|
const delim = openers[openers.length - 1][2];
|
|
501
635
|
i++;
|
|
502
636
|
while (i < lines.length && lines[i].trim() !== delim)
|
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
|
|
10
|
+
import { extractShellWriteOps } 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,9 +84,7 @@ function summarize(toolName, toolInput) {
|
|
|
84
84
|
// Bash / shell — semantic rerendering
|
|
85
85
|
// ───────────────────────────────────────────────────────────────────────
|
|
86
86
|
function summarizeBash(command, sizeBytes) {
|
|
87
|
-
|
|
88
|
-
// (and don't bloat the card) — the redirect/target on the opener line stays.
|
|
89
|
-
const cmd = stripHeredocBodies(command || "").trim();
|
|
87
|
+
const cmd = (command || "").trim();
|
|
90
88
|
if (!cmd)
|
|
91
89
|
return { title: "Run empty command", kind: "unknown_bash" };
|
|
92
90
|
// SQL detection runs before shell-write detection so that inline-interpreter
|
|
@@ -123,13 +121,17 @@ function summarizeBash(command, sizeBytes) {
|
|
|
123
121
|
};
|
|
124
122
|
}
|
|
125
123
|
// git
|
|
126
|
-
if (/\bgit\s+push\s+(?:--force|-f)\b/.test(cmd)) {
|
|
127
|
-
const m = cmd.match(/git\s+push\s+(?:--force|-f)\s+(\S+)\s+(\S+)/);
|
|
128
|
-
return { title: "Force push", target: m ? `${m[2]} → ${m[1]}` : "current branch", kind: "git_force_push" };
|
|
129
|
-
}
|
|
130
124
|
if (/\bgit\s+push\b/.test(cmd)) {
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
// Parse the remote + branch ignoring flags (-u, --force, --set-upstream, …)
|
|
126
|
+
// so `git push -u origin feat` renders "feat → origin", not "origin → -u".
|
|
127
|
+
const after = cmd.match(/\bgit\s+push\b(.*)$/s)?.[1] ?? "";
|
|
128
|
+
const args = after.split(/\s+/).filter((a) => a && !a.startsWith("-"));
|
|
129
|
+
const [remote, branch] = args;
|
|
130
|
+
const target = remote && branch ? `${branch} → ${remote}` : remote || "current branch";
|
|
131
|
+
const forced = /\bgit\s+push\b[^\n]*\s(?:--force(?:-with-lease)?|-f)\b/.test(cmd);
|
|
132
|
+
return forced
|
|
133
|
+
? { title: "Force push", target, kind: "git_force_push" }
|
|
134
|
+
: { title: "Push", target, kind: "git_push" };
|
|
133
135
|
}
|
|
134
136
|
if (/\bgit\s+reset\s+--hard\b/.test(cmd))
|
|
135
137
|
return { title: "Hard reset — discard all local changes", kind: "git_reset_hard" };
|
|
@@ -291,12 +293,16 @@ function extractSqlFromScriptBody(body) {
|
|
|
291
293
|
}
|
|
292
294
|
return sqls.length > 0 ? sqls.join("\n") : null;
|
|
293
295
|
}
|
|
296
|
+
// A SQL CLI or code interpreter as a command word — at the start of the command
|
|
297
|
+
// (allowing env=val / sudo prefixes) or after a pipe / `&&` / `;` / `$(`. Used
|
|
298
|
+
// to gate SQL extraction so SQL words appearing in *argument data* (a `gh pr
|
|
299
|
+
// create --body` mentioning "TRUNCATE", a path like `better-sqlite3`) don't get
|
|
300
|
+
// misread as a statement to run.
|
|
301
|
+
const SQL_CONSUMER_RE = /(?:^|\||&&|;|\$\(|`)\s*(?:\w+=\S+\s+)*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlite3?|node|python\d?|ruby|perl|deno|bun)\b/;
|
|
294
302
|
export function findSqlInCommand(cmd) {
|
|
295
|
-
// Inline interpreter flags: node -e, python -c, ruby -e, perl -e.
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
// `require('better-sqlite3')`) and would extract the wrong fragment.
|
|
299
|
-
const inline = cmd.match(/\b(?:node|python\d?|ruby|perl|deno|bun)\s+-[ec]\s+(?:"([\s\S]+?)"|'([\s\S]+?)')\s*$/);
|
|
303
|
+
// Inline interpreter flags: node -e, python -c, ruby -e, perl -e. Anchored to
|
|
304
|
+
// a command position so a SQL-looking string elsewhere doesn't match.
|
|
305
|
+
const inline = cmd.match(/(?:^|\||&&|;|\$\()\s*(?:\w+=\S+\s+)*(?:sudo\s+)?(?:node|python\d?|ruby|perl|deno|bun)\s+-[ec]\s+(?:"([\s\S]+?)"|'([\s\S]+?)')\s*$/);
|
|
300
306
|
if (inline) {
|
|
301
307
|
const body = inline[1] ?? inline[2];
|
|
302
308
|
if (body && SQL_KEYWORDS_RE.test(body)) {
|
|
@@ -304,19 +310,23 @@ export function findSqlInCommand(cmd) {
|
|
|
304
310
|
}
|
|
305
311
|
}
|
|
306
312
|
// psql -c "..." / mysql -e "..." / sqlite3 db "..." — outer-quoted statement.
|
|
307
|
-
|
|
313
|
+
// Anchored to the start of the command so "psql"/"mysql" appearing inside an
|
|
314
|
+
// argument (another tool's --body, etc.) can't trigger a false SQL match.
|
|
315
|
+
const dq = cmd.match(/^(?:\s*\w+=\S+\s+)*(?:sudo\s+)?(?:psql|mysql|sqlite3?|mariadb)\b[^"]*"([\s\S]+?)"\s*$/i);
|
|
308
316
|
if (dq)
|
|
309
317
|
return dq[1];
|
|
310
|
-
const sq = cmd.match(
|
|
318
|
+
const sq = cmd.match(/^(?:\s*\w+=\S+\s+)*(?:sudo\s+)?(?:psql|mysql|sqlite3?|mariadb)\b[^']*'([\s\S]+?)'\s*$/i);
|
|
311
319
|
if (sq)
|
|
312
320
|
return sq[1];
|
|
313
|
-
// Heredoc-piped script: <<EOF / <<'EOF' / <<"EOF" / <<-EOF.
|
|
314
|
-
//
|
|
315
|
-
//
|
|
321
|
+
// Heredoc-piped script: <<EOF / <<'EOF' / <<"EOF" / <<-EOF. Only when the
|
|
322
|
+
// command (outside the body) actually feeds the heredoc to a SQL CLI or
|
|
323
|
+
// interpreter — otherwise a `gh`/`cat`/`mail` heredoc whose body merely
|
|
324
|
+
// mentions SQL words would be misclassified as a statement to run.
|
|
316
325
|
const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1\b/);
|
|
317
326
|
if (hd) {
|
|
318
327
|
const body = hd[2];
|
|
319
|
-
|
|
328
|
+
const context = cmd.slice(0, hd.index) + cmd.slice((hd.index ?? 0) + hd[0].length);
|
|
329
|
+
if (body && SQL_CONSUMER_RE.test(context) && SQL_KEYWORDS_RE.test(body)) {
|
|
320
330
|
return extractSqlFromScriptBody(body) ?? body;
|
|
321
331
|
}
|
|
322
332
|
}
|