@f5-sales-demo/xcsh 19.100.1 → 19.101.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.100.1",
4
+ "version": "19.101.1",
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.100.1",
61
- "@f5-sales-demo/pi-agent-core": "19.100.1",
62
- "@f5-sales-demo/pi-ai": "19.100.1",
63
- "@f5-sales-demo/pi-natives": "19.100.1",
64
- "@f5-sales-demo/pi-resource-management": "19.100.1",
65
- "@f5-sales-demo/pi-tui": "19.100.1",
66
- "@f5-sales-demo/pi-utils": "19.100.1",
60
+ "@f5-sales-demo/xcsh-stats": "19.101.1",
61
+ "@f5-sales-demo/pi-agent-core": "19.101.1",
62
+ "@f5-sales-demo/pi-ai": "19.101.1",
63
+ "@f5-sales-demo/pi-natives": "19.101.1",
64
+ "@f5-sales-demo/pi-resource-management": "19.101.1",
65
+ "@f5-sales-demo/pi-tui": "19.101.1",
66
+ "@f5-sales-demo/pi-utils": "19.101.1",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -20,55 +20,182 @@ const here = import.meta.dir; // packages/coding-agent/scripts
20
20
  const vendoredPath = path.resolve(here, "../src/browser/capabilities.json");
21
21
  const generatedPath = path.resolve(here, "../src/browser/capabilities.generated.ts");
22
22
 
