@vibedgc/sdk 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/policy.js ADDED
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Embedder policy: tool, path, network and shell limits for every session of a DGC client.
3
+ * Keep in sync with sdk/python/dgc_sdk/policy.py (a test compares the compiled output).
4
+ *
5
+ * The policy reaches a session's runtime only through its environment (`DGC_SESSION_POLICY`),
6
+ * which DGC reads at startup, confirms in the ready handshake, and never saves: nothing is
7
+ * written to any config.json, so a policy never outlives its session and never touches the
8
+ * user's own ~/.dgc (inheritUserState included).
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, realpathSync, statSync } from "node:fs";
12
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { userInfo } from "node:os";
14
+ import { DGCConfigError, DGCUnsupportedError } from "./errors.js";
15
+ // DGC's internal tool name -> the display name its permission rules use. Mirrors
16
+ // dgc.permissions.DISPLAY, minus the ExternalDirectory pseudo-tool.
17
+ export const DISPLAY = {
18
+ read_file: "Read", view_image: "ViewImage", write_file: "Write", edit_file: "Edit",
19
+ multi_edit: "MultiEdit", apply_patch: "ApplyPatch", repo_map: "RepoMap",
20
+ code_intel: "CodeIntel", git_diff: "GitDiff", bash: "Bash", bash_output: "BashOutput",
21
+ bash_kill: "BashKill", python: "Python", monitor: "Monitor", monitor_stop: "MonitorStop",
22
+ glob: "Glob", grep: "Grep", web_fetch: "WebFetch", web_search: "WebSearch",
23
+ browser: "Browser", todo: "Todo", notes: "Notes", skill: "Skill",
24
+ add_skill: "AddSkill", save_memory: "SaveMemory", mcp_search: "MCPSearch",
25
+ mcp_call: "MCPCall", present_plan: "PresentPlan", present_document: "PresentDocument",
26
+ propose_options: "ProposeOptions", ask_user: "AskUser", artifact: "Artifact", task: "Task",
27
+ };
28
+ // Tools a rule cannot name (goal bookkeeping), and tools an allowlist keeps unless it is denied
29
+ // by name: the option picker is how the agent asks the application a question.
30
+ const UNRULED_TOOLS = new Set(["update_goal"]);
31
+ const ALWAYS_OFFERED = new Set(["propose_options", "ask_user"]);
32
+ // The display spelling permission rules use -> DGC's internal tool name, so denyTools/allowTools
33
+ // accept either ("Bash" and "bash", "Write" and "write_file"), case-insensitively.
34
+ const DISPLAY_TO_INTERNAL = Object.fromEntries(Object.entries(DISPLAY).map(([internal, display]) => [display.toLowerCase(), internal]));
35
+ const WRITE_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "apply_patch"]);
36
+ const READ_PATH_TOOLS = new Set(["read_file", "view_image", "code_intel", "git_diff", "repo_map", "grep", "glob"]);
37
+ const PATH_TOOLS = new Set([...READ_PATH_TOOLS, ...WRITE_TOOLS, "artifact"]);
38
+ // Tools that search a tree rather than open one path; with a denied path inside a readable tree
39
+ // they become permission requests the SDK answers from the search root.
40
+ const SEARCH_TOOLS = new Set(["grep", "glob", "repo_map", "code_intel", "git_diff"]);
41
+ // Rules match these tools' `path` argument, in absolute and project-relative spellings.
42
+ const PATH_RULE_TOOLS = ["Read", "ViewImage", "Write", "Edit", "MultiEdit", "ApplyPatch", "Artifact",
43
+ "CodeIntel", "GitDiff"];
44
+ const NETWORK_TOOLS = ["WebFetch", "WebSearch", "Browser", "AddSkill"];
45
+ const APP_SERVER = "app";
46
+ const MCP_EXACT = /^mcp__[A-Za-z0-9_-]{1,506}$/;
47
+ const MCP_WILDCARD = /^mcp__[A-Za-z0-9_-]{0,505}\*$/;
48
+ const NET_HINTS = [
49
+ "curl ", "wget ", "nc ", "ncat ", "ssh ", "scp ", "sftp ", "telnet ", "ftp ", "rsync ",
50
+ "http://", "https://", "ftp://", "invoke-webrequest", "fetch(", "socket", "urllib",
51
+ "http.client", "requests.", "git push", "git fetch", "git pull", "git clone",
52
+ ];
53
+ const NET_BASH_PATTERNS = [
54
+ "*curl*", "*wget*", "*http://*", "*https://*", "*ftp://*", "nc *", "* nc *", "ncat *",
55
+ "* ncat *", "ssh *", "* ssh *", "scp *", "* scp *", "sftp *", "rsync *", "* rsync *",
56
+ "telnet *", "*socket*", "*urllib*", "*http.client*", "*requests.*", "git push*",
57
+ "* git push*", "git fetch*", "git pull*", "git clone*",
58
+ ];
59
+ // Command-screening globs (`shell: "screened"` only). Deny matches ANY compound subcommand.
60
+ // `*>[!&]*` is `>`/`>>` except `>&fd` (so `ls 2>&1` still runs).
61
+ const WRITE_BASH_PATTERNS = [
62
+ "*>[!&]*",
63
+ "cp *", "* cp *", "mv *", "* mv *", "tee *", "* tee *", "dd *", "* dd *",
64
+ "touch *", "* touch *", "truncate *", "* truncate *",
65
+ "rm *", "* rm *", "rmdir *", "* rmdir *", "ln *", "* ln *", "install *", "* install *",
66
+ "mkdir *", "* mkdir *", "chmod *", "* chmod *", "rsync *", "* rsync *",
67
+ "patch *", "* patch *", "unlink *", "* unlink *",
68
+ "git apply*", "git checkout*", "git restore*", "git rm*", "git reset*", "git clean*",
69
+ "git mv*", "git stash*", "git commit*", "git init*", "git add*",
70
+ "sed -i*", "* sed -i*", "perl -i*", "* perl -i*", "perl -pi*",
71
+ "*open(*", "*write_text(*", "*write_bytes(*", "*writeFile*", "*writeFileSync*", "*shutil*",
72
+ "*os.remove*", "*os.rename*", "*os.replace*", "*os.unlink*", "*os.mkdir*", "*os.makedirs*",
73
+ "*.unlink(*", "*.rename(*", "*.touch(*", "*.mkdir(*",
74
+ ];
75
+ const WRITE_ARGV = new RegExp("(?:^|[\\s;|&])\\s*(?:(?:sudo|command|env|nice|nohup)\\s+)*"
76
+ + "(?:cp|mv|tee|dd|touch|truncate|rm|rmdir|ln|install|mkdir|chmod|rsync|patch|unlink)\\b"
77
+ + "|(?:^|[\\s;|&])\\s*git\\s+(?:apply|checkout|restore|rm|reset|clean|mv|stash|commit|init|add)\\b", "i");
78
+ const INPLACE = /(?:^|[\s;|&])\s*(?:sed|perl)\s+-\S*i/i;
79
+ const REDIRECT = /(?:^|[^>&])(?:\d*)(?:>>|>\||>|&>>|&>)(?!&)/;
80
+ const INTERPRETER_WRITE = new RegExp("open\\s*\\(|write_text\\s*\\(|write_bytes\\s*\\(|writefilesync|writefile\\s*\\(|shutil|"
81
+ + "os\\.(?:remove|rename|replace|unlink|mkdir|makedirs)|\\.(?:unlink|rename|touch|mkdir)\\s*\\(", "i");
82
+ export const SESSION_POLICY_ENV = "DGC_SESSION_POLICY";
83
+ const SESSION_POLICY_VERSION = 1;
84
+ // Linux refuses a single environment string over 128 KiB (MAX_ARG_STRLEN).
85
+ const SESSION_POLICY_MAX_BYTES = 96 * 1024;
86
+ function warn(message) {
87
+ process.emitWarning(message, { code: "DGC_SDK" });
88
+ }
89
+ function asNames(value, field) {
90
+ if (!Array.isArray(value)) {
91
+ throw new DGCConfigError(`RuntimePolicy.${field} must be an array of tool names, not ${JSON.stringify(value)}`);
92
+ }
93
+ const normalized = [];
94
+ for (const name of value) {
95
+ if (typeof name !== "string" || !name) {
96
+ throw new DGCConfigError(`RuntimePolicy.${field} must contain non-empty tool names`);
97
+ }
98
+ if (Object.hasOwn(DISPLAY, name) || (field === "allowTools" && UNRULED_TOOLS.has(name))
99
+ || MCP_EXACT.test(name) || MCP_WILDCARD.test(name)) {
100
+ normalized.push(name);
101
+ continue;
102
+ }
103
+ // The display spelling ("Bash", "Write") names the same tool as the internal one.
104
+ const lower = name.toLowerCase();
105
+ const internal = Object.hasOwn(DISPLAY_TO_INTERNAL, lower) ? DISPLAY_TO_INTERNAL[lower] : undefined;
106
+ if (internal !== undefined || (field === "allowTools" && UNRULED_TOOLS.has(lower))) {
107
+ normalized.push(internal ?? lower);
108
+ continue;
109
+ }
110
+ const hint = UNRULED_TOOLS.has(name) ? " (update_goal cannot be denied)"
111
+ : "; known tools: " + Object.keys(DISPLAY).sort().join(", ")
112
+ + ", and MCP routes such as mcp__app__<tool> or mcp__app__*";
113
+ throw new DGCConfigError(`RuntimePolicy.${field} names an unknown tool ${JSON.stringify(name)}${hint}`);
114
+ }
115
+ return normalized;
116
+ }
117
+ function asPaths(value, field) {
118
+ if (!Array.isArray(value)) {
119
+ throw new DGCConfigError(`RuntimePolicy.${field} must be an array of paths, not ${JSON.stringify(value)}`);
120
+ }
121
+ const paths = value.map((item) => String(item));
122
+ if (paths.some((item) => !item.trim())) {
123
+ throw new DGCConfigError(`RuntimePolicy.${field} must not contain empty paths`);
124
+ }
125
+ return paths;
126
+ }
127
+ /** Quote fnmatch metacharacters so a literal path or route matches only itself. */
128
+ export function escapeGlob(text) {
129
+ return text.replace(/[[\]*?]/g, (ch) => (ch === "[" ? "[[]" : `[${ch}]`));
130
+ }
131
+ function unique(items) {
132
+ return [...new Set(items)];
133
+ }
134
+ function bracket(chars) {
135
+ const items = [...new Set(chars)].sort();
136
+ const tail = items.includes("-") ? ["-"] : [];
137
+ const head = items.includes("]") ? ["]"] : [];
138
+ const body = items.filter((c) => !["-", "]", "!", "^"].includes(c));
139
+ const bang = items.includes("!") ? ["!"] : [];
140
+ const caret = items.includes("^") ? ["^"] : [];
141
+ return [...head, ...body, ...bang, ...caret, ...tail].join("");
142
+ }
143
+ /**
144
+ * fnmatch patterns that match every string except the allowed ones. An entry ending in `*`
145
+ * allows every string starting with the text before it. DGC rules can only deny, so an allowlist
146
+ * becomes denies of everything else.
147
+ */
148
+ export function complementPatterns(allowed) {
149
+ const root = {};
150
+ for (const entry of allowed) {
151
+ const wildcard = entry.endsWith("*");
152
+ const text = wildcard ? entry.slice(0, -1) : entry;
153
+ let node = root;
154
+ let covered = false;
155
+ for (const ch of text) {
156
+ if ("$all" in node) {
157
+ covered = true;
158
+ break;
159
+ }
160
+ if (!(ch in node))
161
+ node[ch] = {};
162
+ node = node[ch];
163
+ }
164
+ if (covered || "$all" in node)
165
+ continue;
166
+ if (wildcard) {
167
+ for (const key of Object.keys(node))
168
+ delete node[key];
169
+ node.$all = true;
170
+ }
171
+ else {
172
+ node.$end = true;
173
+ }
174
+ }
175
+ const patterns = [];
176
+ const walk = (prefix, node) => {
177
+ if ("$all" in node)
178
+ return;
179
+ if (prefix && !("$end" in node))
180
+ patterns.push(escapeGlob(prefix));
181
+ // Python sorts by code point; so does this comparison.
182
+ const chars = Object.keys(node).filter((key) => [...key].length === 1).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
183
+ patterns.push(escapeGlob(prefix) + (chars.length ? `[!${bracket(chars)}]*` : "?*"));
184
+ for (const ch of chars)
185
+ walk(prefix + ch, node[ch]);
186
+ };
187
+ walk("", root);
188
+ return patterns;
189
+ }
190
+ function appRoute(name) {
191
+ const safe = String(name).replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") || "unnamed";
192
+ return `mcp__${APP_SERVER}__${safe}`;
193
+ }
194
+ function subject(name, args) {
195
+ if (name.startsWith("mcp__"))
196
+ return ["mcp_call", name];
197
+ if (name === "mcp_call")
198
+ return ["mcp_call", String(args.name || "")];
199
+ return [name, ""];
200
+ }
201
+ function routeMatches(route, entry) {
202
+ return entry.endsWith("*") ? route.startsWith(entry.slice(0, -1)) : route === entry;
203
+ }
204
+ /** Like Python's Path.resolve(strict=False): symlinks resolved for the part that exists. */
205
+ export function resolvePath(raw, cwd) {
206
+ try {
207
+ let text = raw;
208
+ if (text === "~" || text.startsWith("~/"))
209
+ text = join(userInfo().homedir, text.slice(1));
210
+ let path = isAbsolute(text) ? resolve(text) : resolve(cwd || process.cwd(), text);
211
+ const rest = [];
212
+ // Walk up until an existing ancestor, realpath it, then re-append the missing tail.
213
+ for (;;) {
214
+ if (existsSync(path)) {
215
+ const real = realpathSync(path);
216
+ return rest.length ? join(real, ...rest.reverse()) : real;
217
+ }
218
+ const parent = resolve(path, "..");
219
+ if (parent === path)
220
+ return resolve(text);
221
+ rest.push(path.slice(parent.length).replace(/^[\\/]+/, ""));
222
+ path = parent;
223
+ }
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ export function within(child, parent) {
230
+ const rel = relative(parent, child);
231
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep));
232
+ }
233
+ export function looksLikeNetwork(command) {
234
+ const low = command.toLowerCase();
235
+ return NET_HINTS.some((hint) => low.includes(hint));
236
+ }
237
+ /** True when a shell/python snippet is a file-write, not merely a read or `2>&1`. */
238
+ export function looksLikeWrite(command) {
239
+ if (!command || !command.trim())
240
+ return false;
241
+ return REDIRECT.test(command) || WRITE_ARGV.test(command) || INPLACE.test(command)
242
+ || INTERPRETER_WRITE.test(command);
243
+ }
244
+ /** A validated {@link RuntimePolicy}. */
245
+ export class Policy {
246
+ network;
247
+ extraReadDirs;
248
+ denyPathPrefixes;
249
+ denyTools;
250
+ allowTools;
251
+ redactEvents;
252
+ shell;
253
+ constructor(options) {
254
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
255
+ throw new DGCConfigError("policy must be a RuntimePolicy object");
256
+ }
257
+ const known = new Set(["network", "extraReadDirs", "denyPathPrefixes", "denyTools", "allowTools",
258
+ "redactEvents", "shell"]);
259
+ const unknown = Object.keys(options).filter((key) => !known.has(key)).sort();
260
+ if (unknown.length)
261
+ throw new DGCConfigError(`RuntimePolicy has an unknown field ${JSON.stringify(unknown[0])}`);
262
+ const network = options.network ?? "deny";
263
+ if (network !== "deny" && network !== "allow")
264
+ throw new DGCConfigError("RuntimePolicy.network must be 'deny' or 'allow'");
265
+ const shell = options.shell ?? "sandboxed";
266
+ if (shell !== "sandboxed" && shell !== "screened") {
267
+ throw new DGCConfigError("RuntimePolicy.shell must be 'sandboxed' or 'screened'");
268
+ }
269
+ this.network = network;
270
+ this.shell = shell;
271
+ this.denyTools = asNames(options.denyTools ?? [], "denyTools");
272
+ this.allowTools = options.allowTools === undefined || options.allowTools === null
273
+ ? null : asNames(options.allowTools, "allowTools");
274
+ this.extraReadDirs = asPaths(options.extraReadDirs ?? [], "extraReadDirs");
275
+ this.denyPathPrefixes = asPaths(options.denyPathPrefixes ?? [], "denyPathPrefixes");
276
+ this.redactEvents = options.redactEvents ?? true;
277
+ }
278
+ writesDenied() {
279
+ if (this.denyTools.some((name) => WRITE_TOOLS.has(name)))
280
+ return true;
281
+ if (this.allowTools !== null && !this.allowTools.some((name) => WRITE_TOOLS.has(name)))
282
+ return true;
283
+ return false;
284
+ }
285
+ /** True when this policy forbids the tool by name, independent of command screening. */
286
+ namedToolDenied(name, args = {}) {
287
+ const [tool, route] = subject(name, args);
288
+ if (tool === "mcp_call") {
289
+ if (this.denyTools.includes("mcp_call")
290
+ || this.denyTools.some((entry) => entry.startsWith("mcp__") && routeMatches(route, entry))) {
291
+ return true;
292
+ }
293
+ if (this.allowTools === null || this.allowTools.includes("mcp_call"))
294
+ return false;
295
+ return !this.allowTools.some((entry) => entry.startsWith("mcp__") && routeMatches(route, entry));
296
+ }
297
+ if (this.denyTools.includes(tool))
298
+ return true;
299
+ if (this.allowTools !== null && !this.allowTools.includes(tool)) {
300
+ return !ALWAYS_OFFERED.has(tool) && !UNRULED_TOOLS.has(tool);
301
+ }
302
+ return false;
303
+ }
304
+ /** Throw when an `mcp__app__` route names none of the session's own tools. */
305
+ checkSessionTools(customTools) {
306
+ const routes = new Set(customTools.map(appRoute));
307
+ if (!routes.size)
308
+ return;
309
+ for (const [field, entries] of [["denyTools", this.denyTools], ["allowTools", this.allowTools ?? []]]) {
310
+ for (const entry of entries) {
311
+ if (!entry.startsWith(`mcp__${APP_SERVER}__`) || entry.endsWith("*"))
312
+ continue;
313
+ if (!routes.has(entry)) {
314
+ throw new DGCConfigError(`RuntimePolicy.${field} names ${JSON.stringify(entry)}, which is not one of `
315
+ + `this session's tools (defined: ${[...routes].sort().join(", ") || "none"})`);
316
+ }
317
+ }
318
+ }
319
+ }
320
+ namedRules() {
321
+ const rules = [];
322
+ const mcpDenied = [];
323
+ for (const name of this.denyTools) {
324
+ if (name.startsWith("mcp__"))
325
+ mcpDenied.push(name);
326
+ else
327
+ rules.push(DISPLAY[name]);
328
+ }
329
+ for (const name of mcpDenied) {
330
+ rules.push(`MCPCall(${name.endsWith("*") ? escapeGlob(name.slice(0, -1)) + "*" : escapeGlob(name)})`);
331
+ }
332
+ if (this.allowTools !== null) {
333
+ const allowed = new Set(this.allowTools);
334
+ for (const [internal, display] of Object.entries(DISPLAY)) {
335
+ if (allowed.has(internal) || ALWAYS_OFFERED.has(internal))
336
+ continue;
337
+ if (internal === "mcp_call") {
338
+ const routes = [...allowed].filter((name) => name.startsWith("mcp__"));
339
+ if (routes.length) {
340
+ rules.push(...complementPatterns(routes).map((pattern) => `MCPCall(${pattern})`));
341
+ continue;
342
+ }
343
+ }
344
+ rules.push(display);
345
+ }
346
+ }
347
+ return rules;
348
+ }
349
+ /**
350
+ * Named-tool, network-tool and write-path rules, plus command-screening globs (best-effort
351
+ * screening, not a boundary; what `shell: "screened"` applies in auto mode).
352
+ */
353
+ engineDenyRules(inspectBash = true) {
354
+ let rules = unique(this.namedRules());
355
+ if (this.network === "deny") {
356
+ rules = unique([...rules, ...NETWORK_TOOLS]);
357
+ if (inspectBash)
358
+ rules = unique([...rules, ...NET_BASH_PATTERNS.map((p) => `Bash(${p})`)]);
359
+ }
360
+ if (this.writesDenied() && inspectBash) {
361
+ rules = unique([...rules, ...WRITE_BASH_PATTERNS.map((p) => `Bash(${p})`)]);
362
+ }
363
+ for (const prefix of this.denyPathPrefixes) {
364
+ const text = prefix.replace(/\/+$/, "");
365
+ if (text) {
366
+ rules = unique([...rules, ...["Write", "Edit", "MultiEdit", "ApplyPatch"].map((tool) => `${tool}(${escapeGlob(text)}/**)`)]);
367
+ }
368
+ }
369
+ return rules;
370
+ }
371
+ /**
372
+ * Classify one permission request: "deny" (the policy forbids it), "once" (the policy itself
373
+ * allows it: a read in extraReadDirs, or a search clear of denied paths), "screen" (command
374
+ * text looks like a network call or file write; best effort), or null (no opinion).
375
+ */
376
+ evaluate(request, cwd) {
377
+ const name = request.name || "";
378
+ const args = request.args && typeof request.args === "object" ? request.args : {};
379
+ if (this.namedToolDenied(name, args))
380
+ return "deny";
381
+ if (PATH_TOOLS.has(name)) {
382
+ const verdict = this.pathVerdict(name, args, cwd);
383
+ if (verdict !== null)
384
+ return verdict;
385
+ }
386
+ const command = String(args.command || request.command || "");
387
+ if (name === "bash" || name === "monitor") {
388
+ if (this.network === "deny" && looksLikeNetwork(command))
389
+ return "screen";
390
+ if (this.writesDenied() && looksLikeWrite(command))
391
+ return "screen";
392
+ }
393
+ if (name === "python") {
394
+ // The python tool runs arbitrary code and is never sandboxed, so it is screened exactly
395
+ // like the shell: code that looks like a network call or a file write is a screen signal.
396
+ const code = String(args.code || "");
397
+ if (this.network === "deny" && looksLikeNetwork(code))
398
+ return "screen";
399
+ if (this.writesDenied() && looksLikeWrite(code))
400
+ return "screen";
401
+ }
402
+ return null;
403
+ }
404
+ /** "deny" when this policy forbids the call, "once" when it allows it itself, else null. */
405
+ decision(request, cwd = null) {
406
+ const verdict = this.evaluate(request, cwd);
407
+ if (verdict === "deny" || verdict === "screen")
408
+ return "deny";
409
+ if (verdict === "once")
410
+ return "once";
411
+ return null;
412
+ }
413
+ pathVerdict(name, args, cwd) {
414
+ const raw = String(args.path || "");
415
+ if (!raw && !SEARCH_TOOLS.has(name))
416
+ return null;
417
+ const target = resolvePath(raw || ".", cwd);
418
+ if (target === null)
419
+ return "deny";
420
+ const denied = this.deniedPrefixes(cwd);
421
+ if (denied.some((prefix) => within(target, prefix)))
422
+ return "deny";
423
+ if (SEARCH_TOOLS.has(name) && denied.some((prefix) => within(prefix, target)))
424
+ return "deny";
425
+ if (cwd !== null) {
426
+ const root = resolvePath(cwd) || cwd;
427
+ if (!within(target, root)) {
428
+ if (READ_PATH_TOOLS.has(name) && this.extraDirs(cwd).some((extra) => within(target, extra)))
429
+ return "once";
430
+ return "deny";
431
+ }
432
+ }
433
+ if (SEARCH_TOOLS.has(name) && this.searchGuard(cwd))
434
+ return "once";
435
+ return null;
436
+ }
437
+ deniedPrefixes(cwd) {
438
+ return this.denyPathPrefixes.map((prefix) => resolvePath(prefix, cwd)).filter((p) => p !== null);
439
+ }
440
+ extraDirs(cwd) {
441
+ return this.extraReadDirs.map((extra) => resolvePath(extra, cwd)).filter((p) => p !== null);
442
+ }
443
+ /** True when a denied prefix sits inside a tree the agent may search. */
444
+ searchGuard(cwd) {
445
+ const roots = [...(cwd !== null ? [resolvePath(cwd) || cwd] : []), ...this.extraDirs(cwd)];
446
+ return this.deniedPrefixes(cwd).some((prefix) => roots.some((root) => within(prefix, root)));
447
+ }
448
+ }
449
+ /** Normalize the public policy option. */
450
+ export function toPolicy(value) {
451
+ if (value === undefined || value === null)
452
+ return null;
453
+ return value instanceof Policy ? value : new Policy(value);
454
+ }
455
+ /**
456
+ * Compile bash write/network globs only when nobody will answer a permission_request. Auto mode
457
+ * never raises one, so the engine must deny by itself. Kept for API compatibility.
458
+ */
459
+ export function inspectBashForEngine(onPermission, mode) {
460
+ return !onPermission || mode === "auto";
461
+ }
462
+ /** The policy's named-tool, network and path rules plus screening globs (see {@link Policy.engineDenyRules}). */
463
+ export function engineDenyRules(policy, opts) {
464
+ const normalized = toPolicy(policy);
465
+ return normalized ? normalized.engineDenyRules(opts?.inspectBash ?? true) : [];
466
+ }
467
+ /** "deny" when the policy forbids this request (including screened command text), else null. */
468
+ export function policyDecision(policy, request, cwd = null) {
469
+ const normalized = toPolicy(policy);
470
+ if (!normalized)
471
+ return null;
472
+ const verdict = normalized.evaluate(request, cwd);
473
+ return verdict === "deny" || verdict === "screen" ? "deny" : null;
474
+ }
475
+ // ---------------------------------------------------------------- settings ---
476
+ /**
477
+ * Normalize `session({ permissions })` into [mode, unhandled]. `unhandled: "deny"`: a request the
478
+ * callback does not answer is denied and the agent carries on (no callback denies every request).
479
+ * `"callback"`: every request must go to your callback, so `session()` refuses to start without
480
+ * one; a request it still leaves unanswered (it threw, returned something else, or ran past
481
+ * `decisionTimeoutMs`) is denied and the run stops with `reason: "decision_failed"`.
482
+ */
483
+ export function permissionSettings(value, mode, defaultMode, onPermission) {
484
+ let data = {};
485
+ if (value !== undefined && value !== null) {
486
+ if (typeof value !== "object" || Array.isArray(value)) {
487
+ throw new DGCConfigError("permissions must be an object such as { mode, unhandled }");
488
+ }
489
+ data = { ...value };
490
+ const unknown = Object.keys(data).filter((key) => key !== "mode" && key !== "unhandled").sort();
491
+ if (unknown.length) {
492
+ throw new DGCConfigError(`permissions has an unknown key ${JSON.stringify(unknown[0])} (expected 'mode' and 'unhandled')`);
493
+ }
494
+ }
495
+ const unhandled = (data.unhandled || "deny");
496
+ if (unhandled !== "deny" && unhandled !== "callback") {
497
+ throw new DGCConfigError("permissions.unhandled must be 'deny' or 'callback'");
498
+ }
499
+ const chosen = (mode || data.mode || defaultMode);
500
+ if (!["default", "acceptEdits", "plan", "auto"].includes(chosen)) {
501
+ throw new DGCConfigError("invalid permission mode");
502
+ }
503
+ if (unhandled === "callback" && !onPermission) {
504
+ throw new DGCConfigError("permissions.unhandled='callback' needs onPermission; pass a callback or use "
505
+ + "unhandled='deny' to deny every request");
506
+ }
507
+ return [chosen, unhandled];
508
+ }
509
+ /** Normalize `sandbox` ("required" | "preferred" | "off", or `{ requirement }`). */
510
+ export function sandboxRequirement(value) {
511
+ if (value === undefined || value === null)
512
+ return "off";
513
+ let requirement;
514
+ if (typeof value === "string")
515
+ requirement = value;
516
+ else if (typeof value === "object" && !Array.isArray(value)) {
517
+ const unknown = Object.keys(value).filter((key) => key !== "requirement").sort();
518
+ if (unknown.length) {
519
+ throw new DGCConfigError(`sandbox has an unknown key ${JSON.stringify(unknown[0])} (expected 'requirement')`);
520
+ }
521
+ requirement = value.requirement || "off";
522
+ }
523
+ else {
524
+ throw new DGCConfigError("sandbox must be 'required', 'preferred', 'off' or { requirement }");
525
+ }
526
+ if (requirement !== "required" && requirement !== "preferred" && requirement !== "off") {
527
+ throw new DGCConfigError("sandbox.requirement must be required, preferred, or off");
528
+ }
529
+ return requirement;
530
+ }
531
+ function onPath(name) {
532
+ for (const dir of (process.env.PATH || "").split(delimiter)) {
533
+ if (!dir)
534
+ continue;
535
+ try {
536
+ if (statSync(join(dir, name)).isFile())
537
+ return true;
538
+ }
539
+ catch { /* keep looking */ }
540
+ }
541
+ return false;
542
+ }
543
+ /** Fail early for "required" when this host plainly has no backend (the runtime decides finally). */
544
+ export function sandboxPrecheck(requirement) {
545
+ if (requirement !== "required")
546
+ return;
547
+ let name;
548
+ if (process.platform === "linux")
549
+ name = "bwrap";
550
+ else if (process.platform === "darwin")
551
+ name = "sandbox-exec";
552
+ else {
553
+ throw new DGCUnsupportedError(`sandbox.requirement is 'required' but DGC has no sandbox backend on ${process.platform}`);
554
+ }
555
+ if (!onPath(name))
556
+ throw new DGCUnsupportedError(`sandbox.requirement is 'required' but this host has no ${name}`);
557
+ }
558
+ // ------------------------------------------------------------ per session ---
559
+ /** What one session's runtime is told, and how to confirm it took effect. */
560
+ export class SessionPlan {
561
+ env;
562
+ requirement;
563
+ policy;
564
+ notes;
565
+ /**
566
+ * The policy asks for a sandboxed shell that could still run (the mode is not plan) and the
567
+ * caller did not accept a weaker fallback: a runtime with no OS sandbox must then refuse the
568
+ * session rather than run the shell unconfined.
569
+ */
570
+ strictShell;
571
+ constructor(env, requirement, policy, notes = [], strictShell = false) {
572
+ this.env = env;
573
+ this.requirement = requirement;
574
+ this.policy = policy;
575
+ this.notes = notes;
576
+ this.strictShell = strictShell;
577
+ }
578
+ get payload() {
579
+ return this.env[SESSION_POLICY_ENV] || "";
580
+ }
581
+ /**
582
+ * Check the ready handshake: the runtime read this policy and has the sandbox asked for.
583
+ * Throws DGCUnsupportedError when the runtime cannot honour it.
584
+ */
585
+ confirm(ready) {
586
+ const caps = ready.capabilities && typeof ready.capabilities === "object"
587
+ ? ready.capabilities : {};
588
+ const report = caps.session_policy;
589
+ if (!this.payload)
590
+ return { requirement: this.requirement, active: false, backend: "", reason: "" };
591
+ const version = String(ready.version || "unknown");
592
+ if (!report || typeof report !== "object") {
593
+ throw new DGCUnsupportedError(`DGC runtime ${version} cannot apply a session policy (RuntimePolicy or `
594
+ + "sandbox); CLI 0.41.6 or newer is required");
595
+ }
596
+ const info = report;
597
+ if (info.error)
598
+ throw new DGCUnsupportedError(`DGC runtime ${version} rejected the session policy: ${String(info.error)}`);
599
+ const digest = createHash("sha256").update(this.payload, "utf8").digest("hex");
600
+ if (info.digest !== digest)
601
+ throw new DGCUnsupportedError(`DGC runtime ${version} did not confirm this session's policy`);
602
+ this.confirmTools(ready);
603
+ for (const note of this.notes)
604
+ warn(`DGC session policy: ${note}`);
605
+ const backend = String(info.sandbox || "");
606
+ if (this.requirement === "required" && !backend) {
607
+ throw new DGCUnsupportedError("sandbox.requirement is 'required' but the DGC runtime has no OS sandbox "
608
+ + "(bubblewrap on Linux, sandbox-exec on macOS)");
609
+ }
610
+ if (this.strictShell && !backend) {
611
+ throw new DGCUnsupportedError('RuntimePolicy shell "sandboxed" needs an OS sandbox (bubblewrap on Linux, '
612
+ + "sandbox-exec on macOS) but the DGC runtime has none, so shell commands would run unconfined. Install "
613
+ + "bubblewrap (apt install bubblewrap / dnf install bubblewrap), or accept a weaker mode on purpose: "
614
+ + 'sandbox: "preferred" (the shell runs unconfined outside auto mode and is refused in auto mode) or '
615
+ + 'policy shell: "screened".');
616
+ }
617
+ if (this.requirement === "preferred" && !backend) {
618
+ const detail = this.policy !== null && this.policy.shell === "sandboxed"
619
+ ? "; in auto mode the shell is refused, and in the other modes an approved command runs unconfined"
620
+ : "; shell commands run unconfined";
621
+ const reason = "no OS sandbox is available to the DGC runtime" + detail;
622
+ warn(`DGC sandbox 'preferred' fell back: ${reason}`);
623
+ return { requirement: this.requirement, active: false, backend: "", reason };
624
+ }
625
+ return { requirement: this.requirement, active: Boolean(backend), backend, reason: "" };
626
+ }
627
+ confirmTools(ready) {
628
+ const policy = this.policy;
629
+ if (policy === null || !Array.isArray(ready.tools) || policy.allowTools === null)
630
+ return;
631
+ const allowed = new Set([...policy.allowTools, ...ALWAYS_OFFERED, ...UNRULED_TOOLS]);
632
+ const unknown = ready.tools.map(String)
633
+ .filter((name) => !Object.hasOwn(DISPLAY, name) && !allowed.has(name) && !name.startsWith("mcp__")).sort();
634
+ if (unknown.length) {
635
+ throw new DGCUnsupportedError(`RuntimePolicy.allowTools cannot refuse the runtime's tool ${JSON.stringify(unknown[0])}, `
636
+ + "which this SDK does not know; upgrade the SDK or allow it");
637
+ }
638
+ }
639
+ }
640
+ function sortedJson(value) {
641
+ const sort = (item) => {
642
+ if (Array.isArray(item))
643
+ return item.map(sort);
644
+ if (item && typeof item === "object") {
645
+ const out = {};
646
+ for (const key of Object.keys(item).sort())
647
+ out[key] = sort(item[key]);
648
+ return out;
649
+ }
650
+ return item;
651
+ };
652
+ return JSON.stringify(sort(value));
653
+ }
654
+ /**
655
+ * True when DGC's sandbox surely masks `path`, which lies outside cwd. Both backends hide the
656
+ * account's home directory; bubblewrap also gives the shell private /root, /tmp and /run.
657
+ */
658
+ function sandboxHides(path, workspace) {
659
+ if (within(path, workspace))
660
+ return false;
661
+ const hidden = [];
662
+ try {
663
+ hidden.push(userInfo().homedir);
664
+ }
665
+ catch { /* no account entry */ }
666
+ if (process.platform === "linux")
667
+ hidden.push("/root", "/tmp", "/run");
668
+ for (const base of hidden) {
669
+ const real = resolvePath(base);
670
+ if (real && within(path, real))
671
+ return true;
672
+ }
673
+ return false;
674
+ }
675
+ /**
676
+ * Turn a RuntimePolicy and sandbox choice into the runtime's per-session policy. It travels to
677
+ * `dgc serve` in the DGC_SESSION_POLICY environment variable; nothing is written to any config.
678
+ */
679
+ export function compileSession(policy, options) {
680
+ let requirement = options.sandbox;
681
+ const trust = Boolean(options.trustWorkspace);
682
+ // A workspace's own .dgc/permissions.json allow rules and .dgc/agents definitions (which can
683
+ // pick a model endpoint and a credential variable) load only when the app trusts it; otherwise
684
+ // the workspace can narrow what runs, never grant it. Every isolated session says so.
685
+ const project = { project_allow: trust, project_agents: trust };
686
+ if (policy === null) {
687
+ if (requirement === "off" && options.isolated === false)
688
+ return new SessionPlan({}, "off", null);
689
+ const payload = { version: SESSION_POLICY_VERSION, sandbox: requirement, ...project };
690
+ return new SessionPlan({ [SESSION_POLICY_ENV]: sortedJson(payload) }, requirement, null);
691
+ }
692
+ policy.checkSessionTools(options.tools ?? []);
693
+ const workspace = resolvePath(options.cwd) || resolve(options.cwd);
694
+ const deny = policy.namedRules();
695
+ const ask = [];
696
+ const autoDeny = [];
697
+ const notes = [];
698
+ if (policy.network === "deny") {
699
+ deny.push(...NETWORK_TOOLS);
700
+ // MCP servers other than the application's own tools run as unconfined processes.
701
+ deny.push(...complementPatterns([`mcp__${APP_SERVER}__*`]).map((pattern) => `MCPCall(${pattern})`));
702
+ }
703
+ const denied = policy.deniedPrefixes(workspace);
704
+ for (const prefix of denied) {
705
+ const spellings = [prefix];
706
+ if (within(prefix, workspace) && prefix !== workspace) {
707
+ // The project-relative spelling also covers a subagent's worktree copy of the tree.
708
+ spellings.push(relative(workspace, prefix).split(sep).join("/"));
709
+ }
710
+ for (const text of spellings.map(escapeGlob)) {
711
+ for (const tool of PATH_RULE_TOOLS)
712
+ deny.push(`${tool}(${text})`, `${tool}(${text}/**)`);
713
+ }
714
+ }
715
+ if (policy.searchGuard(workspace))
716
+ ask.push(...[...SEARCH_TOOLS].sort().map((name) => DISPLAY[name]));
717
+ (policy.extraDirs(workspace).length ? ask : deny).push("ExternalDirectory");
718
+ let sandboxReadOnly = false;
719
+ let shellRequiresSandbox = false;
720
+ let strictShell = false;
721
+ if (policy.shell === "sandboxed") {
722
+ shellRequiresSandbox = true;
723
+ if (requirement === "off") {
724
+ // The default sandboxed shell with no explicit sandbox choice: a runtime that cannot
725
+ // confine it must refuse the session (unless the mode is plan, which runs no shell). An
726
+ // explicit sandbox: "preferred" opts into the weaker fallback and keeps only the warning.
727
+ requirement = "preferred";
728
+ strictShell = options.mode !== "plan";
729
+ }
730
+ sandboxReadOnly = policy.writesDenied();
731
+ const exposed = denied.filter((prefix) => !sandboxHides(prefix, workspace));
732
+ if (exposed.length) {
733
+ // The sandbox shows the rest of the host read-only, so it cannot keep the shell away from
734
+ // these paths. Unattended, the shell is refused.
735
+ autoDeny.push("Bash", "Monitor");
736
+ if (options.mode === "auto") {
737
+ notes.push(`RuntimePolicy.denyPathPrefixes names ${exposed[0]}, which the OS sandbox cannot hide, `
738
+ + "so bash and monitor are refused in auto mode");
739
+ }
740
+ }
741
+ }
742
+ else {
743
+ // shell "screened": the shell and the (never sandboxed) python tool run unconfined, so in auto
744
+ // mode both are screened for network calls and, with writes denied, file writes.
745
+ if (policy.network === "deny") {
746
+ autoDeny.push(...NET_BASH_PATTERNS.map((p) => `Bash(${p})`));
747
+ autoDeny.push(...NET_BASH_PATTERNS.map((p) => `Python(${p})`));
748
+ }
749
+ if (policy.writesDenied()) {
750
+ autoDeny.push(...WRITE_BASH_PATTERNS.map((p) => `Bash(${p})`));
751
+ autoDeny.push(...WRITE_BASH_PATTERNS.map((p) => `Python(${p})`));
752
+ }
753
+ }
754
+ const payload = {
755
+ version: SESSION_POLICY_VERSION,
756
+ deny: unique(deny),
757
+ ask: unique(ask),
758
+ auto_deny: unique(autoDeny),
759
+ sandbox: requirement,
760
+ sandbox_network: policy.network === "allow",
761
+ sandbox_read_only: sandboxReadOnly,
762
+ shell_requires_sandbox: shellRequiresSandbox,
763
+ ...project,
764
+ };
765
+ const text = sortedJson(payload);
766
+ if (Buffer.byteLength(text, "utf8") > SESSION_POLICY_MAX_BYTES) {
767
+ throw new DGCConfigError("RuntimePolicy compiles to more rules than one environment variable can carry; "
768
+ + "use fewer denyPathPrefixes or broader ones");
769
+ }
770
+ return new SessionPlan({ [SESSION_POLICY_ENV]: text }, requirement, policy, notes, strictShell);
771
+ }
772
+ /**
773
+ * Answer one permission_request: the policy first, then the callback. A policy deny is final; a
774
+ * request the policy raised only to check it ("once") is answered without the callback; command
775
+ * screening denies unless a reviewing callback is present outside auto mode. A denial the policy
776
+ * made carries a reason, so the runtime tells the model it was the application's policy (not "the
777
+ * user").
778
+ */
779
+ export async function resolvePermission(policy, request, options) {
780
+ if (policy !== null) {
781
+ const verdict = policy.evaluate(request, options.cwd);
782
+ if (verdict === "deny") {
783
+ return { action: "deny", reason: "the application's RuntimePolicy does not allow this "
784
+ + "(a tool, path or network rule it set for this session)" };
785
+ }
786
+ if (verdict === "once")
787
+ return { action: "once", reason: "" };
788
+ if (verdict === "screen" && (!options.onPermission || options.permissionMode === "auto")) {
789
+ return { action: "deny", reason: "the application's RuntimePolicy screened this as a network call "
790
+ + "or file write and runs unattended, so it was refused" };
791
+ }
792
+ }
793
+ const action = await options.ask();
794
+ return { action: action === "once" || action === "always" || action === "deny" ? action : "deny", reason: "" };
795
+ }