@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.
@@ -8,10 +8,14 @@ export interface ShellWriteOp {
8
8
  content?: string;
9
9
  }
10
10
  /**
11
- * Detects shell idioms that mutate the filesystem. Used by classify (to
12
- * choose tier) and describe (to render the operation as a Create/Append/
13
- * Copy/etc. sentence rather than as a shell command).
14
- *
15
- * Skips /dev/null and bare-digit FD duplicates (2>&1).
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
- /\bDROP\s+(TABLE|DATABASE|INDEX|VIEW)\b/i,
97
- /\bDELETE\s+FROM\b/i,
98
- /\bTRUNCATE\b/i,
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 - review (spawns subagent, not directly destructive)
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 "review";
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 on top-level pipe characters, ignoring `||` and
209
- * pipes inside quoted strings. Returns trimmed segments. */
210
- function splitOnPipe(cmd) {
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
- for (let i = 0; i < cmd.length; i++) {
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
- else if (ch === '"' || ch === "'") {
308
+ if (ch === '"' || ch === "'") {
222
309
  cur += ch;
223
310
  quote = ch;
311
+ i++;
312
+ continue;
224
313
  }
225
- else if (ch === "|" && cmd[i + 1] !== "|" && cmd[i - 1] !== "|") {
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
- else {
230
- cur += ch;
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
- const trimmed = command.trim();
241
- // Pipelines: classify each stage and take the highest tier. Without this,
242
- // `cat /tmp/draft.eml | himalaya message send` would match `^cat\b` first
243
- // and silently allow the email send. The right-hand stage is what matters.
244
- // Only split when there are 2+ stages so single commands don't recurse.
245
- const stages = splitOnPipe(trimmed);
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: classify based on the inner command, not sudo itself
250
- if (/^sudo\s/.test(trimmed)) {
251
- const inner = trimmed.replace(/^sudo\s+/, "");
252
- for (const pattern of HIGH_STAKES_COMMANDS) {
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, heredoc) require approval they're exactly the
271
- // bypass route from a denied Write. cp/mv just rearrange existing bytes
272
- // and stay safe. Writes to ephemeral temp dirs (/tmp, %TEMP%) downgrade
273
- // to warning: the temp file alone can't do harm, only what consumes it.
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
- if (creates.every((o) => isEphemeralPath(o.target)))
279
- return "warning";
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
- export function extractShellWriteOps(command) {
320
- const cmd = command.trim();
321
- const ops = [];
322
- // Output redirects: > path, >> path, &> path, 2> path. Try to pull the
323
- // literal content when the LHS is echo/printf.
324
- const redirRe = /(?:^|[^>])([12]?>>?|&>>?)\s*([^\s>|&;]+)/g;
325
- for (const m of cmd.matchAll(redirRe)) {
326
- const op = m[1];
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 (!target || /^\d+$/.test(target) || isDevNullish(target))
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
- const append = op === ">>" || op === "&>>";
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
- const cmd = (command || "").trim();
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
- const m = cmd.match(/-m\s+["']([^"']+)["']/);
142
- return m ? { title: `Git commit "${m[1]}"`, kind: "git_commit" } : { title: "Git commit", kind: "git_commit" };
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oked/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "OKed SDK - human approval layer for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",