23
- // Co-build refresh: prefer the sibling extension's freshly-built contract.
24
- const siblingPath =
25
- process.env.XCSH_EXTENSION_CAPABILITIES ?? path.resolve(here, "../../../../xcsh-chrome-extension/capabilities.json");
26
- if (fs.existsSync(siblingPath) && path.resolve(siblingPath) !== vendoredPath) {
27
- fs.copyFileSync(siblingPath, vendoredPath);
23
+ // Guarded so the regression checks below can be imported by tests without regenerating files.
24
+ // Importing this module used to run the whole generator as a side effect (#2578).
25
+ if (import.meta.main) {
26
+ // Co-build refresh: adopt the sibling extension's freshly-built contract — but only when it really is
27
+ // fresher. "The directory exists" is not evidence of that, and treating it as evidence silently rewrote a
28
+ // committed contract from whatever happened to be on the developer's disk (#2578).
29
+ //
30
+ // Measured on a real machine: a sibling checkout left at contractVersion 1.8.0 overwrote the committed
31
+ // 1.12.0 and deleted the whole `handshake` feature block, on every `bun run check`, `bun test` and
32
+ // `bun run build`. `release.ts` runs `git commit -a`, so the downgrade was one release away from shipping.
33
+ // The guard applies to the path found by *proximity*, which is the accidental one. An explicitly set
34
+ // XCSH_EXTENSION_CAPABILITIES is a deliberate statement about which manifest to compile in — including a
35
+ // major-version co-build that removes tools on purpose — so it is honoured and merely announced.
36
+ const explicitPath = process.env.XCSH_EXTENSION_CAPABILITIES;
37
+ const siblingPath = explicitPath ?? path.resolve(here, "../../../../xcsh-chrome-extension/capabilities.json");
38
+ if (explicitPath !== undefined && fs.existsSync(explicitPath) && path.resolve(explicitPath) !== vendoredPath) {
39
+ console.log(`Adopting ${explicitPath} (XCSH_EXTENSION_CAPABILITIES set explicitly).`);
40
+ fs.copyFileSync(explicitPath, vendoredPath);
41
+ } else if (explicitPath === undefined && fs.existsSync(siblingPath) && path.resolve(siblingPath) !== vendoredPath) {
42
+ adoptSiblingIfNotARegression(siblingPath, vendoredPath);
43
+ }
44
+
45
+ const manifest = JSON.parse(fs.readFileSync(vendoredPath, "utf8")) as {
46
+ contractVersion: string;
47
+ tools: Array<{ name: string }>;
48
+ };
49
+ const toolNames = manifest.tools.map(t => t.name);
50
+
51
+ const output = [
52
+ "// Auto-generated by scripts/generate-extension-capabilities.ts - DO NOT EDIT",
53
+ "// Source: src/browser/capabilities.json (the Chrome extension's published contract).",
54
+ "",
55
+ "export interface ExtensionToolFlags {",
56
+ "\treadonly readOnly?: boolean;",
57
+ "\treadonly mutates?: boolean;",
58
+ "\treadonly requiresExplainMode?: boolean;",
59
+ "}",
60
+ "",
61
+ "export interface ExtensionToolDef {",
62
+ "\treadonly name: string;",
63
+ "\treadonly summary: string;",
64
+ "\treadonly category: string;",
65
+ "\treadonly params: Record<string, unknown>;",
66
+ "\treadonly flags?: ExtensionToolFlags;",
67
+ "}",
68
+ "",
69
+ "export interface ExtensionCapabilities {",
70
+ "\treadonly version: string;",
71
+ "\treadonly contractVersion: string;",
72
+ "\treadonly multiPortDiscovery?: boolean;",
73
+ "\treadonly protocol: string;",
74
+ "\treadonly tools: readonly ExtensionToolDef[];",
75
+ "\treadonly features: Record<string, unknown>;",
76
+ "}",
77
+ "",
78
+ `export const EXTENSION_CAPABILITIES: ExtensionCapabilities = ${JSON.stringify(manifest, null, "\t")};`,
79
+ "",
80
+ `export const EXTENSION_CONTRACT_VERSION = ${JSON.stringify(manifest.contractVersion)};`,
81
+ "",
82
+ `export const EXTENSION_TOOL_NAMES: readonly string[] = ${JSON.stringify(toolNames)};`,
83
+ "",
84
+ ].join("\n");
85
+
86
+ await Bun.write(generatedPath, output);
87
+ console.log(
88
+ `Generated ${path.relative(process.cwd(), generatedPath)} (${toolNames.length} tools, contract ${manifest.contractVersion})`,
89
+ );
90
+ }
91
+
92
+ /** A capability manifest, as far as the regression check needs to understand one. */
93
+ interface CapabilityManifest {
94
+ contractVersion?: string;
95
+ tools?: Array<{ name?: string }>;
96
+ features?: Record<string, unknown>;
97
+ }
98
+
99
+ /**
100
+ * Decide whether a sibling manifest may replace the committed one.
101
+ *
102
+ * Exported for tests. Returns the reason to refuse, or undefined when adoption is safe.
103
+ *
104
+ * Two independent guards, because they catch different mistakes:
105
+ * - a lower `contractVersion` is a stale checkout, the case that actually happened;
106
+ * - a missing tool or feature key at the same-or-higher version is a broken build, which a version
107
+ * comparison alone would wave through.
108
+ */
109
+ export function regressionReason(sibling: CapabilityManifest, vendored: CapabilityManifest): string | undefined {
110
+ const siblingVersion = sibling.contractVersion ?? "";
111
+ const vendoredVersion = vendored.contractVersion ?? "";
112
+ const versionOrder = compareContractVersions(siblingVersion, vendoredVersion);
113
+ if (versionOrder < 0) {
114
+ return `contractVersion would go backwards, ${vendoredVersion} -> ${siblingVersion}`;
115
+ }
116
+
117
+ const lostTools = missingKeys(
118
+ (vendored.tools ?? []).map(tool => tool.name ?? ""),
119
+ (sibling.tools ?? []).map(tool => tool.name ?? ""),
120
+ );
121
+ if (lostTools.length > 0) return `tools would be lost: ${lostTools.join(", ")}`;
122
+
123
+ const lostFeatures = missingKeys(Object.keys(vendored.features ?? {}), Object.keys(sibling.features ?? {}));
124
+ if (lostFeatures.length > 0) return `features would be lost: ${lostFeatures.join(", ")}`;
125
+
126
+ // Equal versions with different content mean one side changed without bumping, so the version says
127
+ // nothing about which is fresher. That is not hypothetical: a tool was once added while contractVersion
128
+ // stayed put, which is exactly how a stale same-version sibling could regress nested tool params or
129
+ // feature internals past a check that only compares names. Refuse and let the operator decide, via
130
+ // XCSH_EXTENSION_CAPABILITIES if they mean it.
131
+ if (versionOrder === 0 && !sameContent(sibling, vendored)) {
132
+ return `contractVersion is unchanged at ${vendoredVersion} but the content differs, so neither copy is provably fresher`;
133
+ }
134
+
135
+ return undefined;
28
136
  }
29
137
 
30
- const manifest = JSON.parse(fs.readFileSync(vendoredPath, "utf8")) as {
31
- contractVersion: string;
32
- tools: Array<{ name: string }>;
33
- };
34
- const toolNames = manifest.tools.map(t => t.name);
35
-
36
- const output = [
37
- "// Auto-generated by scripts/generate-extension-capabilities.ts - DO NOT EDIT",
38
- "// Source: src/browser/capabilities.json (the Chrome extension's published contract).",
39
- "",
40
- "export interface ExtensionToolFlags {",
41
- "\treadonly readOnly?: boolean;",
42
- "\treadonly mutates?: boolean;",
43
- "\treadonly requiresExplainMode?: boolean;",
44
- "}",
45
- "",
46
- "export interface ExtensionToolDef {",
47
- "\treadonly name: string;",
48
- "\treadonly summary: string;",
49
- "\treadonly category: string;",
50
- "\treadonly params: Record<string, unknown>;",
51
- "\treadonly flags?: ExtensionToolFlags;",
52
- "}",
53
- "",
54
- "export interface ExtensionCapabilities {",
55
- "\treadonly version: string;",
56
- "\treadonly contractVersion: string;",
57
- "\treadonly multiPortDiscovery?: boolean;",
58
- "\treadonly protocol: string;",
59
- "\treadonly tools: readonly ExtensionToolDef[];",
60
- "\treadonly features: Record<string, unknown>;",
61
- "}",
62
- "",
63
- `export const EXTENSION_CAPABILITIES: ExtensionCapabilities = ${JSON.stringify(manifest, null, "\t")};`,
64
- "",
65
- `export const EXTENSION_CONTRACT_VERSION = ${JSON.stringify(manifest.contractVersion)};`,
66
- "",
67
- `export const EXTENSION_TOOL_NAMES: readonly string[] = ${JSON.stringify(toolNames)};`,
68
- "",
69
- ].join("\n");
70
-
71
- await Bun.write(generatedPath, output);
72
- console.log(
73
- `Generated ${path.relative(process.cwd(), generatedPath)} (${toolNames.length} tools, contract ${manifest.contractVersion})`,
74
- );
138
+ /**
139
+ * Whole-manifest equality, by canonical JSON.
140
+ *
141
+ * Deliberately not a deep field-by-field comparison: the question is only "are these the same document",
142
+ * and any difference at an equal version is enough to refuse.
143
+ */
144
+ function sameContent(left: CapabilityManifest, right: CapabilityManifest): boolean {
145
+ return canonicalJson(left) === canonicalJson(right);
146
+ }
147
+
148
+ /** JSON with object keys sorted, so key order alone does not read as a difference. */
149
+ function canonicalJson(value: unknown): string {
150
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
151
+ if (value !== null && typeof value === "object") {
152
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
153
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
154
+ }
155
+ return JSON.stringify(value) ?? "null";
156
+ }
157
+
158
+ function missingKeys(expected: readonly string[], actual: readonly string[]): string[] {
159
+ const present = new Set(actual.filter(Boolean));
160
+ return expected.filter(key => key !== "" && !present.has(key));
161
+ }
162
+
163
+ /** Numeric-segment comparison. Enough for a dotted contract version, and no dependency. */
164
+ export function compareContractVersions(left: string, right: string): number {
165
+ const parse = (value: string): number[] => value.split(".").map(part => Number.parseInt(part, 10) || 0);
166
+ const a = parse(left);
167
+ const b = parse(right);
168
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
169
+ const diff = (a[index] ?? 0) - (b[index] ?? 0);
170
+ if (diff !== 0) return diff < 0 ? -1 : 1;
171
+ }
172
+ return 0;
173
+ }
174
+
175
+ function adoptSiblingIfNotARegression(from: string, to: string): void {
176
+ let sibling: CapabilityManifest;
177
+ let vendored: CapabilityManifest;
178
+ try {
179
+ sibling = JSON.parse(fs.readFileSync(from, "utf8")) as CapabilityManifest;
180
+ vendored = JSON.parse(fs.readFileSync(to, "utf8")) as CapabilityManifest;
181
+ } catch (error) {
182
+ // Unreadable input must not silently leave the committed copy in place either — say so.
183
+ console.warn(`Keeping the committed capabilities: could not compare with ${from} (${String(error)}).`);
184
+ return;
185
+ }
186
+
187
+ const reason = regressionReason(sibling, vendored);
188
+ if (reason === undefined) {
189
+ fs.copyFileSync(from, to);
190
+ return;
191
+ }
192
+
193
+ // Refused rather than fatal: this generator runs from `check:types`, `test` and `build`, so failing
194
+ // here would block every command on any machine with a stale sibling checkout. Skipping keeps the
195
+ // committed contract — the correct one — and names what was ignored so it is fixable.
196
+ console.warn(
197
+ `Ignoring ${from}: ${reason}.\n` +
198
+ " The committed capabilities.json is being kept. Update or remove that checkout, or set\n" +
199
+ " XCSH_EXTENSION_CAPABILITIES to the manifest you actually want compiled in.",
200
+ );
201
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
2
2
  import { settings } from "../../../config/settings";
3
+ import { containmentStatus } from "../../../sandbox/containment";
3
4
  import { evaluateToolCall } from "../../../sandbox/enforce";
4
5
  import { resolveSessionPolicy } from "../../../sandbox/session-policy";
5
6
 
@@ -30,6 +31,11 @@ export default function sandboxGuard(pi: ExtensionAPI): void {
30
31
  input: event.input as Record<string, unknown>,
31
32
  cwd: ctx.cwd,
32
33
  policy,
34
+ // Asked per call rather than cached at load: the answer comes from a probe of the running
35
+ // kernel, and a session can be created before the native module has been reached. The probe
36
+ // itself memoises, so this costs nothing after the first call. When an OS backend confines the
37
+ // shell, the command-text scan stops deciding for `bash` — see the #2582 note in enforce.ts.
38
+ shellOsConfined: containmentStatus(true).osEnforced,
33
39
  });
