@f5-sales-demo/xcsh 19.76.0 → 19.78.0

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.76.0",
4
+ "version": "19.78.0",
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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.76.0",
60
- "@f5-sales-demo/pi-agent-core": "19.76.0",
61
- "@f5-sales-demo/pi-ai": "19.76.0",
62
- "@f5-sales-demo/pi-natives": "19.76.0",
63
- "@f5-sales-demo/pi-resource-management": "19.76.0",
64
- "@f5-sales-demo/pi-tui": "19.76.0",
65
- "@f5-sales-demo/pi-utils": "19.76.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.78.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.78.0",
61
+ "@f5-sales-demo/pi-ai": "19.78.0",
62
+ "@f5-sales-demo/pi-natives": "19.78.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.78.0",
64
+ "@f5-sales-demo/pi-tui": "19.78.0",
65
+ "@f5-sales-demo/pi-utils": "19.78.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
package/src/cli/args.ts CHANGED
@@ -12,6 +12,10 @@ export type Mode = "text" | "json" | "rpc" | "acp";
12
12
  export interface Args {
13
13
  cwd?: string;
14
14
  allowHome?: boolean;
15
+ /** Disable the session filesystem sandbox (widen to unrestricted access). */
16
+ noSandbox?: boolean;
17
+ /** Extra directories the session may read AND write, beyond its CWD subtree (repeatable). */
18
+ allowPath?: string[];
15
19
  provider?: string;
16
20
  model?: string;
17
21
  smol?: string;
@@ -69,6 +73,11 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
69
73
  result.version = true;
70
74
  } else if (arg === "--allow-home") {
71
75
  result.allowHome = true;
76
+ } else if (arg === "--no-sandbox") {
77
+ result.noSandbox = true;
78
+ } else if (arg === "--allow-path" && i + 1 < args.length) {
79
+ result.allowPath = result.allowPath ?? [];
80
+ result.allowPath.push(args[++i]);
72
81
  } else if (arg === "--mode" && i + 1 < args.length) {
73
82
  const mode = args[++i];
74
83
  if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp") {
@@ -268,6 +277,10 @@ ${chalk.bold("Available Tools (default-enabled unless noted):")}
268
277
  web_search - Search the web
269
278
  ask - Ask user questions (interactive mode only)
270
279
 
280
+ ${chalk.bold("Sandbox Options:")}
281
+ --no-sandbox Disable session filesystem isolation (allow access outside the CWD)
282
+ --allow-path <path> Grant read+write access to an extra directory (repeatable)
283
+
271
284
  ${chalk.bold("Plugin Options:")}
272
285
  --plugin-dir <path> Load plugin from directory (repeatable)
273
286
 
@@ -24,7 +24,8 @@ export type SettingTab =
24
24
  | "editing"
25
25
  | "tools"
26
26
  | "tasks"
27
- | "providers";
27
+ | "providers"
28
+ | "sandbox";
28
29
 
29
30
  /** Tab display metadata - icon is resolved via theme.symbol() */
30
31
  export type TabMetadata = { label: string; icon: `tab.${string}` };
@@ -39,6 +40,7 @@ export const SETTING_TABS: SettingTab[] = [
39
40
  "tools",
40
41
  "tasks",
41
42
  "providers",
43
+ "sandbox",
42
44
  ];
43
45
 
44
46
  /** Tab display metadata - icon is a symbol key from theme.ts (tab.*) */
@@ -51,6 +53,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
51
53
  tools: { label: "Tools", icon: "tab.tools" },
52
54
  tasks: { label: "Tasks", icon: "tab.tasks" },
53
55
  providers: { label: "Providers", icon: "tab.providers" },
56
+ sandbox: { label: "Sandbox", icon: "tab.sandbox" },
54
57
  };
55
58
 
56
59
  /** Status line segment identifiers */
@@ -147,10 +150,9 @@ export interface ModelTagsSettings {
147
150
  [key: string]: ModelTagDef;
148
151
  }
149
152
 
150
- // Typed defaults for array/record settings — named constants avoid `as` casts
151
- // under `as const` while still letting SettingValue infer the correct element type.
153
+ // Typed defaults for array settings — named constants avoid `as` casts under
154
+ // `as const` while still letting SettingValue infer the correct element type.
152
155
  const EMPTY_STRING_ARRAY: string[] = [];
153
- const EMPTY_STRING_RECORD: Record<string, string> = {};
154
156
  const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
