@theokit/sdk-tools 0.17.0 → 0.19.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `createSearchTextTool` gains two ADDITIVE, opt-in options (both default OFF ⇒ existing literal, project-
8
+ scoped behavior unchanged): `regex` — match `query` as a JavaScript RegExp (grep semantics; an invalid
9
+ pattern returns `{ ok: false, error: 'invalid_regex' }` before walking), and `allowAbsolute` — honor an
10
+ absolute `path` scope outside `projectRoot` (Codex read-only "reads-anywhere"; forbidden dirs still
11
+ skipped). Together they let one built-in cover both literal content search and grep-style regex search.
12
+
13
+ ## 0.18.0
14
+
15
+ ### Minor Changes
16
+
17
+ - `createReadFileTool` gains three ADDITIVE, opt-in Codex-grade capabilities (all default OFF, so existing
18
+ consumers are byte-identical): `lineNumbers` (render a `cat -n` `<n>\t<line>` view so the model can cite/
19
+ edit by line), `offset`/`limit` input params (page through a large file), and `allowAbsolute` (honor an
20
+ absolute path outside `projectRoot` — the Codex read-only "reads-anywhere" sandbox). Security: with
21
+ `allowAbsolute`, the secret guard now blocks `.env`/`.git`/`node_modules`/`.theo` at ANY path depth (not
22
+ just the project-relative first segment), closing an absolute-path exfiltration hole. Opt-in only.
23
+
3
24
  ## 0.17.0
4
25
 
5
26
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -1579,23 +1579,53 @@ function createQuestionTool(opts) {
1579
1579
  }
1580
1580
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
1581
1581
  var BINARY_PROBE_BYTES = 8 * 1024;
