@theokit/sdk-tools 0.2.0 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 986f340: V3-1 — harden `catastrophicShellReason` to theocode's security-reviewed shell-guard. The `shell_exec` guardrail previously missed 18 of 42 catastrophic commands (empirical probe): `git reset --hard`, `git clean -fd`, secret-file exfiltration (`cat .env | curl`, `tar ~/.aws | nc`), command-substitution / eval RCE (`eval "$(curl)"`, `. <(curl)`, `bash -c "$(curl)"`), `find / -delete` / `-exec rm`, `truncate /dev/sda`, and a range of `rm -rf` targets (`~/sub`, `/usr/local`, `../..`, `$HOME/x`, flags after the operand, any absolute non-scratch path). The rules are ported from theocode's hardened guard (proven by a 42-blocked + 24-allowed corpus at 0 misses / 0 false-positives) as a SUPERSET — the SDK's extra screens (recursive `chmod`/`chown` on a root path, extra block-device families, `//` collapse) are kept, and the segment splitter now also covers `&` and newlines. Reason strings widened to describe each category; the public API (`catastrophicShellReason(cmd): string | null`, `CatastrophicCommandError`) is unchanged. No new dependency.
8
+
3
9
  ## 0.2.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -4,6 +4,8 @@ Built-in tools for `@theokit/sdk` agents. File system, git, subprocess, search-t
4
4
 
5
5
  Extracted from `@theokit/sdk@1.7.0` as part of the SDK 2.0 package split.
6
6
 
7
+ See the [**Theo Harness Capability Map**](../../docs/harness-capability-map.md) for every tool + guard primitive (`buildRepoMap`, `isBlockedIp`, `screenedFetch`, `catastrophicShellReason`, ...) with import paths and examples.
8
+
7
9
  ## Install
8
10
 
