@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/dist/index.d.cts CHANGED
@@ -671,6 +671,18 @@ interface CreateReadFileToolOptions {
671
671
  * identical current behavior (multi-tenant / per-request roots).
672
672
  */
673
673
  filesystem?: FilesystemProvider;
674
+ /**
675
+ * M17 — render a `cat -n`-style numbered view (`<n>\t<line>`) instead of raw content, so the model can
676
+ * cite / edit by line number (Codex / Claude-Code read idiom). Default `false` ⇒ raw content (unchanged).
677
+ */
678
+ lineNumbers?: boolean;
679
+ /**
680
+ * M17 — opt-in "reads-anywhere" (Codex read-only sandbox): honor an ABSOLUTE `path` outside `projectRoot`
681
+ * instead of rejecting it as traversal. The `isForbiddenPath` secret guard STILL fires first, so `.env`/
682
+ * `.git`/lock files remain blocked. Default `false` ⇒ absolute paths rejected (unchanged). Opt-in only —
683
+ * this widens the read boundary to the whole filesystem; enable only for a trusted local agent.
684
+ */
685
+ allowAbsolute?: boolean;
674
686
  }
675
687
  declare function createReadFileTool(opts: CreateReadFileToolOptions): CustomTool;
676
688
 
@@ -768,6 +780,12 @@ interface CreateSearchTextToolOptions {
768
780
  /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
769
781
  * through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
770
782
  filesystem?: FilesystemProvider;
783
+ /** M17 — treat `query` as a JavaScript RegExp instead of a literal substring (grep semantics). An
784
+ * invalid pattern returns `{ ok: false, error: 'invalid_regex' }`. Default `false` ⇒ literal (unchanged). */
785
+ regex?: boolean;
786
+ /** M17 — opt-in "reads-anywhere": honor an ABSOLUTE `path` scope outside `projectRoot` (Codex read-only
787
+ * sandbox). Forbidden dirs are still skipped. Default `false` ⇒ absolute scope rejected (unchanged). */
788
+ allowAbsolute?: boolean;
771
789
  }
772
790
  declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
773
791
 
package/dist/index.d.ts CHANGED
@@ -671,6 +671,18 @@ interface CreateReadFileToolOptions {
671
671
  * identical current behavior (multi-tenant / per-request roots).
672
672
  */
673
673
  filesystem?: FilesystemProvider;
674
+ /**
675
+ * M17 — render a `cat -n`-style numbered view (`<n>\t<line>`) instead of raw content, so the model can
676
+ * cite / edit by line number (Codex / Claude-Code read idiom). Default `false` ⇒ raw content (unchanged).
677
+ */
678
+ lineNumbers?: boolean;
679
+ /**
680
+ * M17 — opt-in "reads-anywhere" (Codex read-only sandbox): honor an ABSOLUTE `path` outside `projectRoot`
681
+ * instead of rejecting it as traversal. The `isForbiddenPath` secret guard STILL fires first, so `.env`/
682
+ * `.git`/lock files remain blocked. Default `false` ⇒ absolute paths rejected (unchanged). Opt-in only —
683
+ * this widens the read boundary to the whole filesystem; enable only for a trusted local agent.
684
+ */
685
+ allowAbsolute?: boolean;
674
686
  }
675
687
  declare function createReadFileTool(opts: CreateReadFileToolOptions): CustomTool;
676
688
 
@@ -768,6 +780,12 @@ interface CreateSearchTextToolOptions {
768
780
  /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
769
781
  * through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
770
782
  filesystem?: FilesystemProvider;
783
+ /** M17 — treat `query` as a JavaScript RegExp instead of a literal substring (grep semantics). An
784
+ * invalid pattern returns `{ ok: false, error: 'invalid_regex' }`. Default `false` ⇒ literal (unchanged). */
785
+ regex?: boolean;
786
+ /** M17 — opt-in "reads-anywhere": honor an ABSOLUTE `path` scope outside `projectRoot` (Codex read-only
787
+ * sandbox). Forbidden dirs are still skipped. Default `false` ⇒ absolute scope rejected (unchanged). */
788
+ allowAbsolute?: boolean;
771
789
  }
772
790
  declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
