@f5-sales-demo/xcsh 19.75.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/discovery/helpers.ts +61 -0
- package/src/discovery/plugin-summaries.test.ts +83 -0
- 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/prompts/system/system-prompt.md +4 -1
- package/src/sandbox/enforce.ts +319 -0
- package/src/sandbox/policy.ts +161 -0
- package/src/system-prompt.plugin-pointer.test.ts +36 -18
- package/src/system-prompt.ts +17 -12
- 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",
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -856,6 +856,67 @@ export async function listXcshPluginRoots(
|
|
|
856
856
|
return result;
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
+
export interface XcshPluginSummary {
|
|
860
|
+
/** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
|
|
861
|
+
id: string;
|
|
862
|
+
/** Display name from the plugin's own manifest (falls back to the registry id). */
|
|
863
|
+
name: string;
|
|
864
|
+
description: string;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Sanitize a manifest/package description for single-line rendering in the system prompt.
|
|
869
|
+
* Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated).
|
|
870
|
+
* Defense-in-depth: the value is rendered with `noEscape:true`, so a newline would break the
|
|
871
|
+
* single-line bullet and an oversized description would bloat the prompt.
|
|
872
|
+
*/
|
|
873
|
+
function sanitizePluginDescription(description: unknown): string {
|
|
874
|
+
if (typeof description !== "string") return "";
|
|
875
|
+
const collapsed = description.replace(/\s+/g, " ").trim();
|
|
876
|
+
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */
|
|
880
|
+
export async function readPluginSummary(root: { plugin: string; path: string }): Promise<XcshPluginSummary> {
|
|
881
|
+
// Precedence mirrors MarketplaceManager#resolvePluginVersion.
|
|
882
|
+
const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [
|
|
883
|
+
{ rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) },
|
|
884
|
+
{
|
|
885
|
+
rel: "package.json",
|
|
886
|
+
pick: m => ({ name: m?.xcsh?.name ?? m?.pi?.name, description: m?.xcsh?.description ?? m?.pi?.description }),
|
|
887
|
+
},
|
|
888
|
+
];
|
|
889
|
+
for (const c of candidates) {
|
|
890
|
+
try {
|
|
891
|
+
const m = await Bun.file(path.join(root.path, c.rel)).json();
|
|
892
|
+
const { name, description } = c.pick(m);
|
|
893
|
+
if (name || description) {
|
|
894
|
+
return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) };
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
// try next candidate
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { id: root.plugin, name: root.plugin, description: "" };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
|
|
904
|
+
export async function listXcshPluginSummaries(home: string, cwd?: string): Promise<XcshPluginSummary[]> {
|
|
905
|
+
try {
|
|
906
|
+
const { roots } = await listXcshPluginRoots(home, cwd);
|
|
907
|
+
const seen = new Set<string>();
|
|
908
|
+
const out: XcshPluginSummary[] = [];
|
|
909
|
+
for (const r of roots) {
|
|
910
|
+
if (seen.has(r.plugin)) continue;
|
|
911
|
+
seen.add(r.plugin);
|
|
912
|
+
out.push(await readPluginSummary(r));
|
|
913
|
+
}
|
|
914
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
915
|
+
} catch {
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
859
920
|
/**
|
|
860
921
|
* Clear the plugin roots cache (useful for testing or when plugins change).
|
|
861
922
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readPluginSummary } from "./helpers";
|
|
6
|
+
|
|
7
|
+
async function mkRoot(files: Record<string, unknown>): Promise<string> {
|
|
8
|
+
const dir = await mkdtemp(join(tmpdir(), "xps-"));
|
|
9
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
10
|
+
const p = join(dir, rel);
|
|
11
|
+
await mkdir(join(p, ".."), { recursive: true });
|
|
12
|
+
await writeFile(p, JSON.stringify(content));
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("readPluginSummary", () => {
|
|
18
|
+
test("reads name+description from .xcsh-plugin/plugin.json", async () => {
|
|
19
|
+
const path = await mkRoot({
|
|
20
|
+
".xcsh-plugin/plugin.json": { name: "meddpicc", description: "MEDDPICC framework" },
|
|
21
|
+
});
|
|
22
|
+
expect(await readPluginSummary({ plugin: "meddpicc", path })).toEqual({
|
|
23
|
+
id: "meddpicc",
|
|
24
|
+
name: "meddpicc",
|
|
25
|
+
description: "MEDDPICC framework",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
test("falls back to package.json xcsh field", async () => {
|
|
29
|
+
const path = await mkRoot({ "package.json": { xcsh: { name: "foo", description: "Foo tool" } } });
|
|
30
|
+
expect(await readPluginSummary({ plugin: "foo", path })).toEqual({
|
|
31
|
+
id: "foo",
|
|
32
|
+
name: "foo",
|
|
33
|
+
description: "Foo tool",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
test("falls back to package.json pi field", async () => {
|
|
37
|
+
const path = await mkRoot({ "package.json": { pi: { name: "legacy", description: "Legacy tool" } } });
|
|
38
|
+
expect(await readPluginSummary({ plugin: "legacy", path })).toEqual({
|
|
39
|
+
id: "legacy",
|
|
40
|
+
name: "legacy",
|
|
41
|
+
description: "Legacy tool",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("no manifest → name from root.plugin, empty description", async () => {
|
|
45
|
+
const path = await mkRoot({});
|
|
46
|
+
expect(await readPluginSummary({ plugin: "bare", path })).toEqual({
|
|
47
|
+
id: "bare",
|
|
48
|
+
name: "bare",
|
|
49
|
+
description: "",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
test("manifest without name → falls back to root.plugin", async () => {
|
|
53
|
+
const path = await mkRoot({ ".xcsh-plugin/plugin.json": { description: "desc only" } });
|
|
54
|
+
expect(await readPluginSummary({ plugin: "baz", path })).toEqual({
|
|
55
|
+
id: "baz",
|
|
56
|
+
name: "baz",
|
|
57
|
+
description: "desc only",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
test("id is the registry id even when the manifest name differs", async () => {
|
|
61
|
+
const path = await mkRoot({
|
|
62
|
+
".xcsh-plugin/plugin.json": { name: "Display Name", description: "mismatched name" },
|
|
63
|
+
});
|
|
64
|
+
expect(await readPluginSummary({ plugin: "registry-id", path })).toEqual({
|
|
65
|
+
id: "registry-id",
|
|
66
|
+
name: "Display Name",
|
|
67
|
+
description: "mismatched name",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
test("multi-line / oversized description is collapsed to one line and capped with …", async () => {
|
|
71
|
+
const longDescription = `line one\nline two\t${"x".repeat(400)}`;
|
|
72
|
+
const path = await mkRoot({
|
|
73
|
+
".xcsh-plugin/plugin.json": { name: "big", description: longDescription },
|
|
74
|
+
});
|
|
75
|
+
const summary = await readPluginSummary({ plugin: "big", path });
|
|
76
|
+
expect(summary.id).toBe("big");
|
|
77
|
+
expect(summary.description).not.toContain("\n");
|
|
78
|
+
expect(summary.description).not.toContain("\t");
|
|
79
|
+
expect(summary.description.startsWith("line one line two ")).toBe(true);
|
|
80
|
+
expect(summary.description.length).toBe(301); // 300 chars + the "…" ellipsis
|
|
81
|
+
expect(summary.description.endsWith("…")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -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> = {
|
|
@@ -307,7 +307,10 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
|
|
|
307
307
|
- `xcsh://console/<resource>/<operation>` — the exact ordered UI steps (selectors) for that operation.
|
|
308
308
|
- `xcsh://extension` — Chrome extension bridge tool API reference: which tool to use (click, typeahead, input, navigation) for each automation task.
|
|
309
309
|
{{#if hasPlugins}}
|
|
310
|
-
Installed plugins expose capabilities, schemas, and executable helpers on demand.
|
|
310
|
+
**Installed plugins** expose domain capabilities, schemas, and executable helpers on demand. When a task falls within a plugin's domain, consult that plugin: read `xcsh://plugin/<name>` for its summary, then **run its engine/helpers to produce any computed, scored, ranked, or "what to do next" result** rather than deriving it yourself or reading it from a data file — a plugin's engine is the source of truth, and values already written into an artifact may be stale or wrong:
|
|
311
|
+
{{#each plugins}}
|
|
312
|
+
- **{{name}}** — {{description}} → `xcsh://plugin/{{id}}`
|
|
313
|
+
{{/each}}
|
|
311
314
|
{{/if}}
|
|
312
315
|
|
|
313
316
|
### Presentation profile
|
|
@@ -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
|
+
}
|
|
@@ -3,36 +3,54 @@ import * as path from "node:path";
|
|
|
3
3
|
import { prompt } from "@f5-sales-demo/pi-utils";
|
|
4
4
|
import { registerCodingAgentPromptHelpers } from "./config/prompt-templates";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Technique A of the MEDDPICC progressive-hints ladder: a generic, framework-agnostic
|
|
7
|
+
// "Installed Plugins" capability index. When ≥1 plugin is present, the template enumerates
|
|
8
|
+
// each installed plugin by name + own-manifest description + `xcsh://plugin/<name>` pointer,
|
|
9
|
+
// so the agent reliably consults the plugin instead of answering from memory. It is gated on
|
|
10
|
+
// the `hasPlugins` render variable and iterates the `plugins` array that `buildSystemPrompt`
|
|
11
|
+
// computes from discovered plugin summaries.
|
|
10
12
|
//
|
|
11
|
-
// Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// true-case fixture through it would be non-deterministic.
|
|
13
|
+
// Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same seam every
|
|
14
|
+
// other conditional-rendering assertion in this package uses. It is deterministic (no
|
|
15
|
+
// dependency on what is installed under the real home dir) and exercises the exact template
|
|
16
|
+
// block this task renders. `buildSystemPrompt` resolves plugins from `os.homedir()` and takes
|
|
17
|
+
// no `home` override, so a true-case fixture through it would be non-deterministic.
|
|
17
18
|
|
|
18
19
|
const systemPromptPath = path.resolve(import.meta.dir, "prompts/system/system-prompt.md");
|
|
19
20
|
|
|
20
|
-
describe("
|
|
21
|
+
describe("Installed Plugins index (technique A)", () => {
|
|
21
22
|
beforeAll(() => {
|
|
22
23
|
registerCodingAgentPromptHelpers();
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
test("
|
|
26
|
+
test("enumerates installed plugins with pointer, names no plugin in the core template", async () => {
|
|
26
27
|
const template = await Bun.file(systemPromptPath).text();
|
|
27
|
-
const rendered = prompt.render(template, {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const rendered = prompt.render(template, {
|
|
29
|
+
hasPlugins: true,
|
|
30
|
+
plugins: [{ id: "meddpicc", name: "meddpicc", description: "MEDDPICC qualification helper" }],
|
|
31
|
+
});
|
|
32
|
+
expect(rendered).toContain("Installed plugins");
|
|
33
|
+
expect(rendered).toContain("xcsh://plugin/meddpicc");
|
|
34
|
+
expect(rendered).toContain("MEDDPICC qualification helper");
|
|
31
35
|
});
|
|
32
36
|
|
|
33
|
-
test("
|
|
37
|
+
test("pointer uses the registry id while the label uses the manifest name", async () => {
|
|
34
38
|
const template = await Bun.file(systemPromptPath).text();
|
|
35
|
-
const rendered = prompt.render(template, {
|
|
39
|
+
const rendered = prompt.render(template, {
|
|
40
|
+
hasPlugins: true,
|
|
41
|
+
plugins: [{ id: "registry-id", name: "Display Name", description: "mismatched name and id" }],
|
|
42
|
+
});
|
|
43
|
+
// POINTER must resolve against the registry id, not the display name.
|
|
44
|
+
expect(rendered).toContain("xcsh://plugin/registry-id");
|
|
45
|
+
expect(rendered).not.toContain("xcsh://plugin/Display Name");
|
|
46
|
+
// DISPLAY label keeps the manifest name.
|
|
47
|
+
expect(rendered).toContain("**Display Name**");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("omits the block entirely when no plugins are present", async () => {
|
|
51
|
+
const template = await Bun.file(systemPromptPath).text();
|
|
52
|
+
const rendered = prompt.render(template, { hasPlugins: false, plugins: [] });
|
|
53
|
+
expect(rendered).not.toContain("Installed plugins");
|
|
36
54
|
expect(rendered).not.toContain("xcsh://plugin");
|
|
37
55
|
});
|
|
38
56
|
});
|
package/src/system-prompt.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
|
|
|
13
13
|
import type { SkillsSettings } from "./config/settings";
|
|
14
14
|
import { renderDeprecationGuardrails } from "./deprecations";
|
|
15
15
|
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
|
|
16
|
-
import {
|
|
16
|
+
import { listXcshPluginSummaries, type XcshPluginSummary } from "./discovery/helpers";
|
|
17
17
|
import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/skills";
|
|
18
18
|
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
|
19
19
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
@@ -556,12 +556,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
556
556
|
? loadSkills({ ...mergedSkillsSettings, cwd: resolvedCwd }).then(result => result.skills)
|
|
557
557
|
: Promise.resolve([]);
|
|
558
558
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
const
|
|
563
|
-
.
|
|
564
|
-
|
|
559
|
+
// Installed-plugins capability index: enumerate installed plugins (name + own-manifest
|
|
560
|
+
// description + xcsh://plugin/<name> pointer) so the agent reliably consults them. Discovery
|
|
561
|
+
// is cached; fails safe to [] (no block) on error.
|
|
562
|
+
const pluginSummariesPromise: Promise<XcshPluginSummary[]> = listXcshPluginSummaries(
|
|
563
|
+
os.homedir(),
|
|
564
|
+
resolvedCwd,
|
|
565
|
+
).catch(() => []);
|
|
565
566
|
|
|
566
567
|
return Promise.all([
|
|
567
568
|
resolvePromptInput(customPrompt, "system prompt"),
|
|
@@ -570,7 +571,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
570
571
|
contextFilesPromise,
|
|
571
572
|
agentsMdSearchPromise,
|
|
572
573
|
skillsPromise,
|
|
573
|
-
|
|
574
|
+
pluginSummariesPromise,
|
|
574
575
|
]).then(
|
|
575
576
|
([
|
|
576
577
|
resolvedCustomPrompt,
|
|
@@ -579,7 +580,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
579
580
|
contextFiles,
|
|
580
581
|
agentsMdSearch,
|
|
581
582
|
skills,
|
|
582
|
-
|
|
583
|
+
pluginSummaries,
|
|
583
584
|
]) => ({
|
|
584
585
|
resolvedCustomPrompt,
|
|
585
586
|
resolvedAppendPrompt,
|
|
@@ -587,7 +588,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
587
588
|
contextFiles,
|
|
588
589
|
agentsMdSearch,
|
|
589
590
|
skills,
|
|
590
|
-
|
|
591
|
+
pluginSummaries,
|
|
591
592
|
}),
|
|
592
593
|
);
|
|
593
594
|
})();
|
|
@@ -612,7 +613,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
612
613
|
files: [],
|
|
613
614
|
};
|
|
614
615
|
let skills: Skill[] = providedSkills ?? [];
|
|
615
|
-
let
|
|
616
|
+
let pluginSummaries: XcshPluginSummary[] = [];
|
|
616
617
|
|
|
617
618
|
if (prepResult.type === "timeout") {
|
|
618
619
|
logger.warn("System prompt preparation timed out; using minimal startup context", {
|
|
@@ -635,9 +636,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
635
636
|
contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
|
|
636
637
|
agentsMdSearch = prepResult.value.agentsMdSearch;
|
|
637
638
|
skills = prepResult.value.skills;
|
|
638
|
-
|
|
639
|
+
pluginSummaries = prepResult.value.pluginSummaries;
|
|
639
640
|
}
|
|
640
641
|
|
|
642
|
+
const plugins = pluginSummaries;
|
|
643
|
+
const hasPlugins = plugins.length > 0;
|
|
644
|
+
|
|
641
645
|
const date = new Date().toISOString().slice(0, 10);
|
|
642
646
|
const dateTime = date;
|
|
643
647
|
const promptCwd = resolvedCwd.replace(/\\/g, "/");
|
|
@@ -692,6 +696,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
692
696
|
agentsMdSearch,
|
|
693
697
|
skills: contextFilteredSkills,
|
|
694
698
|
rules: rules ?? [],
|
|
699
|
+
plugins,
|
|
695
700
|
hasPlugins,
|
|
696
701
|
alwaysApplyRules: injectedAlwaysApplyRules,
|
|
697
702
|
date,
|
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;
|