1582
+ var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
1583
+ function isForbiddenAtAnyDepth(path) {
1584
+ const segs = path.replace(/\\/g, "/").split("/").filter(Boolean);
1585
+ return segs.some((s) => {
1586
+ if (s === ".env.example") return false;
1587
+ return SENSITIVE_SEGMENTS.has(s) || /^\.env\./.test(s);
1588
+ });
1589
+ }
1590
+ function forbiddenReadError(path$1, allowAbsolute) {
1591
+ if (isForbiddenPath(path$1)) {
1592
+ return JSON.stringify({ ok: false, error: "forbidden_path", path: path$1 });
1593
+ }
1594
+ if (allowAbsolute && path.isAbsolute(path$1) && isForbiddenAtAnyDepth(path$1)) {
1595
+ return JSON.stringify({ ok: false, error: "forbidden_path", path: path$1 });
1596
+ }
1597
+ return null;
1598
+ }
1599
+ function renderView(content, opts) {
1600
+ const paginated = opts.offset !== void 0 || opts.limit !== void 0;
1601
+ if (!opts.lineNumbers && !paginated) return content;
1602
+ const lines = content.split("\n");
1603
+ const start = Math.max(1, opts.offset ?? 1);
1604
+ const end = opts.limit !== void 0 ? Math.min(lines.length, start - 1 + opts.limit) : lines.length;
1605
+ const slice = lines.slice(start - 1, end);
1606
+ return opts.lineNumbers ? slice.map((l, i) => `${start + i} ${l}`).join("\n") : slice.join("\n");
1607
+ }
1582
1608
  function createReadFileTool(opts) {
1583
- const { projectRoot, readTracker, filesystem: filesystem$1 } = opts;
1609
+ const { projectRoot, readTracker, filesystem: filesystem$1, lineNumbers, allowAbsolute } = opts;
1610
+ const numbered = lineNumbers === true ? " Returns a cat -n numbered view (`<n>\\t<line>`)." : "";
1611
+ const abs = allowAbsolute === true ? " Absolute paths outside the project are honored." : "";
1584
1612
  return sdk.Tool.create({
1585
1613
  name: "read_file",
1586
- description: "Read a project-relative text file as UTF-8. ALWAYS read a file before you edit it (edit_file) or overwrite it (write_file), so your old_string / new content matches the real bytes exactly. Returns the WHOLE file (there is no offset or line-range parameter); to locate a symbol inside a large file, use search_text instead of re-reading. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary files (null byte in the first 8 KB); caps at 5 MB. Returns { ok, content, size } or { ok: false, error }.",
1614
+ description: "Read a text file as UTF-8. ALWAYS read a file before you edit it (edit_file) or overwrite it (write_file), so your old_string / new content matches the real bytes exactly." + numbered + abs + " By default returns the whole file; use the optional offset (1-based first line) + limit to page through a large file, or search_text to locate a symbol. Refuses sensitive files (.env, .git/, node_modules/, .theo/, lock files) and binary files (null byte in the first 8 KB); caps at 5 MB. Returns { ok, content, size } or { ok: false, error }.",
1587
1615
  inputSchema: zod.z.object({
1588
- path: zod.z.string().min(1).describe("Project-relative file path.")
1616
+ path: zod.z.string().min(1).describe("File path (project-relative; absolute when allowed)."),
1617
+ offset: zod.z.number().int().min(1).optional().describe("1-based first line to read (default 1)."),
1618
+ limit: zod.z.number().int().min(1).optional().describe("Max number of lines to read (default: all).")
1589
1619
  }),
1590
- handler: async ({ path }, ctx) => {
1591
- if (isForbiddenPath(path)) {
1592
- return JSON.stringify({ ok: false, error: "forbidden_path", path });
1593
- }
1620
+ handler: async ({ path, offset, limit }, ctx) => {
1621
+ const forbidden = forbiddenReadError(path, allowAbsolute === true);
1622
+ if (forbidden !== null) return forbidden;
1623
+ const view = { lineNumbers, offset, limit };
1594
1624
  if (filesystem$1) {
1595
1625
  const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1596
- return readViaBackend(backend, path, (mtimeMs) => readTracker?.record(path, mtimeMs));
1626
+ return readViaBackend(backend, path, view, (mtimeMs) => readTracker?.record(path, mtimeMs));
1597
1627
  }
1598
- const boundary = resolveBoundary(path, projectRoot);
1628
+ const boundary = resolveBoundary(path, projectRoot, allowAbsolute === true);
1599
1629
  if ("error" in boundary) return boundary.error;
1600
1630
  const opened = await openHandleSafe(boundary.absolutePath, path);
1601
1631
  if ("error" in opened) return opened.error;
@@ -1603,6 +1633,7 @@ function createReadFileTool(opts) {
1603
1633
  return await readContent(
1604
1634
  opened.handle,
1605
1635
  path,
1636
+ view,
1606
1637
  (mtimeMs) => readTracker?.record(path, mtimeMs)
1607
1638
  );
1608
1639
  } finally {
@@ -1611,7 +1642,7 @@ function createReadFileTool(opts) {
1611
1642
  }
1612
1643
  });
1613
1644
  }
1614
- async function readViaBackend(backend, path, onRead) {
1645
+ async function readViaBackend(backend, path, view, onRead) {
1615
1646
  try {
1616
1647
  const stat2 = await backend.stat(path);
1617
1648
  if (stat2.size > MAX_FILE_SIZE) {
@@ -1623,12 +1654,12 @@ async function readViaBackend(backend, path, onRead) {
1623
1654
  limit: MAX_FILE_SIZE
1624
1655
  });
1625
1656
  }
1626
- const content = await backend.readFile(path);
1627
- if (content.includes("\0")) {
1657
+ const raw = await backend.readFile(path);
1658
+ if (raw.includes("\0")) {
1628
1659
  return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1629
1660
  }
1630
1661
  onRead?.(stat2.mtimeMs);
1631
- return JSON.stringify({ ok: true, content, size: stat2.size });
1662
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1632
1663
  } catch (err) {
1633
1664
  if (err instanceof filesystem.FileNotFoundError) {
1634
1665
  return JSON.stringify({ ok: false, error: "not_found", path });
@@ -1639,14 +1670,17 @@ async function readViaBackend(backend, path, onRead) {
1639
1670
  throw err;
1640
1671
  }
1641
1672
  }
1642
- function resolveBoundary(path, projectRoot) {
1673
+ function resolveBoundary(path$1, projectRoot, allowAbsolute) {
1674
+ if (allowAbsolute && path.isAbsolute(path$1)) {
1675
+ return { absolutePath: path$1 };
1676
+ }
1643
1677
  try {
1644
- const absolutePath = safePathJoin(projectRoot, path);
1678
+ const absolutePath = safePathJoin(projectRoot, path$1);
1645
1679
  assertNoSymlinkEscape(absolutePath, projectRoot);
1646
1680
  return { absolutePath };
1647
1681
  } catch (err) {
1648
1682
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1649
- return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
1683
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
1650
1684
  }
1651
1685
  throw err;
1652
1686
  }
@@ -1663,7 +1697,7 @@ async function openHandleSafe(absolutePath, path) {
1663
1697
  throw err;
1664
1698
  }
1665
1699
  }
1666
- async function readContent(handle, path, onRead) {
1700
+ async function readContent(handle, path, view, onRead) {
1667
1701
  const stat2 = await handle.stat();
1668
1702
  if (stat2.size > MAX_FILE_SIZE) {
1669
1703
  return JSON.stringify({
@@ -1677,9 +1711,9 @@ async function readContent(handle, path, onRead) {
1677
1711
  if (await isBinaryProbe(handle, Number(stat2.size))) {
1678
1712
  return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1679
1713
  }
1680
- const content = await handle.readFile({ encoding: "utf-8" });
1714
+ const raw = await handle.readFile({ encoding: "utf-8" });
1681
1715
  onRead?.(stat2.mtimeMs);
1682
- return JSON.stringify({ ok: true, content, size: stat2.size });
1716
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1683
1717
  }
1684
1718
  async function isBinaryProbe(handle, size) {
1685
1719
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -1859,27 +1893,36 @@ function createSearchTextTool(opts) {
1859
1893
  projectRoot,
1860
1894
  maxMatches = DEFAULT_MAX_MATCHES,
1861
1895
  maxFileSize = DEFAULT_MAX_FILE_SIZE,
1862
- filesystem: filesystem$1
1896
+ filesystem: filesystem$1,
1897
+ regex = false,
1898
+ allowAbsolute = false
1863
1899
  } = opts;
1900
+ const queryKind = regex ? "a JavaScript REGULAR EXPRESSION" : "LITERAL, CASE-SENSITIVE text";
1901
+ const queryMatch = regex ? "matched as a regex" : "matched as a substring, not a regex";
1864
1902
  return sdk.Tool.create({
1865
1903
  name: "search_text",
1866
- description: `Search file CONTENTS for a LITERAL, CASE-SENSITIVE query across the project tree (the query is matched as a substring, not a regex). Use search_text when you know the content; use glob_files when you know the filename shape; use read_file when you know the exact path. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB; 'path' scopes the search to a subdirectory. Returns up to ${String(maxMatches)} matches as { file, line, preview } \u2014 cite locations to the user as file:line. Returns { ok, matches } or { ok: false, error }.`,
1904
+ description: `Search file CONTENTS for ${queryKind} across the project tree (the query is ${queryMatch}). Use search_text when you know the content; use glob_files when you know the filename shape; use read_file when you know the exact path. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB; 'path' scopes the search to a subdirectory. Returns up to ${String(maxMatches)} matches as { file, line, preview } \u2014 cite locations to the user as file:line. Returns { ok, matches } or { ok: false, error }.`,
1867
1905
  inputSchema: zod.z.object({
1868
- query: zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1869
- path: zod.z.string().optional().describe("Optional project-relative directory to scope the search.")
1906
+ query: regex ? zod.z.string().min(1).describe("A JavaScript regular expression, e.g. 'function\\\\s+main'.") : zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1907
+ path: zod.z.string().optional().describe(
1908
+ "Optional directory to scope the search (project-relative; absolute when allowed)."
1909
+ )
1870
1910
  }),
1871
1911
  handler: async ({ query, path }, ctx) => {
1912
+ const built = buildMatcher(query, regex);
1913
+ if ("error" in built) return built.error;
1914
+ const matcher = built.matcher;
1872
1915
  const state = {
1873
1916
  matches: [],
1874
1917
  totalMatches: 0,
1875
1918
  truncated: false,
1876
- query,
1919
+ matcher,
1877
1920
  maxMatches,
1878
1921
  maxFileSize,
1879
1922
  projectRoot
1880
1923
  };
1881
1924
  if (filesystem$1 !== void 0) {
1882
- const scopeRel = resolveScopeRel(path, projectRoot);
1925
+ const scopeRel = resolveScopeRel(path, projectRoot, allowAbsolute);
1883
1926
  if ("error" in scopeRel) return scopeRel.error;
1884
1927
  const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
1885
1928
  await walkBackend(backend, scopeRel.rel, state, 0);
@@ -1890,7 +1933,7 @@ function createSearchTextTool(opts) {
1890
1933
  totalMatches: state.totalMatches
1891
1934
  });
1892
1935
  }
1893
- const scope = resolveSearchScope(path, projectRoot);
1936
+ const scope = resolveSearchScope(path, projectRoot, allowAbsolute);
1894
1937
  if ("error" in scope) return scope.error;
1895
1938
  await walk(scope.scopeAbs, state);
1896
1939
  return JSON.stringify({
@@ -1902,15 +1945,27 @@ function createSearchTextTool(opts) {
1902
1945
  }
1903
1946
  });
1904
1947
  }
1905
- function resolveSearchScope(path, projectRoot) {
1906
- const scopeRel = path === void 0 || path === "" || path === "." ? "." : path;
1948
+ function buildMatcher(query, regex) {
1949
+ if (!regex) return { matcher: (line) => line.includes(query) };
1950
+ try {
1951
+ const re = new RegExp(query);
1952
+ return { matcher: (line) => re.test(line) };
1953
+ } catch {
1954
+ return { error: JSON.stringify({ ok: false, error: "invalid_regex", query }) };
1955
+ }
1956
+ }
1957
+ function resolveSearchScope(path$1, projectRoot, allowAbsolute) {
1958
+ const scopeRel = path$1 === void 0 || path$1 === "" || path$1 === "." ? "." : path$1;
1959
+ if (allowAbsolute && path.isAbsolute(scopeRel)) {
1960
+ return { scopeAbs: scopeRel };
1961
+ }
1907
1962
  try {
1908
1963
  const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
1909
1964
  assertNoSymlinkEscape(scopeAbs, projectRoot);
1910
1965
  return { scopeAbs };
1911
1966
  } catch (err) {
1912
1967
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1913
- return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
1968
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
1914
1969
  }
1915
1970
  throw err;
1916
1971
  }
@@ -1975,19 +2030,20 @@ async function scanFile(absPath, relPath, state) {
1975
2030
  const lines = buffer.toString("utf-8").split("\n");
1976
2031
  for (let i = 0; i < lines.length; i += 1) {
1977
2032
  const line = lines[i];
1978
- if (!line.includes(state.query)) continue;
2033
+ if (!state.matcher(line)) continue;
1979
2034
  if (!recordMatch(state, relPath, i + 1, line)) return;
1980
2035
  }
1981
2036
  }
1982
- function resolveScopeRel(path, projectRoot) {
1983
- const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
2037
+ function resolveScopeRel(path$1, projectRoot, allowAbsolute) {
2038
+ const scopeRel = path$1 === void 0 || path$1 === "" || path$1 === "." ? "" : path$1;
1984
2039
  if (scopeRel === "") return { rel: "" };
2040
+ if (allowAbsolute && path.isAbsolute(scopeRel)) return { rel: scopeRel };
1985
2041
  try {
1986
2042
  assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
1987
2043
  return { rel: scopeRel };
1988
2044
  } catch (err) {
1989
2045
  if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1990
- return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
2046
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path: path$1 }) };
1991
2047
  }
1992
2048
  throw err;
1993
2049
  }
@@ -2033,7 +2089,7 @@ async function scanFileBackend(backend, relPath, size, state) {
2033
2089
  const lines = content.split("\n");
2034
2090
  for (let i = 0; i < lines.length; i += 1) {
2035
2091
  const line = lines[i];
2036
- if (!line.includes(state.query)) continue;
2092
+ if (!state.matcher(line)) continue;
2037
2093
  if (!recordMatch(state, relPath, i + 1, line)) return;
2038
2094
  }
2039
2095
  }