@theokit/sdk-tools 0.8.0 → 0.9.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3af329f: **SE31 — `Filesystem` provider seam (`@theokit/sdk/filesystem`).**
8
+
9
+ A pluggable filesystem _storage_ provider, the storage-side twin of `@theokit/sdk/sandbox`. `FilesystemBackend` is an abstract class with four methods (`readFile` / `writeFile` / `stat` / `list`), an `exists()` derived on the base, a boundary `basePath`, a `readOnly` flag, structured `stat().mtimeMs` (the read-before-write oracle for SE32), and typed errors (`FileNotFoundError` / `FilesystemSecurityError` / `FilesystemReadOnlyError` / `StaleFileError`). `LocalFilesystem` is the local-process implementation, boundary-enforced by reusing the core path-guard (traversal + symlink escape → `FilesystemSecurityError`). `FilesystemProvider` + `resolveFilesystem` support a per-request resolver `(ctx) => FilesystemBackend` for multi-tenant roots.
10
+
11
+ Unlike `SandboxBackend` (whose file ops shell out via `execute`, require command execution, and give no structured `stat`), a `FilesystemBackend` serves a filesystem-only workspace with no sandbox — see ADR 0011 for why file ops are NOT routed through `SandboxBackend`. `@theokit/sdk-tools`' `createWriteFileTool` now accepts an optional `filesystem` backend (writes route through it; omitted ⇒ identical local-`projectRoot` behavior). This is the backend seam, NOT a bundled `Workspace` and NOT a new toolset — bring-your-own-tools stands; `mounts`/FUSE, S3/GCS, and LSP remain out of core. From the Mastra Workspaces comparison (SDK Evolution roadmap SE31).
12
+
13
+ - 84df83a: **SE32 — read-before-write safety (`requireReadBeforeWrite` + `ReadTracker`).**
14
+
15
+ An opt-in guard on `createWriteFileTool` that refuses to blindly overwrite a file the agent has not seen. A per-run `ReadTracker` (exported from `@theokit/sdk-tools`) records each file's mtime when `createReadFileTool` reads it; when `createWriteFileTool` is created with `{ requireReadBeforeWrite: true, readTracker }`, a write is refused with `read_required` if the existing file was never read, or `stale_file` if it changed on disk since it was read. A NEW file writes freely (nothing to clobber). Default OFF — omitting the flag preserves current behavior exactly.
16
+
17
+ Works on both the local `projectRoot` path and the SE31 `filesystem` backend path (the backend also gets `expectedMtime` forwarded so it re-checks at write time — TOCTOU defense). The tracker is deliberately per-instance, not a global singleton, so state never leaks across runs. `edit_file` already has implicit read-before-write safety via `old_string` content matching, so the guard targets the blind-overwrite path (`write_file`). Mirrors Mastra Workspaces' read-before-write (`FileReadRequiredError` / `StaleFileError`). From the Mastra Workspaces comparison (SDK Evolution roadmap SE32).
18
+
3
19
  ## 0.8.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -10,6 +10,7 @@ var pathSafety = require('@theokit/sdk/path-safety');
10
10
  var child_process = require('child_process');
11
11
  var promises$1 = require('dns/promises');
12
12
  var net = require('net');
13
+ var filesystem = require('@theokit/sdk/filesystem');
13
14
 
14
15
  // src/apply-patch.ts
15
16
  var PathTraversalError = class extends sdk.ConfigurationError {
@@ -1054,7 +1055,10 @@ function withToolResultGuidance(tool, guidance) {
1054
1055
  name: tool.name,
1055
1056
  description: tool.description,
1056
1057
  inputSchema: tool.inputSchema,
1057
- handler: async (input) => injectGuidance(await tool.handler(input), guidance)
1058
+ handler: async (input) => {
1059
+ const out = await tool.handler(input);
1060
+ return typeof out === "string" ? injectGuidance(out, guidance) : out;
1061
+ }
1058
1062
  };
1059
1063
  }
