@f5-sales-demo/xcsh 19.76.0 → 19.77.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 +8 -8
- package/src/cli/args.ts +13 -0
- package/src/config/settings-schema.ts +25 -4
- package/src/extensibility/extensions/bundled/sandbox-guard.ts +61 -0
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +10 -1
- package/src/modes/theme/theme.ts +5 -1
- package/src/sandbox/enforce.ts +319 -0
- package/src/sandbox/policy.ts +161 -0
- package/src/tools/path-utils.ts +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.77.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.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.77.0",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.77.0",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.77.0",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.77.0",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.77.0",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.77.0",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.77.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
|
|
151
|
-
//
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.77.0",
|
|
21
|
+
"commit": "bdabc05ed9f27370b8cb0f794923cf981d71cc82",
|
|
22
|
+
"shortCommit": "bdabc05",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.77.0",
|
|
25
|
+
"commitDate": "2026-07-23T01:40:52Z",
|
|
26
|
+
"buildDate": "2026-07-23T02:01:44.889Z",
|
|
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.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/bdabc05ed9f27370b8cb0f794923cf981d71cc82",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.77.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
|
-
|
|
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.
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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;
|