@celestea/tools 2.7.1

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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. package/package.json +28 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * File IO for `read_file` / `write_file` / `list_dir`, with the two protections
3
+ * the tool contract promises: **truncation** (a bounded read keeps a huge file
4
+ * from flooding the context) and **binary protection** (a NUL-bearing file is
5
+ * rejected instead of being decoded into mojibake).
6
+ *
7
+ * The guard already arbitrated *where* the path may point; this module only
8
+ * performs the IO and reports structured failures (`<tool>: code=… msg="…"`).
9
+ */
10
+ import { open, readdir, writeFile } from "node:fs/promises";
11
+ import { contractError } from "../errors.js";
12
+ import { ToolFailure } from "../tool-failure.js";
13
+ /** Bytes returned by one `read_file` call (beyond this: `truncated`). */
14
+ export const MAX_READ_BYTES = 256 * 1024;
15
+ /** Entry names returned by one `list_dir` call. */
16
+ export const MAX_DIR_ENTRIES = 1000;
17
+ /** Window inspected for the binary heuristic. */
18
+ export const BINARY_SNIFF_BYTES = 8192;
19
+ /** Default `limit` (lines) for a paged `read_file` when only `offset` is given. */
20
+ export const DEFAULT_READ_LIMIT = 2000;
21
+ /** Bytes read per streaming chunk by `readTextLines`. */
22
+ const READ_CHUNK_BYTES = 64 * 1024;
23
+ /** NUL byte inside the sniff window ⇒ binary (the classic, cheap heuristic). */
24
+ export function isProbablyBinary(bytes) {
25
+ const window = bytes.subarray(0, BINARY_SNIFF_BYTES);
26
+ return window.includes(0);
27
+ }
28
+ export async function readTextFile(path) {
29
+ let handle;
30
+ try {
31
+ handle = await open(path, "r");
32
+ }
33
+ catch (e) {
34
+ throw ioFailure("read_file", "io", describe(e));
35
+ }
36
+ try {
37
+ const stat = await handle.stat();
38
+ if (stat.isDirectory())
39
+ throw ioFailure("read_file", "io", `'${path}' is a directory, not a file`);
40
+ const wanted = Math.min(MAX_READ_BYTES + 1, Math.max(stat.size, 1));
41
+ const buffer = Buffer.alloc(wanted);
42
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
43
+ const bytes = buffer.subarray(0, bytesRead);
44
+ if (isProbablyBinary(bytes)) {
45
+ throw ioFailure("read_file", "binary_file", `'${path}' looks binary (NUL byte in the first ${BINARY_SNIFF_BYTES} bytes)`);
46
+ }
47
+ const truncated = bytesRead > MAX_READ_BYTES || stat.size > MAX_READ_BYTES;
48
+ return { text: bytes.subarray(0, MAX_READ_BYTES).toString("utf8"), truncated, totalBytes: stat.size };
49
+ }
50
+ catch (e) {
51
+ throw e instanceof ToolFailure ? e : ioFailure("read_file", "io", describe(e));
52
+ }
53
+ finally {
54
+ await handle.close().catch(() => undefined);
55
+ }
56
+ }
57
+ /** Accumulates one line window under the byte budget (read_file pagination). */
58
+ class LineWindow {
59
+ captured = [];
60
+ capturedBytes = 0;
61
+ offset;
62
+ limit;
63
+ lineCount = 0;
64
+ totalLines = 0;
65
+ truncated = false;
66
+ constructor(offset, limit) {
67
+ this.offset = offset;
68
+ this.limit = limit;
69
+ }
70
+ /** Admit one line unit (its bytes including the trailing `\n`, when present). */
71
+ admit(unit) {
72
+ this.totalLines += 1;
73
+ const index = this.totalLines - 1;
74
+ if (index < this.offset || this.lineCount >= this.limit || this.truncated)
75
+ return;
76
+ if (this.capturedBytes + unit.length <= MAX_READ_BYTES) {
77
+ this.captured.push(unit);
78
+ this.capturedBytes += unit.length;
79
+ this.lineCount += 1;
80
+ return;
81
+ }
82
+ if (this.lineCount === 0) {
83
+ const prefix = utf8WholePrefix(unit, MAX_READ_BYTES);
84
+ if (prefix.length > 0) {
85
+ this.captured.push(prefix);
86
+ this.capturedBytes += prefix.length;
87
+ this.lineCount += 1;
88
+ }
89
+ }
90
+ this.truncated = true;
91
+ }
92
+ text() {
93
+ return Buffer.concat(this.captured).toString("utf8");
94
+ }
95
+ }
96
+ /**
97
+ * Feed one buffered chunk through the window; complete line units go to
98
+ * `admit`, the unterminated tail is returned as the carry. A tail larger than
99
+ * the budget with no terminator is itself an over-budget line: admit it and
100
+ * flag that the remainder of the line is discarded.
101
+ */
102
+ function drainUnits(data, window, state) {
103
+ let start = 0;
104
+ for (;;) {
105
+ const nl = data.indexOf(0x0a, start);
106
+ if (nl === -1)
107
+ break;
108
+ if (state.discarding)
109
+ state.discarding = false; // this terminator closes the over-budget line
110
+ else
111
+ window.admit(data.subarray(start, nl + 1));
112
+ start = nl + 1;
113
+ }
114
+ let tail = data.subarray(start);
115
+ if (!state.discarding && tail.length > MAX_READ_BYTES) {
116
+ window.admit(tail);
117
+ state.discarding = true;
118
+ tail = Buffer.alloc(0);
119
+ }
120
+ return tail;
121
+ }
122
+ /**
123
+ * Line-window read for `read_file` pagination (W846).
124
+ *
125
+ * A SINGLE streaming pass from byte 0: `readTextFile` only ever returns the
126
+ * first MAX_READ_BYTES from offset 0, so an `offset` past that window needs its
127
+ * own reader. The window itself is bounded by MAX_READ_BYTES; `totalLines`
128
+ * costs a scan to EOF (O(file size)) and is the price of exact
129
+ * `hasMore`/`nextOffset`. `truncated` means the byte budget clipped the window
130
+ * before `limit` lines; a line larger than the budget is returned as a
131
+ * UTF-8-safe prefix and `nextOffset` skips past it (use run_shell for such a
132
+ * pathological line).
133
+ */
134
+ export async function readTextLines(path, offset, limit) {
135
+ let handle;
136
+ try {
137
+ handle = await open(path, "r");
138
+ }
139
+ catch (e) {
140
+ throw ioFailure("read_file", "io", describe(e));
141
+ }
142
+ try {
143
+ const stat = await handle.stat();
144
+ if (stat.isDirectory())
145
+ throw ioFailure("read_file", "io", `'${path}' is a directory, not a file`);
146
+ const totalBytes = stat.size;
147
+ // Binary protection first: a NUL in the sniff window rejects before windowing.
148
+ const sniffLen = Math.min(BINARY_SNIFF_BYTES, totalBytes);
149
+ if (sniffLen > 0) {
150
+ const sniff = Buffer.alloc(sniffLen);
151
+ const { bytesRead } = await handle.read(sniff, 0, sniffLen, 0);
152
+ if (isProbablyBinary(sniff.subarray(0, bytesRead))) {
153
+ throw ioFailure("read_file", "binary_file", `'${path}' looks binary (NUL byte in the first ${BINARY_SNIFF_BYTES} bytes)`);
154
+ }
155
+ }
156
+ const window = new LineWindow(offset, limit);
157
+ const state = { discarding: false };
158
+ let position = 0;
159
+ let carry = Buffer.alloc(0);
160
+ for (;;) {
161
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
162
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
163
+ if (bytesRead === 0)
164
+ break;
165
+ position += bytesRead;
166
+ const data = carry.length === 0 ? chunk.subarray(0, bytesRead) : Buffer.concat([carry, chunk.subarray(0, bytesRead)]);
167
+ carry = drainUnits(data, window, state);
168
+ }
169
+ if (carry.length > 0 && !state.discarding)
170
+ window.admit(carry);
171
+ const hasMore = window.totalLines > offset + window.lineCount;
172
+ return {
173
+ text: window.text(),
174
+ offset,
175
+ limit,
176
+ lineCount: window.lineCount,
177
+ totalLines: window.totalLines,
178
+ hasMore,
179
+ nextOffset: hasMore ? offset + window.lineCount : null,
180
+ truncated: window.truncated,
181
+ totalBytes,
182
+ };
183
+ }
184
+ catch (e) {
185
+ throw e instanceof ToolFailure ? e : ioFailure("read_file", "io", describe(e));
186
+ }
187
+ finally {
188
+ await handle.close().catch(() => undefined);
189
+ }
190
+ }
191
+ /** Longest prefix of `buf` no longer than `max` that ends on a UTF-8 boundary. */
192
+ function utf8WholePrefix(buf, max) {
193
+ if (buf.length <= max)
194
+ return buf;
195
+ let end = max;
196
+ while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80)
197
+ end -= 1;
198
+ return buf.subarray(0, end);
199
+ }
200
+ export async function writeTextFile(path, content) {
201
+ try {
202
+ await writeFile(path, content, "utf8");
203
+ }
204
+ catch (e) {
205
+ throw ioFailure("write_file", "io", describe(e));
206
+ }
207
+ }
208
+ export async function listDirNames(path) {
209
+ let entries;
210
+ try {
211
+ entries = await readdir(path);
212
+ }
213
+ catch (e) {
214
+ throw ioFailure("list_dir", "io", describe(e));
215
+ }
216
+ entries.sort();
217
+ return { names: entries.slice(0, MAX_DIR_ENTRIES), truncated: entries.length > MAX_DIR_ENTRIES, total: entries.length };
218
+ }
219
+ /**
220
+ * The authored omission note (W855).
221
+ *
222
+ * Discipline: this note — and every `truncated` flag it accompanies — means THE
223
+ * BUDGET kept obtainable content out. An upstream that returned an incomplete
224
+ * body is a different fact and keeps its own domain field; it must never be
225
+ * described by this note.
226
+ *
227
+ * The omission is ALWAYS paired with a retrieval instruction (`retrieve`), so
228
+ * the model is never told "there was more" without being told how to get it.
229
+ */
230
+ export function truncationNote(what, shown, total, unit, retrieve) {
231
+ const base = "[truncated] " + what + ": showing first " + String(shown) + " of " + String(total) + " " + unit + " (budget)";
232
+ return retrieve === "" ? base : base + "; " + retrieve;
233
+ }
234
+ function ioFailure(tool, code, message) {
235
+ return new ToolFailure(code, contractError(tool, code, message));
236
+ }
237
+ function describe(e) {
238
+ return e instanceof Error ? e.message : String(e);
239
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The production path-whitelist guard (`crates/tools/src/guard.rs`).
3
+ *
4
+ * Policy:
5
+ * - the **workspace** (`CELESTEA_TOOL_WORKDIR`, default: process cwd) is the
6
+ * only writable root — W768: for a composed SESSION it is that session's own
7
+ * workspace root (passed as a [SessionFsScope]), because a process serves many
8
+ * sessions and one env knob cannot be all of their workspaces;
9
+ * - `CELESTEA_TOOL_ROOTS` is a comma-separated list of extra READ roots
10
+ * (whitelist roots are read-only: the workspace is the writable subset);
11
+ * - **argument-driven, never a name whitelist (W738 P1)**: the guard inspects the
12
+ * ARGUMENTS of the call. Any tool whose arguments carry a path-like value (see
13
+ * [PATH_ARG_KEYS]) is arbitrated; [PATH_ACCESS] only declares *which* access a
14
+ * **known** tool needs — `read` (`read_file`, `list_dir`), `write`
15
+ * (`write_file`), or `self` for the tools that carry their own confinement
16
+ * layer (`run_shell`: the sandbox root; `spawn_worker`: the host-side session
17
+ * RPC). A tool that is NOT declared is checked as a **write**: it may only
18
+ * touch the writable roots. A newly registered tool is therefore constrained
19
+ * by default and can never be fail-OPEN just because nobody added it to a
20
+ * list;
21
+ * - a missing/ill-typed path argument passes through: the tool's own validation
22
+ * reports it, the guard only arbitrates real paths.
23
+ *
24
+ * **Fail closed**: when `CELESTEA_TOOL_ROOTS` is set but an entry cannot be used
25
+ * (missing, not a directory, unlistable), the policy denies every path-bearing
26
+ * call with `code=tool_roots_invalid` instead of silently ignoring the entry —
27
+ * an operator typo must never quietly widen or narrow access.
28
+ *
29
+ * `CELESTEA_TOOL_GUARD=0` skips *mounting* the chain (explicit escape hatch; it
30
+ * never weakens the http policy or the sandbox).
31
+ *
32
+ * W516 (session grants): a host may pass a [PathGuardGrants] view with extra
33
+ * read/write roots read from the session's `grants.json`. Grants are strictly
34
+ * ADDITIVE — the workspace stays writable, env read roots stay read-only, the
35
+ * mount decision is untouched — and a bad grant root is dropped by the host
36
+ * (ignore-the-entry), the exact opposite of the env fail-closed rule above.
37
+ * Both policies are deliberate: env is the operator's posture (a typo must be
38
+ * loud), grants are a per-session widening (ignoring one falls back to least
39
+ * privilege, and a hard failure would only push users to `CELESTEA_TOOL_GUARD=0`).
40
+ */
41
+ import type { ToolDecision, ToolGuard, ToolInput, ToolRegistry } from "@celestea/core";
42
+ import type { SessionFsScope } from "../sandbox/config.js";
43
+ export declare const ENV_TOOL_ROOTS = "CELESTEA_TOOL_ROOTS";
44
+ export declare const ENV_TOOL_WORKDIR = "CELESTEA_TOOL_WORKDIR";
45
+ export declare const ENV_TOOL_GUARD = "CELESTEA_TOOL_GUARD";
46
+ export declare const GUARD_ERROR_PREFIX = "toolguard";
47
+ /** Access a tool needs to its path-like arguments. */
48
+ export type PathAccess = "read" | "write" | "self";
49
+ /**
50
+ * Declared access per **known** tool (W738 P1). `self` = the tool confines the
51
+ * path in its own layer, so this guard stays out of the way. Everything absent
52
+ * from this map is treated as `write` (the fail-closed floor), NOT as `allow`.
53
+ */
54
+ export declare const PATH_ACCESS: ReadonlyMap<string, PathAccess>;
55
+ /**
56
+ * Argument names carrying a path. Deliberately argument-based: a new tool with a
57
+ * `path`/`dir`/`workspace` argument is arbitrated without any registration step.
58
+ */
59
+ export declare const PATH_ARG_KEYS: readonly string[];
60
+ /**
61
+ * Split a root list (platform separator or comma; empty entries skipped).
62
+ *
63
+ * W885: the separator is resolved from the INJECTED platform (default: the
64
+ * host), so a win32 test proves a drive letter survives the split instead of
65
+ * being cut at its colon.
66
+ */
67
+ export declare function parseToolRoots(value: string | undefined, platform?: string): string[];
68
+ export interface PathGuardPolicyInit {
69
+ workspace: string;
70
+ readRoots?: readonly string[];
71
+ /**
72
+ * Extra WRITABLE roots (session grants only, W516). The workspace is always a
73
+ * writable root and can never be removed: grants only ADD roots.
74
+ */
75
+ writeRoots?: readonly string[];
76
+ /** W9: false = a read-only permission; the workspace is NOT a write root. */
77
+ workspaceWritable?: boolean;
78
+ /** Set when the declared roots were unusable → every path call is denied. */
79
+ failClosedReason?: string | null;
80
+ }
81
+ /**
82
+ * Session-grant view of the path policy (W516). Structural on purpose: the
83
+ * tools package never imports the host's grants module. Both lists are already
84
+ * validated + canonicalized by the host (`effectiveGrantsOf`), and neither can
85
+ * *narrow* anything — they are appended to the env-derived roots.
86
+ */
87
+ export interface PathGuardGrants {
88
+ readRoots?: readonly string[];
89
+ writeRoots?: readonly string[];
90
+ /** W9: the permission baseline's write capability (false = read-only). */
91
+ workspaceWritable?: boolean;
92
+ }
93
+ /** Canonical writable workspace + canonical read/write roots (workspace first). */
94
+ export declare class PathGuardPolicy {
95
+ readonly workspace: string;
96
+ readonly readRoots: readonly string[];
97
+ /** Workspace first; grants may only append (never remove or demote). */
98
+ readonly writeRoots: readonly string[];
99
+ readonly failClosedReason: string | null;
100
+ constructor(init: PathGuardPolicyInit);
101
+ /**
102
+ * Policy from the environment (`CELESTEA_TOOL_WORKDIR` + `CELESTEA_TOOL_ROOTS`).
103
+ *
104
+ * W768: `scope` replaces the WORKSPACE with the session's own root. That is the
105
+ * one thing a session may move, and it moves only the writable root plus the
106
+ * implicit read root ([PathGuardPolicy] always lists the workspace first in
107
+ * both) — `CELESTEA_TOOL_ROOTS` keeps contributing exactly the read roots the
108
+ * operator declared, and grants keep appending. No env entry is dropped, so a
109
+ * session cannot end up narrower than the posture it was composed under.
110
+ */
111
+ static fromEnv(env?: NodeJS.ProcessEnv, grants?: PathGuardGrants, scope?: SessionFsScope | null): PathGuardPolicy;
112
+ /** read/list: the canonical target must resolve inside a read root. */
113
+ checkRead(target: string): ToolDecision;
114
+ /**
115
+ * write: the canonical target must land inside ONE writable root. The
116
+ * workspace is always one (§5.6: grants can only add roots); read roots are
117
+ * still read-only and a write root overlapping a read root is rejected by the
118
+ * host before it ever reaches this policy.
119
+ */
120
+ checkWrite(target: string): ToolDecision;
121
+ /** Verbatim legacy message with no extra roots; explicit root list beyond it. */
122
+ private writeDenyMessage;
123
+ private failClosed;
124
+ }
125
+ /**
126
+ * The guard: arbitrates every path-like argument of every tool. Only an explicit
127
+ * `self` declaration (see [PATH_ACCESS]) hands a tool's paths back to its own
128
+ * confinement layer; an unknown tool is checked as a write.
129
+ */
130
+ export declare class PathGuard implements ToolGuard {
131
+ private readonly policy;
132
+ private readonly access;
133
+ constructor(policy: PathGuardPolicy, access?: ReadonlyMap<string, PathAccess>);
134
+ static fromEnv(env?: NodeJS.ProcessEnv, grants?: PathGuardGrants, access?: ReadonlyMap<string, PathAccess>, scope?: SessionFsScope | null): PathGuard;
135
+ check(input: ToolInput): Promise<ToolDecision>;
136
+ /** Every path-like argument must pass; the first denial wins. */
137
+ private checkAll;
138
+ }
139
+ /**
140
+ * Mount the production guard chain. Returns whether it was mounted;
141
+ * `CELESTEA_TOOL_GUARD=0` explicitly opts out (documented escape hatch — the
142
+ * caller is responsible for surfacing that in its own diagnostics).
143
+ */
144
+ export declare function mountProductionGuards(registry: ToolRegistry, env?: NodeJS.ProcessEnv, grants?: PathGuardGrants, scope?: SessionFsScope | null): boolean;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The production path-whitelist guard (`crates/tools/src/guard.rs`).
3
+ *
4
+ * Policy:
5
+ * - the **workspace** (`CELESTEA_TOOL_WORKDIR`, default: process cwd) is the
6
+ * only writable root — W768: for a composed SESSION it is that session's own
7
+ * workspace root (passed as a [SessionFsScope]), because a process serves many
8
+ * sessions and one env knob cannot be all of their workspaces;
9
+ * - `CELESTEA_TOOL_ROOTS` is a comma-separated list of extra READ roots
10
+ * (whitelist roots are read-only: the workspace is the writable subset);
11
+ * - **argument-driven, never a name whitelist (W738 P1)**: the guard inspects the
12
+ * ARGUMENTS of the call. Any tool whose arguments carry a path-like value (see
13
+ * [PATH_ARG_KEYS]) is arbitrated; [PATH_ACCESS] only declares *which* access a
14
+ * **known** tool needs — `read` (`read_file`, `list_dir`), `write`
15
+ * (`write_file`), or `self` for the tools that carry their own confinement
16
+ * layer (`run_shell`: the sandbox root; `spawn_worker`: the host-side session
17
+ * RPC). A tool that is NOT declared is checked as a **write**: it may only
18
+ * touch the writable roots. A newly registered tool is therefore constrained
19
+ * by default and can never be fail-OPEN just because nobody added it to a
20
+ * list;
21
+ * - a missing/ill-typed path argument passes through: the tool's own validation
22
+ * reports it, the guard only arbitrates real paths.
23
+ *
24
+ * **Fail closed**: when `CELESTEA_TOOL_ROOTS` is set but an entry cannot be used
25
+ * (missing, not a directory, unlistable), the policy denies every path-bearing
26
+ * call with `code=tool_roots_invalid` instead of silently ignoring the entry —
27
+ * an operator typo must never quietly widen or narrow access.
28
+ *
29
+ * `CELESTEA_TOOL_GUARD=0` skips *mounting* the chain (explicit escape hatch; it
30
+ * never weakens the http policy or the sandbox).
31
+ *
32
+ * W516 (session grants): a host may pass a [PathGuardGrants] view with extra
33
+ * read/write roots read from the session's `grants.json`. Grants are strictly
34
+ * ADDITIVE — the workspace stays writable, env read roots stay read-only, the
35
+ * mount decision is untouched — and a bad grant root is dropped by the host
36
+ * (ignore-the-entry), the exact opposite of the env fail-closed rule above.
37
+ * Both policies are deliberate: env is the operator's posture (a typo must be
38
+ * loud), grants are a per-session widening (ignoring one falls back to least
39
+ * privilege, and a hard failure would only push users to `CELESTEA_TOOL_GUARD=0`).
40
+ */
41
+ import { resolve } from "node:path";
42
+ import { envFlag, envString } from "../env.js";
43
+ import { contractError } from "../errors.js";
44
+ import { pathDelimiter } from "../platform/paths.js";
45
+ import { absolutize, isDirectory, isInside, resolveExistingTarget, resolveWriteTarget } from "./paths.js";
46
+ export const ENV_TOOL_ROOTS = "CELESTEA_TOOL_ROOTS";
47
+ export const ENV_TOOL_WORKDIR = "CELESTEA_TOOL_WORKDIR";
48
+ export const ENV_TOOL_GUARD = "CELESTEA_TOOL_GUARD";
49
+ export const GUARD_ERROR_PREFIX = "toolguard";
50
+ const ALLOW = { kind: "allow" };
51
+ /**
52
+ * Declared access per **known** tool (W738 P1). `self` = the tool confines the
53
+ * path in its own layer, so this guard stays out of the way. Everything absent
54
+ * from this map is treated as `write` (the fail-closed floor), NOT as `allow`.
55
+ */
56
+ export const PATH_ACCESS = new Map([
57
+ ["read_file", "read"],
58
+ ["list_dir", "read"],
59
+ // W819-7: read_image(path=...) is the documented peer of read_file under
60
+ // the same sandbox guard (docs/feature-multimodal-attachments.md 5.5);
61
+ // leaving it undeclared made the write floor refuse a readable root.
62
+ ["read_image", "read"],
63
+ ["write_file", "write"],
64
+ ["run_shell", "self"],
65
+ ["spawn_worker", "self"],
66
+ ]);
67
+ /**
68
+ * Argument names carrying a path. Deliberately argument-based: a new tool with a
69
+ * `path`/`dir`/`workspace` argument is arbitrated without any registration step.
70
+ */
71
+ export const PATH_ARG_KEYS = [
72
+ "path",
73
+ "paths",
74
+ "file",
75
+ "files",
76
+ "dir",
77
+ "dirs",
78
+ "directory",
79
+ "workdir",
80
+ "cwd",
81
+ "root",
82
+ "roots",
83
+ "workspace",
84
+ ];
85
+ /**
86
+ * Platform path-list separator: `:` on unix, `;` on windows — the same
87
+ * semantics as `std::env::split_paths`. A comma is ALSO accepted (the
88
+ * earlier TS-only documentation used commas), so `CELESTEA_TOOL_ROOTS` may be
89
+ * written either way.
90
+ *
91
+ * Note the platform distinction matters: a windows drive letter (`C:\dir`)
92
+ * must not be split on `:`.
93
+ */
94
+ function listSeparator(platform) {
95
+ return new RegExp(`[${pathDelimiter(platform)},]`);
96
+ }
97
+ /**
98
+ * Split a root list (platform separator or comma; empty entries skipped).
99
+ *
100
+ * W885: the separator is resolved from the INJECTED platform (default: the
101
+ * host), so a win32 test proves a drive letter survives the split instead of
102
+ * being cut at its colon.
103
+ */
104
+ export function parseToolRoots(value, platform = process.platform) {
105
+ if (value === undefined)
106
+ return [];
107
+ return value
108
+ .split(listSeparator(platform))
109
+ .map((entry) => entry.trim())
110
+ .filter((entry) => entry !== "");
111
+ }
112
+ /** Canonical writable workspace + canonical read/write roots (workspace first). */
113
+ export class PathGuardPolicy {
114
+ workspace;
115
+ readRoots;
116
+ /** Workspace first; grants may only append (never remove or demote). */
117
+ writeRoots;
118
+ failClosedReason;
119
+ constructor(init) {
120
+ this.workspace = init.workspace;
121
+ this.readRoots = [init.workspace, ...(init.readRoots ?? [])];
122
+ this.writeRoots = init.workspaceWritable === false ? [...(init.writeRoots ?? [])] : [init.workspace, ...(init.writeRoots ?? [])];
123
+ this.failClosedReason = init.failClosedReason ?? null;
124
+ }
125
+ /**
126
+ * Policy from the environment (`CELESTEA_TOOL_WORKDIR` + `CELESTEA_TOOL_ROOTS`).
127
+ *
128
+ * W768: `scope` replaces the WORKSPACE with the session's own root. That is the
129
+ * one thing a session may move, and it moves only the writable root plus the
130
+ * implicit read root ([PathGuardPolicy] always lists the workspace first in
131
+ * both) — `CELESTEA_TOOL_ROOTS` keeps contributing exactly the read roots the
132
+ * operator declared, and grants keep appending. No env entry is dropped, so a
133
+ * session cannot end up narrower than the posture it was composed under.
134
+ */
135
+ static fromEnv(env = process.env, grants = {}, scope = null) {
136
+ const workspaceRaw = scope?.workspace ?? envString(env, ENV_TOOL_WORKDIR) ?? process.cwd();
137
+ const workspace = resolveExistingTarget(workspaceRaw, process.cwd()) ?? resolve(workspaceRaw);
138
+ const grantRead = [...(grants.readRoots ?? [])];
139
+ const writeRoots = [...(grants.writeRoots ?? [])];
140
+ const raw = envString(env, ENV_TOOL_ROOTS);
141
+ if (raw === undefined)
142
+ return new PathGuardPolicy({ workspace, readRoots: grantRead, writeRoots, workspaceWritable: grants.workspaceWritable });
143
+ const entries = parseToolRoots(raw);
144
+ if (entries.length === 0) {
145
+ return new PathGuardPolicy({
146
+ workspace,
147
+ readRoots: grantRead,
148
+ writeRoots,
149
+ workspaceWritable: grants.workspaceWritable,
150
+ failClosedReason: `${ENV_TOOL_ROOTS} is set but lists no directory`,
151
+ });
152
+ }
153
+ const readRoots = [];
154
+ let failClosedReason = null;
155
+ for (const entry of entries) {
156
+ const canonical = resolveExistingTarget(entry, workspace);
157
+ if (canonical === null)
158
+ failClosedReason ??= `${ENV_TOOL_ROOTS} entry '${entry}' does not exist`;
159
+ else if (!isDirectory(canonical))
160
+ failClosedReason ??= `${ENV_TOOL_ROOTS} entry '${entry}' is not a directory`;
161
+ else
162
+ readRoots.push(canonical);
163
+ }
164
+ return new PathGuardPolicy({ workspace, readRoots: [...readRoots, ...grantRead], writeRoots, failClosedReason });
165
+ }
166
+ /** read/list: the canonical target must resolve inside a read root. */
167
+ checkRead(target) {
168
+ const blocked = this.failClosed();
169
+ if (blocked !== null)
170
+ return blocked;
171
+ const canonical = resolveExistingTarget(target, this.workspace);
172
+ if (canonical === null)
173
+ return ALLOW;
174
+ if (this.readRoots.some((root) => isInside(canonical, root)))
175
+ return ALLOW;
176
+ return deny("path_forbidden", `read/list path '${target}' is outside the allowed roots (workspace '${this.workspace}' + ${ENV_TOOL_ROOTS})`);
177
+ }
178
+ /**
179
+ * write: the canonical target must land inside ONE writable root. The
180
+ * workspace is always one (§5.6: grants can only add roots); read roots are
181
+ * still read-only and a write root overlapping a read root is rejected by the
182
+ * host before it ever reaches this policy.
183
+ */
184
+ checkWrite(target) {
185
+ const blocked = this.failClosed();
186
+ if (blocked !== null)
187
+ return blocked;
188
+ const canonical = resolveWriteTarget(target, this.workspace);
189
+ if (canonical === null)
190
+ return ALLOW;
191
+ if (this.writeRoots.some((root) => isInside(canonical, root)))
192
+ return ALLOW;
193
+ return deny("path_forbidden", this.writeDenyMessage(target));
194
+ }
195
+ /** Verbatim legacy message with no extra roots; explicit root list beyond it. */
196
+ writeDenyMessage(target) {
197
+ if (this.writeRoots.length <= 1)
198
+ return `write path '${target}' is outside the workspace '${this.workspace}'`;
199
+ return `write path '${target}' is outside every writable root (${this.writeRoots.join(", ")})`;
200
+ }
201
+ failClosed() {
202
+ if (this.failClosedReason === null)
203
+ return null;
204
+ return deny("tool_roots_invalid", `${this.failClosedReason} — failing closed: path tools are denied until ${ENV_TOOL_ROOTS} is fixed`);
205
+ }
206
+ }
207
+ /**
208
+ * The guard: arbitrates every path-like argument of every tool. Only an explicit
209
+ * `self` declaration (see [PATH_ACCESS]) hands a tool's paths back to its own
210
+ * confinement layer; an unknown tool is checked as a write.
211
+ */
212
+ export class PathGuard {
213
+ policy;
214
+ access;
215
+ constructor(policy, access = PATH_ACCESS) {
216
+ this.policy = policy;
217
+ this.access = access;
218
+ }
219
+ static fromEnv(env = process.env, grants = {}, access = PATH_ACCESS, scope = null) {
220
+ return new PathGuard(PathGuardPolicy.fromEnv(env, grants, scope), access);
221
+ }
222
+ async check(input) {
223
+ const access = this.access.get(input.name) ?? "write";
224
+ if (access === "self")
225
+ return ALLOW;
226
+ // W824 (W812 P0-2): normalize relative path arguments against the SESSION
227
+ // workspace BEFORE arbitrating and before the tool opens them. The fs tools
228
+ // receive the same args object the guard inspected, so rewriting it here
229
+ // makes the checked path and the opened path byte-identical; leaving them
230
+ // relative let the guard check <workspace>/x while fs opened
231
+ // <process.cwd()>/x.
232
+ const targets = normalizePathArguments(input.args, this.policy.workspace);
233
+ if (targets.length === 0)
234
+ return ALLOW;
235
+ return this.checkAll(targets, access);
236
+ }
237
+ /** Every path-like argument must pass; the first denial wins. */
238
+ checkAll(targets, access) {
239
+ for (const target of targets) {
240
+ const decision = access === "read" ? this.policy.checkRead(target) : this.policy.checkWrite(target);
241
+ if (decision.kind !== "allow")
242
+ return decision;
243
+ }
244
+ return ALLOW;
245
+ }
246
+ }
247
+ /**
248
+ * Rewrite every relative path-like argument to its absolute workspace-relative
249
+ * form (in place) and return the normalized values in [PATH_ARG_KEYS] order.
250
+ */
251
+ function normalizePathArguments(args, workspace) {
252
+ if (typeof args !== "object" || args === null)
253
+ return [];
254
+ const record = args;
255
+ const found = [];
256
+ for (const key of PATH_ARG_KEYS) {
257
+ const value = record[key];
258
+ if (typeof value === "string") {
259
+ const absolute = absolutize(value, workspace);
260
+ if (absolute !== value)
261
+ record[key] = absolute;
262
+ found.push(absolute);
263
+ }
264
+ else if (Array.isArray(value)) {
265
+ record[key] = value.map((entry) => {
266
+ if (typeof entry !== "string")
267
+ return entry;
268
+ const absolute = absolutize(entry, workspace);
269
+ found.push(absolute);
270
+ return absolute;
271
+ });
272
+ }
273
+ }
274
+ return found;
275
+ }
276
+ function deny(code, message) {
277
+ return { kind: "deny", reason: contractError(GUARD_ERROR_PREFIX, code, message) };
278
+ }
279
+ /**
280
+ * Mount the production guard chain. Returns whether it was mounted;
281
+ * `CELESTEA_TOOL_GUARD=0` explicitly opts out (documented escape hatch — the
282
+ * caller is responsible for surfacing that in its own diagnostics).
283
+ */
284
+ export function mountProductionGuards(registry, env = process.env, grants = {}, scope = null) {
285
+ if (!envFlag(envString(env, ENV_TOOL_GUARD), true))
286
+ return false;
287
+ registry.addGuard(PathGuard.fromEnv(env, grants, PATH_ACCESS, scope));
288
+ return true;
289
+ }