155
157
  /**
156
158
  * Binary-baked default model role. Ships in the binary so a fresh install needs
@@ -1790,6 +1792,25 @@ export const SETTINGS_SCHEMA = {
1790
1792
  ui: { tab: "providers", label: "Hide Secrets", description: "Obfuscate secrets before sending to AI providers" },
1791
1793
  },
1792
1794
 
1795
+ // Session filesystem isolation. Confines the file tools (read/write/edit/find/grep)
1796
+ // and the Bash working directory to the session's CWD subtree plus a curated global
1797
+ // allowlist, so concurrent sessions in different customer folders cannot read or
1798
+ // write each other's files, secrets, or memory. See src/sandbox/.
1799
+ "sandbox.enabled": {
1800
+ type: "boolean",
1801
+ default: true,
1802
+ ui: {
1803
+ tab: "sandbox",
1804
+ label: "Filesystem isolation",
1805
+ description: "Confine file tools to the working directory subtree (blocks cross-session access)",
1806
+ },
1807
+ },
1808
+ // Extra roots (beyond the CWD subtree) the session may read/write. Config/CLI only —
1809
+ // e.g. `--allow-path <dir>` maps into both. The hardcoded cross-session leak-denies
1810
+ // (other sessions' memories/sessions and the shared tenant contexts) always win.
1811
+ "sandbox.allowRead": { type: "array", default: [] as string[] },
1812
+ "sandbox.allowWrite": { type: "array", default: [] as string[] },
1813
+
1793
1814
  // Provider selection
1794
1815
  "providers.webSearch": {
1795
1816
  type: "enum",
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
2
+ import { settings } from "../../../config/settings";
3
+ import { evaluateToolCall } from "../../../sandbox/enforce";
4
+ import { buildDefaultSandboxPolicy, type SandboxPolicy } from "../../../sandbox/policy";
5
+
6
+ /**
7
+ * Session filesystem sandbox (bundled, default-on).
8
+ *
9
+ * Confines the model-invoked file tools (read/write/edit/find/grep) and the Bash
10
+ * working directory to the session's CWD subtree plus a curated global allowlist, so
11
+ * concurrent sessions in different customer folders cannot read or write each other's
12
+ * files, secrets, or memory. Enforcement is a `tool_call` gate: the extension wrapper
13
+ * blocks the tool when this returns `{ block: true }`, and fails safe (a thrown handler
14
+ * also blocks).
15
+ *
16
+ * The boundary is derived from `ctx.cwd` (always the live session's directory) plus the
17
+ * `sandbox.*` settings. Controlled by `sandbox.enabled` (default true); widened per run
18
+ * with `--allow-path` / `--no-sandbox` or the `sandbox.allow*` settings.
19
+ */
20
+ export default function sandboxGuard(pi: ExtensionAPI): void {
21
+ // One policy per cwd — rebuilt only when the working directory changes.
22
+ let cache: { cwd: string; policy: SandboxPolicy } | undefined;
23
+
24
+ // The global settings proxy throws before Settings.init() (e.g. some SDK/test
25
+ // contexts). Fall back to the given default there so the guard never hard-fails.
26
+ function readSetting<T>(key: string, fallback: T): T {
27
+ try {
28
+ const value = (settings as unknown as { get(k: string): unknown }).get(key);
29
+ return value === undefined ? fallback : (value as T);
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ function policyFor(cwd: string): SandboxPolicy | undefined {
36
+ // Fail closed: if `sandbox.enabled` can't be read, keep isolation on rather than
37
+ // silently disabling it. In a normal session settings resolves the true default.
38
+ if (!readSetting<boolean>("sandbox.enabled", true)) return undefined;
39
+ if (cache?.cwd === cwd) return cache.policy;
40
+ const policy = buildDefaultSandboxPolicy({
41
+ cwd,
42
+ enabled: true,
43
+ allowRead: readSetting<string[]>("sandbox.allowRead", []),
44
+ allowWrite: readSetting<string[]>("sandbox.allowWrite", []),
45
+ });
46
+ cache = { cwd, policy };
47
+ return policy;
48
+ }
49
+
50
+ pi.on("tool_call", (event, ctx) => {
51
+ const policy = policyFor(ctx.cwd);
52
+ if (!policy) return undefined;
53
+ const decision = evaluateToolCall({
54
+ toolName: event.toolName,
55
+ input: event.input as Record<string, unknown>,
56
+ cwd: ctx.cwd,
57
+ policy,
58
+ });
59
+ return decision.block ? { block: true, reason: decision.reason } : undefined;
60
+ });
61
+ }
@@ -21,6 +21,7 @@ import { EventBus } from "../../utils/event-bus";
21
21
  import { getAllPluginExtensionPaths } from "../plugins/loader";
22
22
  import { resolvePath } from "../utils";
23
23
  import herdrReporter from "./bundled/herdr-reporter";
24
+ import sandboxGuard from "./bundled/sandbox-guard";
24
25
  import type {
25
26
  Extension,
26
27
  ExtensionAPI,
@@ -496,6 +497,7 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
496
497
  /** Extensions bundled with xcsh and loaded by default (before user extensions). */
497
498
  const BUNDLED_EXTENSIONS: ReadonlyArray<{ name: string; factory: ExtensionFactory }> = [
498
499
  { name: "herdr-reporter", factory: herdrReporter },
500
+ { name: "sandbox-guard", factory: sandboxGuard },
499
501
  ];
500
502
 
501
503
  /**
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.76.0",
21
- "commit": "2e4e3b0d461f6dc353419167c54b0da3c7310765",
22
- "shortCommit": "2e4e3b0",
20
+ "version": "19.78.0",
21
+ "commit": "4908d518ea7dc67ce318f16c193098ae0921fb34",
22
+ "shortCommit": "4908d51",
23
23
  "branch": "main",
24
- "tag": "v19.76.0",
25
- "commitDate": "2026-07-22T16:43:35Z",
26
- "buildDate": "2026-07-22T17:06:30.694Z",
24
+ "tag": "v19.78.0",
25
+ "commitDate": "2026-07-23T02:11:04Z",
26
+ "buildDate": "2026-07-23T02:38:25.473Z",
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/2e4e3b0d461f6dc353419167c54b0da3c7310765",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.76.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/4908d518ea7dc67ce318f16c193098ae0921fb34",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.78.0"
33
33
  };
package/src/main.ts CHANGED
@@ -604,7 +604,16 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
604
604
  }
605
605
 
606
606
  const cwd = getProjectDir();
607
- await logger.time("settings:init", Settings.init, { cwd });
607
+ const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
608
+ if (parsedArgs.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
609
+ if (parsedArgs.allowPath?.length) {
610
+ sandboxOverrides["sandbox.allowRead"] = parsedArgs.allowPath;
611
+ sandboxOverrides["sandbox.allowWrite"] = parsedArgs.allowPath;
612
+ }
613
+ await logger.time("settings:init", Settings.init, {
614
+ cwd,
615
+ overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
616
+ });
608
617
 
609
618
  // F5 XC context is session-scoped: nothing loads at startup. We still init the
610
619
  // ContextService singleton so /context commands and the session bootstrap work.
@@ -191,7 +191,8 @@ export type SymbolKey =
191
191
  | "tab.editing"
192
192
  | "tab.tools"
193
193
  | "tab.tasks"
194
- | "tab.providers";
194
+ | "tab.providers"
195
+ | "tab.sandbox";
195
196
 
196
197
  type SymbolMap = Record<SymbolKey, string>;
197
198
 
@@ -356,6 +357,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
356
357
  "tab.tools": "🔧",
357
358
  "tab.tasks": "📦",
358
359
  "tab.providers": "🌐",
360
+ "tab.sandbox": "🔒",
359
361
  };
360
362
 
361
363
  const NERD_SYMBOLS: SymbolMap = {
@@ -616,6 +618,7 @@ const NERD_SYMBOLS: SymbolMap = {
616
618
  "tab.tools": "󰠭",
617
619
  "tab.tasks": "󰐱",
618
620
  "tab.providers": "󰖟",
621
+ "tab.sandbox": "󰌾",
619
622
  };
620
623
 
621
624
  const ASCII_SYMBOLS: SymbolMap = {
@@ -778,6 +781,7 @@ const ASCII_SYMBOLS: SymbolMap = {
778
781
  "tab.tools": "[T]",
779
782
  "tab.tasks": "[K]",
780
783
  "tab.providers": "[P]",
784
+ "tab.sandbox": "[S]",
781
785
  };
782
786
 
783
787
  const SYMBOL_PRESETS: Record<SymbolPreset, SymbolMap> = {
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Pure evaluation of a tool call against a SandboxPolicy.
3
+ *
4
+ * Maps each path-taking tool to its path argument(s) and access mode, resolves the
5
+ * path relative to the session cwd, and asks the policy whether it is allowed. Kept
6
+ * free of settings/runtime so it is fully unit-testable; the bundled `sandbox-guard`
7
+ * extension is a thin wrapper that supplies cwd, settings, and the policy.
8
+ *
9
+ * Search tools (`find`/`grep`/`ast_grep`/`ast_edit`) accept comma-/whitespace-delimited
10
+ * multi-path inputs that the tools split (via `splitTopLevel`) and search from a common
11
+ * base directory. We split the same way and policy-check EVERY token's base, so a
12
+ * multi-path input cannot smuggle a sibling directory past the gate.
13
+ *
14
+ * Arbitrary-code tools (`bash`, `python`) cannot be fully contained in-process: this
15
+ * checks the `cwd` argument precisely and scans the command/code for path tokens
16
+ * (bare, quoted, `~`, `..`, absolute) that escape the tree. OS system paths are exempt.
17
+ * This is best-effort; the opt-in OS-level sandbox (Phase 2) is the airtight enforcement
18
+ * for both bash and python.
19
+ */
20
+ import * as path from "node:path";
21
+ import { parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
22
+ import type { SandboxAccess, SandboxPolicy } from "./policy";
23
+
24
+ export interface ToolCallCheck {
25
+ toolName: string;
26
+ input: Record<string, unknown>;
27
+ cwd: string;
28
+ policy: SandboxPolicy;
29
+ }
30
+
31
+ export interface ToolCallDecision {
32
+ block: boolean;
33
+ reason?: string;
34
+ }
35
+
36
+ const ALLOW: ToolCallDecision = { block: false };
37
+
38
+ interface PathArgSpec {
39
+ /** Candidate input keys, tried in order; first non-empty string wins. */
40
+ keys: string[];
41
+ access: SandboxAccess;
42
+ /** Exempt OS system paths (for tools that legitimately execute system binaries). */
43
+ systemExempt?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Tools that touch explicit path argument(s). Each tool lists one or more specs; every
48
+ * present path is checked. Remote/in-memory path-looking args (xcsh_api HTTP paths,
49
+ * ssh remote cwd) are intentionally absent.
50
+ */
51
+ const TOOL_PATHS: Record<string, PathArgSpec[]> = {
52
+ read: [{ keys: ["file_path", "path"], access: "read" }],
53
+ write: [{ keys: ["file_path", "path"], access: "write" }],
54
+ notebook: [{ keys: ["notebook_path"], access: "write" }],
55
+ inspect_image: [{ keys: ["path"], access: "read" }],
56
+ display_image: [{ keys: ["path"], access: "read" }],
57
+ lsp: [{ keys: ["file"], access: "read" }], // read for most actions; rename-apply writes
58
+ catalog_workflow_runner: [
59
+ { keys: ["catalog_path"], access: "read" },
60
+ { keys: ["screenshot_dir"], access: "write" },
61
+ ],
62
+ // debug executes an arbitrary program and reads source files; system binaries are ok.
63
+ debug: [
64
+ { keys: ["program"], access: "read", systemExempt: true },
65
+ { keys: ["file"], access: "read", systemExempt: true },
66
+ { keys: ["cwd"], access: "read", systemExempt: true },
67
+ ],
68
+ };
69
+
70
+ interface SearchSpec {
71
+ /** Input key holding the (possibly multi-) path or glob list. */
72
+ key: string;
73
+ /** Extract the base directory of one token (matches the tool's own resolver). */
74
+ base: (token: string) => string;
75
+ access: SandboxAccess;
76
+ }
77
+
78
+ /** Tools that accept multi-path search inputs split from a common base directory. */
79
+ const SEARCH_TOOLS: Record<string, SearchSpec> = {
80
+ find: { key: "pattern", base: token => parseFindPattern(token).basePath, access: "read" },
81
+ grep: { key: "path", base: token => parseSearchPath(token).basePath, access: "read" },
82
+ ast_grep: { key: "path", base: token => parseSearchPath(token).basePath, access: "read" },
83
+ ast_edit: { key: "path", base: token => parseSearchPath(token).basePath, access: "write" },
84
+ };
85
+
86
+ /** Arbitrary-code tools whose command/code strings are scanned best-effort. */
87
+ const CODE_FIELDS: Record<string, string[]> = {
88
+ bash: ["command"],
89
+ python: ["code"],
90
+ };
91
+
92
+ /**
93
+ * Standard OS directories a subprocess may legitimately read or traverse (interpreters,
94
+ * libraries, device files) and which hold no customer data. Only the code-tool scan
95
+ * treats these as benign; the file tools stay strictly confined to the policy. Notably
96
+ * excludes /tmp, /var, /private, and home — those can hold per-session or per-user data.
97
+ */
98
+ const SYSTEM_READ_ROOTS = [
99
+ "/usr",
100
+ "/bin",
101
+ "/sbin",
102
+ "/lib",
103
+ "/lib64",
104
+ "/opt",
105
+ "/etc",
106
+ "/dev",
107
+ "/proc",
108
+ "/sys",
109
+ "/System",
110
+ "/Library",
111
+ ];
112
+
113
+ function isSystemPath(resolved: string): boolean {
114
+ return SYSTEM_READ_ROOTS.some(root => resolved === root || resolved.startsWith(`${root}${path.sep}`));
115
+ }
116
+
117
+ function firstString(input: Record<string, unknown>, keys: string[]): string | undefined {
118
+ for (const key of keys) {
119
+ const value = input[key];
120
+ if (typeof value === "string" && value.length > 0) return value;
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ function deny(policy: SandboxPolicy, resolved: string, access: SandboxAccess): ToolCallDecision {
126
+ return { block: true, reason: policy.describe(resolved, access) };
127
+ }
128
+
129
+ function looksLikePath(token: string): boolean {
130
+ return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
131
+ }
132
+
133
+ /** Path-like tokens in a command/code string: bare (whitespace-split) and quoted. */
134
+ function codePathTokens(command: string): string[] {
135
+ const tokens = new Set<string>();
136
+ for (const raw of command.split(/\s+/)) tokens.add(raw.replace(/^["']|["']$/g, ""));
137
+ for (const match of command.matchAll(/["']([^"']+)["']/g)) tokens.add(match[1]);
138
+ return [...tokens].filter(token => token.length > 0 && looksLikePath(token));
139
+ }
140
+
141
+ /** Base directories a search input would actually search, split like the tools do. */
142
+ function searchBases(raw: string, base: (token: string) => string): string[] {
143
+ const trimmed = raw.trim();
144
+ if (!trimmed) return [];
145
+ const tokens = new Set<string>();
146
+ // Union of comma and whitespace splits — over-splitting only adds harmless in-tree
147
+ // checks; it can never hide an out-of-tree token from the gate.
148
+ for (const separator of ["comma", "whitespace"] as const) {
149
+ for (const token of splitTopLevel(trimmed, separator)) {
150
+ const cleaned = token.trim();
151
+ if (cleaned) tokens.add(base(cleaned));
152
+ }
153
+ }
154
+ return [...tokens];
155
+ }
156
+
157
+ function evaluateCodeTool(check: ToolCallCheck, fields: string[]): ToolCallDecision {
158
+ const { input, cwd, policy } = check;
159
+
160
+ const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
161
+ const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
162
+ if (rawCwd && !policy.isAllowed(base, "read")) return deny(policy, base, "read");
163
+
164
+ const commands: string[] = [];
165
+ for (const field of fields) {
166
+ if (typeof input[field] === "string") commands.push(input[field] as string);
167
+ }
168
+ // python also accepts a `cells` array of { code }.
169
+ if (Array.isArray(input.cells)) {
170
+ for (const cell of input.cells) {
171
+ const code = (cell as { code?: unknown })?.code;
172
+ if (typeof code === "string") commands.push(code);
173
+ }
174
+ }
175
+
176
+ for (const command of commands) {
177
+ for (const token of codePathTokens(command)) {
178
+ const resolved = resolveToCwd(token, base);
179
+ if (!policy.isAllowed(resolved, "read") && !isSystemPath(resolved)) {
180
+ return deny(policy, resolved, "read");
181
+ }
182
+ }
183
+ }
184
+
185
+ return ALLOW;
186
+ }
187
+
188
+ /**
189
+ * The `edit` tool is mode-dependent; every mode writes. Targets, across modes:
190
+ * - vim mode: top-level `file`
191
+ * - replace/patch/hashline/chunk: each `edits[]` entry's `path`
192
+ * - hashline rename: `edits[].move`; patch rename: `edits[].rename`
193
+ * (Chunk paths embed a `:selector#hash~` suffix, but a `..`/absolute escape still resolves
194
+ * out of tree via the leading segments, so no stripping is needed for the boundary check.)
195
+ */
196
+ function evaluateEdit(check: ToolCallCheck): ToolCallDecision {
197
+ const { input, cwd, policy } = check;
198
+ const targets: string[] = [];
199
+ const add = (value: unknown): void => {
200
+ if (typeof value === "string" && value.length > 0) targets.push(value);
201
+ };
202
+ add(input.file); // vim mode
203
+ add(input.file_path);
204
+ add(input.path);
205
+ if (Array.isArray(input.edits)) {
206
+ for (const entry of input.edits) {
207
+ if (entry && typeof entry === "object") {
208
+ const e = entry as Record<string, unknown>;
209
+ add(e.path);
210
+ add(e.move); // hashline rename
211
+ add(e.rename); // patch rename
212
+ }
213
+ }
214
+ }
215
+ for (const target of targets) {
216
+ const resolved = resolveToCwd(target, cwd);
217
+ if (!policy.isAllowed(resolved, "write")) return deny(policy, resolved, "write");
218
+ }
219
+ return ALLOW;
220
+ }
221
+
222
+ /**
223
+ * `generate_image` reads local files named in its `input[]` array (each `{ path?, data? }`)
224
+ * and sends the bytes to an external API — so an out-of-tree path is both a read escape
225
+ * and an exfiltration. Registered dynamically (not in BUILTIN_TOOLS).
226
+ */
227
+ function evaluateGenerateImage(check: ToolCallCheck): ToolCallDecision {
228
+ const { input, cwd, policy } = check;
229
+ if (Array.isArray(input.input)) {
230
+ for (const entry of input.input) {
231
+ const value = entry && typeof entry === "object" ? (entry as Record<string, unknown>).path : undefined;
232
+ if (typeof value === "string" && value.length > 0) {
233
+ const resolved = resolveToCwd(value, cwd);
234
+ if (!policy.isAllowed(resolved, "read")) return deny(policy, resolved, "read");
235
+ }
236
+ }
237
+ }
238
+ return ALLOW;
239
+ }
240
+
241
+ const REMOTE_URL_SCHEME = /^(https?|about|data|chrome|chrome-extension|blob|ws|wss):/i;
242
+
243
+ /**
244
+ * If a browser navigation target refers to the LOCAL filesystem, return the path to
245
+ * check; otherwise undefined (remote/benign scheme). Covers file: URLs, the filesystem:
246
+ * wrapper, and bare/relative/absolute paths that `goto` would resolve against disk.
247
+ */
248
+ function navLocalPath(raw: string): string | undefined {
249
+ const url = raw.trim();
250
+ if (!url || REMOTE_URL_SCHEME.test(url)) return undefined;
251
+ if (url.toLowerCase().startsWith("file:")) return url; // resolveToCwd → stripFileUrl
252
+ if (url.toLowerCase().startsWith("filesystem:")) return url.slice("filesystem:".length);
253
+ if (path.isAbsolute(url) || url.startsWith("~") || url.startsWith(".")) return url;
254
+ return undefined; // domain-like host or unknown scheme → not local disk
255
+ }
256
+
257
+ /**
258
+ * `puppeteer` (the browser tool) writes screenshots to `path` and navigates to `url`.
259
+ * A file:// / local navigation target followed by get_text/evaluate/screenshot returns
260
+ * another session's file contents to the model, so local navigation is a read escape.
261
+ */
262
+ function evaluatePuppeteer(check: ToolCallCheck): ToolCallDecision {
263
+ const { input, cwd, policy } = check;
264
+ const screenshot = firstString(input, ["path"]);
265
+ if (screenshot) {
266
+ const resolved = resolveToCwd(screenshot, cwd);
267
+ if (!policy.isAllowed(resolved, "write")) return deny(policy, resolved, "write");
268
+ }
269
+ if (typeof input.url === "string") {
270
+ const local = navLocalPath(input.url);
271
+ if (local) {
272
+ const resolved = resolveToCwd(local, cwd);
273
+ if (!policy.isAllowed(resolved, "read")) return deny(policy, resolved, "read");
274
+ }
275
+ }
276
+ return ALLOW;
277
+ }
278
+
279
+ function evaluateSearchTool(check: ToolCallCheck, spec: SearchSpec): ToolCallDecision {
280
+ const { input, cwd, policy } = check;
281
+ const raw = typeof input[spec.key] === "string" ? (input[spec.key] as string) : "";
282
+ for (const basePath of searchBases(raw, spec.base)) {
283
+ const resolved = resolveToCwd(basePath, cwd);
284
+ if (!policy.isAllowed(resolved, spec.access)) return deny(policy, resolved, spec.access);
285
+ }
286
+ return ALLOW;
287
+ }
288
+
289
+ /**
290
+ * Decide whether a tool call is allowed under the policy. Tools with no recognized
291
+ * path argument are always allowed.
292
+ */
293
+ export function evaluateToolCall(check: ToolCallCheck): ToolCallDecision {
294
+ const { toolName, input, cwd, policy } = check;
295
+ if (!policy.enabled) return ALLOW;
296
+
297
+ const codeFields = CODE_FIELDS[toolName];
298
+ if (codeFields) return evaluateCodeTool(check, codeFields);
299
+
300
+ if (toolName === "edit") return evaluateEdit(check);
301
+ if (toolName === "generate_image") return evaluateGenerateImage(check);
302
+ if (toolName === "puppeteer") return evaluatePuppeteer(check);
303
+
304
+ const searchSpec = SEARCH_TOOLS[toolName];
305
+ if (searchSpec) return evaluateSearchTool(check, searchSpec);
306
+
307
+ const specs = TOOL_PATHS[toolName];
308
+ if (!specs) return ALLOW;
309
+
310
+ for (const spec of specs) {
311
+ const raw = firstString(input, spec.keys);
312
+ if (!raw) continue; // optional path → defaults to cwd, which is allowed
313
+ const resolved = resolveToCwd(raw, cwd);
314
+ if (policy.isAllowed(resolved, spec.access)) continue;
315
+ if (spec.systemExempt && isSystemPath(resolved)) continue;
316
+ return deny(policy, resolved, spec.access);
317
+ }
318
+ return ALLOW;
319
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * SandboxPolicy — resolves whether a file path is inside the current session's
3
+ * read/write boundary.
4
+ *
5
+ * The model is longest-prefix-wins over an ordered set of allow/deny rules, with
6
+ * deny beating allow on an exact-depth tie (fail-safe). A path matched by no rule
7
+ * is denied (default-deny outside the working tree). This mirrors the deny-then-allow
8
+ * precedence used by Anthropic's sandbox-runtime, but is enforced in-process for the
9
+ * structured file tools (read/write/edit/find/grep) and the Bash `cwd`, rather than at
10
+ * the OS level.
11
+ *
12
+ * The boundary only gates model-invoked tools. Internal subsystems (memory pipeline,
13
+ * session manager, settings) do not go through the file tools, so they are unaffected.
14
+ */
15
+ import * as path from "node:path";
16
+ import {
17
+ getAgentDir,
18
+ getConfigRootDir,
19
+ getMemoriesDir,
20
+ getPluginsDir,
21
+ getSessionsDir,
22
+ getXCSHContextsDir,
23
+ normalizePathForComparison,
24
+ pathIsWithin,
25
+ } from "@f5-sales-demo/pi-utils";
26
+ import { expandPath } from "../tools/path-utils";
27
+
28
+ export type SandboxAccess = "read" | "write";
29
+
30
+ export interface SandboxRule {
31
+ /** Absolute directory or file this rule governs. */
32
+ root: string;
33
+ /** true = allow, false = deny. */
34
+ allow: boolean;
35
+ }
36
+
37
+ export interface SandboxPolicyConfig {
38
+ enabled: boolean;
39
+ /** Session working directory, used for messaging and as the primary allowed root. */
40
+ cwd: string;
41
+ read: SandboxRule[];
42
+ write: SandboxRule[];
43
+ }
44
+
45
+ interface NormalizedRule {
46
+ root: string;
47
+ depth: number;
48
+ allow: boolean;
49
+ }
50
+
51
+ function normalizeRule(rule: SandboxRule): NormalizedRule {
52
+ const normalized = normalizePathForComparison(rule.root);
53
+ return {
54
+ root: rule.root,
55
+ depth: normalized.split(path.sep).filter(Boolean).length,
56
+ allow: rule.allow,
57
+ };
58
+ }
59
+
60
+ export class SandboxPolicy {
61
+ readonly enabled: boolean;
62
+ readonly cwd: string;
63
+ readonly #read: NormalizedRule[];
64
+ readonly #write: NormalizedRule[];
65
+
66
+ constructor(config: SandboxPolicyConfig) {
67
+ this.enabled = config.enabled;
68
+ this.cwd = config.cwd;
69
+ this.#read = config.read.map(normalizeRule);
70
+ this.#write = config.write.map(normalizeRule);
71
+ }
72
+
73
+ /**
74
+ * Whether `candidate` (an absolute, already-resolved path) may be accessed for
75
+ * the given mode. Callers must resolve `~`/relative/`..` first (see resolveToCwd).
76
+ */
77
+ isAllowed(candidate: string, access: SandboxAccess): boolean {
78
+ if (!this.enabled) return true;
79
+ const rules = access === "write" ? this.#write : this.#read;
80
+ let best: NormalizedRule | undefined;
81
+ for (const rule of rules) {
82
+ if (!pathIsWithin(rule.root, candidate)) continue;
83
+ const deeper = !best || rule.depth > best.depth;
84
+ const denyTie = best !== undefined && rule.depth === best.depth && best.allow && !rule.allow;
85
+ if (deeper || denyTie) best = rule;
86
+ }
87
+ return best?.allow ?? false;
88
+ }
89
+
90
+ /** Human-readable reason surfaced to the model when a path is blocked. */
91
+ describe(candidate: string, access: SandboxAccess): string {
92
+ return `Path is outside this session's ${access} boundary (working directory: ${this.cwd}): ${candidate}. Use --allow-path or the sandbox.allow* settings to widen it, or --no-sandbox to disable isolation.`;
93
+ }
94
+ }
95
+
96
+ export interface DefaultSandboxOptions {
97
+ /** Session working directory. */
98
+ cwd: string;
99
+ /** Defaults to true — isolation is enforced by default. */
100
+ enabled?: boolean;
101
+ /** A session-specific temp dir to allow (read+write). NOT the shared OS temp dir. */
102
+ tmpDir?: string;
103
+ /** Extra roots (read+write) — e.g. from `--allow-path`. */
104
+ extraAllowRoots?: string[];
105
+ allowRead?: string[];
106
+ allowWrite?: string[];
107
+ }
108
+
109
+ /**
110
+ * Build the default session policy: confine reads/writes to the CWD subtree, plus a
111
+ * curated global allowlist (plugin cache, user-level skills, operator profile/settings)
112
+ * for reads. Cross-session leak surfaces under `~/.xcsh` and the shared global tenant
113
+ * contexts are explicitly denied, so even a broad user-configured allow cannot re-expose
114
+ * another customer's memory, session, or credentials.
115
+ *
116
+ * Note: the OS temp dir is deliberately NOT allowlisted. It is shared across all
117
+ * sessions, so allowing it would let one customer's session read another's scratch
118
+ * files. The agent should work in temp directories under its CWD; internal tool temp
119
+ * usage bypasses this boundary (it is not a model-invoked path). A specific session
120
+ * temp dir can still be granted via `tmpDir`.
121
+ */
122
+ export function buildDefaultSandboxPolicy(opts: DefaultSandboxOptions): SandboxPolicy {
123
+ const cwd = path.resolve(opts.cwd);
124
+ const configRoot = getConfigRootDir();
125
+ const skillsDir = path.join(getAgentDir(), "skills");
126
+
127
+ const allow = (root: string): SandboxRule => ({ root, allow: true });
128
+ const deny = (root: string): SandboxRule => ({ root, allow: false });
129
+ const expand = (roots: string[] | undefined, allowed: boolean): SandboxRule[] =>
130
+ (roots ?? []).map(r => ({ root: expandPath(r), allow: allowed }));
131
+
132
+ // Denied even though some sit under otherwise-allowlisted roots. Longest-prefix
133
+ // precedence makes these win over any broader allow.
134
+ const leakDenies = [deny(getMemoriesDir()), deny(getSessionsDir()), deny(getXCSHContextsDir())];
135
+ const extraAllow = expand(opts.extraAllowRoots, true);
136
+ // Only allowed if a specific (non-shared) session temp dir is passed in.
137
+ const sessionTmp = opts.tmpDir ? [allow(opts.tmpDir)] : [];
138
+
139
+ const read: SandboxRule[] = [
140
+ allow(cwd),
141
+ ...sessionTmp,
142
+ allow(getPluginsDir()), // plugin engines/schemas (e.g. meddpicc)
143
+ allow(skillsDir), // user-level skills
144
+ allow(path.join(configRoot, "user-profile.json")),
145
+ allow(path.join(configRoot, "computer-profile.json")),
146
+ allow(path.join(configRoot, "settings.json")),
147
+ ...leakDenies,
148
+ ...extraAllow,
149
+ ...expand(opts.allowRead, true),
150
+ ];
151
+
152
+ const write: SandboxRule[] = [
153
+ allow(cwd),
154
+ ...sessionTmp,
155
+ ...leakDenies,
156
+ ...extraAllow,
157
+ ...expand(opts.allowWrite, true),
158
+ ];
159
+
160
+ return new SandboxPolicy({ enabled: opts.enabled ?? true, cwd, read, write });
161
+ }
@@ -244,9 +244,9 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st
244
244
  return `${normalizedPrefix}/${normalizedSuffix}`;
245
245
  }
246
246
 
247
- type TopLevelSeparator = "comma" | "whitespace";
247
+ export type TopLevelSeparator = "comma" | "whitespace";
248
248
 
249
- function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
249
+ export function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
250
250
  const parts: string[] = [];
251
251
  let current = "";
252
252
  let braceDepth = 0;