@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.
@@ -8,14 +8,14 @@ export interface ShellWriteOp {
8
8
  content?: string;
9
9
  }
10
10
  /**
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.
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 list)\b/,
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
- // Bash commands classified as high stakes (destructive, irreversible, external)
105
- const HIGH_STAKES_COMMANDS = [
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
- // 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) {
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 false;
229
+ return true; // unknown target → err toward review
230
+ let resolved;
206
231
  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
- });
232
+ resolved = path.resolve(filePath);
214
233
  }
215
234
  catch {
216
- return false;
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
- catch {
229
- return false;
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: warning if inside project or an ephemeral temp dir,
243
- // review otherwise. Temp-dir writes are "warning" because the file itself
244
- // can't do harm only what consumes it can.
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
- if (isEphemeralPath(filePath) || isInsideProject(filePath) || isAgentScratchPath(filePath))
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
- if (isEphemeralPath(writePath) || isInsideProject(writePath) || isAgentScratchPath(writePath))
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: 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.
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
- const allLocal = creates.every((o) => isEphemeralPath(o.target) || isInsideProject(o.target) || isAgentScratchPath(o.target));
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
- // 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)) {
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* 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.
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 && openerWritesToFile(line)) {
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, stripHeredocBodies } from "./classify.js";
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
- // 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();
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
- const m = cmd.match(/git\s+push\s+(\S+)\s+(\S+)/);
132
- return { title: "Push", target: m ? `${m[2]} → ${m[1]}` : "current branch", kind: "git_push" };
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. Checked
296
- // before the SQL-CLI prefix matchers below because those prefixes (e.g.
297
- // `sqlite3`) can appear as substrings inside the interpreter body (e.g.
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
- const dq = cmd.match(/(?:psql|mysql|sqlite3?|mariadb)\b[^"]*"([\s\S]+?)"\s*$/i);
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(/(?:psql|mysql|sqlite3?|mariadb)\b[^']*'([\s\S]+?)'\s*$/i);
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
- // Single capture for the delimiter (quote-stripped) lets the closing
315
- // anchor reference it without going through alternation.
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
- if (body && SQL_KEYWORDS_RE.test(body)) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oked/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "OKed SDK - human approval layer for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",