@oked/sdk 0.1.6 → 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,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/,
@@ -178,56 +179,73 @@ const WARNING_COMMANDS = [
178
179
  /^npm\s+run\b/,
179
180
  /^bun\b/,
180
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/,
181
187
  ];
182
188
  // Ephemeral filesystem locations. Writes here have no lasting effect on
183
189
  // their own — what matters is whatever subsequent command CONSUMES the file
184
190
  // (e.g. `himalaya message send < /tmp/draft.eml`). Without this carve-out,
185
191
  // every multi-step skill that drafts a temp file generates two approval
186
192
  // 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;
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)\}?(?:\/|$)/;
188
198
  function isEphemeralPath(filePath) {
189
199
  if (!filePath)
190
200
  return false;
191
- return EPHEMERAL_PATH_RE.test(filePath);
201
+ return TEMP_VAR_RE.test(filePath) || EPHEMERAL_PATH_RE.test(filePath);
192
202
  }
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) {
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) {
204
210
  if (!filePath)
205
- return false;
211
+ return true; // unknown target → err toward review
212
+ let resolved;
206
213
  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
+ resolved = path.resolve(filePath);
214
215
  }
215
216
  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));
217
+ return true;
227
218
  }
228
- catch {
229
- return false;
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;
230
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;
231
249
  }
232
250
  export function classify(toolName, toolInput) {
233
251
  // Check tool-level classification first
@@ -239,14 +257,13 @@ export function classify(toolName, toolInput) {
239
257
  return "high_stakes";
240
258
  if (REVIEW_TOOLS.has(toolName))
241
259
  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.
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`.
245
264
  if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
246
265
  const filePath = toolInput.file_path;
247
- if (isEphemeralPath(filePath) || isInsideProject(filePath) || isAgentScratchPath(filePath))
248
- return "warning";
249
- return "review";
266
+ return isSensitiveWritePath(filePath) ? "review" : "warning";
250
267
  }
251
268
  // Agent tool - safe. Launching a sub-agent is not itself a side effect, and
252
269
  // the sub-agent's own tool calls (Bash/Write/Edit/MCP) each fire their own
@@ -276,9 +293,7 @@ export function classify(toolName, toolInput) {
276
293
  const writePath = (toolInput.file_path ?? toolInput.path);
277
294
  const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
278
295
  if (typeof writePath === "string" && typeof writeContent === "string") {
279
- if (isEphemeralPath(writePath) || isInsideProject(writePath) || isAgentScratchPath(writePath))
280
- return "warning";
281
- return "review";
296
+ return isSensitiveWritePath(writePath) ? "review" : "warning";
282
297
  }
283
298
  // Unknown tool - default to review (require approval)
284
299
  return "review";
@@ -406,16 +421,15 @@ function classifyBashCommand(command) {
406
421
  return classifySqlSeverity(sql);
407
422
  // File-mutating shell patterns. Content-creation idioms (echo > X, tee,
408
423
  // 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.
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.
413
428
  const ops = extractShellWriteOps(trimmed);
414
429
  if (ops.length > 0) {
415
430
  const creates = ops.filter((o) => o.kind !== "copy" && o.kind !== "move");
416
431
  if (creates.length > 0) {
417
- const allLocal = creates.every((o) => isEphemeralPath(o.target) || isInsideProject(o.target) || isAgentScratchPath(o.target));
418
- return allLocal ? "warning" : "review";
432
+ return creates.some((o) => isSensitiveWritePath(o.target)) ? "review" : "warning";
419
433
  }
420
434
  return "review";
421
435
  }
@@ -461,29 +475,23 @@ function classifySqlSeverity(sql) {
461
475
  *
462
476
  * Skips /dev/null and bare-digit FD duplicates (2>&1).
463
477
  */
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;
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);
477
485
  }
478
486
  /**
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
+ * 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.
487
495
  */
488
496
  export function stripHeredocBodies(command) {
489
497
  const lines = command.split("\n");
@@ -496,7 +504,7 @@ export function stripHeredocBodies(command) {
496
504
  // space). Require a word-char delimiter so numeric left-shifts ($((1<<2)))
497
505
  // don't match. Use the last opener on the line as the active delimiter.
498
506
  const openers = [...line.matchAll(/<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/g)];
499
- if (openers.length > 0 && openerWritesToFile(line)) {
507
+ if (openers.length > 0 && !openerFeedsInterpreter(line)) {
500
508
  const delim = openers[openers.length - 1][2];
501
509
  i++;
502
510
  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.7",
4
4
  "description": "OKed SDK - human approval layer for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",