9
11
  ```bash
package/dist/index.cjs CHANGED
@@ -96,8 +96,8 @@ function isForbiddenPath(input) {
96
96
  if (first === ".git") return true;
97
97
  if (first === "node_modules") return true;
98
98
  if (first === ".theo") return true;
99
- const basename2 = segments[segments.length - 1];
100
- if (LOCK_FILES.has(basename2)) return true;
99
+ const basename = segments[segments.length - 1];
100
+ if (LOCK_FILES.has(basename)) return true;
101
101
  return false;
102
102
  }
103
103
 
@@ -601,128 +601,96 @@ var CatastrophicCommandError = class extends sdk.ConfigurationError {
601
601
  });
602
602
  }
603
603
  };
604
- var SHELL_NAMES = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
605
- var PREFIX_TOKENS = /* @__PURE__ */ new Set([
606
- "sudo",
607
- "doas",
608
- "env",
609
- "command",
610
- "time",
611
- "nice",
612
- "nohup",
613
- "exec",
614
- "builtin"
615
- ]);
616
- var FORK_BOMB = /:\s*\(\s*\)\s*\{[^}]*\|[^}]*&[^}]*\}/;
617
- var DEVICE_REDIRECT = /[>]\s*\/dev\/(?:sd|nvme|hd|vd|mmcblk|disk|loop|dm-)\w*/;
618
- var SYSTEM_DIR = /^\/(?:etc|usr|bin|sbin|lib|lib64|var|boot|home|root|opt|sys|proc|dev)(?:\/\*?)?$/;
619
- function basename(p) {
620
- const i = p.lastIndexOf("/");
621
- return i >= 0 ? p.slice(i + 1) : p;
622
- }
623
- function unquote(t) {
624
- if (t.length >= 2) {
625
- const a = t[0];
626
- const b = t[t.length - 1];
627
- if (a === '"' && b === '"' || a === "'" && b === "'") return t.slice(1, -1);
628
- }
629
- return t;
630
- }
631
- function tokenize(s) {
632
- return s.trim().split(/\s+/).filter(Boolean);
633
- }
634
- function splitSegments(cmd) {
635
- return cmd.split(/&&|\|\||;|\|/);
636
- }
637
- function stripPrefixTokens(tokens) {
638
- let t = tokens;
639
- let head = t[0];
640
- while (head !== void 0 && PREFIX_TOKENS.has(basename(unquote(head)))) {
641
- t = t.slice(1);
642
- head = t[0];
643
- }
644
- return t;
645
- }
646
- function operandsOf(tokens) {
647
- return tokens.slice(1).filter((t) => !t.startsWith("-")).map(unquote);
648
- }
649
- var HOME_VAR = /^\$\{?HOME\}?$/;
650
- function isRootishPath(op) {
651
- if (op === "~" || op === "*" || op === "." || HOME_VAR.test(op)) return true;
652
- let collapsed = op.replace(/\/+/g, "/");
653
- if (collapsed.length > 1 && collapsed.endsWith("/")) collapsed = collapsed.slice(0, -1);
654
- if (collapsed === "/" || collapsed === "/*" || collapsed === "/.") return true;
655
- return SYSTEM_DIR.test(collapsed);
656
- }
657
- function hasRecursiveForce(tokens) {
658
- const flags = tokens.slice(1).filter((t) => t.startsWith("-"));
659
- const recursive = flags.some(
660
- (f) => f === "--recursive" || !f.startsWith("--") && /[rR]/.test(f)
661
- );
662
- const force = flags.some((f) => f === "--force" || !f.startsWith("--") && f.includes("f"));
604
+ function unquote(token) {
605
+ return token.replace(/^(['"])(.*)\1$/, "$2").replace(/^['"]|['"]$/g, "");
606
+ }
607
+ function commandSegments(command) {
608
+ return command.split(/&&|\|\||[;|&\n]/).map((s) => s.trim()).filter((s) => s.length > 0);
609
+ }
610
+ function commandArgs(segment, name) {
611
+ const tokens = segment.split(/\s+/);
612
+ let i = 0;
613
+ if (tokens[i] === "sudo") i += 1;
614
+ if (tokens[i] !== name) return null;
615
+ return tokens.slice(i + 1);
616
+ }
617
+ function commandSegmentsNamed(command, name) {
618
+ return commandSegments(command).map((s) => commandArgs(s, name)).filter((args) => args !== null);
619
+ }
620
+ function isRecursiveForce(args) {
621
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
622
+ if (flags.length === 0) return false;
623
+ const recursive = /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
624
+ const force = /-[a-z]*f/i.test(flags) || /--force/.test(flags);
663
625
  return recursive && force;
664
626
  }
665
- function hasRecursiveFlag(tokens) {
666
- return tokens.slice(1).some(
667
- (t) => t === "--recursive" || t.startsWith("-") && !t.startsWith("--") && /[rR]/.test(t)
668
- );
627
+ function isRecursive(args) {
628
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
629
+ return /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
630
+ }
631
+ var SAFE_ABSOLUTE_TARGET = /^\/(tmp|var\/tmp)(\/|$)/;
632
+ function targetsDangerousPath(args) {
633
+ const targets = args.filter((token) => token.length > 0 && !token.startsWith("-")).map(unquote);
634
+ return targets.some((raw) => {
635
+ const t = raw.replace(/\/+/g, "/");
636
+ if (t === "/dev/null" || SAFE_ABSOLUTE_TARGET.test(t)) return false;
637
+ return /^\/($|\*)/.test(t) || // "/" or "/*"
638
+ /^\/[^/]/.test(t) || // an absolute path like /etc, /usr/local, /home/user/x
639
+ t === "~" || t.startsWith("~/") || /\$\{?HOME\b\}?/.test(t) || // $HOME or ${HOME}
640
+ t === ".." || t.startsWith("../") || t.includes("/..") || t === "*";
641
+ });
669
642
  }
670
- function isCurlPipedToShell(cmd) {
671
- const segs = cmd.replace(/\|\|/g, ";").split(/[;|]/).map((s) => s.trim()).filter(Boolean);
672
- let fetcher = false;
673
- let shell = false;
674
- for (const s of segs) {
675
- const tk = stripPrefixTokens(tokenize(s));
676
- const head = tk[0];
677
- if (head === void 0) continue;
678
- const c = basename(unquote(head));
679
- if (c === "curl" || c === "wget") fetcher = true;
680
- if (SHELL_NAMES.has(c)) shell = true;
681
- }
682
- return fetcher && shell;
683
- }
684
- var rmCheck = (cmd0, tokens) => {
685
- if (cmd0 !== "rm" || !hasRecursiveForce(tokens)) return null;
686
- const ops = operandsOf(tokens);
687
- return ops.length === 0 || ops.some(isRootishPath) ? "rm -rf of a root/home/glob path" : null;
688
- };
689
- var mkfsCheck = (cmd0) => cmd0.startsWith("mkfs") ? "mkfs on a device" : null;
690
- var ddCheck = (cmd0, tokens) => {
691
- if (cmd0 !== "dd") return null;
692
- return tokens.some((t) => unquote(t).startsWith("of=/dev/")) ? "dd writing to a device" : null;
693
- };
694
- var gitForceCheck = (cmd0, tokens) => {
695
- if (cmd0 !== "git" || !tokens.includes("push") || tokens.includes("--force-with-lease")) {
696
- return null;
643
+ var DEVICE_WIPE = [
644
+ /\bmkfs(\.\w+)?\b/,
645
+ /\bdd\b[^\n]*\bof=\/dev\//,
646
+ /\btruncate\b[^\n]*\s\/dev\//,
647
+ />\s*\/dev\/(sd|nvme|hd|vd|mmcblk|disk|loop|dm-)/
648
+ ];
649
+ var checkRemoteExec = (cmd) => /\b(curl|wget|fetch)\b[^\n]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|python[0-9.]*|node|ruby|perl)\b/i.test(
650
+ cmd
651
+ ) || /(\$\(|<\()\s*(sudo\s+)?(curl|wget|fetch)\b/i.test(cmd) || /\b(eval|source)\b[^\n]*\b(curl|wget|fetch)\b/i.test(cmd) ? "executes a remote download (pipe / command-substitution / eval) \u2014 remote code execution" : null;
652
+ var checkDeviceWipe = (cmd) => DEVICE_WIPE.some((re) => re.test(cmd)) ? "writes to a raw block device / formats a disk" : null;
653
+ var checkForkBomb = (cmd) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(cmd) ? "fork bomb" : null;
654
+ var checkDestructiveGit = (cmd) => {
655
+ if (/\bgit\b[^\n]*\bpush\b[^\n]*(--force(?!-with-lease)\b|\s-f\b|\s\+\S)/.test(cmd)) {
656
+ return "git force-push (overwrites remote history)";
657
+ }
658
+ if (/\bgit\b[^\n]*\breset\b[^\n]*--hard\b/.test(cmd)) {
659
+ return "git reset --hard (discards committed and working changes)";
660
+ }
661
+ if (/\bgit\b[^\n]*\bclean\b[^\n]*(-[a-z]*f[a-z]*d|-[a-z]*d[a-z]*f)/.test(cmd)) {
662
+ return "git clean -fd (permanently deletes untracked files)";
697
663
  }
698
- const force = tokens.includes("--force") || tokens.some((t) => /^-[a-z]*f[a-z]*$/.test(t)) || operandsOf(tokens).some((op) => /^\+[^+]/.test(op));
699
- return force ? "git push --force" : null;
664
+ return null;
700
665
  };
701
- var permCheck = (cmd0, tokens) => {
702
- if (cmd0 !== "chmod" && cmd0 !== "chown" || !hasRecursiveFlag(tokens)) return null;
703
- return operandsOf(tokens).some(isRootishPath) ? `${cmd0} -R on a root path` : null;
666
+ var checkRm = (cmd) => commandSegmentsNamed(cmd, "rm").some((a) => isRecursiveForce(a) && targetsDangerousPath(a)) ? "recursive force-delete of an absolute, home, or parent path" : null;
667
+ var checkPerm = (cmd) => ["chmod", "chown"].some(
668
+ (name) => commandSegmentsNamed(cmd, name).some((a) => isRecursive(a) && targetsDangerousPath(a))
669
+ ) ? "recursive permission change on an absolute, home, or parent path" : null;
670
+ var checkFind = (cmd) => /\bfind\s+(\/\S*|~\S*|\$\{?HOME\}?\S*)\s[^\n]*(-delete\b|-exec\s+rm\b)/.test(cmd) ? "find -delete / -exec rm on an absolute or home path" : null;
671
+ var checkExfiltration = (cmd) => {
672
+ const touchesSecret = /(^|[\s/'"])(\.env(\.\w+)?|id_rsa|id_ed25519|\.ssh(\/|\b)|credentials|\.aws(\/|\b)|\.npmrc)\b/.test(
673
+ cmd
674
+ );
675
+ const sendsNetwork = /\b(curl|wget|nc|netcat|scp|ftp|telnet)\b/.test(cmd) || /\bpython[0-9.]*\s+-m\s+http/.test(cmd);
676
+ return touchesSecret && sendsNetwork ? "sends a secret/credential file over the network (exfiltration)" : null;
704
677
  };
705
- var redirectCheck = (_cmd0, _tokens, seg) => DEVICE_REDIRECT.test(seg) ? "redirect to a device" : null;
706
- var SEGMENT_CHECKS = [
707
- rmCheck,
708
- mkfsCheck,
709
- ddCheck,
710
- gitForceCheck,
711
- permCheck,
712
- redirectCheck
678
+ var CATEGORY_CHECKS = [
679
+ checkRemoteExec,
680
+ checkDeviceWipe,
681
+ checkForkBomb,
682
+ checkDestructiveGit,
683
+ checkRm,
684
+ checkPerm,
685
+ checkFind,
686
+ checkExfiltration
713
687
  ];
714
- function catastrophicShellReason(cmd) {
715
- if (FORK_BOMB.test(cmd)) return "fork bomb";
716
- if (isCurlPipedToShell(cmd)) return "curl/wget piped into a shell";
717
- for (const seg of splitSegments(cmd)) {
718
- const tokens = stripPrefixTokens(tokenize(seg));
719
- const head = tokens[0];
720
- if (head === void 0) continue;
721
- const cmd0 = basename(unquote(head));
722
- for (const check of SEGMENT_CHECKS) {
723
- const reason = check(cmd0, tokens, seg);
724
- if (reason) return reason;
725
- }
688
+ function catastrophicShellReason(command) {
689
+ const cmd = command.trim();
690
+ if (cmd.length === 0) return null;
691
+ for (const check of CATEGORY_CHECKS) {
692
+ const reason = check(cmd);
693
+ if (reason) return reason;
726
694
  }
727
695
  return null;
728
696
  }