34
40
  return decision.block ? { block: true, reason: decision.reason } : undefined;
35
41
  });
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.100.1",
21
- "commit": "ab1af6daf7fdebc0fd0a193bdae578f0e9395eab",
22
- "shortCommit": "ab1af6d",
20
+ "version": "19.101.1",
21
+ "commit": "fbb716396d667153d37600b5c654893f7cdb3385",
22
+ "shortCommit": "fbb7163",
23
23
  "branch": "main",
24
- "tag": "v19.100.1",
25
- "commitDate": "2026-07-29T00:48:34Z",
26
- "buildDate": "2026-07-29T01:14:16.102Z",
24
+ "tag": "v19.101.1",
25
+ "commitDate": "2026-07-30T02:09:00Z",
26
+ "buildDate": "2026-07-30T02:36:13.835Z",
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/ab1af6daf7fdebc0fd0a193bdae578f0e9395eab",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.100.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/fbb716396d667153d37600b5c654893f7cdb3385",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.101.1"
33
33
  };
@@ -210,6 +210,127 @@ export function classifyRepo(classes: RepoClasses | null, repo: RepoIdentity | n
210
210
  return { className: className || CLASS_UNCLASSIFIED, declared, trustedOrg, definition };
211
211
  }
212
212
 
213
+ /** The fleet split by what may be done in each repository, rather than by class name. */
214
+ export interface AuthorityPartition {
215
+ /** Repositories xcsh authors in directly. */
216
+ readonly authored: readonly string[];
217
+ /** Repositories whose implementation belongs to a coding harness. */
218
+ readonly delegated: readonly string[];
219
+ /** Repositories that change only through the governed path. */
220
+ readonly governed: readonly string[];
221
+ /** Declared repositories whose class carries an authority this build does not know. */
222
+ readonly unknown: readonly string[];
223
+ /** Distinct `delegate_to` targets across the delegating classes. */
224
+ readonly delegateTargets: readonly string[];
225
+ }
226
+
227
+ /**
228
+ * Group every *declared* repository by the authority its class carries.
229
+ *
230
+ * Classes are the manifest's vocabulary; authority is what actually decides how xcsh
231
+ * contributes, and it is the only thing that answers "is this one mine?". Grouping by it
232
+ * keeps that answer correct if docs-control ever adds a fourth class — a new authoring
233
+ * class lands in `authored` without a change here.
234
+ *
235
+ * Only declared repositories appear. An unlisted one is UNCLASSIFIED and must never be
236
+ * presented as authorable, which is the same fail-closed stance `classifyRepo` takes.
237
+ */
238
+ export function partitionByAuthority(classes: RepoClasses): AuthorityPartition {
239
+ const authored: string[] = [];
240
+ const delegated: string[] = [];
241
+ const governed: string[] = [];
242
+ const unknown: string[] = [];
243
+ const delegateTargets = new Set<string>();
244
+
245
+ for (const [repo, className] of Object.entries(classes.repos)) {
246
+ // An assignment naming an undefined class has no authority to read, so it falls
247
+ // through to `unknown` — never to `authored`.
248
+ const definition = classes.classes[className];
249
+ switch (definition?.authority) {
250
+ case AUTHORITY_AUTHOR:
251
+ authored.push(repo);
252
+ break;
253
+ case AUTHORITY_DELEGATE:
254
+ delegated.push(repo);
255
+ if (definition.delegateTo) delegateTargets.add(definition.delegateTo);
256
+ break;
257
+ case AUTHORITY_GOVERNED:
258
+ governed.push(repo);
259
+ break;
260
+ default:
261
+ unknown.push(repo);
262
+ break;
263
+ }
264
+ }
265
+
266
+ return {
267
+ authored: authored.sort(),
268
+ delegated: delegated.sort(),
269
+ governed: governed.sort(),
270
+ unknown: unknown.sort(),
271
+ delegateTargets: [...delegateTargets].sort(),
272
+ };
273
+ }
274
+
275
+ /** Repository names as inline code, or an explicit marker when the group is empty. */
276
+ function renderRepoList(repos: readonly string[]): string {
277
+ return repos.length > 0 ? repos.map(r => `\`${r}\``).join(" ") : "_none_";
278
+ }
279
+
280
+ /**
281
+ * The roster, stated plainly: which repositories xcsh authors in, which belong to a
282
+ * coding harness, and which move only through the governed path.
283
+ *
284
+ * `renderFleet` below already lists every class in manifest detail, but reading it means
285
+ * mapping class → authority → "is this mine?" for each entry. That question comes up on
286
+ * its own ("which repositories do I manage?"), so answer it once, by name, and let it be
287
+ * read rather than inferred.
288
+ */
289
+ function renderTerritory(classes: RepoClasses): string[] {
290
+ const { authored, delegated, governed, unknown, delegateTargets } = partitionByAuthority(classes);
291
+ const handOff =
292
+ delegateTargets.length > 0 ? delegateTargets.map(t => `\`${t}\``).join(", ") : "a dedicated coding harness";
293
+
294
+ const lines = [
295
+ "## Your territory",
296
+ "",
297
+ `**You author in these ${authored.length} repositories** — every one whose class carries`,
298
+ `\`authority: ${AUTHORITY_AUTHOR}\`. Documentation, Terraform plans, network diagrams, howtos and`,
299
+ "demo scripts are yours to write here, through the governed path:",
300
+ "",
301
+ renderRepoList(authored),
302
+ "",
303
+ `**${delegated.length} repositories are delegated** to ${handOff}. Your deliverable there is a`,
304
+ "verified issue plus the specification, review and documentation around it — never an",
305
+ "implementation:",
306
+ "",
307
+ renderRepoList(delegated),
308
+ "",
309
+ `**${governed.length} repositories change through the governed path only** — fleet plumbing whose`,
310
+ "changes propagate everywhere:",
311
+ "",
312
+ renderRepoList(governed),
313
+ "",
314
+ ];
315
+
316
+ if (unknown.length > 0) {
317
+ lines.push(
318
+ `**${unknown.length} repositories carry an authority this build does not recognize.** Treat them`,
319
+ "as `delegate`, the restrictive case, and ask docs-control to fix the manifest:",
320
+ "",
321
+ renderRepoList(unknown),
322
+ "",
323
+ );
324
+ }
325
+
326
+ lines.push(
327
+ "This roster is read from the manifest, never inferred from what a repository contains. A",
328
+ "repository missing from it is UNCLASSIFIED and is never authored, whatever it holds.",
329
+ "",
330
+ );
331
+ return lines;
332
+ }
333
+
213
334
  /** The behaviour each authority implies, stated so the agent does not have to infer it. */
