@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.
- package/dist/classify.d.ts +9 -5
- package/dist/classify.js +317 -73
- package/dist/describe.js +34 -19
- package/package.json +1 -1
package/dist/classify.d.ts
CHANGED
|
@@ -8,10 +8,14 @@ export interface ShellWriteOp {
|
|
|
8
8
|
content?: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
211
|
+
return true; // unknown target → err toward review
|
|
212
|
+
let resolved;
|
|
145
213
|
try {
|
|
146
|
-
|
|
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
|
|
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:
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
171
|
-
return "warning";
|
|
172
|
-
return "review";
|
|
266
|
+
return isSensitiveWritePath(filePath) ? "review" : "warning";
|
|
173
267
|
}
|
|
174
|
-
// Agent tool -
|
|
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 "
|
|
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
|
-
|
|
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
|
|
209
|
-
*
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
cur
|
|
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
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
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:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
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.
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
for (const
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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.
|
|
291
|
-
//
|
|
292
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
310
|
-
//
|
|
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
|
-
|
|
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
|
}
|