1060
1064
  function withDefaultGuidance(tool) {
@@ -1068,6 +1072,7 @@ function withShellExitGuidance(tool) {
1068
1072
  inputSchema: tool.inputSchema,
1069
1073
  handler: async (input) => {
1070
1074
  const out = await tool.handler(input);
1075
+ if (typeof out !== "string") return out;
1071
1076
  let parsed;
1072
1077
  try {
1073
1078
  parsed = JSON.parse(out);
@@ -1264,7 +1269,7 @@ function createQuestionTool(opts) {
1264
1269
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
1265
1270
  var BINARY_PROBE_BYTES = 8 * 1024;
1266
1271
  function createReadFileTool(opts) {
1267
- const { projectRoot } = opts;
1272
+ const { projectRoot, readTracker } = opts;
1268
1273
  return sdk.defineTool({
1269
1274
  name: "read_file",
1270
1275
  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 }.",
@@ -1280,7 +1285,11 @@ function createReadFileTool(opts) {
1280
1285
  const opened = await openHandleSafe(boundary.absolutePath, path);
1281
1286
  if ("error" in opened) return opened.error;
1282
1287
  try {
1283
- return await readContent(opened.handle, path);
1288
+ return await readContent(
1289
+ opened.handle,
1290
+ path,
1291
+ (mtimeMs) => readTracker?.record(path, mtimeMs)
1292
+ );
1284
1293
  } finally {
1285
1294
  await opened.handle.close();
1286
1295
  }
@@ -1311,7 +1320,7 @@ async function openHandleSafe(absolutePath, path) {
1311
1320
  throw err;
1312
1321
  }
1313
1322
  }
1314
- async function readContent(handle, path) {
1323
+ async function readContent(handle, path, onRead) {
1315
1324
  const stat2 = await handle.stat();
1316
1325
  if (stat2.size > MAX_FILE_SIZE) {
1317
1326
  return JSON.stringify({
@@ -1326,6 +1335,7 @@ async function readContent(handle, path) {
1326
1335
  return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
1327
1336
  }
1328
1337
  const content = await handle.readFile({ encoding: "utf-8" });
1338
+ onRead?.(stat2.mtimeMs);
1329
1339
  return JSON.stringify({ ok: true, content, size: stat2.size });
1330
1340
  }
1331
1341
  async function isBinaryProbe(handle, size) {
@@ -1338,6 +1348,26 @@ async function isBinaryProbe(handle, size) {
1338
1348
  }
1339
1349
  return false;
1340
1350
  }
1351
+
1352
+ // src/read-tracker.ts
1353
+ var ReadTracker = class {
1354
+ seen = /* @__PURE__ */ new Map();
1355
+ /** Record the mtime observed when `path` was read. */
1356
+ record(path, mtimeMs) {
1357
+ this.seen.set(path, mtimeMs);
1358
+ }
1359
+ /** The mtime last recorded for `path`, or `undefined` if never read. */
1360
+ expected(path) {
1361
+ return this.seen.get(path);
1362
+ }
1363
+ };
1364
+ function evaluateReadBeforeWrite(tracker, path, currentMtimeMs) {
1365
+ if (currentMtimeMs === null) return "ok";
1366
+ const recorded = tracker.expected(path);
1367
+ if (recorded === void 0) return "read_required";
1368
+ if (recorded !== currentMtimeMs) return "stale";
1369
+ return "ok";
1370
+ }
1341
1371
  var DEFAULT_TIMEOUT_MS2 = 12e4;
1342
1372
  var DEFAULT_MAX_STDOUT_BYTES2 = 10 * 1024 * 1024;
1343
1373
  function createRunVitestTool(opts) {
@@ -1970,38 +2000,111 @@ function createGenericHttpSearchAdapter(opts = {}) {
1970
2000
  }
1971
2001
  var BINARY_PROBE_BYTES3 = 8 * 1024;
1972
2002
  function createWriteFileTool(opts) {
1973
- const { projectRoot } = opts;
2003
+ const { projectRoot, filesystem: filesystem$1 } = opts;
2004
+ if (opts.requireReadBeforeWrite && !opts.readTracker) {
2005
+ throw new Error(
2006
+ "createWriteFileTool: requireReadBeforeWrite is true but no readTracker was provided \u2014 pass the same ReadTracker instance to createReadFileTool and createWriteFileTool."
2007
+ );
2008
+ }
2009
+ const guard = opts.requireReadBeforeWrite ? opts.readTracker : void 0;
1974
2010
  return sdk.defineTool({
1975
2011
  name: "write_file",
1976
- description: "Write UTF-8 content to a project-relative file, creating parent directories as needed. OVERWRITES any existing file at the path. Prefer editing an existing file with edit_file over rewriting it; use write_file to create a NEW file or fully replace a small one. If the file already exists, read_file it first so you do not discard content you have not seen. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
2012
+ description: "Write UTF-8 content to a project-relative file, creating parent directories as needed. OVERWRITES any existing file at the path. Prefer editing an existing file with edit_file over rewriting it; use write_file to create a NEW file or fully replace a small one. If the file already exists, read_file it first so you do not discard content you have not seen. Refuses paths that escape the write root and sensitive files (.env, .git/, node_modules/, .theo/, lock files); the default local root also refuses binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
1977
2013
  inputSchema: zod.z.object({
1978
2014
  path: zod.z.string().min(1).describe("Project-relative file path."),
1979
2015
  content: zod.z.string().describe("UTF-8 content to write.")
1980
2016
  }),
1981
- handler: async ({ path: path$1, content }) => {
1982
- if (isForbiddenPath(path$1)) {
1983
- return JSON.stringify({ ok: false, error: "forbidden_path", path: path$1 });
1984
- }
1985
- let absolutePath;
1986
- try {
1987
- absolutePath = safePathJoin(projectRoot, path$1);
1988
- assertNoSymlinkEscape(absolutePath, projectRoot);
1989
- } catch (err) {
1990
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1991
- return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
1992
- }
1993
- throw err;
2017
+ handler: async ({ path, content }, ctx) => {
2018
+ if (isForbiddenPath(path)) {
2019
+ return JSON.stringify({ ok: false, error: "forbidden_path", path });
1994
2020
  }
1995
- if (await isBinaryFile(absolutePath)) {
1996
- return JSON.stringify({ ok: false, error: "binary_file", path: path$1 });
2021
+ if (filesystem$1) {
2022
+ const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
2023
+ return writeViaBackend(backend, path, content, guard);
1997
2024
  }
1998
- await promises.mkdir(path.dirname(absolutePath), { recursive: true });
1999
- await promises.writeFile(absolutePath, content, "utf-8");
2000
- const bytes = Buffer.byteLength(content, "utf-8");
2001
- return JSON.stringify({ ok: true, path: path$1, bytes });
2025
+ return writeViaLocalFs(projectRoot, path, content, guard);
2002
2026
  }
2003
2027
  });
2004
2028
  }
2029
+ function readBeforeWriteError(guard, path, currentMtimeMs) {
2030
+ if (!guard) return null;
2031
+ const decision = evaluateReadBeforeWrite(guard, path, currentMtimeMs);
2032
+ if (decision === "read_required")
2033
+ return JSON.stringify({ ok: false, error: "read_required", path });
2034
+ if (decision === "stale") return JSON.stringify({ ok: false, error: "stale_file", path });
2035
+ return null;
2036
+ }
2037
+ async function writeViaLocalFs(projectRoot, path$1, content, guard) {
2038
+ let absolutePath;
2039
+ try {
2040
+ absolutePath = safePathJoin(projectRoot, path$1);
2041
+ assertNoSymlinkEscape(absolutePath, projectRoot);
2042
+ } catch (err) {
2043
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
2044
+ return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
2045
+ }
2046
+ throw err;
2047
+ }
2048
+ const rbw = readBeforeWriteError(guard, path$1, await statMtimeOrNull(absolutePath));
2049
+ if (rbw) return rbw;
2050
+ if (await isBinaryFile(absolutePath)) {
2051
+ return JSON.stringify({ ok: false, error: "binary_file", path: path$1 });
2052
+ }
2053
+ await promises.mkdir(path.dirname(absolutePath), { recursive: true });
2054
+ await promises.writeFile(absolutePath, content, "utf-8");
2055
+ const bytes = Buffer.byteLength(content, "utf-8");
2056
+ return JSON.stringify({ ok: true, path: path$1, bytes });
2057
+ }
2058
+ async function statMtimeOrNull(absolutePath) {
2059
+ try {
2060
+ return (await promises.stat(absolutePath)).mtimeMs;
2061
+ } catch (err) {
2062
+ if (err.code === "ENOENT") return null;
2063
+ throw err;
2064
+ }
2065
+ }
2066
+ async function backendMtimeOrNull(backend, path) {
2067
+ try {
2068
+ return (await backend.stat(path)).mtimeMs;
2069
+ } catch (err) {
2070
+ if (err instanceof filesystem.FileNotFoundError) return null;
2071
+ throw err;
2072
+ }
2073
+ }
2074
+ async function writeViaBackend(backend, path, content, guard) {
2075
+ try {
2076
+ let expectedMtime;
2077
+ if (guard) {
2078
+ const current = await backendMtimeOrNull(backend, path);
2079
+ const rbw = readBeforeWriteError(guard, path, current);
2080
+ if (rbw) return rbw;
2081
+ expectedMtime = current ?? void 0;
2082
+ }
2083
+ const stat2 = await backend.writeFile(
2084
+ path,
2085
+ content,
2086
+ expectedMtime !== void 0 ? { expectedMtime } : void 0
2087
+ );
2088
+ return JSON.stringify({ ok: true, path, bytes: stat2.size });
2089
+ } catch (err) {
2090
+ return backendErrorToJson(err, path);
2091
+ }
2092
+ }
2093
+ function backendErrorToJson(err, path) {
2094
+ if (err instanceof filesystem.FilesystemSecurityError) {
2095
+ return JSON.stringify({ ok: false, error: "path_traversal", path });
2096
+ }
2097
+ if (err instanceof filesystem.FilesystemReadOnlyError) {
2098
+ return JSON.stringify({ ok: false, error: "read_only", path });
2099
+ }
2100
+ if (err instanceof filesystem.StaleFileError) {
2101
+ return JSON.stringify({ ok: false, error: "stale_file", path });
2102
+ }
2103
+ if (err instanceof filesystem.FilesystemError) {
2104
+ return JSON.stringify({ ok: false, error: "write_failed", path });
2105
+ }
2106
+ throw err;
2107
+ }
2005
2108
  async function isBinaryFile(absolutePath) {
2006
2109
  let handle;
2007
2110
  try {
@@ -2026,6 +2129,7 @@ async function isBinaryFile(absolutePath) {
2026
2129
 
2027
2130
  exports.CatastrophicCommandError = CatastrophicCommandError;
2028
2131
  exports.DEFAULT_TOOL_GUIDANCE = DEFAULT_TOOL_GUIDANCE;
2132
+ exports.ReadTracker = ReadTracker;
2029
2133
  exports.RedirectBlockedError = RedirectBlockedError;
2030
2134
  exports.SsrfBlockedError = SsrfBlockedError;
2031
2135
  exports.buildEnvContext = buildEnvContext;