@theokit/sdk-tools 0.17.0 → 0.18.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `createReadFileTool` gains three ADDITIVE, opt-in Codex-grade capabilities (all default OFF, so existing
8
+ consumers are byte-identical): `lineNumbers` (render a `cat -n` `<n>\t<line>` view so the model can cite/
9
+ edit by line), `offset`/`limit` input params (page through a large file), and `allowAbsolute` (honor an
10
+ absolute path outside `projectRoot` — the Codex read-only "reads-anywhere" sandbox). Security: with
11
+ `allowAbsolute`, the secret guard now blocks `.env`/`.git`/`node_modules`/`.theo` at ANY path depth (not
12
+ just the project-relative first segment), closing an absolute-path exfiltration hole. Opt-in only.
13
+
3
14
  ## 0.17.0
4
15
 
5
16
  ### 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);