214
335
  function authorityGuidance(authority: string): string[] {
215
336
  switch (authority) {
@@ -309,16 +430,7 @@ function renderFleet(classes: RepoClasses): string[] {
309
430
  const def = classes.classes[className];
310
431
  lines.push(`### ${className} (${repos.length}) — authority: ${def?.authority ?? "unknown"}`);
311
432
  if (def?.description) lines.push("", def.description);
312
- lines.push(
313
- "",
314
- repos.length > 0
315
- ? repos
316
- .sort()
317
- .map(r => `\`${r}\``)
318
- .join(" ")
319
- : "_none_",
320
- "",
321
- );
433
+ lines.push("", renderRepoList(repos.sort()), "");
322
434
  }
323
435
 
324
436
  lines.push(
@@ -392,6 +504,7 @@ export function renderFleetDoc(
392
504
  "# Fleet — repository classes and your authority here",
393
505
  "",
394
506
  ...renderCurrentRepo(slug, verdict, classes),
507
+ ...renderTerritory(classes),
395
508
  ...renderFleet(classes),
396
509
  ...renderProvenance(origin, classes),
397
510
  ...FOOTER,
@@ -7,15 +7,34 @@ Filesystem isolation is **on**. Enforcement for the `bash` tool: **{{containment
7
7
  Your shell is confined by the operating system, not by inspecting the command you wrote. A path is
8
8
  checked where it is actually opened, after the shell has expanded variables, resolved aliases and
9
9
  followed symlinks — so how a path is spelled does not change what is reachable.
10
- - Ordinary work is unrestricted: system paths, `/tmp`, package caches (`~/.bun`, `~/.cargo`, …), the
11
- network, and running programs are all untouched.
10
+ - Ordinary work is unrestricted: system paths, `/tmp`, package caches (`~/.bun`, `~/.cargo`,
11
+ `~/go/pkg/mod`, …), the network, and running programs are all untouched.
12
+ - The CLIs you drive keep their own configuration, so `gh`, `glab`, `az`, `aws`, `gcloud`, `sf`,
13
+ `docker`, `kubectl` and `terraform` all work normally, including the token refreshes and logs they
14
+ write as they go.
15
+ {{#unless containment.commandConfigWritable}}
16
+ What you cannot do is *rewrite* the settings that name a command to run — `~/.aws/config`,
17
+ `~/.kube/config`, `~/.docker/config.json`, a plugin directory. Those stay readable and are refused for
18
+ writing, because a later unfenced run of that CLI would execute what you wrote.
19
+ {{else}}
20
+ This backend cannot hold a file read-only inside a writable directory, so those settings are
21
+ writable here. Do not edit the ones that name a command to run — `~/.aws/config`, `~/.kube/config`,
22
+ `~/.docker/config.json`, `~/.azure/config`, a plugin directory — unless the operator asked you to: a
23
+ later unfenced run of that CLI would execute what you wrote.
24
+ {{/unless}}
12
25
  - What is refused: reading or writing outside the session directory — another checkout, `~/.ssh`,
13
- `~/.aws`, `~/Documents`, and other sessions' transcripts. `~/.gitconfig` is readable, not writable.
14
- - `cd` out of the session tree is refused, because every later relative path would resolve there.
26
+ `~/.gnupg`, `~/Documents`, and other sessions' transcripts. `~/.gitconfig` is readable, not writable.
27
+ - `cd` follows the same boundary as everything else: moving somewhere reachable is fine, and `cd` into
28
+ a place you could not read is refused. Where you stand does not widen anything — the boundary is
29
+ fixed for the session, so it never follows the shell.
30
+
31
+ If a command is refused, do not try to reach the same path a different way. Say what you needed and why.
32
+ The operator can widen it with `--allow-path <dir>`, which grants read and write.
15
33
 
16
- If a command is refused, do not try to reach the same path a different way: the boundary is enforced
17
- below the command text, so no rewriting will succeed. Say what you needed and why. The operator can
18
- widen it with `--allow-path <dir>`, which grants read and write.
34
+ That is an instruction, not a claim that rewriting is impossible. Two layers decide: the OS confinement
35
+ above, and a scan of the command text before it runs. The scan reads what you wrote, so a path assembled
36
+ at runtime can get past it and reaching for that spelling after a refusal is precisely the behaviour
37
+ being asked for here, whether or not it would work.
19
38
 
20
39
  {{#if containment.landlock}}
21
40
  Three things behave differently under this backend, and none of them is a bug to work around:
@@ -44,7 +44,9 @@ Terraform, howtos, demo and traffic-generation scripts — through the governed
44
44
  classified **developer** your deliverable is a rigorously-verified issue plus the specification,
45
45
  review and documentation around it, and the implementation is delegated to a development
46
46
  environment (Claude Code / Codex). In one classified **scaffolding**, changes go through the
47
- governed path only. An unclassified repository is treated as **developer**.
47
+ governed path only. An unclassified repository is treated as **developer**. That document also
48
+ names, one by one, every repository in each group — so "which repositories do I manage?" is a
49
+ question you read the answer to, never one you estimate.
48
50
 
49
51
  You are also a **work in progress** under active development — you improve by verified
50
52
  contribution, never by claiming. See `<self-awareness>`.
@@ -313,7 +315,7 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
313
315
  - `local://<TITLE>.md` — Finalized plan artifact created after `exit_plan_mode` approval
314
316
  - `jobs://<job-id>` — Specific job status and result
315
317
  - `mcp://<resource-uri>` — MCP resource from a connected server; matched against exact resource URIs first, then RFC 6570 URI templates advertised by connected servers
316
- - `xcsh://fleet` — The class of the repository you are working in and what you may author there. **MUST** read before creating, updating, or deleting content in any repository of this organization — this is about the *current repository*, not about xcsh, so the gate on the other `xcsh://` documents does not apply.
318
+ - `xcsh://fleet` — The class of the repository you are working in and what you may author there, plus the full roster: every repository in the fleet listed by name under the authority that governs it. **MUST** read before creating, updating, or deleting content in any repository of this organization, and whenever you are asked which repositories you author in or manage — this is about the *current repository* and its fleet, not about xcsh, so the gate on the other `xcsh://` documents does not apply.
317
319
  - `xcsh://..` — Internal xcsh documentation. **MUST NOT** read unless the user asks about xcsh itself.
318
320
  - `xcsh://about` — Identity, version, build fingerprint, architecture, self-improvement. **MUST** read for any question about xcsh before exploring `~/.xcsh/`.
319
321
  This document contains the authoritative repository URL, issues URL, and source location.
@@ -349,7 +351,8 @@ from the contents**. Before you create, update, or delete content in any reposit
349
351
  organization, read `xcsh://fleet` and act on its verdict:
350
352
  - **content** → author directly. You do not need permission to write documentation, Terraform,
351
353
  howtos, diagrams or demo scripts here; you do need the governed path — linked issue → branch →
352
- pull request → CI → auto-merge, never a commit to `main`.
354
+ pull request → CI → auto-merge, never a commit to `main`. `xcsh://fleet` lists every content
355
+ repository by name under "Your territory"; that list is the answer to what you author in.
353
356
  - **developer** → do not implement feature code. File a CONTRIBUTING-compliant issue (reproduce
354
357
  first, no unverified claims) and delegate the implementation to a dedicated coding harness.
355
358
  Specifying, reviewing and documenting remain yours.
@@ -51,6 +51,14 @@ export interface ContainmentOptions {
51
51
  writeOnlyRoots?: readonly string[];
52
52
  /** Cross-session leak roots to deny. Defaults to the real memories/sessions/contexts dirs. */
53
53
  leakRoots?: readonly string[];
54
+ /**
55
+ * Whether the active backend can express "writable directory, except this file" — see
56
+ * COMMAND_BEARING_CONFIG. True for seatbelt, false for Landlock, which cannot.
57
+ *
58
+ * Defaults to false, so a caller that does not know its backend gets the portable policy rather
59
+ * than one that silently breaks the CLIs on Linux.
60
+ */
61
+ narrowsWithinGrant?: boolean;
54
62
  }
55
63
 
56
64
  /**
@@ -75,6 +83,12 @@ const CACHE_DIRS = [
75
83
  path.join(".yarn", "berry", "cache"),
76
84
  path.join(".rustup", "toolchains"),
77
85
  path.join(".rustup", "downloads"),
86
+ // Go keeps its module cache under ~/go, but ~/go also holds checked-out source and `go install`
87
+ // output, so only the cache is granted. Missed in the original list; `go build` failed for it.
88
+ path.join("go", "pkg", "mod"),
89
+ // The build cache is separate from the module cache and `go build` needs both. On macOS it lands in
90
+ // ~/Library/Caches (already granted); on Linux it is ~/.cache/go-build, which nothing else covers.
91
+ path.join(".cache", "go-build"),
78
92
  // No credential convention of their own, so granted whole.
79
93
  ".pnpm-store",
80
94
  ".deno",
@@ -82,6 +96,88 @@ const CACHE_DIRS = [
82
96
  path.join("Library", "pnpm"),
83
97
  ];
84
98
 
99
+ /**
100
+ * Config and state directories of the CLIs xcsh ships plugins and skills for — the same list it probes
101
+ * for in `internal-urls/computer-profile.ts`.
102
+ *
103
+ * Granted read and write. In v19.100.0 the home deny covered all of these, so every one of `gh`, `glab`,
104
+ * `sf`, `az`, `aws` and `gcloud` failed on its own configuration — and the agent is instructed to file
105
+ * issues with `gh` (#2581). Write is required, not convenience: `az` writes a log per invocation to
106
+ * `~/.azure/commands`, `sf` writes a dated log into `~/.sf`, and both `aws` and `gcloud` refresh cached
107
+ * tokens without being asked. Measured with these read-only instead: `az` exits 1 on
108
+ * `~/.azure/commands/<stamp>.log`, and `sf` reproduces the original `EPERM` crash on `~/.sf/sf-<date>.log`.
109
+ *
110
+ * The cost, stated plainly: a fence keyed on paths cannot let `aws` read `~/.aws/credentials` without
111
+ * letting `cat` read it, so the operator's cloud credentials are readable from a fenced shell. Accepted,
112
+ * because the fence exists to stop the assistant wandering between customer workspaces rather than to
113
+ * withhold the operator's credential store from the operator's own CLIs — and the native `az`/`aws` tools
114
+ * already act with those credentials, so denying only the shell path broke the CLIs without protecting
115
+ * anything. `~/.ssh` and `~/.gnupg` are still denied: no shipped tool needs them.
116
+ *
117
+ * Reading a credential is not the same as being able to *replace* one, so see COMMAND_BEARING_CONFIG.
118
+ */
119
+ const TOOL_CONFIG_DIRS = [
120
+ path.join(".config", "gh"), // gh
121
+ path.join(".config", "glab-cli"), // glab, XDG layout
122
+ path.join("Library", "Application Support", "glab-cli"), // glab, macOS layout
123
+ ".sf", // sf
124
+ ".sfdx", // sf, legacy layout still read by current versions
125
+ ".azure", // az
126
+ ".aws", // aws
127
+ path.join(".config", "gcloud"), // gcloud
128
+ ".docker", // docker
129
+ ".kube", // kubectl
130
+ ".terraform.d", // terraform
131
+ ];
132
+
133
+ /**
134
+ * Paths inside TOOL_CONFIG_DIRS that name a command or hold a loadable executable. Read-only, so the
135
+ * grant above never becomes a way to run code later.
136
+ *
137
+ * This is the distinction that matters: reading `~/.aws/credentials` discloses a secret, but *writing*
138
+ * `~/.aws/config` installs a `credential_process` that the operator's next — unfenced — `aws` call
139
+ * executes, with access to every customer workspace and private file the fence exists to protect. That
140
+ * is an escape from the sandbox rather than a leak inside it. `~/.cargo/config.toml` and
141
+ * `~/.gradle/init.gradle` are excluded from the cache carve-out for exactly this reason; these are the
142
+ * same class, and none of them was writable before #2581, so keeping them read-only means that change
143
+ * adds no new write capability on any path that can cause execution.
144
+ *
145
+ * Each entry is a documented mechanism, not a guess: `credential_process` (aws), `user.exec`
146
+ * (kubeconfig), `credsStore`/`credHelpers` and CLI plugins (docker), Python extensions (az), provider
147
+ * binaries (terraform), the virtualenv `activate` that gcloud's launcher sources, and `!`-prefixed shell
148
+ * aliases (gh, glab). The CLIs still read all of them, so nothing stops working; only rewriting does.
149
+ * `gh auth login` and `glab auth login` are interactive and belong outside a fenced session anyway.
150
+ */
151
+ const COMMAND_BEARING_CONFIG = [
152
+ path.join(".aws", "config"), // credential_process = <command>
153
+ path.join(".aws", "cli", "alias"), // aws aliases; a leading `!` runs through a shell
154
+ path.join(".azure", "config"), // extension.index_url + use_dynamic_install fetch and run wheels
155
+ path.join(".kube", "config"), // users[].user.exec.command
156
+ path.join(".docker", "config.json"), // credsStore / credHelpers -> docker-credential-*
157
+ path.join(".docker", "cli-plugins"), // docker-* plugin executables
158
+ path.join(".azure", "cliextensions"), // az extensions, executed as Python
159
+ path.join(".terraform.d", "plugins"), // provider binaries
160
+ path.join(".config", "gcloud", "virtenv"), // sourced by the gcloud launcher
161
+ path.join(".config", "gh", "config.yml"), // gh alias set x '!sh -c ...', plus editor/browser/pager
162
+ // glab keeps aliases in their own file, so those can be held read-only. Its config.yml cannot:
163
+ // glab rewrites it on ordinary commands (atomic rename) to refresh the OAuth token and the
164
+ // update-check stamp, and holding it read-only reproduced #2581 — `glab auth status` exits 1 with
165
+ // "rename …/.config.yml".
166
+ //
167
+ // Worse than a failed command, and the reason this is not merely a convenience: the OAuth refresh
168
+ // already happened at GitLab, so a denied write loses the rotated refresh token and the operator's
169
+ // login is permanently dead — `invalid_grant` afterwards, even outside the fence. Observed while
170
+ // testing this change, which cost a real `glab auth login`. Any CLI that refreshes a rotating token
171
+ // must be able to persist it.
172
+ //
173
+ // Left writable, and the residual exposure is stated rather than hidden:
174
+ // that file also carries `editor`, `browser` and `duo_cli_binary_path`/`duo_cli_auto_run`, so a write
175
+ // there IS a code-execution vector this fence does not close. `gh` needs no such exception because it
176
+ // was measured working with its config.yml read-only.
177
+ path.join(".config", "glab-cli", "aliases.yml"),
178
+ path.join("Library", "Application Support", "glab-cli", "aliases.yml"),
179
+ ];
180
+
85
181
  /** Read-only inside home: configuration a tool needs to behave correctly, but must not rewrite. */
86
182
  const READ_ONLY_HOME = [".gitconfig", path.join(".config", "git")];
87
183
 
@@ -100,6 +196,33 @@ function canonical(root: string): string | undefined {
100
196
  }
101
197
  }
102
198
 
199
+ /**
200
+ * Canonicalise as much of `target` as already exists, keeping the absent tail.
201
+ *
202
+ * `canonical` gives up on a path whose leaf is missing, and falling back to the literal string is not
203
+ * safe for a rule whose job is to *narrow* another one. With `~/.aws` symlinked to a vault — chezmoi,
204
+ * stow and yadm all do this — and no config file yet, the grant is emitted resolved (`<vault>`) while
205
+ * the read-only rule keeps the link spelling (`~/.aws/config`). Both backends match a rule against the
206
+ * path the kernel resolved, so the narrower rule covers nothing.
207
+ *
208
+ * That was verified as a real escape before this existed: through the real seatbelt profile,
209
+ * `printf 'credential_process = …' > <vault>/config` succeeded. Note `fenceVerdict` did NOT show it,
210
+ * because `pathIsWithin` normalises symlinks — the in-process check was safe while the emitted profile
211
+ * was not, so a test asserting only the verdict passes while the boundary leaks.
212
+ */
213
+ function canonicalThroughExisting(target: string): string {
214
+ const tail: string[] = [];
215
+ let current = target;
216
+ for (;;) {
217
+ const resolved = canonical(current);
218
+ if (resolved !== undefined) return path.join(resolved, ...tail);
219
+ const parent = path.dirname(current);
220
+ if (parent === current) return target;
221
+ tail.unshift(path.basename(current));
222
+ current = parent;
223
+ }
224
+ }
225
+
103
226
  /**
104
227
  * Parents that must never be denied, however the workspace is placed.
105
228
  *
@@ -112,6 +235,11 @@ function tooBroadToDeny(candidate: string): boolean {
112
235
  if (candidate === path.parse(candidate).root) return true;
113
236
  const never = [
114
237
  safeReal(os.tmpdir()),
238
+ // Both spellings: `/tmp` resolves to `/private/tmp` on macOS, and the ancestor walk works on
239
+ // resolved paths. Without the resolved form, a workspace at `/tmp/<x>/repo` denied `/private/tmp`
240
+ // — every other temp path with it — which contradicts the `/tmp` guarantee in `xcsh://about`.
241
+ "/tmp",
242
+ safeReal("/tmp"),
115
243
  "/usr",
116
244
  "/bin",
117
245
  "/sbin",
@@ -166,13 +294,28 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
166
294
  const allowWriteOnly = new Set<string>();
167
295
  const deny = new Set<string>();
168
296
 
169
- // Home is one fence. The workspace's own parent is the other, and it is the one that matters when
297
+ // Home is one fence. The workspace's ancestors are the other, and they are what matters when
170
298
  // checkouts live outside home: with /work/customer-a as the workspace, /work/customer-b matched no
171
- // rule at all and was readable and writable. Denying the parent closes sibling access wherever the
172
- // checkouts sit, which is the threat this exists for.
299
+ // rule at all and was readable and writable.
300
+ //
301
+ // Every ancestor, not just the immediate parent. One level only works when the parent happens to be
302
+ // the container the tenants sit in; with `<container>/<tenant>/repo` the immediate parent IS the
303
+ // tenant, so every OTHER tenant matched nothing and the fence allowed it, read and write. That went
304
+ // unnoticed because the command-text scan is deny-by-default and refused those paths on the way in —
305
+ // the composite looked right while the fence alone did not. Removing that scan for OS-confined shells
306
+ // (#2582) is what exposed it, so the two changes ship together.
307
+ //
308
+ // Denying an ancestor costs nothing above it: the walk stops before anything `tooBroadToDeny`
309
+ // rejects, so `/`, `/usr`, `/tmp` and `$TMPDIR` are never denied and operational paths stay
310
+ // reachable. And it costs nothing below: the workspace is allowed at greater depth, and
311
+ // `fenceVerdict` takes the deepest match.
173
312
  if (home !== undefined && home !== workspace) deny.add(home);
174
- const parent = path.dirname(workspace);
175
- if (parent !== workspace && !tooBroadToDeny(parent)) deny.add(parent);
313
+ for (let ancestor = path.dirname(workspace); ; ancestor = path.dirname(ancestor)) {
314
+ if (tooBroadToDeny(ancestor)) break;
315
+ deny.add(ancestor);
316
+ const next = path.dirname(ancestor);
317
+ if (next === ancestor) break; // reached a filesystem root that `tooBroadToDeny` did not name
318
+ }
176
319
 
177
320
  for (const root of [options.sessionTmp, ...(options.extraRoots ?? [])]) {
178
321
  if (root === undefined) continue;
@@ -194,9 +337,25 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
194
337
  // Granted whether or not they exist yet. `~/.bun` has to be writable *before* the first
195
338
  // `bun install` creates it, so dropping absent caches would break exactly the first run.
196
339
  // Canonicalised when present, so a symlinked cache resolves to its real location.
197
- for (const cache of CACHE_DIRS) allow.add(canonical(path.join(home, cache)) ?? path.join(home, cache));
198
- for (const config of READ_ONLY_HOME) {
199
- allowReadOnly.add(canonical(path.join(home, config)) ?? path.join(home, config));
340
+ for (const cache of [...CACHE_DIRS, ...TOOL_CONFIG_DIRS]) {
341
+ allow.add(canonicalThroughExisting(path.join(home, cache)));
342
+ }
343
+ // Deeper than the grant above, so `fenceVerdict` picks these and write becomes a deny while read
344
+ // stays allowed. Emitted even when absent, or creating the file would be the way around it — and
345
+ // resolved through existing ancestors, or a symlinked config dir puts the two rules in different
346
+ // namespaces and the narrower one stops applying.
347
+ //
348
+ // COMMAND_BEARING_CONFIG only when the backend can express a narrowing *inside* a grant.
349
+ // Seatbelt can: it is last-match-wins, so a deeper deny simply overrides. Landlock cannot — its
350
+ // rules are allow-only and always recursive, so a read-only child turns its parent into a split
351
+ // dir that loses write on its own inode. Verified with `containment-check plan`: adding
352
+ // `~/.azure/cliextensions` as read-only made `~/.azure` itself `r-`, which would stop `az` from
353
+ // creating `azureProfile.json` at all. Applying it anyway would trade a code-execution path for
354
+ // breaking the very CLIs #2581 is about, so the gap is reported instead — the same choice already
355
+ // made for `truncationUngoverned`.
356
+ const narrowed = options.narrowsWithinGrant ? COMMAND_BEARING_CONFIG : [];
357
+ for (const config of [...READ_ONLY_HOME, ...narrowed]) {
358
+ allowReadOnly.add(canonicalThroughExisting(path.join(home, config)));
200
359
  }
201
360
  }
202
361
 
@@ -260,6 +419,14 @@ export interface ContainmentStatus {
260
419
  * different claims and an operator is entitled to know which one they have.
261
420
  */
262
421
  readonly truncationUngoverned?: boolean;
422
+ /**
423
+ * Set when the backend cannot hold a file read-only inside a writable directory, so the
424
+ * command-bearing CLI settings (`~/.aws/config`, `~/.kube/config`, …) are writable. Landlock's rules
425
+ * are allow-only and recursive; narrowing inside a grant would strip write from the parent directory
426
+ * and break the CLIs the grant exists for. Reported rather than folded into `osEnforced`, because it
427
+ * changes what a write to those paths means, not whether the boundary holds.
428
+ */
429
+ readonly commandConfigWritable?: boolean;
263
430
  }
264
431
 
265
432
  /**
@@ -301,6 +468,8 @@ export function containmentStatus(
301
468
  osEnforced: true,
302
469
  // Absent on the ABI that governs truncation; present, and stated, on the one that does not.
303
470
  ...(probed.truncateHandled === false ? { truncationUngoverned: true } : {}),
471
+ // Always true here: no Landlock ABI can narrow a right inside a grant.
472
+ commandConfigWritable: true,
304
473
  };
305
474
  }
306
475
  return { enabled: true, backend: "scanner-only", osEnforced: false };
@@ -30,8 +30,18 @@
30
30
  *
31
31
  * So: keep it, and do not extend it. Another spelling caught here buys little now, and the pattern of
32
32
  * adding one has a poor record — two adversarial rounds on the #2542 fix alone produced six bypasses.
33
+ *
34
+ * **It is still a real layer, though, not a legacy one (#2582.)** It was tempting to conclude that the
35
+ * fence sits below this and therefore this can only produce false positives. That is wrong: the fence is
36
+ * allow-by-default and denies only home plus the workspace's ancestors, so it does not cover a second
37
+ * customer tree under an unrelated root, and `policy.ts` withholds the shared OS temp dir from the file
38
+ * tools on purpose. Deleting this would hand both away. What #2582 actually fixed is narrower — the
39
+ * *false refusals*, where this layer refused what the fence grants; see `fenceAlsoPermits`.
33
40
  */
41
+ import * as fs from "node:fs";
42
+ import * as os from "node:os";
34
43
  import * as path from "node:path";
44
+ import { pathIsWithin } from "@f5-sales-demo/pi-utils";
35
45
  import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
36
46
  import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
37
47
  import { provenExemptWords } from "./command-operands";
@@ -42,6 +52,16 @@ export interface ToolCallCheck {
42
52
  input: Record<string, unknown>;
43
53
  cwd: string;
44
54
  policy: SandboxPolicy;
55
+ /**
56
+ * Whether an OS backend is confining the `bash` tool's shell — seatbelt or Landlock.
57
+ *
58
+ * It does not stand this scan down. It only stops the scan refusing the operational paths the fence
59
+ * grants, so the composite is no longer stricter than either engine — see `fenceAlsoPermits`.
60
+ *
61
+ * Absent means "not known", which keeps every refusal in place: understating the backend costs a
62
+ * false refusal, while overstating it would relax the only boundary a scanner-only host has.
63
+ */
64
+ shellOsConfined?: boolean;
45
65
  }
46
66
 
47
67
  export interface ToolCallDecision {
@@ -153,6 +173,65 @@ function isSystemWriteSink(resolved: string): boolean {
153
173
  return SYSTEM_WRITE_SINKS.has(resolved) || resolved.startsWith("/dev/fd/");
154
174
  }
155
175
 
176
+ /**
177
+ * Paths the containment fence permits, which this scan must therefore stop refusing (#2582).
178
+ *
179
+ * The two engines have opposite defaults: this one is `SandboxPolicy`, deny-by-default and confined to
180
+ * the cwd, while the fence is allow-by-default with targeted denies. Running both made the composite
181
+ * their intersection, so the scan refused work the fence permits and `xcsh://about` promises — a `/tmp`
182
+ * write, a `~/.gitconfig` read, `cd /tmp`. That falsified the model's own instructions and taught it to
183
+ * abandon operations that were allowed.
184
+ *
185
+ * Only the *false refusals* go away. The scan keeps deciding, because it is not merely a slower copy of
186
+ * the fence: the fence denies home and the workspace's ancestors and allows everything else, so it does
187
+ * NOT cover a second customer tree under an unrelated root (`/data/globex`), and `policy.ts` deliberately
188
+ * withholds the shared OS temp dir from the *file* tools to stop one session reading another's scratch.
189
+ * Deleting this layer would hand both of those away. Adversarial review caught that; #2582's premise that
190
+ * the scan "cannot add security" is wrong.
191
+ *
192
+ * So this is a narrow, enumerated set — the operational paths the fence grants — and it applies to the
193
+ * shell only, gated on a backend actually being there. `python` is covered by no fence on any platform,
194
+ * and on a host with no backend this scan is the whole boundary, so neither is widened.
195
+ */
196
+ function fenceAlsoPermits(resolved: string, access: SandboxAccess): boolean {
197
+ // The fence never mentions the temp directories, so they are reachable below the text whatever this
198
+ // says. Refusing them here only produced a diagnostic that contradicted the documentation.
199
+ if (tempRoots().some(root => pathIsWithin(root, resolved))) return true;
200
+ // Granted read-only by the fence (`READ_ONLY_HOME`): a tool needs it to behave correctly, and it must
201
+ // not be rewritten. The write direction stays refused, which matches the fence exactly.
202
+ if (access === "read") {
203
+ const home = os.homedir();
204
+ return [path.join(home, ".gitconfig"), path.join(home, ".config", "git")].some(root =>
205
+ pathIsWithin(root, resolved),
206
+ );
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * The temp directories, resolved once.
213
+ *
214
+ * Both spellings are needed, and conflating them is the bug this comment exists to prevent: on macOS
215
+ * `os.tmpdir()` is the *per-user* `/var/folders/…/T`, which is not `/tmp` at all. `xcsh://about` promises
216
+ * `/tmp` specifically, so covering only `os.tmpdir()` left the exact case #2582 reported still refused.
217
+ * Real paths as well as nominal ones, because `/tmp` is a symlink to `/private/tmp` and a resolved
218
+ * candidate carries the latter.
219
+ */
220
+ let cachedTempRoots: string[] | undefined;
221
+ function tempRoots(): string[] {
222
+ if (cachedTempRoots === undefined) {
223
+ const resolve = (input: string): string => {
224
+ try {
225
+ return fs.realpathSync(input);
226
+ } catch {
227
+ return input;
228
+ }
229
+ };
230
+ cachedTempRoots = [...new Set(["/tmp", resolve("/tmp"), os.tmpdir(), resolve(os.tmpdir())])];
231
+ }
232
+ return cachedTempRoots;
233
+ }
234
+
156
235
  function firstString(input: Record<string, unknown>, keys: string[]): string | undefined {
157
236
  for (const key of keys) {
158
237
  const value = input[key];
@@ -554,13 +633,18 @@ function searchBases(raw: string, base: (token: string) => string): string[] {
554
633
 
555
634
  function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean): ToolCallDecision {
556
635
  const { input, cwd, policy } = check;
636
+ // The fence's allowances apply to the shell only, and only where a backend is actually enforcing.
637
+ const fenced = shell && check.shellOsConfined === true;
557
638
 
558
639
  const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
559
640
  const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
560
641
  // Both boundaries, for the same reason a `cd` target needs both: relative paths are never
561
642
  // scanned, so wherever the command runs is somewhere it can write freely. Read alone let
562
643
  // `{ cwd: "/shared/ctx", command: "touch notes.md" }` write into a read-only root.
563
- if (rawCwd && !(policy.isAllowed(base, "read") && policy.isAllowed(base, "write"))) {
644
+ // `cwd: "/tmp"` has to answer the same as `cd /tmp`, or the false refusal simply moves to the other
645
+ // interface — and `cwd` is the one the bash prompt tells the model to prefer over `cd`.
646
+ const fencePermitsBase = fenced && fenceAlsoPermits(base, "read") && fenceAlsoPermits(base, "write");
647
+ if (rawCwd && !fencePermitsBase && !(policy.isAllowed(base, "read") && policy.isAllowed(base, "write"))) {
564
648
  return { block: true, reason: describeDirectoryChange(policy, base) };
565
649
  }
566
650
 
@@ -600,6 +684,9 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
600
684
  // because relative paths go unchecked wherever the shell is standing.
601
685
  const moved = path.resolve(base, expandPath(token));
602
686
  if (policy.isAllowed(moved, "read") && policy.isAllowed(moved, "write")) continue;
687
+ // `cd /tmp` was refused while the fence permits standing there, and the boundary no
688
+ // longer follows the shell (#2589), so where it stands widens nothing.
689
+ if (fenced && fenceAlsoPermits(moved, "read") && fenceAlsoPermits(moved, "write")) continue;
603
690
  return { block: true, reason: describeDirectoryChange(policy, moved) };
604
691
  }
605
692
  const resolved = resolveToCwd(token, base);
@@ -608,6 +695,9 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
608
695
  // read or traverse". It never licenses writing into /etc, /usr or /opt; only the
609
696
  // discard-and-echo devices are writable.
610
697
  if (access === "read" ? isSystemPath(resolved) : isSystemWriteSink(resolved)) continue;
698
+ // Anything the fence grants outright: refusing it here contradicted the documentation and
699
+ // bought nothing, since the fence permits it below the text either way.
700
+ if (fenced && fenceAlsoPermits(resolved, access)) continue;
611
701
  return deny(policy, resolved, access);
612
702
  }
613
703
  }
@@ -113,11 +113,19 @@ export interface DefaultSandboxOptions {
113
113
  * contexts are explicitly denied, so even a broad user-configured allow cannot re-expose
114
114
  * another customer's memory, session, or credentials.
115
115
  *
116
- * Note: the OS temp dir is deliberately NOT allowlisted. It is shared across all
116
+ * Note: the OS temp dir is deliberately NOT allowlisted here. It is shared across all
117
117
  * sessions, so allowing it would let one customer's session read another's scratch
118
118
  * files. The agent should work in temp directories under its CWD; internal tool temp
119
119
  * usage bypasses this boundary (it is not a model-invoked path). A specific session
120
120
  * temp dir can still be granted via `tmpDir`.
121
+ *
122
+ * That still holds for every tool this policy governs directly. It does NOT hold for
123
+ * `bash` on a host with an OS backend: the containment fence never mentions the temp
124
+ * directories, so they are reachable below the command text regardless, and refusing
125
+ * them in the text scan produced only a diagnostic that contradicted `xcsh://about`
126
+ * (#2582). The protection was spelling-deep in any case — `T=/tmp/other; cat "$T"`
127
+ * never went through this check. Treat shared temp as readable by a fenced shell, and
128
+ * put anything that must not cross sessions under the session directory or `tmpDir`.
121
129
  */
122
130
  export function buildDefaultSandboxPolicy(opts: DefaultSandboxOptions): SandboxPolicy {
123
131
  const cwd = path.resolve(opts.cwd);
package/src/tools/bash.ts CHANGED
@@ -543,6 +543,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
543
543
  extraRoots: artifactsDir ? [artifactsDir] : [],
544
544
  readOnlyRoots: (this.session.settings.get("sandbox.allowRead") as string[] | undefined) ?? [],
545
545
  writeOnlyRoots: (this.session.settings.get("sandbox.allowWrite") as string[] | undefined) ?? [],
546
+ // Only seatbelt can hold a file read-only inside a writable directory; Landlock's rules are
547
+ // recursive, so asking for it there would strip write from the parent and break the CLIs.
548
+ narrowsWithinGrant: containmentStatus(true).backend === "seatbelt",
546
549
  });
547
550
  }
548
551