@oked/sdk 0.1.5 → 0.1.7

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* 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.
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.)
@@ -62,7 +63,8 @@ const SAFE_COMMANDS = [
62
63
  /^node\s+(-v|--version)/,
63
64
  /^npm\s+(list|ls|--version|-v|view|info|outdated|audit)\b/,
64
65
  /^npx\s+-v/,
65
- /^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/,
66
68
  /^docker\s+(ps|images|inspect|logs)\b/,
67
69
  /^docker\s+compose\s+(ps|logs)\b/,
68
70
  /^tree\b/,
@@ -78,6 +80,27 @@ const SAFE_COMMANDS = [
78
80
  // `message delete` / `folder delete|expunge|purge` matter; those land in
79
81
  // the review/high-stakes paths.
80
82
  /^himalaya\s+(account|folder|envelope|message\s+(?:read|export|search|copy|move)|attachment\s+(?:download|list)|template|search)\b/,
83
+ // cd just changes directory — no side effect of its own. Any dangerous
84
+ // command chained after it (`cd x && rm -rf`) is caught per-stage.
85
+ /^cd\b/,
86
+ // sed without -i / --in-place only prints to stdout (read-only). In-place
87
+ // edits are detected as a write op (below) before this is reached.
88
+ /^sed\b/,
89
+ // gh read-only subcommands — listing/viewing PRs, issues, runs, etc.
90
+ /^gh\s+(pr|issue|repo|run|workflow|release|api)\s+(list|view|status|diff|checks)\b/,
91
+ // Test runners — running the project's own tests is part of the dev loop.
92
+ // (Arbitrary node/npx/python execution is `warning`, see WARNING_COMMANDS.)
93
+ /^npm\s+(test|t)\b/,
94
+ /^npx\s+(tsx|ts-node|jest|vitest|mocha|ava|cypress|playwright|tsc)\b/,
95
+ /^(jest|vitest|mocha|pytest|ava)\b/,
96
+ /^python3?\s+-m\s+pytest\b/,
97
+ // Shell control-flow keywords. A compound like `for f in *; do cmd; done`
98
+ // is split on `;` into stages; these keyword stages carry no risk of their
99
+ // own, and any real command in the body is classified per-stage. Dangerous
100
+ // commands hidden in a $(...) on a keyword line are still caught by the
101
+ // high-stakes scan, which runs on the full command first.
102
+ /^(for|while|until|do|done|then|else|elif|fi|case|esac|if|select)\b/,
103
+ /^(done|fi|esac|\}|\{|:|true|false)\s*$/,
81
104
  ];
82
105
  // Bash commands classified as high stakes (destructive, irreversible, external)
83
106
  const HIGH_STAKES_COMMANDS = [
@@ -93,9 +116,11 @@ const HIGH_STAKES_COMMANDS = [
93
116
  /\bgit\s+clean\s+-f/,
94
117
  /\bgit\s+checkout\s+--\s+\./,
95
118
  /\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,
119
+ // NOTE: SQL severity (DROP/DELETE FROM/TRUNCATE/…) is intentionally NOT matched
120
+ // here. Raw word patterns fire on ordinary text — `grep truncate`, `echo "drop
121
+ // table"` — producing false high_stakes. SQL is handled by findSqlInCommand,
122
+ // which only extracts statements from real SQL contexts (psql/mysql/sqlite3
123
+ // -c/-e, interpreter -e/-c bodies, heredocs), then classifySqlSeverity.
99
124
  /\bdocker\s+(rm|rmi|system\s+prune)\b/,
100
125
  /\bdocker\s+compose\s+down\b/,
101
126
  /\bkill\b/,
@@ -128,29 +153,99 @@ const HIGH_STAKES_COMMANDS = [
128
153
  /\bhimalaya\s+folder\s+(delete|expunge|purge)\b/,
129
154
  /\bhimalaya\s+account\s+delete\b/,
130
155
  ];
156
+ // Commands that auto-allow without a phone prompt but are logged (warning):
157
+ // reversible/local actions where an audit line is enough.
158
+ const WARNING_COMMANDS = [
159
+ // Local, reversible git ops — branch/stage/commit/stash/switch. They touch
160
+ // only the local repo and can be undone (amend, reset, checkout).
161
+ // Destructive/remote git (push, reset --hard, clean, checkout -- .) is matched
162
+ // by HIGH_STAKES_COMMANDS above and wins first. `git stash drop|clear` is
163
+ // excluded — those discard stashed work — so it stays `review`.
164
+ /^git\s+add\b/,
165
+ /^git\s+commit\b/,
166
+ /^git\s+checkout\s+-b\b/,
167
+ /^git\s+switch\b/,
168
+ /^git\s+stash\b(?!\s+(?:drop|clear))/,
169
+ // PR creation is reversible (a PR can be closed); the underlying branch push
170
+ // is separately high_stakes.
171
+ /^gh\s+pr\s+create\b/,
172
+ // Arbitrary code execution (node/npx/python/npm run/bun/deno). The spawned
173
+ // process can do anything and its syscalls don't pass back through OKed, so
174
+ // we don't prompt but keep a local trail. Known test runners and read-only
175
+ // version flags are handled as `safe` (SAFE_COMMANDS) before reaching here.
176
+ /^node\b/,
177
+ /^npx\b/,
178
+ /^python3?\b/,
179
+ /^npm\s+run\b/,
180
+ /^bun\b/,
181
+ /^deno\b/,
182
+ // Package installs run dependency postinstall scripts (code execution), so
183
+ // they're not "safe" — but they're a constant part of the dev loop, so log
184
+ // (warning) rather than prompt.
185
+ /^npm\s+(install|ci|i|update|upgrade|rebuild|prune|dedupe)\b/,
186
+ /^(pnpm|yarn)\s+(install|add|ci|up|upgrade)\b/,
187
+ ];
131
188
  // Ephemeral filesystem locations. Writes here have no lasting effect on
132
189
  // their own — what matters is whatever subsequent command CONSUMES the file
133
190
  // (e.g. `himalaya message send < /tmp/draft.eml`). Without this carve-out,
134
191
  // every multi-step skill that drafts a temp file generates two approval
135
192
  // prompts (the temp write + the real send) instead of one.
136
- const EPHEMERAL_PATH_RE = /^(?:\/tmp\/|\/var\/tmp\/|\/private\/tmp\/|[A-Za-z]:[\\/](?:Windows[\\/]Temp|Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp)[\\/])/i;
193
+ const EPHEMERAL_PATH_RE = /^(?:\/tmp\/|\/var\/tmp\/|\/var\/folders\/|\/private\/tmp\/|\/private\/var\/folders\/|[A-Za-z]:[\\/](?:Windows[\\/]Temp|Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp)[\\/])/i;
194
+ // A temp-dir env var (the conventional output of `mktemp -d` etc.): $TMPDIR,
195
+ // $TMP, $TEMP, ${TMPDIR}, and paths beneath them. Treated as ephemeral since
196
+ // we can't resolve the value but the intent is unambiguous.
197
+ const TEMP_VAR_RE = /^\$\{?(?:TMPDIR|TMP|TEMP)\}?(?:\/|$)/;
137
198
  function isEphemeralPath(filePath) {
138
199
  if (!filePath)
139
200
  return false;
140
- return EPHEMERAL_PATH_RE.test(filePath);
201
+ return TEMP_VAR_RE.test(filePath) || EPHEMERAL_PATH_RE.test(filePath);
141
202
  }
142
- function isInsideProject(filePath) {
203
+ // Paths where a write/edit is genuinely dangerous and must stay `review`:
204
+ // system directories, credential/secret stores, shell startup files (a
205
+ // persistence vector), and OKed's own config (so an agent can't disable its
206
+ // guardrails). Everything else — project files, sibling repos, scratch — is
207
+ // treated as `warning` (a file write can't act on its own; whatever later
208
+ // executes it is classified separately).
209
+ function isSensitiveWritePath(filePath) {
143
210
  if (!filePath)
144
- return false;
211
+ return true; // unknown target → err toward review
212
+ let resolved;
145
213
  try {
146
- const projectRoot = path.resolve(process.cwd());
147
- const resolved = path.resolve(filePath);
148
- const relative = path.relative(projectRoot, resolved);
149
- return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
214
+ resolved = path.resolve(filePath);
150
215
  }
151
216
  catch {
152
- return false;
217
+ return true;
153
218
  }
219
+ const home = os.homedir();
220
+ const underHome = (rel) => {
221
+ const base = path.join(home, rel);
222
+ return resolved === base || resolved.startsWith(base + path.sep);
223
+ };
224
+ // OKed self-config — never let an agent edit its own hook config silently.
225
+ if (resolved === path.join(home, ".claude", "settings.json") ||
226
+ resolved === path.join(home, ".claude", "settings.local.json"))
227
+ return true;
228
+ // Credential / secret stores.
229
+ for (const d of [".ssh", ".aws", ".gnupg", ".kube", ".docker", path.join(".config", "gcloud")]) {
230
+ if (underHome(d))
231
+ return true;
232
+ }
233
+ // Sensitive dotfiles directly in $HOME (creds + shell startup persistence).
234
+ const sensitiveHomeFiles = new Set([
235
+ ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".bash_history", ".zsh_history",
236
+ ".bashrc", ".zshrc", ".bash_profile", ".zprofile", ".profile", ".zshenv", ".zlogin",
237
+ ]);
238
+ if (path.dirname(resolved) === home && sensitiveHomeFiles.has(path.basename(resolved)))
239
+ return true;
240
+ // System directories.
241
+ if (/^\/(etc|usr|bin|sbin|boot|sys|proc|opt|Library|System)(\/|$)/.test(resolved))
242
+ return true;
243
+ if (/^\/private\/etc(\/|$)/.test(resolved))
244
+ return true;
245
+ // /var, except the ephemeral temp subtrees.
246
+ if (/^\/var(\/|$)/.test(resolved) && !/^\/var\/(tmp|folders)(\/|$)/.test(resolved))
247
+ return true;
248
+ return false;
154
249
  }
155
250
  export function classify(toolName, toolInput) {
156
251
  // Check tool-level classification first
@@ -162,18 +257,21 @@ export function classify(toolName, toolInput) {
162
257
  return "high_stakes";
163
258
  if (REVIEW_TOOLS.has(toolName))
164
259
  return "review";
165
- // File-editing tools: warning if inside project or an ephemeral temp dir,
166
- // review otherwise. Temp-dir writes are "warning" because the file itself
167
- // can't do harm only what consumes it can.
260
+ // File-editing tools: a write/edit can't act on its own whatever later
261
+ // executes it is classified separately so it's `warning` (logged, no
262
+ // prompt) everywhere EXCEPT sensitive targets (system dirs, secret stores,
263
+ // shell startup files, OKed's own config), which stay `review`.
168
264
  if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
169
265
  const filePath = toolInput.file_path;
170
- if (isEphemeralPath(filePath) || isInsideProject(filePath))
171
- return "warning";
172
- return "review";
266
+ return isSensitiveWritePath(filePath) ? "review" : "warning";
173
267
  }
174
- // Agent tool - review (spawns subagent, not directly destructive)
268
+ // Agent tool - safe. Launching a sub-agent is not itself a side effect, and
269
+ // the sub-agent's own tool calls (Bash/Write/Edit/MCP) each fire their own
270
+ // PreToolUse hook and get classified independently. Gating the launch on top
271
+ // of that just double-prompts — once for the spawn, again for every real
272
+ // action the sub-agent takes — so the launch auto-allows.
175
273
  if (toolName === "Agent")
176
- return "review";
274
+ return "safe";
177
275
  // Bash commands need deeper analysis
178
276
  if (toolName === "Bash") {
179
277
  return classifyBashCommand(toolInput.command);
@@ -195,9 +293,7 @@ export function classify(toolName, toolInput) {
195
293
  const writePath = (toolInput.file_path ?? toolInput.path);
196
294
  const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
197
295
  if (typeof writePath === "string" && typeof writeContent === "string") {
198
- if (isEphemeralPath(writePath) || isInsideProject(writePath))
199
- return "warning";
200
- return "review";
296
+ return isSensitiveWritePath(writePath) ? "review" : "warning";
201
297
  }
202
298
  // Unknown tool - default to review (require approval)
203
299
  return "review";
@@ -205,79 +301,135 @@ export function classify(toolName, toolInput) {
205
301
  function maxTier(a, b) {
206
302
  return TIER_ORDER[a] >= TIER_ORDER[b] ? a : b;
207
303
  }
208
- /** Split a shell command on top-level pipe characters, ignoring `||` and
209
- * pipes inside quoted strings. Returns trimmed segments. */
210
- function splitOnPipe(cmd) {
304
+ /** Split a shell command into top-level segments on the operators that
305
+ * sequence separate commands: `|`, `||`, `&&`, `;`. Operators inside quoted
306
+ * strings — including the `"$(cat <<'EOF' … )"` heredoc form used for commit
307
+ * messages — are kept intact so message text isn't split. Returns trimmed,
308
+ * non-empty segments. */
309
+ function splitTopLevel(cmd) {
211
310
  const out = [];
212
311
  let cur = "";
213
312
  let quote = null;
214
- for (let i = 0; i < cmd.length; i++) {
313
+ let i = 0;
314
+ while (i < cmd.length) {
215
315
  const ch = cmd[i];
216
316
  if (quote) {
217
317
  cur += ch;
218
318
  if (ch === quote)
219
319
  quote = null;
320
+ i++;
321
+ continue;
220
322
  }
221
- else if (ch === '"' || ch === "'") {
323
+ if (ch === '"' || ch === "'") {
222
324
  cur += ch;
223
325
  quote = ch;
326
+ i++;
327
+ continue;
328
+ }
329
+ // Heredoc: consume the opener and the entire body (up to the closing
330
+ // delimiter line) as part of the current segment, so operators inside a
331
+ // heredoc fed to an interpreter (psql/node/…) aren't treated as separators.
332
+ const hd = cmd.slice(i).match(/^<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/);
333
+ if (hd) {
334
+ cur += hd[0];
335
+ i += hd[0].length;
336
+ const close = cmd.slice(i).match(new RegExp(`\\n[ \\t]*${hd[2]}\\b`));
337
+ if (close) {
338
+ const end = i + (close.index ?? 0) + close[0].length;
339
+ cur += cmd.slice(i, end);
340
+ i = end;
341
+ }
342
+ else {
343
+ cur += cmd.slice(i);
344
+ i = cmd.length;
345
+ }
346
+ continue;
224
347
  }
225
- else if (ch === "|" && cmd[i + 1] !== "|" && cmd[i - 1] !== "|") {
348
+ const next = cmd[i + 1];
349
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
226
350
  out.push(cur.trim());
227
351
  cur = "";
352
+ i += 2; // consume both operator chars
353
+ continue;
228
354
  }
229
- else {
230
- cur += ch;
355
+ if (ch === "|" || ch === ";") {
356
+ out.push(cur.trim());
357
+ cur = "";
358
+ i++;
359
+ continue;
231
360
  }
361
+ cur += ch;
362
+ i++;
232
363
  }
233
364
  if (cur.trim())
234
365
  out.push(cur.trim());
235
- return out;
366
+ return out.filter(Boolean);
367
+ }
368
+ // rm/rmdir/trash whose every target is an ephemeral temp path (/tmp, %TEMP%,
369
+ // …). Deleting throwaway temp files is low-risk, so it downgrades to warning.
370
+ // Any non-temp target (or deleting a temp ROOT like `/tmp` itself, which isn't
371
+ // an ephemeral *path*) means this returns false and the deletion stays
372
+ // high_stakes.
373
+ function isEphemeralOnlyDeletion(command) {
374
+ const m = command.match(/^(?:sudo\s+)?(?:rm|rmdir|trash|trash-put)\b\s+(.+)$/s);
375
+ if (!m)
376
+ return false;
377
+ const targets = splitArgs(m[1]).filter((a) => !a.startsWith("-"));
378
+ if (targets.length === 0)
379
+ return false;
380
+ return targets.every((a) => isEphemeralPath(unquote(a)));
236
381
  }
237
382
  function classifyBashCommand(command) {
238
383
  if (!command)
239
384
  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);
385
+ // Strip heredoc bodies up front: their contents are literal data, not shell,
386
+ // and must not be scanned for high-stakes tokens, operators, or redirects.
387
+ const trimmed = stripHeredocBodies(command).trim();
388
+ // rm/trash of only ephemeral temp files warning (before the high-stakes
389
+ // scan, which would otherwise match the bare `rm`).
390
+ if (isEphemeralOnlyDeletion(trimmed))
391
+ return "warning";
392
+ // High-stakes scan on the FULL command, before any splitting. These patterns
393
+ // use \b and several intentionally span an operator (e.g. `curl … | bash`,
394
+ // `wget … | sh` — download-and-execute), so they have to be matched against
395
+ // the whole string. Most-restrictive-wins: a high-stakes match anywhere in a
396
+ // compound command takes the whole command to high_stakes.
397
+ for (const pattern of HIGH_STAKES_COMMANDS) {
398
+ if (pattern.test(trimmed))
399
+ return "high_stakes";
400
+ }
401
+ // Compound commands: split on top-level `|`, `||`, `&&`, `;` and take the
402
+ // highest tier. Without this, `cat /tmp/draft.eml | himalaya message send`
403
+ // would match `^cat\b` and silently allow the send, and `git add … && git
404
+ // commit …` couldn't be recognized as the local git ops they are. Only
405
+ // recurse when there are 2+ stages so single commands don't loop.
406
+ const stages = splitTopLevel(trimmed);
246
407
  if (stages.length > 1) {
247
408
  return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
248
409
  }
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
- }
410
+ // sudo: privilege escalation. A high-stakes inner command was already caught
411
+ // by the full-string scan above (\b patterns match through the `sudo` prefix);
412
+ // anything else still warrants review.
413
+ if (/^sudo\s/.test(trimmed))
256
414
  return "review";
257
- }
258
415
  // SQL hidden inside an interpreter wrapper (python -c, node -e, heredoc),
259
416
  // 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.
417
+ // command. Severity comes from the statement, not the wrapper. (High-stakes
418
+ // SQL — DROP/TRUNCATE/DELETE FROM — is already covered by the scan above.)
261
419
  const sql = findSqlInCommand(trimmed);
262
420
  if (sql)
263
421
  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
422
  // 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.
423
+ // dd of=, touch, sed -i) are the bypass route from a denied Write, so they're
424
+ // classified exactly like the Write/Edit tool: `warning` unless a target is a
425
+ // sensitive path (system dir, secret store, shell rc, OKed config), which
426
+ // stays `review`. cp/mv (which can clobber/relocate existing files) stay
427
+ // review.
274
428
  const ops = extractShellWriteOps(trimmed);
275
429
  if (ops.length > 0) {
276
430
  const creates = ops.filter((o) => o.kind !== "copy" && o.kind !== "move");
277
431
  if (creates.length > 0) {
278
- if (creates.every((o) => isEphemeralPath(o.target)))
279
- return "warning";
280
- return "review";
432
+ return creates.some((o) => isSensitiveWritePath(o.target)) ? "review" : "warning";
281
433
  }
282
434
  return "review";
283
435
  }
@@ -286,6 +438,13 @@ function classifyBashCommand(command) {
286
438
  if (pattern.test(trimmed))
287
439
  return "safe";
288
440
  }
441
+ // Reversible/local commands (local git, gh pr create, code execution) →
442
+ // warning: logged, no phone approval. Checked after SAFE so read-only git
443
+ // (status/log/`stash list`) and known test runners stay fully silent.
444
+ for (const pattern of WARNING_COMMANDS) {
445
+ if (pattern.test(trimmed))
446
+ return "warning";
447
+ }
289
448
  // Default: review (require approval for unknown commands)
290
449
  return "review";
291
450
  }
@@ -316,20 +475,105 @@ function classifySqlSeverity(sql) {
316
475
  *
317
476
  * Skips /dev/null and bare-digit FD duplicates (2>&1).
318
477
  */
478
+ // Commands that EXECUTE a heredoc body fed to their stdin — a SQL CLI, a code
479
+ // interpreter, or a shell. Their heredoc bodies are code/SQL and must stay
480
+ // scannable. Detected as a command word at the start of the opener line or
481
+ // after a pipe / `&&` / `;` / `$(` / backtick.
482
+ 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/;
483
+ function openerFeedsInterpreter(line) {
484
+ return HEREDOC_INTERPRETER_RE.test(line);
485
+ }
486
+ /**
487
+ * Removes heredoc *bodies* unless they're fed to an interpreter/DB/shell that
488
+ * executes them. The default is to strip: `cat >> file <<'EOF'`, `git commit -F
489
+ * - <<'MSG'`, `gh pr create --body "$(cat <<'BODY')"`, `mail <<'EOF'` and the
490
+ * like all treat the body as literal DATA, which must not be parsed as shell
491
+ * (a commit message with `->` or a PR body mentioning "TRUNCATE"/"rm -rf" would
492
+ * otherwise wreck classification). Only heredocs whose opener line invokes an
493
+ * interpreter (`psql <<EOF`, `node - <<EOF`, `cat <<EOF | bash`) keep their
494
+ * body, so SQL/code detection still runs. The opener line is always preserved.
495
+ */
496
+ export function stripHeredocBodies(command) {
497
+ const lines = command.split("\n");
498
+ const out = [];
499
+ let i = 0;
500
+ while (i < lines.length) {
501
+ const line = lines[i];
502
+ out.push(line);
503
+ // Heredoc openers: <<DELIM, <<'DELIM', <<"DELIM", <<-DELIM (with optional
504
+ // space). Require a word-char delimiter so numeric left-shifts ($((1<<2)))
505
+ // don't match. Use the last opener on the line as the active delimiter.
506
+ const openers = [...line.matchAll(/<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/g)];
507
+ if (openers.length > 0 && !openerFeedsInterpreter(line)) {
508
+ const delim = openers[openers.length - 1][2];
509
+ i++;
510
+ while (i < lines.length && lines[i].trim() !== delim)
511
+ i++; // drop body
512
+ if (i < lines.length)
513
+ i++; // drop the closing delimiter line
514
+ continue;
515
+ }
516
+ i++;
517
+ }
518
+ return out.join("\n");
519
+ }
520
+ /** Find output redirects (`>`, `>>`, `&>`, `2>`, …) outside quoted strings.
521
+ * The target token may itself be quoted. Skips `2>&1`-style FD dups, bare
522
+ * digits, and /dev/null. Heredoc `<<` is ignored (only `>` is a write). */
523
+ function findRedirects(cmd) {
524
+ const res = [];
525
+ let quote = null;
526
+ let i = 0;
527
+ while (i < cmd.length) {
528
+ const ch = cmd[i];
529
+ if (quote) {
530
+ if (ch === quote)
531
+ quote = null;
532
+ i++;
533
+ continue;
534
+ }
535
+ if (ch === '"' || ch === "'") {
536
+ quote = ch;
537
+ i++;
538
+ continue;
539
+ }
540
+ const op = cmd.slice(i).match(/^([12]?>>?|&>>?)/);
541
+ if (op && cmd[i - 1] !== ">") {
542
+ let j = i + op[1].length;
543
+ while (j < cmd.length && /\s/.test(cmd[j]))
544
+ j++;
545
+ let target = "";
546
+ if (cmd[j] === '"' || cmd[j] === "'") {
547
+ const q = cmd[j];
548
+ j++;
549
+ while (j < cmd.length && cmd[j] !== q)
550
+ target += cmd[j++];
551
+ if (j < cmd.length)
552
+ j++; // closing quote
553
+ }
554
+ else {
555
+ while (j < cmd.length && !/[\s>|&;]/.test(cmd[j]))
556
+ target += cmd[j++];
557
+ }
558
+ if (target && !/^\d+$/.test(target) && !isDevNullish(target)) {
559
+ res.push({ append: op[1] === ">>" || op[1] === "&>>", target });
560
+ }
561
+ i = j;
562
+ continue;
563
+ }
564
+ i++;
565
+ }
566
+ return res;
567
+ }
319
568
  export function extractShellWriteOps(command) {
320
- const cmd = command.trim();
569
+ const cmd = stripHeredocBodies(command).trim();
321
570
  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];
327
- const target = unquote(m[2]);
328
- if (!target || /^\d+$/.test(target) || isDevNullish(target))
329
- continue;
330
- const append = op === ">>" || op === "&>>";
571
+ // Output redirects: > path, >> path, &> path, 2> path. Quote-aware so a `>`
572
+ // inside a quoted argument (e.g. a grep pattern `"echo > x"`) isn't mistaken
573
+ // for a redirect. The target token itself may be quoted (`> "my file"`).
574
+ for (const r of findRedirects(cmd)) {
331
575
  const content = extractEchoContent(cmd);
332
- ops.push({ kind: append ? "append" : "create", target, content });
576
+ ops.push({ kind: r.append ? "append" : "create", target: r.target, content });
333
577
  }
334
578
  // tee [-a] path
335
579
  const teeM = cmd.match(/\btee\b\s+(-[aA]\s+)?([^\s|;&]+)/);
@@ -355,8 +599,8 @@ export function extractShellWriteOps(command) {
355
599
  ops.push({ kind: "move", target: unquote(args[args.length - 1]), source: unquote(args[0]) });
356
600
  }
357
601
  }
358
- // sed -i
359
- if (/\bsed\b/.test(cmd) && /-i(?:\.\w+)?\b/.test(cmd)) {
602
+ // sed -i / --in-place
603
+ if (/\bsed\b/.test(cmd) && (/-i(?:\.\w+)?\b/.test(cmd) || /--in-place\b/.test(cmd))) {
360
604
  const sedM = cmd.match(/^\s*sed\b\s+(.+)$/);
361
605
  if (sedM) {
362
606
  const args = splitArgs(sedM[1]);
package/dist/describe.js CHANGED
@@ -121,13 +121,17 @@ function summarizeBash(command, sizeBytes) {
121
121
  };
122
122
  }
123
123
  // git
124
- if (/\bgit\s+push\s+(?:--force|-f)\b/.test(cmd)) {
125
- const m = cmd.match(/git\s+push\s+(?:--force|-f)\s+(\S+)\s+(\S+)/);
126
- return { title: "Force push", target: m ? `${m[2]} → ${m[1]}` : "current branch", kind: "git_force_push" };
127
- }
128
124
  if (/\bgit\s+push\b/.test(cmd)) {
129
- const m = cmd.match(/git\s+push\s+(\S+)\s+(\S+)/);
130
- 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" };
131
135
  }
132
136
  if (/\bgit\s+reset\s+--hard\b/.test(cmd))
133
137
  return { title: "Hard reset — discard all local changes", kind: "git_reset_hard" };
@@ -138,8 +142,11 @@ function summarizeBash(command, sizeBytes) {
138
142
  if (/\bgit\s+restore\s+--staged\s+\./.test(cmd))
139
143
  return { title: "Unstage all staged changes", kind: "git_restore" };
140
144
  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" };
145
+ // Pull a simple quoted -m message. Bail (show plain "Git commit") when the
146
+ // message is a command substitution / heredoc `-m "$(cat <<'EOF' )"`
147
+ // since that has no clean inline title to extract.
148
+ const m = cmd.match(/-m\s+["']([^"'$]+)["']/);
149
+ return m ? { title: `Git commit "${truncate(m[1], 60)}"`, kind: "git_commit" } : { title: "Git commit", kind: "git_commit" };
143
150
  }
144
151
  // gh pr create — reversible (PRs can be closed). Extract --title when present.
145
152
  if (/\bgh\s+pr\s+create\b/.test(cmd)) {
@@ -286,12 +293,16 @@ function extractSqlFromScriptBody(body) {
286
293
  }
287
294
  return sqls.length > 0 ? sqls.join("\n") : null;
288
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/;
289
302
  export function findSqlInCommand(cmd) {
290
- // Inline interpreter flags: node -e, python -c, ruby -e, perl -e. Checked
291
- // before the SQL-CLI prefix matchers below because those prefixes (e.g.
292
- // `sqlite3`) can appear as substrings inside the interpreter body (e.g.
293
- // `require('better-sqlite3')`) and would extract the wrong fragment.
294
- 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*$/);
295
306
  if (inline) {
296
307
  const body = inline[1] ?? inline[2];
297
308
  if (body && SQL_KEYWORDS_RE.test(body)) {
@@ -299,19 +310,23 @@ export function findSqlInCommand(cmd) {
299
310
  }
300
311
  }
301
312
  // psql -c "..." / mysql -e "..." / sqlite3 db "..." — outer-quoted statement.
302
- 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);
303
316
  if (dq)
304
317
  return dq[1];
305
- 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);
306
319
  if (sq)
307
320
  return sq[1];
308
- // Heredoc-piped script: <<EOF / <<'EOF' / <<"EOF" / <<-EOF.
309
- // Single capture for the delimiter (quote-stripped) lets the closing
310
- // 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.
311
325
  const hd = cmd.match(/<<-?\s*['"]?(\w+)['"]?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1\b/);
312
326
  if (hd) {
313
327
  const body = hd[2];
314
- 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)) {
315
330
  return extractSqlFromScriptBody(body) ?? body;
316
331
  }
317
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oked/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "OKed SDK - human approval layer for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",