@theokit/sdk-tools 0.1.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/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
- import { readFile, copyFile, mkdir, writeFile, readdir, open } from 'fs/promises';
1
+ import { readFile, copyFile, mkdir, writeFile, readdir, open, stat } from 'fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'path';
3
3
  import { defineTool, ConfigurationError } from '@theokit/sdk';
4
4
  import { z } from 'zod';
5
- import { existsSync, mkdirSync, writeFileSync, realpathSync, lstatSync, readlinkSync } from 'fs';
5
+ import { existsSync, statSync, mkdirSync, writeFileSync, realpathSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'fs';
6
+ import { replaceFileAtomic } from '@theokit/sdk/internal/persistence';
7
+ import { safeFilenameForId, safePathJoin as safePathJoin$1 } from '@theokit/sdk/path-safety';
6
8
  import { spawn } from 'child_process';
9
+ import { lookup } from 'dns/promises';
10
+ import { isIP } from 'net';
7
11
 
8
12
  // src/apply-patch.ts
9
13
  var PathTraversalError = class extends ConfigurationError {
@@ -55,8 +59,8 @@ function realpathOfDeepestExisting(path) {
55
59
  } catch {
56
60
  }
57
61
  try {
58
- const stat = lstatSync(path);
59
- if (stat.isSymbolicLink()) {
62
+ const stat2 = lstatSync(path);
63
+ if (stat2.isSymbolicLink()) {
60
64
  const target = readlinkSync(path);
61
65
  const parentReal = realpathOfDeepestExisting(dirname(path));
62
66
  const parentBase = parentReal ?? dirname(path);
@@ -219,6 +223,45 @@ function applyHunks(content, changes) {
219
223
  }
220
224
  return result.join("\n");
221
225
  }
226
+ function createSessionArtifactStore(options) {
227
+ const { dir } = options;
228
+ const idStrategy = options.idStrategy ?? ((id) => safeFilenameForId(id));
229
+ const extension = options.extension ?? ".md";
230
+ function path(id) {
231
+ return safePathJoin$1(dir, `${idStrategy(id)}${extension}`);
232
+ }
233
+ async function write(id, content) {
234
+ const target = path(id);
235
+ await mkdir(dir, { recursive: true });
236
+ await replaceFileAtomic(target, content);
237
+ return target;
238
+ }
239
+ async function read(id) {
240
+ try {
241
+ return await readFile(path(id), "utf8");
242
+ } catch {
243
+ return void 0;
244
+ }
245
+ }
246
+ async function has(id) {
247
+ try {
248
+ await stat(path(id));
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+ async function list() {
255
+ let entries;
256
+ try {
257
+ entries = await readdir(dir);
258
+ } catch {
259
+ return [];
260
+ }
261
+ return entries.filter((name) => name.endsWith(extension)).map((name) => name.slice(0, name.length - extension.length));
262
+ }
263
+ return { write, read, has, list, path };
264
+ }
222
265
  function createEditFileTool(opts) {
223
266
  const { projectRoot } = opts;
224
267
  return defineTool({
@@ -548,6 +591,439 @@ function globToRegex(pattern) {
548
591
  }
549
592
  return new RegExp(`^${regexStr}$`);
550
593
  }
594
+ var CatastrophicCommandError = class extends ConfigurationError {
595
+ name = "CatastrophicCommandError";
596
+ constructor(reason) {
597
+ super(`Refused a catastrophic shell command: ${reason} (shell guardrail).`, {
598
+ code: "catastrophic_command"
599
+ });
600
+ }
601
+ };
602
+ function unquote(token) {
603
+ return token.replace(/^(['"])(.*)\1$/, "$2").replace(/^['"]|['"]$/g, "");
604
+ }
605
+ function commandSegments(command) {
606
+ return command.split(/&&|\|\||[;|&\n]/).map((s) => s.trim()).filter((s) => s.length > 0);
607
+ }
608
+ function commandArgs(segment, name) {
609
+ const tokens = segment.split(/\s+/);
610
+ let i = 0;
611
+ if (tokens[i] === "sudo") i += 1;
612
+ if (tokens[i] !== name) return null;
613
+ return tokens.slice(i + 1);
614
+ }
615
+ function commandSegmentsNamed(command, name) {
616
+ return commandSegments(command).map((s) => commandArgs(s, name)).filter((args) => args !== null);
617
+ }
618
+ function isRecursiveForce(args) {
619
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
620
+ if (flags.length === 0) return false;
621
+ const recursive = /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
622
+ const force = /-[a-z]*f/i.test(flags) || /--force/.test(flags);
623
+ return recursive && force;
624
+ }
625
+ function isRecursive(args) {
626
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
627
+ return /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
628
+ }
629
+ var SAFE_ABSOLUTE_TARGET = /^\/(tmp|var\/tmp)(\/|$)/;
630
+ function targetsDangerousPath(args) {
631
+ const targets = args.filter((token) => token.length > 0 && !token.startsWith("-")).map(unquote);
632
+ return targets.some((raw) => {
633
+ const t = raw.replace(/\/+/g, "/");
634
+ if (t === "/dev/null" || SAFE_ABSOLUTE_TARGET.test(t)) return false;
635
+ return /^\/($|\*)/.test(t) || // "/" or "/*"
636
+ /^\/[^/]/.test(t) || // an absolute path like /etc, /usr/local, /home/user/x
637
+ t === "~" || t.startsWith("~/") || /\$\{?HOME\b\}?/.test(t) || // $HOME or ${HOME}
638
+ t === ".." || t.startsWith("../") || t.includes("/..") || t === "*";
639
+ });
640
+ }
641
+ var DEVICE_WIPE = [
642
+ /\bmkfs(\.\w+)?\b/,
643
+ /\bdd\b[^\n]*\bof=\/dev\//,
644
+ /\btruncate\b[^\n]*\s\/dev\//,
645
+ />\s*\/dev\/(sd|nvme|hd|vd|mmcblk|disk|loop|dm-)/
646
+ ];
647
+ 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(
648
+ cmd
649
+ ) || /(\$\(|<\()\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;
650
+ var checkDeviceWipe = (cmd) => DEVICE_WIPE.some((re) => re.test(cmd)) ? "writes to a raw block device / formats a disk" : null;
651
+ var checkForkBomb = (cmd) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(cmd) ? "fork bomb" : null;
652
+ var checkDestructiveGit = (cmd) => {
653
+ if (/\bgit\b[^\n]*\bpush\b[^\n]*(--force(?!-with-lease)\b|\s-f\b|\s\+\S)/.test(cmd)) {
654
+ return "git force-push (overwrites remote history)";
655
+ }
656
+ if (/\bgit\b[^\n]*\breset\b[^\n]*--hard\b/.test(cmd)) {
657
+ return "git reset --hard (discards committed and working changes)";
658
+ }
659
+ if (/\bgit\b[^\n]*\bclean\b[^\n]*(-[a-z]*f[a-z]*d|-[a-z]*d[a-z]*f)/.test(cmd)) {
660
+ return "git clean -fd (permanently deletes untracked files)";
661
+ }
662
+ return null;
663
+ };
664
+ var checkRm = (cmd) => commandSegmentsNamed(cmd, "rm").some((a) => isRecursiveForce(a) && targetsDangerousPath(a)) ? "recursive force-delete of an absolute, home, or parent path" : null;
665
+ var checkPerm = (cmd) => ["chmod", "chown"].some(
666
+ (name) => commandSegmentsNamed(cmd, name).some((a) => isRecursive(a) && targetsDangerousPath(a))
667
+ ) ? "recursive permission change on an absolute, home, or parent path" : null;
668
+ 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;
669
+ var checkExfiltration = (cmd) => {
670
+ const touchesSecret = /(^|[\s/'"])(\.env(\.\w+)?|id_rsa|id_ed25519|\.ssh(\/|\b)|credentials|\.aws(\/|\b)|\.npmrc)\b/.test(
671
+ cmd
672
+ );
673
+ const sendsNetwork = /\b(curl|wget|nc|netcat|scp|ftp|telnet)\b/.test(cmd) || /\bpython[0-9.]*\s+-m\s+http/.test(cmd);
674
+ return touchesSecret && sendsNetwork ? "sends a secret/credential file over the network (exfiltration)" : null;
675
+ };
676
+ var CATEGORY_CHECKS = [
677
+ checkRemoteExec,
678
+ checkDeviceWipe,
679
+ checkForkBomb,
680
+ checkDestructiveGit,
681
+ checkRm,
682
+ checkPerm,
683
+ checkFind,
684
+ checkExfiltration
685
+ ];
686
+ function catastrophicShellReason(command) {
687
+ const cmd = command.trim();
688
+ if (cmd.length === 0) return null;
689
+ for (const check of CATEGORY_CHECKS) {
690
+ const reason = check(cmd);
691
+ if (reason) return reason;
692
+ }
693
+ return null;
694
+ }
695
+
696
+ // src/internal/command-policy.ts
697
+ function denyCatastrophicCommands() {
698
+ return (command) => catastrophicShellReason(command);
699
+ }
700
+ function commandDenialReason(command, policies) {
701
+ for (const policy of policies) {
702
+ const reason = policy(command);
703
+ if (reason !== null) return reason;
704
+ }
705
+ return null;
706
+ }
707
+ function isCommandAllowed(command, policies) {
708
+ return commandDenialReason(command, policies) === null;
709
+ }
710
+ var SsrfBlockedError = class extends ConfigurationError {
711
+ name = "SsrfBlockedError";
712
+ constructor(host, detail) {
713
+ super(
714
+ `Blocked request to "${host}"${detail ? ` (${detail})` : ""}: address is private, loopback, link-local, or reserved (SSRF guard).`,
715
+ { code: "ssrf_blocked" }
716
+ );
717
+ }
718
+ };
719
+ function v4ToInt(ip) {
720
+ const parts = ip.split(".");
721
+ return (Number(parts[0]) << 24 | Number(parts[1]) << 16 | Number(parts[2]) << 8 | Number(parts[3])) >>> 0;
722
+ }
723
+ function inV4Cidr(ipInt, base, prefix) {
724
+ const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
725
+ return (ipInt & mask) === (v4ToInt(base) & mask);
726
+ }
727
+ var V4_BLOCKED = [
728
+ ["0.0.0.0", 8],
729
+ // "this host"
730
+ ["10.0.0.0", 8],
731
+ // private
732
+ ["100.64.0.0", 10],
733
+ // CGNAT
734
+ ["127.0.0.0", 8],
735
+ // loopback
736
+ ["169.254.0.0", 16],
737
+ // link-local + cloud metadata
738
+ ["172.16.0.0", 12],
739
+ // private
740
+ ["192.168.0.0", 16],
741
+ // private
742
+ ["224.0.0.0", 4],
743
+ // multicast
744
+ ["240.0.0.0", 4]
745
+ // reserved
746
+ ];
747
+ function isBlockedV4(ip) {
748
+ const n = v4ToInt(ip);
749
+ return V4_BLOCKED.some(([base, prefix]) => inV4Cidr(n, base, prefix));
750
+ }
751
+ function foldDottedTail(s) {
752
+ const lastColon = s.lastIndexOf(":");
753
+ const tail = s.slice(lastColon + 1);
754
+ if (!tail.includes(".")) return s;
755
+ if (isIP(tail) !== 4) return null;
756
+ const o = tail.split(".").map(Number);
757
+ const hi = (o[0] << 8 | o[1]).toString(16);
758
+ const lo = (o[2] << 8 | o[3]).toString(16);
759
+ return `${s.slice(0, lastColon + 1)}${hi}:${lo}`;
760
+ }
761
+ function expandHextets(s) {
762
+ const halves = s.split("::");
763
+ if (halves.length > 2) return null;
764
+ const head = halves[0] ? halves[0].split(":") : [];
765
+ const tailGroups = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
766
+ if (halves.length === 1) return head.length === 8 ? head : null;
767
+ const missing = 8 - head.length - tailGroups.length;
768
+ if (missing < 0) return null;
769
+ return [...head, ...Array(missing).fill("0"), ...tailGroups];
770
+ }
771
+ function ipv6ToBytes(ip) {
772
+ const folded = foldDottedTail(ip.toLowerCase().split("%")[0] ?? "");
773
+ if (folded === null) return null;
774
+ const groups = expandHextets(folded);
775
+ if (groups === null || groups.length !== 8) return null;
776
+ const bytes = [];
777
+ for (const g of groups) {
778
+ const n = Number.parseInt(g || "0", 16);
779
+ bytes.push(n >> 8, n & 255);
780
+ }
781
+ return bytes;
782
+ }
783
+ function allZero(bytes, from, to) {
784
+ for (let i = from; i < to; i += 1) if (bytes[i] !== 0) return false;
785
+ return true;
786
+ }
787
+ function isBlockedV6Bytes(b) {
788
+ if (allZero(b, 0, 10) && b[10] === 255 && b[11] === 255) {
789
+ return isBlockedV4(`${b[12]}.${b[13]}.${b[14]}.${b[15]}`);
790
+ }
791
+ if (allZero(b, 0, 15) && (b[15] === 1 || b[15] === 0)) return true;
792
+ if (allZero(b, 0, 12)) return isBlockedV4(`${b[12]}.${b[13]}.${b[14]}.${b[15]}`);
793
+ if (b[0] === 254 && (b[1] & 192) === 128) return true;
794
+ return (b[0] & 254) === 252;
795
+ }
796
+ function isBlockedIp(ip) {
797
+ const fam = isIP(ip);
798
+ if (fam === 4) return isBlockedV4(ip);
799
+ if (fam === 6) {
800
+ const bytes = ipv6ToBytes(ip);
801
+ return bytes === null ? true : isBlockedV6Bytes(bytes);
802
+ }
803
+ return true;
804
+ }
805
+ async function resolveAndScreen(rawHost, options = {}) {
806
+ const host = rawHost.replace(/^\[|\]$/g, "");
807
+ if (isIP(host) !== 0) {
808
+ if (isBlockedIp(host)) throw new SsrfBlockedError(host);
809
+ return [host];
810
+ }
811
+ const lookup$1 = options.lookup ?? lookup;
812
+ const addrs = await lookup$1(host, { all: true });
813
+ if (addrs.length === 0) throw new SsrfBlockedError(host, "no addresses");
814
+ for (const a of addrs) {
815
+ if (isBlockedIp(a.address)) throw new SsrfBlockedError(host, a.address);
816
+ }
817
+ return addrs.map((a) => a.address);
818
+ }
819
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
820
+ function redirectTarget(res, current, originalUrl) {
821
+ const location = res.headers.get("location");
822
+ if (!REDIRECT_STATUSES.has(res.status) || !location) return void 0;
823
+ const next = new URL(location, current);
824
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
825
+ throw new SsrfBlockedError(originalUrl, `non-http redirect to ${next.protocol}`);
826
+ }
827
+ return next.href;
828
+ }
829
+ async function screenedFetch(url, options = {}) {
830
+ const fetchImpl = options.fetchImpl ?? fetch;
831
+ const maxRedirects = options.maxRedirects ?? 5;
832
+ let current = url;
833
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
834
+ if (!options.allowPrivateHosts) {
835
+ await resolveAndScreen(new URL(current).hostname, { lookup: options.lookup });
836
+ }
837
+ const res = await fetchImpl(current, { redirect: "manual", signal: options.signal });
838
+ const next = redirectTarget(res, current, url);
839
+ if (next === void 0) return res;
840
+ current = next;
841
+ }
842
+ throw new SsrfBlockedError(url, "too many redirects");
843
+ }
844
+ var DEFAULT_BUDGET = 8e3;
845
+ var DEFAULT_MAX_DEPTH = 4;
846
+ var PER_DIR_CAP = 200;
847
+ var TRUNCATED_MARKER = "\u2026 (truncated)";
848
+ var DOC_HEAD_CHARS = 200;
849
+ var DEFAULT_REPO_MAP_IGNORE = [
850
+ "node_modules",
851
+ ".git",
852
+ "dist",
853
+ ".theo",
854
+ ".next",
855
+ "build",
856
+ "coverage",
857
+ "target",
858
+ "out"
859
+ ];
860
+ var PROJECT_DOCS = ["AGENTS.md", "CLAUDE.md", "README.md"];
861
+ var MANIFESTS = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
862
+ function safeExists(p) {
863
+ try {
864
+ return existsSync(p);
865
+ } catch {
866
+ return false;
867
+ }
868
+ }
869
+ function safeReadHead(p, n) {
870
+ try {
871
+ return readFileSync(p, "utf-8").slice(0, n).replace(/\s+/g, " ").trim();
872
+ } catch {
873
+ return "";
874
+ }
875
+ }
876
+ function buildEnvContext(cwd) {
877
+ const lines = [
878
+ "<env>",
879
+ ` Working directory: ${cwd}`,
880
+ ` Platform: ${process.platform} (${process.arch})`,
881
+ ` Node: ${process.version}`,
882
+ ` Is git repo: ${safeExists(join(cwd, ".git")) ? "yes" : "no"}`,
883
+ ` Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}`
884
+ ];
885
+ const docs = PROJECT_DOCS.filter((d) => safeExists(join(cwd, d)));
886
+ if (docs.length > 0) {
887
+ lines.push(` Project docs: ${docs.join(", ")}`);
888
+ const head = safeReadHead(join(cwd, docs[0]), DOC_HEAD_CHARS);
889
+ if (head) lines.push(` ${docs[0]} (head): ${head}`);
890
+ }
891
+ const manifests = MANIFESTS.filter((m) => safeExists(join(cwd, m)));
892
+ if (manifests.length > 0) lines.push(` Manifests: ${manifests.join(", ")}`);
893
+ lines.push("</env>");
894
+ return lines.join("\n");
895
+ }
896
+ function compareEntries(a, b) {
897
+ const ad = a.isDirectory() ? 0 : 1;
898
+ const bd = b.isDirectory() ? 0 : 1;
899
+ return ad !== bd ? ad - bd : a.name.localeCompare(b.name);
900
+ }
901
+ function visibleEntries(dir, ignore) {
902
+ let entries;
903
+ try {
904
+ entries = readdirSync(dir, { withFileTypes: true });
905
+ } catch {
906
+ return [];
907
+ }
908
+ return entries.filter((e) => !ignore.has(e.name) && !e.name.startsWith(".")).sort(compareEntries);
909
+ }
910
+ function pushLine(ctx, line) {
911
+ if (ctx.used + line.length + 1 > ctx.budget) {
912
+ ctx.truncated = true;
913
+ return false;
914
+ }
915
+ ctx.lines.push(line);
916
+ ctx.used += line.length + 1;
917
+ return true;
918
+ }
919
+ function emitEntry(ctx, dir, e, depth, indent) {
920
+ const isDir = e.isDirectory();
921
+ if (!pushLine(ctx, `${indent}${isDir ? `${e.name}/` : e.name}`)) return false;
922
+ if (isDir && depth + 1 < ctx.maxDepth) return walkDir2(ctx, join(dir, e.name), depth + 1);
923
+ return true;
924
+ }
925
+ function walkDir2(ctx, dir, depth) {
926
+ const entries = visibleEntries(dir, ctx.ignore);
927
+ const shown = entries.slice(0, PER_DIR_CAP);
928
+ const indent = " ".repeat(depth);
929
+ for (const e of shown) {
930
+ if (!emitEntry(ctx, dir, e, depth, indent)) return false;
931
+ }
932
+ if (entries.length > shown.length) {
933
+ return pushLine(ctx, `${indent}\u2026 (${entries.length - shown.length} more)`);
934
+ }
935
+ return true;
936
+ }
937
+ function buildRepoMap(cwd, opts = {}) {
938
+ try {
939
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
940
+ return `(unavailable: ${cwd} is not a readable directory)`;
941
+ }
942
+ } catch {
943
+ return `(unavailable: ${cwd})`;
944
+ }
945
+ const ctx = {
946
+ ignore: /* @__PURE__ */ new Set([...DEFAULT_REPO_MAP_IGNORE, ...opts.ignore ?? []]),
947
+ maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
948
+ budget: opts.budget ?? DEFAULT_BUDGET,
949
+ lines: [],
950
+ used: 0,
951
+ truncated: false
952
+ };
953
+ walkDir2(ctx, cwd, 0);
954
+ return ctx.truncated ? `${ctx.lines.join("\n")}
955
+ ${TRUNCATED_MARKER}` : ctx.lines.join("\n");
956
+ }
957
+
958
+ // src/internal/tool-aci.ts
959
+ function withDescription(tool, description) {
960
+ return {
961
+ name: tool.name,
962
+ description,
963
+ inputSchema: tool.inputSchema,
964
+ handler: tool.handler
965
+ };
966
+ }
967
+ function esc(s) {
968
+ return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
969
+ }
970
+ function renderToolList(tools) {
971
+ if (tools.length === 0) return "<tools></tools>";
972
+ const lines = ["<tools>"];
973
+ for (const t of tools) {
974
+ lines.push(
975
+ " <tool>",
976
+ ` <name>${esc(t.name)}</name>`,
977
+ ` <description>${esc(t.description)}</description>`,
978
+ " </tool>"
979
+ );
980
+ }
981
+ lines.push("</tools>");
982
+ return lines.join("\n");
983
+ }
984
+
985
+ // src/internal/tool-guidance.ts
986
+ var DEFAULT_TOOL_GUIDANCE = {
987
+ not_found: "The path does not exist. Use `list_dir` or `glob_files` to find the correct path, then retry.",
988
+ path_traversal: "That path escapes the project root. Use a path inside the project directory.",
989
+ forbidden_path: "That path is a protected file (.env, .git, lock files, etc.). Choose a different, non-sensitive path.",
990
+ no_match: "The search text was not found verbatim. Re-read the file with `read_file` and copy the exact text (including whitespace/indentation) before editing.",
991
+ timeout: "The operation timed out. Narrow the scope or pass a larger `timeout_ms`.",
992
+ invalid_url: "The URL is malformed. Provide a full absolute http(s):// URL.",
993
+ ssrf_blocked: "That host is private/loopback/reserved and is blocked. Use a public URL.",
994
+ catastrophic_command: "That command is blocked as catastrophic. Use a safer, scoped command (e.g. a relative path instead of `/`).",
995
+ binary_file: "The file is binary and cannot be read as text. Use a tool suited to binary data.",
996
+ too_large: "The target is too large to process directly. Use a bounded range or a narrower scope."
997
+ };
998
+ function isRecord(value) {
999
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1000
+ }
1001
+ function injectGuidance(handlerOutput, guidance) {
1002
+ let parsed;
1003
+ try {
1004
+ parsed = JSON.parse(handlerOutput);
1005
+ } catch {
1006
+ return handlerOutput;
1007
+ }
1008
+ if (!isRecord(parsed) || parsed.ok !== false) return handlerOutput;
1009
+ if ("guidance" in parsed) return handlerOutput;
1010
+ const code = parsed.error;
1011
+ if (typeof code !== "string") return handlerOutput;
1012
+ const hint = guidance[code];
1013
+ if (!hint) return handlerOutput;
1014
+ return JSON.stringify({ ...parsed, guidance: hint });
1015
+ }
1016
+ function withToolResultGuidance(tool, guidance) {
1017
+ return {
1018
+ name: tool.name,
1019
+ description: tool.description,
1020
+ inputSchema: tool.inputSchema,
1021
+ handler: async (input) => injectGuidance(await tool.handler(input), guidance)
1022
+ };
1023
+ }
1024
+ function withDefaultGuidance(tool) {
1025
+ return withToolResultGuidance(tool, DEFAULT_TOOL_GUIDANCE);
1026
+ }
551
1027
  var DEFAULT_MAX_ENTRIES = 500;
552
1028
  function createListDirTool(opts) {
553
1029
  const { projectRoot, max = DEFAULT_MAX_ENTRIES } = opts;
@@ -613,39 +1089,82 @@ var PLAN_INSTRUCTIONS = [
613
1089
  "When ready, use plan_mode with action 'exit' to return to normal mode."
614
1090
  ].join("\n");
615
1091
  var NORMAL_INSTRUCTIONS = "Returned to NORMAL MODE. You may now execute changes.";
616
- function createPlanModeTool() {
1092
+ var DESCRIPTION = "Toggle between normal and plan mode. Actions: 'enter' (switch to plan mode), 'exit' (return to normal), 'status' (check current mode). Returns { ok, mode, message }.";
1093
+ function planModeSchema(withPlan) {
1094
+ const properties = {
1095
+ action: {
1096
+ type: "string",
1097
+ enum: ["enter", "exit", "status"],
1098
+ description: "The action to perform."
1099
+ }
1100
+ };
1101
+ if (withPlan) {
1102
+ properties.plan = {
1103
+ type: "string",
1104
+ description: "On 'exit', the plan text to persist to the artifact store."
1105
+ };
1106
+ }
1107
+ return { type: "object", properties, required: ["action"] };
1108
+ }
1109
+ function renderMode(action, mode) {
1110
+ switch (action) {
1111
+ case "enter":
1112
+ return JSON.stringify({ ok: true, mode, message: PLAN_INSTRUCTIONS });
1113
+ case "exit":
1114
+ return JSON.stringify({ ok: true, mode, message: NORMAL_INSTRUCTIONS });
1115
+ case "status":
1116
+ return JSON.stringify({ ok: true, mode, message: `Current mode: ${mode}` });
1117
+ default:
1118
+ return void 0;
1119
+ }
1120
+ }
1121
+ function invalidAction(action) {
1122
+ return JSON.stringify({
1123
+ ok: false,
1124
+ error: "invalid_action",
1125
+ message: `Unknown action '${action}'. Valid: enter, exit, status.`
1126
+ });
1127
+ }
1128
+ function createPlanModeTool(options) {
617
1129
  let mode = "normal";
1130
+ if (options === void 0) {
1131
+ return {
1132
+ name: "plan_mode",
1133
+ description: DESCRIPTION,
1134
+ inputSchema: planModeSchema(false),
1135
+ handler: (input) => {
1136
+ if (input.action === "enter") mode = "plan";
1137
+ else if (input.action === "exit") mode = "normal";
1138
+ return renderMode(input.action, mode) ?? invalidAction(input.action);
1139
+ },
1140
+ currentMode: () => mode
1141
+ };
1142
+ }
1143
+ const { artifactStore, artifactId = "plan" } = options;
618
1144
  return {
619
1145
  name: "plan_mode",
620
- description: "Toggle between normal and plan mode. Actions: 'enter' (switch to plan mode), 'exit' (return to normal), 'status' (check current mode). Returns { ok, mode, message }.",
621
- inputSchema: {
622
- type: "object",
623
- properties: {
624
- action: {
625
- type: "string",
626
- enum: ["enter", "exit", "status"],
627
- description: "The action to perform."
628
- }
629
- },
630
- required: ["action"]
631
- },
632
- handler: (input) => {
633
- switch (input.action) {
634
- case "enter":
635
- mode = "plan";
636
- return JSON.stringify({ ok: true, mode, message: PLAN_INSTRUCTIONS });
637
- case "exit":
638
- mode = "normal";
639
- return JSON.stringify({ ok: true, mode, message: NORMAL_INSTRUCTIONS });
640
- case "status":
641
- return JSON.stringify({ ok: true, mode, message: `Current mode: ${mode}` });
642
- default:
1146
+ description: DESCRIPTION,
1147
+ inputSchema: planModeSchema(true),
1148
+ handler: async (input) => {
1149
+ if (input.action === "enter") {
1150
+ mode = "plan";
1151
+ return JSON.stringify({ ok: true, mode, message: PLAN_INSTRUCTIONS });
1152
+ }
1153
+ if (input.action === "exit") {
1154
+ mode = "normal";
1155
+ if (typeof input.plan === "string" && input.plan.length > 0) {
1156
+ const path = await artifactStore.write(artifactId, input.plan);
643
1157
  return JSON.stringify({
644
- ok: false,
645
- error: "invalid_action",
646
- message: `Unknown action '${input.action}'. Valid: enter, exit, status.`
1158
+ ok: true,
1159
+ mode,
1160
+ message: NORMAL_INSTRUCTIONS,
1161
+ persisted: true,
1162
+ path
647
1163
  });
1164
+ }
1165
+ return JSON.stringify({ ok: true, mode, message: NORMAL_INSTRUCTIONS, persisted: false });
648
1166
  }
1167
+ return renderMode(input.action, mode) ?? invalidAction(input.action);
649
1168
  },
650
1169
  currentMode: () => mode
651
1170
  };
@@ -735,21 +1254,21 @@ async function openHandleSafe(absolutePath, path) {
735
1254
  }
736
1255
  }
737
1256
  async function readContent(handle, path) {
738
- const stat = await handle.stat();
739
- if (stat.size > MAX_FILE_SIZE) {
1257
+ const stat2 = await handle.stat();
1258
+ if (stat2.size > MAX_FILE_SIZE) {
740
1259
  return JSON.stringify({
741
1260
  ok: false,
742
1261
  error: "too_large",
743
1262
  path,
744
- size: stat.size,
1263
+ size: stat2.size,
745
1264
  limit: MAX_FILE_SIZE
746
1265
  });
747
1266
  }
748
- if (await isBinaryProbe(handle, Number(stat.size))) {
749
- return JSON.stringify({ ok: false, error: "binary_file", path, size: stat.size });
1267
+ if (await isBinaryProbe(handle, Number(stat2.size))) {
1268
+ return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
750
1269
  }
751
1270
  const content = await handle.readFile({ encoding: "utf-8" });
752
- return JSON.stringify({ ok: true, content, size: stat.size });
1271
+ return JSON.stringify({ ok: true, content, size: stat2.size });
753
1272
  }
754
1273
  async function isBinaryProbe(handle, size) {
755
1274
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -988,7 +1507,7 @@ var DEFAULT_TIMEOUT_MS3 = 3e4;
988
1507
  var MAX_TIMEOUT_MS = 3e5;
989
1508
  var MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
990
1509
  function createShellTool(opts) {
991
- const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3 } = opts;
1510
+ const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3, allowCatastrophic = false } = opts;
992
1511
  return defineTool({
993
1512
  name: "shell_exec",
994
1513
  description: "Execute a shell command in the project directory. Returns stdout, stderr, and exit code. Default timeout 30s, max 5 minutes. Output capped at 5 MB. Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
@@ -997,6 +1516,13 @@ function createShellTool(opts) {
997
1516
  timeout_ms: z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000, max 300000).")
998
1517
  }),
999
1518
  handler: async ({ command, timeout_ms }) => {
1519
+ if (!allowCatastrophic) {
1520
+ const reason = catastrophicShellReason(command);
1521
+ if (reason) {
1522
+ const err = new CatastrophicCommandError(reason);
1523
+ return JSON.stringify({ ok: false, error: err.code, reason });
1524
+ }
1525
+ }
1000
1526
  const timeoutMs = Math.min(timeout_ms ?? defaultTimeoutMs, MAX_TIMEOUT_MS);
1001
1527
  const result = await runShell(projectRoot, command, timeoutMs);
1002
1528
  return result;
@@ -1075,6 +1601,11 @@ function formatResult(result, timeoutMs) {
1075
1601
  });
1076
1602
  }
1077
1603
 
1604
+ // src/todo-plan-nodes.ts
1605
+ function todoItemsToPlanNodes(items) {
1606
+ return items.map((item) => ({ id: item.id, label: item.title, status: item.status }));
1607
+ }
1608
+
1078
1609
  // src/todolist.ts
1079
1610
  function ok(data) {
1080
1611
  return JSON.stringify({ ok: true, ...data });
@@ -1095,6 +1626,9 @@ function createTodolistTool() {
1095
1626
  function findById(id) {
1096
1627
  return items.find((i) => i.id === id);
1097
1628
  }
1629
+ function listResult(extra) {
1630
+ return ok({ ...extra, items: [...items], items_summary: formatList() });
1631
+ }
1098
1632
  function formatList() {
1099
1633
  if (items.length === 0) return "No tasks. Use action 'add' to create one.";
1100
1634
  const lines = items.map((item) => {
@@ -1117,7 +1651,7 @@ ${done}/${items.length} done | ${inProg} in progress | ${pending} pending`);
1117
1651
  createdAt: Date.now()
1118
1652
  };
1119
1653
  items.push(item);
1120
- return ok({ id: item.id, message: `Added: ${item.title}`, items_summary: formatList() });
1654
+ return listResult({ id: item.id, message: `Added: ${item.title}` });
1121
1655
  }
1122
1656
  function handleSetStatus(input, status) {
1123
1657
  const id = requireId(input);
@@ -1127,7 +1661,7 @@ ${done}/${items.length} done | ${inProg} in progress | ${pending} pending`);
1127
1661
  item.status = status;
1128
1662
  if (status === "done") item.completedAt = Date.now();
1129
1663
  const verb = status === "done" ? "Completed" : "Started";
1130
- return ok({ message: `${verb}: ${item.title}`, items_summary: formatList() });
1664
+ return listResult({ message: `${verb}: ${item.title}` });
1131
1665
  }
1132
1666
  function handleRemove(input) {
1133
1667
  const id = requireId(input);
@@ -1135,29 +1669,26 @@ ${done}/${items.length} done | ${inProg} in progress | ${pending} pending`);
1135
1669
  const idx = items.findIndex((i) => i.id === id);
1136
1670
  if (idx === -1) return fail({ error: "not_found", id });
1137
1671
  const removed = items.splice(idx, 1)[0];
1138
- return ok({ message: `Removed: ${removed.title}`, items_summary: formatList() });
1672
+ return listResult({ message: `Removed: ${removed.title}` });
1139
1673
  }
1140
1674
  function handleClearCompleted() {
1141
1675
  const before = items.length;
1142
1676
  const kept = items.filter((i) => i.status !== "done");
1143
1677
  items.length = 0;
1144
1678
  items.push(...kept);
1145
- return ok({
1146
- message: `Cleared ${before - items.length} completed items`,
1147
- items_summary: formatList()
1148
- });
1679
+ return listResult({ message: `Cleared ${before - items.length} completed items` });
1149
1680
  }
1150
1681
  const actions = {
1151
1682
  add: handleAdd,
1152
1683
  in_progress: (input) => handleSetStatus(input, "in_progress"),
1153
1684
  complete: (input) => handleSetStatus(input, "done"),
1154
1685
  remove: handleRemove,
1155
- list: () => ok({ items_summary: formatList() }),
1686
+ list: () => listResult({}),
1156
1687
  clear_completed: handleClearCompleted
1157
1688
  };
1158
1689
  return {
1159
1690
  name: "todolist",
1160
- description: "Track multi-step task progress. Actions: 'add' (create task with title), 'complete' (mark done by id), 'in_progress' (mark started by id), 'remove' (delete by id), 'list' (show all), 'clear_completed' (remove done items). Returns { ok, items_summary }.",
1691
+ description: "Track multi-step task progress. Actions: 'add' (create task with title), 'complete' (mark done by id), 'in_progress' (mark started by id), 'remove' (delete by id), 'list' (show all), 'clear_completed' (remove done items). Returns { ok, items, items_summary } (items = structured array; items_summary = formatted text).",
1161
1692
  inputSchema: {
1162
1693
  type: "object",
1163
1694
  properties: {
@@ -1210,6 +1741,7 @@ var DEFAULT_TIMEOUT_MS4 = 3e4;
1210
1741
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
1211
1742
  function createWebFetchTool(opts) {
1212
1743
  const defaultTimeoutMs = opts?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS4;
1744
+ const allowPrivateHosts = opts?.allowPrivateHosts ?? false;
1213
1745
  return defineTool({
1214
1746
  name: "web_fetch",
1215
1747
  description: "Fetch content from a URL via HTTP/HTTPS. Rejects non-http(s) URLs. Response body capped at 1 MB. Returns { ok, content, status_code } or { ok: false, error }.",
@@ -1237,7 +1769,10 @@ function createWebFetchTool(opts) {
1237
1769
  const controller = new AbortController();
1238
1770
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1239
1771
  try {
1240
- const response = await fetch(url, { signal: controller.signal });
1772
+ const response = await screenedFetch(url, {
1773
+ signal: controller.signal,
1774
+ allowPrivateHosts
1775
+ });
1241
1776
  clearTimeout(timer);
1242
1777
  const contentLength = response.headers.get("content-length");
1243
1778
  if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
@@ -1269,6 +1804,9 @@ function createWebFetchTool(opts) {
1269
1804
  });
1270
1805
  } catch (err) {
1271
1806
  clearTimeout(timer);
1807
+ if (err instanceof SsrfBlockedError) {
1808
+ return JSON.stringify({ ok: false, error: "ssrf_blocked", url, reason: err.message });
1809
+ }
1272
1810
  const e = err;
1273
1811
  if (e.name === "AbortError") {
1274
1812
  return JSON.stringify({ ok: false, error: "timeout", url, timeout_ms: timeoutMs });
@@ -1312,6 +1850,34 @@ function createWebSearchTool(opts) {
1312
1850
  }
1313
1851
  });
1314
1852
  }
1853
+ var BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
1854
+ function createBraveWebSearchAdapter(opts = {}) {
1855
+ const apiKey = opts.apiKey ?? process.env.BRAVE_API_KEY;
1856
+ if (!apiKey) {
1857
+ throw new ConfigurationError("BRAVE_API_KEY is not set (pass { apiKey } or set the env var).", {
1858
+ code: "no_api_key"
1859
+ });
1860
+ }
1861
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
1862
+ const endpoint = opts.endpoint ?? BRAVE_ENDPOINT;
1863
+ const base = new URL(endpoint);
1864
+ return async (query, maxResults) => {
1865
+ const url = new URL(base);
1866
+ url.searchParams.set("q", query);
1867
+ url.searchParams.set("count", String(maxResults));
1868
+ const res = await fetchImpl(url.toString(), {
1869
+ headers: { "X-Subscription-Token": apiKey, Accept: "application/json" }
1870
+ });
1871
+ if (!res.ok) throw new Error(`brave_search_failed: HTTP ${res.status}`);
1872
+ const json = await res.json();
1873
+ const results = json?.web?.results ?? [];
1874
+ return results.map((r) => ({
1875
+ title: String(r?.title ?? ""),
1876
+ url: String(r?.url ?? ""),
1877
+ snippet: String(r?.description ?? "")
1878
+ }));
1879
+ };
1880
+ }
1315
1881
  var BINARY_PROBE_BYTES3 = 8 * 1024;
1316
1882
  function createWriteFileTool(opts) {
1317
1883
  const { projectRoot } = opts;
@@ -1354,8 +1920,8 @@ async function isBinaryFile(absolutePath) {
1354
1920
  return false;
1355
1921
  }
1356
1922
  try {
1357
- const stat = await handle.stat();
1358
- const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat.size));
1923
+ const stat2 = await handle.stat();
1924
+ const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat2.size));
1359
1925
  if (probeLen <= 0) return false;
1360
1926
  const probe = Buffer.alloc(probeLen);
1361
1927
  const { bytesRead } = await handle.read(probe, 0, probeLen, 0);
@@ -1368,6 +1934,6 @@ async function isBinaryFile(absolutePath) {
1368
1934
  }
1369
1935
  }
1370
1936
 
1371
- export { createApplyPatchTool, createEditFileTool, createGitDiffTool, createGlobTool, createListDirTool, createPlanModeTool, createQuestionTool, createReadFileTool, createRunVitestTool, createSearchTextTool, createShellTool, createTodolistTool, createWebFetchTool, createWebSearchTool, createWriteFileTool, formatCode, formatDiff, formatError, formatFileList, truncateOutput };
1937
+ export { CatastrophicCommandError, DEFAULT_TOOL_GUIDANCE, SsrfBlockedError, buildEnvContext, buildRepoMap, catastrophicShellReason, commandDenialReason, createApplyPatchTool, createBraveWebSearchAdapter, createEditFileTool, createGitDiffTool, createGlobTool, createListDirTool, createPlanModeTool, createQuestionTool, createReadFileTool, createRunVitestTool, createSearchTextTool, createSessionArtifactStore, createShellTool, createTodolistTool, createWebFetchTool, createWebSearchTool, createWriteFileTool, denyCatastrophicCommands, formatCode, formatDiff, formatError, formatFileList, injectGuidance, isBlockedIp, isCommandAllowed, renderToolList, resolveAndScreen, screenedFetch, todoItemsToPlanNodes, truncateOutput, withDefaultGuidance, withDescription, withToolResultGuidance };
1372
1938
  //# sourceMappingURL=index.js.map
1373
1939
  //# sourceMappingURL=index.js.map