@f5-sales-demo/xcsh 19.98.5 → 19.98.7
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.98.
|
|
4
|
+
"version": "19.98.7",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
59
59
|
"@mozilla/readability": "^0.6",
|
|
60
|
-
"@f5-sales-demo/xcsh-stats": "19.98.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.98.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.98.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.98.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.98.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.98.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.98.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "19.98.7",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "19.98.7",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "19.98.7",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "19.98.7",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "19.98.7",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "19.98.7",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "19.98.7",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.98.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.98.7",
|
|
21
|
+
"commit": "0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
|
|
22
|
+
"shortCommit": "0a7dd04",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.98.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.98.7",
|
|
25
|
+
"commitDate": "2026-07-28T11:57:17Z",
|
|
26
|
+
"buildDate": "2026-07-28T12:27:02.779Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.7"
|
|
33
33
|
};
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* for both bash and python.
|
|
19
19
|
*/
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
-
import { parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
|
|
22
|
-
import { lexShellCommand } from "../tools/shell-lex";
|
|
21
|
+
import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
|
|
22
|
+
import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
|
|
23
23
|
import { provenExemptWords } from "./command-operands";
|
|
24
24
|
import type { SandboxAccess, SandboxPolicy } from "./policy";
|
|
25
25
|
|
|
@@ -151,6 +151,21 @@ function deny(policy: SandboxPolicy, resolved: string, access: SandboxAccess): T
|
|
|
151
151
|
return { block: true, reason: policy.describe(resolved, access) };
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `$HOME` and `${HOME}` are spellings of `~`, which `looksLikePath` already treats as a
|
|
156
|
+
* path (#2534). Three ways to name one file, only one of them checked, is an oversight
|
|
157
|
+
* rather than a policy: `cat ~/.ssh/id_rsa` was blocked while `cat $HOME/.ssh/id_rsa`
|
|
158
|
+
* was not. Rewriting to `~` here means detection AND resolution both see the real path,
|
|
159
|
+
* so the denial names the file rather than a literal dollar sign.
|
|
160
|
+
*
|
|
161
|
+
* `\b` keeps `$HOMEBREW_PREFIX` and friends out of it.
|
|
162
|
+
*/
|
|
163
|
+
const HOME_EXPANSION = /^\$(?:HOME\b|\{HOME\})/;
|
|
164
|
+
|
|
165
|
+
function normalizeHomeExpansion(token: string): string {
|
|
166
|
+
return HOME_EXPANSION.test(token) ? token.replace(HOME_EXPANSION, "~") : token;
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
function looksLikePath(token: string): boolean {
|
|
155
170
|
return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
|
|
156
171
|
}
|
|
@@ -179,6 +194,12 @@ function operandAccess(operator: string): SandboxAccess | "skip" {
|
|
|
179
194
|
interface PathCandidate {
|
|
180
195
|
token: string;
|
|
181
196
|
access: SandboxAccess;
|
|
197
|
+
/**
|
|
198
|
+
* A directory the shell is about to move into. It must clear both boundaries on its own merits,
|
|
199
|
+
* and the system-root read exemption does not apply: `/usr` being readable is no reason to let
|
|
200
|
+
* the working directory — and with it every unchecked relative path — move there.
|
|
201
|
+
*/
|
|
202
|
+
mustBeInTree?: boolean;
|
|
182
203
|
}
|
|
183
204
|
|
|
184
205
|
/** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
|
|
@@ -190,6 +211,9 @@ const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
|
|
|
190
211
|
/** A whitespace token that is only a redirection operator, so the next token is its operand. */
|
|
191
212
|
const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
|
|
192
213
|
|
|
214
|
+
/** Where a shell word ends inside a whitespace token: an operator, separator, or grouping. */
|
|
215
|
+
const METACHARACTER = /[;&|<>()]/;
|
|
216
|
+
|
|
193
217
|
/**
|
|
194
218
|
* A path glued to its option, as one shell word (#2524).
|
|
195
219
|
*
|
|
@@ -224,7 +248,8 @@ const OPTION_VALUE_FORMS: readonly RegExp[] = [
|
|
|
224
248
|
*/
|
|
225
249
|
function codePathOccurrences(command: string): PathOccurrence[] {
|
|
226
250
|
const found: PathOccurrence[] = [];
|
|
227
|
-
const add = (
|
|
251
|
+
const add = (raw: string, at: number, access?: SandboxAccess): void => {
|
|
252
|
+
const token = normalizeHomeExpansion(raw);
|
|
228
253
|
if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
|
|
229
254
|
};
|
|
230
255
|
// Set when the previous token was nothing but a redirection operator, so this one is its operand.
|
|
@@ -251,7 +276,17 @@ function codePathOccurrences(command: string): PathOccurrence[] {
|
|
|
251
276
|
const access = operandAccess(operator[0]);
|
|
252
277
|
if (access === "skip") continue;
|
|
253
278
|
const from = operator.index + operator[0].length;
|
|
254
|
-
|
|
279
|
+
// The operand ends at the first shell metacharacter, not at the end of the whitespace
|
|
280
|
+
// token. `>/dev/null; echo x` is one token, and taking all of it produced the "path"
|
|
281
|
+
// `/dev/null;`, which matched no write sink and refused a completely ordinary command
|
|
282
|
+
// (#2540). Truncating only ever shortens a candidate, so nothing blocked becomes allowed.
|
|
283
|
+
const rest = stripped.slice(from);
|
|
284
|
+
const stop = rest.search(METACHARACTER);
|
|
285
|
+
add(
|
|
286
|
+
(stop === -1 ? rest : rest.slice(0, stop)).replace(/^["']|["']$/g, ""),
|
|
287
|
+
match.index + openingQuote + from,
|
|
288
|
+
access,
|
|
289
|
+
);
|
|
255
290
|
}
|
|
256
291
|
|
|
257
292
|
// An option glued to its value is one word too, and the lexer cannot help: it
|
|
@@ -306,11 +341,145 @@ function codePathCandidates(command: string): PathCandidate[] {
|
|
|
306
341
|
* `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
|
|
307
342
|
* confidently, the floor stands alone.
|
|
308
343
|
*/
|
|
309
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Something the shell will act on whose path this layer cannot resolve — `cd "$DEST"`.
|
|
346
|
+
*
|
|
347
|
+
* Refusal is justified here and *not* for a redirect target, which is the asymmetry worth stating: a
|
|
348
|
+
* redirect target that escapes damages one file, while a directory change silently relocates every
|
|
349
|
+
* later relative path in the session, and relative paths are never candidates at all. So an
|
|
350
|
+
* unresolvable `cd` fails closed, and an unresolvable `> "$LOG"` does not.
|
|
351
|
+
*
|
|
352
|
+
* The reverse was tried (#2552) and refused `make > "$LOG"`, `> "$TMPDIR/f"` and `> out-$$.txt` —
|
|
353
|
+
* ordinary shell, writing in-tree. Narrowing it is not possible either: a variable's *value* can
|
|
354
|
+
* contain `../`, so `> "out-$X"` escapes while looking relative. Resolving that needs the expansion,
|
|
355
|
+
* which only the shell has — Phase 2 (#2554).
|
|
356
|
+
*/
|
|
357
|
+
interface UnresolvableTarget {
|
|
358
|
+
text: string;
|
|
359
|
+
/** What the shell was about to do, for a message that names the actual problem. */
|
|
360
|
+
what: "directory change";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface ShellScan {
|
|
364
|
+
candidates: PathCandidate[];
|
|
365
|
+
unresolvable: UnresolvableTarget[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Why a directory change was refused. Distinct from `describe` because the remedy differs: the path
|
|
370
|
+
* may well be readable, and the problem is that standing there redefines every relative path the
|
|
371
|
+
* boundary trusts to stay in the tree.
|
|
372
|
+
*/
|
|
373
|
+
function describeDirectoryChange(policy: SandboxPolicy, target?: string, unresolved?: string): string {
|
|
374
|
+
const what =
|
|
375
|
+
target === undefined
|
|
376
|
+
? `a directory this check cannot resolve from the command text (${unresolved})`
|
|
377
|
+
: `${target}, which is outside it`;
|
|
378
|
+
return `Refusing to change the working directory to ${what} (session directory: ${policy.cwd}). Relative paths are trusted to stay inside the session tree, so moving out of it would silently take every later path with it. Use an absolute path, or --allow-path to widen the boundary first.`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Builtins that move the shell, and therefore move what every later relative path means. */
|
|
382
|
+
const DIRECTORY_CHANGE = new Set(["cd", "pushd"]);
|
|
383
|
+
|
|
384
|
+
/** `cd`'s own options. All boolean, so the target is the first non-option operand. */
|
|
385
|
+
const DIRECTORY_CHANGE_OPTIONS = new Set(["-L", "-P", "-e", "-@"]);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Commands whose operand is a script the shell will run. The lexer hands the script over as one
|
|
389
|
+
* word, so a directory change inside it is invisible unless that word is lexed in turn.
|
|
390
|
+
*/
|
|
391
|
+
const SCRIPT_RUNNERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "eval"]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Prefixes that run the *following words* as a command rather than a script string. `command` is
|
|
395
|
+
* already unwrapped by the lexer; `builtin` is not, and `builtin cd /` would otherwise sail past a
|
|
396
|
+
* gate that only looks at `name`.
|
|
397
|
+
*/
|
|
398
|
+
const COMMAND_PREFIXES = new Set(["builtin"]);
|
|
399
|
+
|
|
400
|
+
/** Cheap pre-filter: only lex a script operand that could contain a directory change at all. */
|
|
401
|
+
const DIRECTORY_CHANGE_TOKEN = /(^|[\s;&|(])(cd|pushd)([\s;&|)]|$)/;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Targets of a directory change: a candidate that must resolve in-tree, or the raw text when it
|
|
405
|
+
* cannot be resolved at all.
|
|
406
|
+
*
|
|
407
|
+
* This gate exists because a relative operand is never a candidate — the floor assumes it resolves
|
|
408
|
+
* under the session directory. `cd` is what breaks that assumption: the bash tool runs one
|
|
409
|
+
* persistent brush-core shell in the agent's own process, so a directory change outlives the call
|
|
410
|
+
* that made it and afterwards `cat tmp/x` reads somewhere else entirely (#2542).
|
|
411
|
+
*
|
|
412
|
+
* It is defence-in-depth, not a boundary. Verified still open: `c=cd; $c /`, `alias g=cd; g /`, and
|
|
413
|
+
* a symlink created in the same command (#2553). Those need the shell's own resolution, which is
|
|
414
|
+
* Phase 2 (#2554). Do not add a seventh spelling here — two adversarial rounds produced six.
|
|
415
|
+
*/
|
|
416
|
+
function directoryChangeTargets(commands: readonly ShellSimpleCommand[], depth = 0): (PathCandidate | string)[] {
|
|
417
|
+
const targets: (PathCandidate | string)[] = [];
|
|
418
|
+
|
|
419
|
+
for (const command of commands) {
|
|
420
|
+
if (command.name === undefined) continue;
|
|
421
|
+
let name = command.name;
|
|
422
|
+
let operands = command.words.slice(command.operandStart).filter(word => word.redirect === undefined);
|
|
423
|
+
// `builtin cd sub` is a `cd`; unwrap the prefix before deciding anything.
|
|
424
|
+
while (COMMAND_PREFIXES.has(name) && operands.length > 0 && operands[0].literal) {
|
|
425
|
+
name = operands[0].text;
|
|
426
|
+
operands = operands.slice(1);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A script operand is text the shell will run, so lex it and gate what it contains. `eval
|
|
430
|
+
// 'cd /'` and `sh -c '…'` both arrive here. One level is enough for every real spelling;
|
|
431
|
+
// deeper nesting is unprovable.
|
|
432
|
+
if (SCRIPT_RUNNERS.has(name)) {
|
|
433
|
+
for (const operand of operands) {
|
|
434
|
+
if (!operand.literal) {
|
|
435
|
+
targets.push(operand.text); // `eval "$cmd"` — nothing to read
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (!DIRECTORY_CHANGE_TOKEN.test(operand.text)) continue;
|
|
439
|
+
if (depth > 0) {
|
|
440
|
+
targets.push(operand.text);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const inner = lexShellCommand(operand.text);
|
|
444
|
+
if (inner.unterminated) targets.push(operand.text);
|
|
445
|
+
else targets.push(...directoryChangeTargets(inner.commands, depth + 1));
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!DIRECTORY_CHANGE.has(name)) continue;
|
|
451
|
+
|
|
452
|
+
// Options precede the target and are all boolean. One the model does not recognise means the
|
|
453
|
+
// target cannot be located, so the proof fails rather than guessing which operand it is.
|
|
454
|
+
let index = 0;
|
|
455
|
+
let unparseable = false;
|
|
456
|
+
while (index < operands.length && operands[index].text.startsWith("-") && operands[index].text !== "-") {
|
|
457
|
+
if (!DIRECTORY_CHANGE_OPTIONS.has(operands[index].text)) {
|
|
458
|
+
unparseable = true;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
index++;
|
|
462
|
+
}
|
|
463
|
+
if (unparseable) {
|
|
464
|
+
targets.push(operands.map(word => word.text).join(" "));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const target = operands[index];
|
|
469
|
+
// `cd` with no operand goes to $HOME; `cd -` returns somewhere only the shell remembers; a
|
|
470
|
+
// non-literal target cannot be resolved from text.
|
|
471
|
+
if (target === undefined) targets.push(`${name} (no target: goes to $HOME)`);
|
|
472
|
+
else if (!target.literal || target.text === "-") targets.push(target.text);
|
|
473
|
+
else targets.push({ token: target.text, access: "read", mustBeInTree: true });
|
|
474
|
+
}
|
|
475
|
+
return targets;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function shellPathCandidates(command: string): ShellScan {
|
|
310
479
|
const lexed = lexShellCommand(command);
|
|
311
480
|
// Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
|
|
312
481
|
// marking below can be trusted, so fall back to the floor, checked as reads.
|
|
313
|
-
if (lexed.unterminated) return codePathCandidates(command);
|
|
482
|
+
if (lexed.unterminated) return { candidates: codePathCandidates(command), unresolvable: [] };
|
|
314
483
|
|
|
315
484
|
const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
|
|
316
485
|
let scanned = command;
|
|
@@ -338,10 +507,19 @@ function shellPathCandidates(command: string): PathCandidate[] {
|
|
|
338
507
|
for (const word of lexed.words) {
|
|
339
508
|
if (word.redirect === undefined || word.redirect === "here-string") continue;
|
|
340
509
|
for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
|
|
341
|
-
|
|
510
|
+
// A non-literal target — `$VAR`, a substitution, a glob — is not checked. `text` is not a
|
|
511
|
+
// stand-in for one filesystem reference, and refusing on that basis rejected ordinary
|
|
512
|
+
// in-tree shell (see UnresolvableTarget). Resolving it is Phase 2's job.
|
|
513
|
+
if (word.literal) candidates.push({ token: word.text, access });
|
|
342
514
|
}
|
|
343
515
|
}
|
|
344
|
-
|
|
516
|
+
|
|
517
|
+
const unresolvable: UnresolvableTarget[] = [];
|
|
518
|
+
for (const target of directoryChangeTargets(lexed.commands)) {
|
|
519
|
+
if (typeof target === "string") unresolvable.push({ text: target, what: "directory change" });
|
|
520
|
+
else candidates.push(target);
|
|
521
|
+
}
|
|
522
|
+
return { candidates, unresolvable };
|
|
345
523
|
}
|
|
346
524
|
|
|
347
525
|
/** Base directories a search input would actually search, split like the tools do. */
|
|
@@ -365,7 +543,12 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
|
|
|
365
543
|
|
|
366
544
|
const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
367
545
|
const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
|
|
368
|
-
|
|
546
|
+
// Both boundaries, for the same reason a `cd` target needs both: relative paths are never
|
|
547
|
+
// scanned, so wherever the command runs is somewhere it can write freely. Read alone let
|
|
548
|
+
// `{ cwd: "/shared/ctx", command: "touch notes.md" }` write into a read-only root.
|
|
549
|
+
if (rawCwd && !(policy.isAllowed(base, "read") && policy.isAllowed(base, "write"))) {
|
|
550
|
+
return { block: true, reason: describeDirectoryChange(policy, base) };
|
|
551
|
+
}
|
|
369
552
|
|
|
370
553
|
const commands: string[] = [];
|
|
371
554
|
for (const field of fields) {
|
|
@@ -383,9 +566,28 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
|
|
|
383
566
|
// Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
|
|
384
567
|
// shell yields one non-absolute word and would lose the check entirely, and it has no
|
|
385
568
|
// redirects, so every candidate it produces is a read.
|
|
569
|
+
const scan: ShellScan = shell
|
|
570
|
+
? shellPathCandidates(command)
|
|
571
|
+
: { candidates: codePathCandidates(command), unresolvable: [] };
|
|
572
|
+
// Refused before any candidate is resolved: there is no path to check, and that is
|
|
573
|
+
// precisely the problem. The residual this does NOT cover is an expansion in an
|
|
574
|
+
// operand rather than a redirect target — `cat "$SECRET"` — which cannot be resolved
|
|
575
|
+
// at this layer at all. That is the Phase 2 OS-sandbox's job; do not read the text
|
|
576
|
+
// boundary as complete (#2534).
|
|
577
|
+
for (const target of scan.unresolvable) {
|
|
578
|
+
return { block: true, reason: describeDirectoryChange(policy, undefined, target.text) };
|
|
579
|
+
}
|
|
386
580
|
const seen = new Set<string>();
|
|
387
|
-
for (const { token, access } of
|
|
388
|
-
if (!seen.add(`${access}\0${token}`)) continue;
|
|
581
|
+
for (const { token, access, mustBeInTree } of scan.candidates) {
|
|
582
|
+
if (!seen.add(`${access}\0${mustBeInTree ? "cd\0" : ""}${token}`)) continue;
|
|
583
|
+
if (mustBeInTree) {
|
|
584
|
+
// `path.resolve`, not `resolveToCwd`: the latter maps an all-slashes path to the cwd,
|
|
585
|
+
// which would read `cd /` as `cd .` and let the shell walk out. Both boundaries,
|
|
586
|
+
// because relative paths go unchecked wherever the shell is standing.
|
|
587
|
+
const moved = path.resolve(base, expandPath(token));
|
|
588
|
+
if (policy.isAllowed(moved, "read") && policy.isAllowed(moved, "write")) continue;
|
|
589
|
+
return { block: true, reason: describeDirectoryChange(policy, moved) };
|
|
590
|
+
}
|
|
389
591
|
const resolved = resolveToCwd(token, base);
|
|
390
592
|
if (policy.isAllowed(resolved, access)) continue;
|
|
391
593
|
// SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately
|