773
791
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile, copyFile, mkdir, writeFile, readdir, open, stat } from 'fs/promises';
2
- import { dirname, join, relative, resolve, sep } from 'path';
2
+ import { dirname, join, relative, isAbsolute, resolve, sep } from 'path';
3
3
  import { Tool, ConfigurationError } from '@theokit/sdk';
4
4
  import { z } from 'zod';
5
5
  import { existsSync, statSync, mkdirSync, writeFileSync, realpathSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'fs';
@@ -1577,23 +1577,53 @@ function createQuestionTool(opts) {
1577
1577
  }
1578
1578
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
1579
1579
  var BINARY_PROBE_BYTES = 8 * 1024;
1580
+ var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
1581
+ function isForbiddenAtAnyDepth(path) {
1582
+ const segs = path.replace(/\\/g, "/").split("/").filter(Boolean);
1583
+ return segs.some((s) => {
1584
+ if (s === ".env.example") return false;
1585
+ return SENSITIVE_SEGMENTS.has(s) || /^\.env\./.test(s);
1586
+ });
1587
+ }
1588
+ function forbiddenReadError(path, allowAbsolute) {
1589
+ if (isForbiddenPath(path)) {
1590
+ return JSON.stringify({ ok: false, error: "forbidden_path", path });
1591
+ }
1592
+ if (allowAbsolute && isAbsolute(path) && isForbiddenAtAnyDepth(path)) {
1593
+ return JSON.stringify({ ok: false, error: "forbidden_path", path });
1594
+ }
1595
+ return null;
1596
+ }
1597
+ function renderView(content, opts) {
1598
+ const paginated = opts.offset !== void 0 || opts.limit !== void 0;
1599
+ if (!opts.lineNumbers && !paginated) return content;
1600
+ const lines = content.split("\n");
1601
+ const start = Math.max(1, opts.offset ?? 1);
1602
+ const end = opts.limit !== void 0 ? Math.min(lines.length, start - 1 + opts.limit) : lines.length;
1603
+ const slice = lines.slice(start - 1, end);
1604
+ return opts.lineNumbers ? slice.map((l, i) => `${start + i} ${l}`).join("\n") : slice.join("\n");
1605
+ }
1580
1606
  function createReadFileTool(opts) {
1581
- const { projectRoot, readTracker, filesystem } = opts;
1607
+ const { projectRoot, readTracker, filesystem, lineNumbers, allowAbsolute } = opts;
1608
+ const numbered = lineNumbers === true ? " Returns a cat -n numbered view (`<n>\\t<line>`)." : "";
1609
+ const abs = allowAbsolute === true ? " Absolute paths outside the project are honored." : "";
1582
1610
  return Tool.create({
1583
1611
  name: "read_file",
1584
- 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 }.",
1612
+ 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 }.",
1585
1613
  inputSchema: z.object({
1586
- path: z.string().min(1).describe("Project-relative file path.")
1614
+ path: z.string().min(1).describe("File path (project-relative; absolute when allowed)."),
1615
+ offset: z.number().int().min(1).optional().describe("1-based first line to read (default 1)."),
1616
+ limit: z.number().int().min(1).optional().describe("Max number of lines to read (default: all).")
1587
1617
  }),
1588
- handler: async ({ path }, ctx) => {
1589
- if (isForbiddenPath(path)) {
1590
- return JSON.stringify({ ok: false, error: "forbidden_path", path });
1591
- }
1618
+ handler: async ({ path, offset, limit }, ctx) => {
1619
+ const forbidden = forbiddenReadError(path, allowAbsolute === true);
1620
+ if (forbidden !== null) return forbidden;
1621
+ const view = { lineNumbers, offset, limit };
1592
1622
  if (filesystem) {
1593
1623
  const backend = await resolveFilesystem(filesystem, ctx ?? {});
1594
- return readViaBackend(backend, path, (mtimeMs) => readTracker?.record(path, mtimeMs));
1624
+ return readViaBackend(backend, path, view, (mtimeMs) => readTracker?.record(path, mtimeMs));
1595
1625
  }
1596
- const boundary = resolveBoundary(path, projectRoot);
1626
+ const boundary = resolveBoundary(path, projectRoot, allowAbsolute === true);
1597
1627
  if ("error" in boundary) return boundary.error;
1598
1628
  const opened = await openHandleSafe(boundary.absolutePath, path);
1599
1629
  if ("error" in opened) return opened.error;
@@ -1601,6 +1631,7 @@ function createReadFileTool(opts) {
1601
1631
  return await readContent(
1602
1632
  opened.handle,
1603
1633
  path,
1634
+ view,
1604
1635
  (mtimeMs) => readTracker?.record(path, mtimeMs)
1605
1636
  );
1606
1637
  } finally {
@@ -1609,7 +1640,7 @@ function createReadFileTool(opts) {
1609
1640
  }
1610
1641
  });
1611
1642
  }
1612
- async function readViaBackend(backend, path, onRead) {
1643
+ async function readViaBackend(backend, path, view, onRead) {
1613
1644
  try {
1614
1645
  const stat2 = await backend.stat(path);
1615
1646
  if (stat2.size > MAX_FILE_SIZE) {
@@ -1621,12 +1652,12 @@ async function readViaBackend(backend, path, onRead) {
1621
1652
  limit: MAX_FILE_SIZE
1622
1653
  });
1623
1654
  }
1624
- const content = await backend.readFile(path);
1625
- if (content.includes("\0")) {
1655
+ const raw = await backend.readFile(path);
1656
+ if (raw.includes("\0")) {
1626
1657
  return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1627
1658
  }
1628
1659
  onRead?.(stat2.mtimeMs);
1629
- return JSON.stringify({ ok: true, content, size: stat2.size });
1660
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1630
1661
  } catch (err) {
1631
1662
  if (err instanceof FileNotFoundError) {
1632
1663
  return JSON.stringify({ ok: false, error: "not_found", path });
@@ -1637,7 +1668,10 @@ async function readViaBackend(backend, path, onRead) {
1637
1668
  throw err;
1638
1669
  }
1639
1670
  }
1640
- function resolveBoundary(path, projectRoot) {
1671
+ function resolveBoundary(path, projectRoot, allowAbsolute) {
1672
+ if (allowAbsolute && isAbsolute(path)) {
1673
+ return { absolutePath: path };
1674
+ }
1641
1675
  try {
1642
1676
  const absolutePath = safePathJoin(projectRoot, path);
1643
1677
  assertNoSymlinkEscape(absolutePath, projectRoot);
@@ -1661,7 +1695,7 @@ async function openHandleSafe(absolutePath, path) {
1661
1695
  throw err;
1662
1696
  }
1663
1697
  }
1664
- async function readContent(handle, path, onRead) {
1698
+ async function readContent(handle, path, view, onRead) {
1665
1699
  const stat2 = await handle.stat();
1666
1700
  if (stat2.size > MAX_FILE_SIZE) {
1667
1701
  return JSON.stringify({
@@ -1675,9 +1709,9 @@ async function readContent(handle, path, onRead) {
1675
1709
  if (await isBinaryProbe(handle, Number(stat2.size))) {
1676
1710
  return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1677
1711
  }
1678
- const content = await handle.readFile({ encoding: "utf-8" });
1712
+ const raw = await handle.readFile({ encoding: "utf-8" });
1679
1713
  onRead?.(stat2.mtimeMs);
1680
- return JSON.stringify({ ok: true, content, size: stat2.size });
1714
+ return JSON.stringify({ ok: true, content: renderView(raw, view), size: stat2.size });
1681
1715
  }
1682
1716
  async function isBinaryProbe(handle, size) {
1683
1717
  const probeLen = Math.min(BINARY_PROBE_BYTES, size);
@@ -1857,27 +1891,36 @@ function createSearchTextTool(opts) {
1857
1891
  projectRoot,
1858
1892
  maxMatches = DEFAULT_MAX_MATCHES,
1859
1893
  maxFileSize = DEFAULT_MAX_FILE_SIZE,
1860
- filesystem
1894
+ filesystem,
1895
+ regex = false,
1896
+ allowAbsolute = false
1861
1897
  } = opts;
1898
+ const queryKind = regex ? "a JavaScript REGULAR EXPRESSION" : "LITERAL, CASE-SENSITIVE text";
1899
+ const queryMatch = regex ? "matched as a regex" : "matched as a substring, not a regex";
1862
1900
  return Tool.create({
1863
1901
  name: "search_text",
1864
- 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 }.`,
1902
+ 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 }.`,
1865
1903
  inputSchema: z.object({
1866
- query: z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1867
- path: z.string().optional().describe("Optional project-relative directory to scope the search.")
1904
+ query: regex ? z.string().min(1).describe("A JavaScript regular expression, e.g. 'function\\\\s+main'.") : z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1905
+ path: z.string().optional().describe(
1906
+ "Optional directory to scope the search (project-relative; absolute when allowed)."
1907
+ )
1868
1908
  }),
1869
1909
  handler: async ({ query, path }, ctx) => {
1910
+ const built = buildMatcher(query, regex);
1911
+ if ("error" in built) return built.error;
1912
+ const matcher = built.matcher;
1870
1913
  const state = {
1871
1914
  matches: [],
1872
1915
  totalMatches: 0,
1873
1916
  truncated: false,
1874
- query,
1917
+ matcher,
1875
1918
  maxMatches,
1876
1919
  maxFileSize,
1877
1920
  projectRoot
1878
1921
  };
1879
1922
  if (filesystem !== void 0) {
1880
- const scopeRel = resolveScopeRel(path, projectRoot);
1923
+ const scopeRel = resolveScopeRel(path, projectRoot, allowAbsolute);
1881
1924
  if ("error" in scopeRel) return scopeRel.error;
1882
1925
  const backend = await resolveFilesystem(filesystem, ctx ?? {});
1883
1926
  await walkBackend(backend, scopeRel.rel, state, 0);
@@ -1888,7 +1931,7 @@ function createSearchTextTool(opts) {
1888
1931
  totalMatches: state.totalMatches
1889
1932
  });
1890
1933
  }
1891
- const scope = resolveSearchScope(path, projectRoot);
1934
+ const scope = resolveSearchScope(path, projectRoot, allowAbsolute);
1892
1935
  if ("error" in scope) return scope.error;
1893
1936
  await walk(scope.scopeAbs, state);
1894
1937
  return JSON.stringify({
@@ -1900,8 +1943,20 @@ function createSearchTextTool(opts) {
1900
1943
  }
1901
1944
  });
1902
1945
  }
1903
- function resolveSearchScope(path, projectRoot) {
1946
+ function buildMatcher(query, regex) {
1947
+ if (!regex) return { matcher: (line) => line.includes(query) };
1948
+ try {
1949
+ const re = new RegExp(query);
1950
+ return { matcher: (line) => re.test(line) };
1951
+ } catch {
1952
+ return { error: JSON.stringify({ ok: false, error: "invalid_regex", query }) };
1953
+ }
1954
+ }
1955
+ function resolveSearchScope(path, projectRoot, allowAbsolute) {
1904
1956
  const scopeRel = path === void 0 || path === "" || path === "." ? "." : path;
1957
+ if (allowAbsolute && isAbsolute(scopeRel)) {
1958
+ return { scopeAbs: scopeRel };
1959
+ }
1905
1960
  try {
1906
1961
  const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
1907
1962
  assertNoSymlinkEscape(scopeAbs, projectRoot);
@@ -1973,13 +2028,14 @@ async function scanFile(absPath, relPath, state) {
1973
2028
  const lines = buffer.toString("utf-8").split("\n");
1974
2029
  for (let i = 0; i < lines.length; i += 1) {
1975
2030
  const line = lines[i];
1976
- if (!line.includes(state.query)) continue;
2031
+ if (!state.matcher(line)) continue;
1977
2032
  if (!recordMatch(state, relPath, i + 1, line)) return;
1978
2033
  }
1979
2034
  }
1980
- function resolveScopeRel(path, projectRoot) {
2035
+ function resolveScopeRel(path, projectRoot, allowAbsolute) {
1981
2036
  const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
1982
2037
  if (scopeRel === "") return { rel: "" };
2038
+ if (allowAbsolute && isAbsolute(scopeRel)) return { rel: scopeRel };
1983
2039
  try {
1984
2040
  assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
1985
2041
  return { rel: scopeRel };
@@ -2031,7 +2087,7 @@ async function scanFileBackend(backend, relPath, size, state) {
2031
2087
  const lines = content.split("\n");
2032
2088
  for (let i = 0; i < lines.length; i += 1) {
2033
2089
  const line = lines[i];
2034
- if (!line.includes(state.query)) continue;
2090
+ if (!state.matcher(line)) continue;
2035
2091
  if (!recordMatch(state, relPath, i + 1, line)) return;
2036
2092
  }
2037
2093
  }