@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.
- package/LICENSE +21 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +55 -0
- package/dist/attachments/image-header.d.ts +28 -0
- package/dist/attachments/image-header.js +148 -0
- package/dist/attachments/store.d.ts +56 -0
- package/dist/attachments/store.js +148 -0
- package/dist/browser/cdp.d.ts +162 -0
- package/dist/browser/cdp.js +269 -0
- package/dist/browser/launch.d.ts +92 -0
- package/dist/browser/launch.js +232 -0
- package/dist/browser/memory-guard.d.ts +55 -0
- package/dist/browser/memory-guard.js +210 -0
- package/dist/browser/session.d.ts +146 -0
- package/dist/browser/session.js +371 -0
- package/dist/browser/snapshot.d.ts +69 -0
- package/dist/browser/snapshot.js +163 -0
- package/dist/browser/types.d.ts +51 -0
- package/dist/browser/types.js +8 -0
- package/dist/builtin.d.ts +54 -0
- package/dist/builtin.js +77 -0
- package/dist/desc.d.ts +20 -0
- package/dist/desc.js +22 -0
- package/dist/disclosure.d.ts +113 -0
- package/dist/disclosure.js +141 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +33 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +60 -0
- package/dist/exposure.d.ts +145 -0
- package/dist/exposure.js +244 -0
- package/dist/fn-tool.d.ts +14 -0
- package/dist/fn-tool.js +14 -0
- package/dist/fs/file-io.d.ts +78 -0
- package/dist/fs/file-io.js +239 -0
- package/dist/guard/path-guard.d.ts +144 -0
- package/dist/guard/path-guard.js +289 -0
- package/dist/guard/paths.d.ts +35 -0
- package/dist/guard/paths.js +100 -0
- package/dist/http/errors.d.ts +24 -0
- package/dist/http/errors.js +64 -0
- package/dist/http/headers.d.ts +19 -0
- package/dist/http/headers.js +62 -0
- package/dist/http/redirects.d.ts +31 -0
- package/dist/http/redirects.js +76 -0
- package/dist/http/ssrf.d.ts +105 -0
- package/dist/http/ssrf.js +272 -0
- package/dist/http/transport.d.ts +50 -0
- package/dist/http/transport.js +130 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +129 -0
- package/dist/memory/log.d.ts +75 -0
- package/dist/memory/log.js +157 -0
- package/dist/memory/store.d.ts +47 -0
- package/dist/memory/store.js +61 -0
- package/dist/platform/exec.d.ts +79 -0
- package/dist/platform/exec.js +218 -0
- package/dist/platform/index.d.ts +12 -0
- package/dist/platform/index.js +12 -0
- package/dist/platform/paths.d.ts +51 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/quote.d.ts +72 -0
- package/dist/platform/quote.js +102 -0
- package/dist/plugin.d.ts +96 -0
- package/dist/plugin.js +101 -0
- package/dist/process/buffers.d.ts +33 -0
- package/dist/process/buffers.js +86 -0
- package/dist/process/registry.d.ts +98 -0
- package/dist/process/registry.js +282 -0
- package/dist/registry.d.ts +52 -0
- package/dist/registry.js +161 -0
- package/dist/run-code/broker.d.ts +68 -0
- package/dist/run-code/broker.js +465 -0
- package/dist/run-code/limits.d.ts +69 -0
- package/dist/run-code/limits.js +88 -0
- package/dist/run-code/lines.d.ts +69 -0
- package/dist/run-code/lines.js +199 -0
- package/dist/run-code/sdk-ts.d.ts +34 -0
- package/dist/run-code/sdk-ts.js +276 -0
- package/dist/run-code/sdk.d.ts +39 -0
- package/dist/run-code/sdk.js +294 -0
- package/dist/sandbox/async.d.ts +10 -0
- package/dist/sandbox/async.js +26 -0
- package/dist/sandbox/bwrap-argv.d.ts +62 -0
- package/dist/sandbox/bwrap-argv.js +113 -0
- package/dist/sandbox/bwrap.d.ts +94 -0
- package/dist/sandbox/bwrap.js +159 -0
- package/dist/sandbox/child.d.ts +38 -0
- package/dist/sandbox/child.js +98 -0
- package/dist/sandbox/config.d.ts +89 -0
- package/dist/sandbox/config.js +149 -0
- package/dist/sandbox/fake-sandbox.d.ts +57 -0
- package/dist/sandbox/fake-sandbox.js +110 -0
- package/dist/sandbox/launch.d.ts +51 -0
- package/dist/sandbox/launch.js +134 -0
- package/dist/sandbox/limits.d.ts +63 -0
- package/dist/sandbox/limits.js +113 -0
- package/dist/sandbox/probe.d.ts +46 -0
- package/dist/sandbox/probe.js +102 -0
- package/dist/sandbox/provider.d.ts +83 -0
- package/dist/sandbox/provider.js +126 -0
- package/dist/sandbox/rlimit.d.ts +60 -0
- package/dist/sandbox/rlimit.js +76 -0
- package/dist/sandbox/seccomp.d.ts +48 -0
- package/dist/sandbox/seccomp.js +115 -0
- package/dist/sandbox/userspace.d.ts +65 -0
- package/dist/sandbox/userspace.js +107 -0
- package/dist/sandbox/workdir.d.ts +13 -0
- package/dist/sandbox/workdir.js +44 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +135 -0
- package/dist/testing/platform-gates.d.ts +54 -0
- package/dist/testing/platform-gates.js +62 -0
- package/dist/tool-failure.d.ts +13 -0
- package/dist/tool-failure.js +19 -0
- package/dist/tools/ask-user.d.ts +32 -0
- package/dist/tools/ask-user.js +145 -0
- package/dist/tools/browser.d.ts +24 -0
- package/dist/tools/browser.js +132 -0
- package/dist/tools/http-request.d.ts +31 -0
- package/dist/tools/http-request.js +117 -0
- package/dist/tools/list-dir.d.ts +9 -0
- package/dist/tools/list-dir.js +45 -0
- package/dist/tools/load-skill.d.ts +37 -0
- package/dist/tools/load-skill.js +76 -0
- package/dist/tools/memory.d.ts +46 -0
- package/dist/tools/memory.js +131 -0
- package/dist/tools/process-control.d.ts +14 -0
- package/dist/tools/process-control.js +49 -0
- package/dist/tools/read-file.d.ts +11 -0
- package/dist/tools/read-file.js +81 -0
- package/dist/tools/read-image.d.ts +33 -0
- package/dist/tools/read-image.js +144 -0
- package/dist/tools/run-code.d.ts +48 -0
- package/dist/tools/run-code.js +115 -0
- package/dist/tools/run-shell.d.ts +22 -0
- package/dist/tools/run-shell.js +81 -0
- package/dist/tools/write-file.d.ts +8 -0
- package/dist/tools/write-file.js +31 -0
- 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
|
+
}
|