@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.75.0",
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.75.0",
60
- "@f5-sales-demo/pi-agent-core": "19.75.0",
61
- "@f5-sales-demo/pi-ai": "19.75.0",
62
- "@f5-sales-demo/pi-natives": "19.75.0",
63
- "@f5-sales-demo/pi-resource-management": "19.75.0",
64
- "@f5-sales-demo/pi-tui": "19.75.0",
65
- "@f5-sales-demo/pi-utils": "19.75.0",
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/record settings — named constants avoid `as` casts
151
- // under `as const` while still letting SettingValue infer the correct element type.
153
+ // Typed defaults for array settings — named constants avoid `as` casts under
154
+ // `as const` while still letting SettingValue infer the correct element type.
152
155
  const EMPTY_STRING_ARRAY: string[] = [];
153
- const EMPTY_STRING_RECORD: Record<string, string> = {};
154
156
  const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
155
157
  /**
156
158
  * Binary-baked default model role. Ships in the binary so a fresh install needs
@@ -1790,6 +1792,25 @@ export const SETTINGS_SCHEMA = {
1790
1792
  ui: { tab: "providers", label: "Hide Secrets", description: "Obfuscate secrets before sending to AI providers" },
1791
1793
  },
1792
1794
 
1795
+ // Session filesystem isolation. Confines the file tools (read/write/edit/find/grep)
1796
+ // and the Bash working directory to the session's CWD subtree plus a curated global
1797
+ // allowlist, so concurrent sessions in different customer folders cannot read or
1798
+ // write each other's files, secrets, or memory. See src/sandbox/.
1799
+ "sandbox.enabled": {
1800
+ type: "boolean",
1801
+ default: true,
1802
+ ui: {
1803
+ tab: "sandbox",
1804
+ label: "Filesystem isolation",
1805
+ description: "Confine file tools to the working directory subtree (blocks cross-session access)",
1806
+ },
1807
+ },
1808
+ // Extra roots (beyond the CWD subtree) the session may read/write. Config/CLI only —
1809
+ // e.g. `--allow-path <dir>` maps into both. The hardcoded cross-session leak-denies
1810
+ // (other sessions' memories/sessions and the shared tenant contexts) always win.
1811
+ "sandbox.allowRead": { type: "array", default: [] as string[] },
1812
+ "sandbox.allowWrite": { type: "array", default: [] as string[] },
1813
+
1793
1814
  // Provider selection
1794
1815
  "providers.webSearch": {
1795
1816
  type: "enum",
@@ -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.75.0",
21
- "commit": "ac9635391c489c70d35c68a0e1cacdbae9f7e631",
22
- "shortCommit": "ac96353",
20
+ "version": "19.77.0",
21
+ "commit": "bdabc05ed9f27370b8cb0f794923cf981d71cc82",
22
+ "shortCommit": "bdabc05",
23
23
  "branch": "main",
24
- "tag": "v19.75.0",
25
- "commitDate": "2026-07-22T13:46:55Z",
26
- "buildDate": "2026-07-22T14:09:19.426Z",
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/ac9635391c489c70d35c68a0e1cacdbae9f7e631",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.75.0"
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
- await logger.time("settings:init", Settings.init, { cwd });
607
+ const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
608
+ if (parsedArgs.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
609
+ if (parsedArgs.allowPath?.length) {
610
+ sandboxOverrides["sandbox.allowRead"] = parsedArgs.allowPath;
611
+ sandboxOverrides["sandbox.allowWrite"] = parsedArgs.allowPath;
612
+ }
613
+ await logger.time("settings:init", Settings.init, {
614
+ cwd,
615
+ overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
616
+ });
608
617
 
609
618
  // F5 XC context is session-scoped: nothing loads at startup. We still init the
610
619
  // ContextService singleton so /context commands and the session bootstrap work.
@@ -191,7 +191,8 @@ export type SymbolKey =
191
191
  | "tab.editing"
192
192
  | "tab.tools"
193
193
  | "tab.tasks"
194
- | "tab.providers";
194
+ | "tab.providers"
195
+ | "tab.sandbox";
195
196
 
196
197
  type SymbolMap = Record<SymbolKey, string>;
197
198
 
@@ -356,6 +357,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
356
357
  "tab.tools": "🔧",
357
358
  "tab.tasks": "📦",
358
359
  "tab.providers": "🌐",
360
+ "tab.sandbox": "🔒",
359
361
  };
360
362
 
361
363
  const NERD_SYMBOLS: SymbolMap = {
@@ -616,6 +618,7 @@ const NERD_SYMBOLS: SymbolMap = {
616
618
  "tab.tools": "󰠭",
617
619
  "tab.tasks": "󰐱",
618
620
  "tab.providers": "󰖟",
621
+ "tab.sandbox": "󰌾",
619
622
  };
620
623
 
621
624
  const ASCII_SYMBOLS: SymbolMap = {
@@ -778,6 +781,7 @@ const ASCII_SYMBOLS: SymbolMap = {
778
781
  "tab.tools": "[T]",
779
782
  "tab.tasks": "[K]",
780
783
  "tab.providers": "[P]",
784
+ "tab.sandbox": "[S]",
781
785
  };
782
786
 
783
787
  const SYMBOL_PRESETS: Record<SymbolPreset, SymbolMap> = {
@@ -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. Read `xcsh://plugin` to list installed plugins and `xcsh://plugin/<name>` for a plugin's summary and how to go deeper pull this in only when a task matches a plugin's domain.
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
- // L0 of the MEDDPICC progressive-hints ladder: a single framework-agnostic line
7
- // telling the agent that installed plugins expose capabilities/schemas via
8
- // `xcsh://plugin/<name>`. It is gated on the `hasPlugins` render variable that
9
- // `buildSystemPrompt` computes from discovered plugin roots.
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
- // seam every other conditional-rendering assertion in this package uses. It is
13
- // deterministic (no dependency on what is installed under the real home dir) and
14
- // exercises the exact template conditional this task adds. `buildSystemPrompt`
15
- // resolves plugins from `os.homedir()` and takes no `home` override, so a
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("L0 plugin-capability pointer", () => {
21
+ describe("Installed Plugins index (technique A)", () => {
21
22
  beforeAll(() => {
22
23
  registerCodingAgentPromptHelpers();
23
24
  });
24
25
 
25
- test("renders the generic pointer when a plugin is present, naming no plugin", async () => {
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, { hasPlugins: true });
28
- expect(rendered).toContain("xcsh://plugin");
29
- // Generic/reusable: it must not name any specific plugin.
30
- expect(rendered).not.toContain("meddpicc");
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("omits the pointer entirely when no plugins are present", async () => {
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, { hasPlugins: false });
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
  });
@@ -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 { listXcshPluginRoots } from "./discovery/helpers";
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
- // L0 plugin-capability pointer: gate a generic `xcsh://plugin` hint on ≥1
560
- // discoverable plugin. Discovery is cached and reads one small registry file,
561
- // so this is effectively free; it fails safe to `false` (no hint) on error.
562
- const hasPluginsPromise: Promise<boolean> = listXcshPluginRoots(os.homedir(), resolvedCwd)
563
- .then(result => result.roots.length > 0)
564
- .catch(() => false);
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
- hasPluginsPromise,
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
- hasPlugins,
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
- hasPlugins,
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 hasPlugins = false;
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
- hasPlugins = prepResult.value.hasPlugins;
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,
@@ -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;