@oked/sdk 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/classify.js +136 -10
  2. package/package.json +1 -1
package/dist/classify.js CHANGED
@@ -101,16 +101,34 @@ const SAFE_COMMANDS = [
101
101
  // high-stakes scan, which runs on the full command first.
102
102
  /^(for|while|until|do|done|then|else|elif|fi|case|esac|if|select)\b/,
103
103
  /^(done|fi|esac|\}|\{|:|true|false)\s*$/,
104
+ // Inert shell builtins / flow helpers with no side effects.
105
+ /^sleep\b/,
106
+ /^exit\b/,
107
+ /^return\b/,
108
+ /^seq\b/,
109
+ /^test\b/,
110
+ /^\[\[?\s/, // [ ... ] and [[ ... ]] conditionals
111
+ // `<cmd> --help` / `--version` just prints usage. (Dangerous verbs like
112
+ // `git push` are matched by HIGH_STAKES on the full command first.)
113
+ /\s--(help|version)\b/,
114
+ // Read-only npm subcommands beyond the install/version ones above.
115
+ /^npm\s+(prefix|root|bin|whoami|ping|docs|repo|why|explain|pkg\s+get|config\s+get)\b/,
104
116
  ];
105
- // Bash commands classified as high stakes (destructive, irreversible, external)
106
- const HIGH_STAKES_COMMANDS = [
117
+ // File deletion (rm/rmdir/trash). Checked PER-STAGE (not on the full command)
118
+ // so the ephemeral-temp downgrade can run first: `something; rm /tmp/x` must be
119
+ // warning, but `rm /tmp/x` mixed with a non-temp delete in another stage stays
120
+ // high_stakes. Matching the bare word also catches it inside a loop body or a
121
+ // $(...) substitution stage.
122
+ const DELETE_PATTERNS = [
107
123
  /\brm\b/,
108
- /\brm\b\s+(?:-[^\s]*[rf][^\s]*\s+)*-[^\s]*[rf][^\s]*\b/,
109
- /\brm\s+--recursive\b/,
110
- /\brm\b.*\s+\//, // rm with absolute path
111
124
  /\brmdir\b/,
112
125
  /\btrash\b/,
113
126
  /\btrash-put\b/,
127
+ ];
128
+ // Bash commands classified as high stakes (destructive, irreversible, external).
129
+ // Scanned on the FULL command (some patterns, e.g. download|shell, span a pipe).
130
+ // File deletion lives in DELETE_PATTERNS (per-stage) instead, see above.
131
+ const HIGH_STAKES_COMMANDS = [
114
132
  /\bgit\s+push\b/,
115
133
  /\bgit\s+reset\s+--hard\b/,
116
134
  /\bgit\s+clean\s+-f/,
@@ -310,6 +328,7 @@ function splitTopLevel(cmd) {
310
328
  const out = [];
311
329
  let cur = "";
312
330
  let quote = null;
331
+ let subDepth = 0; // depth inside $( ... ) / `...` command substitutions
313
332
  let i = 0;
314
333
  while (i < cmd.length) {
315
334
  const ch = cmd[i];
@@ -326,6 +345,31 @@ function splitTopLevel(cmd) {
326
345
  i++;
327
346
  continue;
328
347
  }
348
+ // Command substitution: keep `$( ... )` / backticks intact so a pipe or `;`
349
+ // inside (e.g. `V=$(curl … | sed …)`) isn't treated as a top-level operator.
350
+ if (ch === "$" && cmd[i + 1] === "(") {
351
+ subDepth++;
352
+ cur += "$(";
353
+ i += 2;
354
+ continue;
355
+ }
356
+ if (subDepth > 0 && ch === "(") {
357
+ subDepth++;
358
+ cur += ch;
359
+ i++;
360
+ continue;
361
+ }
362
+ if (subDepth > 0 && ch === ")") {
363
+ subDepth--;
364
+ cur += ch;
365
+ i++;
366
+ continue;
367
+ }
368
+ if (subDepth > 0) {
369
+ cur += ch;
370
+ i++;
371
+ continue;
372
+ }
329
373
  // Heredoc: consume the opener and the entire body (up to the closing
330
374
  // delimiter line) as part of the current segment, so operators inside a
331
375
  // heredoc fed to an interpreter (psql/node/…) aren't treated as separators.
@@ -379,21 +423,86 @@ function isEphemeralOnlyDeletion(command) {
379
423
  return false;
380
424
  return targets.every((a) => isEphemeralPath(unquote(a)));
381
425
  }
426
+ /**
427
+ * Classifies a single stage that begins with one or more `NAME=value`
428
+ * assignments (a pure assignment like `TARGET=abc`, an env prefix like
429
+ * `FOO=bar cmd`, or a capture like `V=$(cmd)`). Strips the leading assignments,
430
+ * then takes the worst tier of: each command-substitution inner found in the
431
+ * values, plus any trailing command. A purely literal assignment is `safe`.
432
+ * Returns null if the stage is not assignment-prefixed.
433
+ */
434
+ function classifyAssignmentStage(stage) {
435
+ if (!/^\w+=/.test(stage))
436
+ return null;
437
+ const inners = [];
438
+ let i = 0;
439
+ while (i < stage.length) {
440
+ const m = stage.slice(i).match(/^(\w+)=/);
441
+ if (!m)
442
+ break; // next token isn't an assignment → it's the command
443
+ i += m[0].length;
444
+ let quote = null;
445
+ while (i < stage.length) {
446
+ const ch = stage[i];
447
+ if (quote) {
448
+ if (ch === quote)
449
+ quote = null;
450
+ i++;
451
+ continue;
452
+ }
453
+ if (ch === '"' || ch === "'") {
454
+ quote = ch;
455
+ i++;
456
+ continue;
457
+ }
458
+ if (ch === "$" && stage[i + 1] === "(") {
459
+ let d = 1, j = i + 2;
460
+ while (j < stage.length && d > 0) {
461
+ if (stage[j] === "(")
462
+ d++;
463
+ else if (stage[j] === ")")
464
+ d--;
465
+ j++;
466
+ }
467
+ inners.push(stage.slice(i + 2, j - 1));
468
+ i = j;
469
+ continue;
470
+ }
471
+ if (ch === "`") {
472
+ let j = i + 1;
473
+ while (j < stage.length && stage[j] !== "`")
474
+ j++;
475
+ inners.push(stage.slice(i + 1, j));
476
+ i = j + 1;
477
+ continue;
478
+ }
479
+ if (/\s/.test(ch))
480
+ break; // end of this value
481
+ i++;
482
+ }
483
+ while (i < stage.length && /\s/.test(stage[i]))
484
+ i++;
485
+ }
486
+ const rest = stage.slice(i).trim();
487
+ let tier = "safe";
488
+ for (const inner of inners)
489
+ tier = maxTier(tier, classifyBashCommand(inner));
490
+ if (rest)
491
+ tier = maxTier(tier, classifyBashCommand(rest));
492
+ return tier;
493
+ }
382
494
  function classifyBashCommand(command) {
383
495
  if (!command)
384
496
  return "safe";
385
497
  // Strip heredoc bodies up front: their contents are literal data, not shell,
386
498
  // and must not be scanned for high-stakes tokens, operators, or redirects.
387
499
  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
500
  // High-stakes scan on the FULL command, before any splitting. These patterns
393
501
  // use \b and several intentionally span an operator (e.g. `curl … | bash`,
394
502
  // `wget … | sh` — download-and-execute), so they have to be matched against
395
503
  // the whole string. Most-restrictive-wins: a high-stakes match anywhere in a
396
- // compound command takes the whole command to high_stakes.
504
+ // compound command takes the whole command to high_stakes. (File deletion is
505
+ // NOT here — it's per-stage below so the ephemeral-temp downgrade can run.)
397
506
  for (const pattern of HIGH_STAKES_COMMANDS) {
398
507
  if (pattern.test(trimmed))
399
508
  return "high_stakes";
@@ -407,6 +516,23 @@ function classifyBashCommand(command) {
407
516
  if (stages.length > 1) {
408
517
  return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
409
518
  }
519
+ // ---- single stage from here ----
520
+ // File deletion, per-stage so the ephemeral-temp carve-out can win: deleting
521
+ // only throwaway temp files (/tmp, $TMPDIR, /var/folders, …) → warning; any
522
+ // non-temp target (or the temp root itself) → high_stakes.
523
+ if (isEphemeralOnlyDeletion(trimmed))
524
+ return "warning";
525
+ for (const pattern of DELETE_PATTERNS) {
526
+ if (pattern.test(trimmed))
527
+ return "high_stakes";
528
+ }
529
+ // Variable assignments: `NAME=value` (pure) or `NAME=$(cmd) rest` (env prefix
530
+ // / capture). Classify the command-substitution inners and any trailing
531
+ // command; a purely literal assignment is safe. Dangerous substitutions were
532
+ // already caught by the high-stakes/delete scans above.
533
+ const assignTier = classifyAssignmentStage(trimmed);
534
+ if (assignTier !== null)
535
+ return assignTier;
410
536
  // sudo: privilege escalation. A high-stakes inner command was already caught
411
537
  // by the full-string scan above (\b patterns match through the `sudo` prefix);
412
538
  // anything else still warrants review.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oked/sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "OKed SDK - human approval layer for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",