@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/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
 
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
 
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);