apex-code 0.0.1-alpha.5 → 0.0.1-alpha.6

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +10 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/core/agent-session.d.ts.map +1 -1
  6. package/dist/core/agent-session.js +18 -3
  7. package/dist/core/agent-session.js.map +1 -1
  8. package/dist/core/package-manager.d.ts.map +1 -1
  9. package/dist/core/package-manager.js +15 -0
  10. package/dist/core/package-manager.js.map +1 -1
  11. package/dist/core/sandbox/cli-launch.d.ts +43 -0
  12. package/dist/core/sandbox/cli-launch.d.ts.map +1 -1
  13. package/dist/core/sandbox/cli-launch.js +70 -3
  14. package/dist/core/sandbox/cli-launch.js.map +1 -1
  15. package/dist/core/sandbox/cli-supervisor.d.ts +2 -0
  16. package/dist/core/sandbox/cli-supervisor.d.ts.map +1 -1
  17. package/dist/core/sandbox/cli-supervisor.js +1 -0
  18. package/dist/core/sandbox/cli-supervisor.js.map +1 -1
  19. package/dist/core/skills.d.ts +19 -7
  20. package/dist/core/skills.d.ts.map +1 -1
  21. package/dist/core/skills.js +77 -19
  22. package/dist/core/skills.js.map +1 -1
  23. package/dist/core/slash-commands.d.ts.map +1 -1
  24. package/dist/core/slash-commands.js +1 -1
  25. package/dist/core/slash-commands.js.map +1 -1
  26. package/dist/core/system-prompt.d.ts.map +1 -1
  27. package/dist/core/system-prompt.js +3 -3
  28. package/dist/core/system-prompt.js.map +1 -1
  29. package/dist/core/tools/index.d.ts +5 -1
  30. package/dist/core/tools/index.d.ts.map +1 -1
  31. package/dist/core/tools/index.js +12 -0
  32. package/dist/core/tools/index.js.map +1 -1
  33. package/dist/core/tools/skill-search.d.ts +32 -0
  34. package/dist/core/tools/skill-search.d.ts.map +1 -0
  35. package/dist/core/tools/skill-search.js +56 -0
  36. package/dist/core/tools/skill-search.js.map +1 -0
  37. package/dist/modes/interactive/components/model-selector.d.ts +19 -6
  38. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  39. package/dist/modes/interactive/components/model-selector.js +172 -89
  40. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  41. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  42. package/dist/modes/interactive/interactive-mode.js +2 -1
  43. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  44. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  45. package/dist/modes/rpc/rpc-mode.js +2 -1
  46. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  47. package/docs/skills.md +54 -4
  48. package/npm-shrinkwrap.json +5 -5
  49. package/package.json +2 -2
@@ -18,6 +18,41 @@ export interface SandboxedCliLaunch extends SandboxLaunch {
18
18
  * `allowDefaultHosts: false` restores the strict behaviour for anyone who wants it.
19
19
  */
20
20
  export declare function resolveSupervisorAllowedHosts(cwd: string, agentDir: string): readonly string[] | undefined;
21
+ /**
22
+ * The host's two user-scope skill roots, each present only when it exists on the
23
+ * host. Kept as two named slots rather than one list because
24
+ * `core/package-manager.ts` discovers them in different modes -- root `.md` files
25
+ * count as skills under `agentSkills` ("pi" mode) and are ignored under
26
+ * `agentsHomeSkills` ("agents" mode), per `docs/skills.md` -- and a flat list of 0-2
27
+ * paths cannot tell the child which root a lone survivor was.
28
+ */
29
+ export interface HostSkillPaths {
30
+ /** Host `<agentDir>/skills`. */
31
+ readonly agentSkills?: string;
32
+ /** Host `<home>/.agents/skills`. */
33
+ readonly agentsHomeSkills?: string;
34
+ }
35
+ /** A candidate skill root that exists but was excluded, and why. */
36
+ export interface HostSkillPathRefusal {
37
+ readonly root: keyof HostSkillPaths;
38
+ readonly path: string;
39
+ readonly reason: string;
40
+ }
41
+ export interface ResolvedHostSkillPaths {
42
+ readonly paths: HostSkillPaths;
43
+ readonly refusals: readonly HostSkillPathRefusal[];
44
+ }
45
+ /**
46
+ * Resolve the host's user-scope skill directories, before the sandbox exists to hide
47
+ * them. Read only from the runtime environment and the host agent directory --
48
+ * never from project files -- matching ADR 0016's rule that supervisor policy is
49
+ * trust-first. Mirrors `core/package-manager.ts`'s own user-scope roots so the two
50
+ * sides agree on where a skill lives. A candidate that resolves (directly or via a
51
+ * symlink) onto the host home or an ancestor of it is refused rather than mounted --
52
+ * see `isHomeOrAncestorOfHome` -- and reported so the caller can surface a startup
53
+ * diagnostic instead of silently mounting or silently skipping it.
54
+ */
55
+ export declare function resolveHostSkillPaths(agentDir: string, homeDir: string): ResolvedHostSkillPaths;
21
56
  /**
22
57
  * Allocate agent-owned state under the sole writable workspace mount. The child does
23
58
  * not inherit a host home or a host session/config directory, preventing the sandbox
@@ -32,5 +67,13 @@ export declare function buildSandboxedCliLaunch(options: {
32
67
  readOnlyPaths?: readonly string[];
33
68
  authPath?: string;
34
69
  toolBinaries?: readonly HostToolBinary[];
70
+ /**
71
+ * Host user-scope skill directories, pre-filtered by the caller to those that
72
+ * exist and pass the host-home escape check (SKILL.4). Mounted read-only at their
73
+ * original host location -- Seatbelt cannot remap a path, so this must hold for
74
+ * both backends -- and named to the child via `APEX_CODE_SKILL_PATH_*` so its
75
+ * discovery can find them under the sandbox's own repointed `HOME`/agent dir.
76
+ */
77
+ skillPaths?: HostSkillPaths;
35
78
  }): SandboxedCliLaunch;
36
79
  //# sourceMappingURL=cli-launch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-launch.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/cli-launch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAGnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAKrD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAKvE;AAED,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACxD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;CACxC;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAK1G;AAmGD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;IAC/B,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;CACzC,GAAG,kBAAkB,CA+CrB","sourcesContent":["import { mkdirSync, readdirSync, rmSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { SettingsManager } from \"../settings-manager.ts\";\nimport { resolveDefaultAllowedHosts } from \"./default-hosts.ts\";\nimport type { SandboxLaunch } from \"./supervisor.ts\";\n\nconst NON_SESSION_COMMANDS = new Set([\"auth\", \"config\", \"install\", \"remove\", \"uninstall\", \"update\", \"list\"]);\nconst METADATA_FLAGS = new Set([\"--version\", \"-v\", \"--export\", \"--list-models\"]);\n\n/**\n * OS containment is the normal startup path for every command that can construct an\n * agent session. Commands that only inspect or maintain host configuration do not\n * create a runtime and therefore remain outside this child boundary.\n */\nexport function requiresSandboxedChild(args: readonly string[]): boolean {\n\tif (NON_SESSION_COMMANDS.has(args[0] ?? \"\")) return false;\n\tif (args.some((argument) => METADATA_FLAGS.has(argument))) return false;\n\tif (args.includes(\"--help\") || args.includes(\"-h\")) return false;\n\treturn true;\n}\n\nexport interface SandboxedCliLaunch extends SandboxLaunch {\n\treadonly environment: NodeJS.ProcessEnv;\n}\n\n/**\n * Read supervisor policy only from global settings; project settings are untrusted here.\n *\n * The built-in provider hosts are added unless explicitly refused, because a deny-all\n * default made a fresh install unable to reach any model while giving the user no way to\n * learn which host to permit. Configured hosts are additive on top, and\n * `allowDefaultHosts: false` restores the strict behaviour for anyone who wants it.\n */\nexport function resolveSupervisorAllowedHosts(cwd: string, agentDir: string): readonly string[] | undefined {\n\tconst network = SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getNetworkSettings();\n\tconst configured = network?.allowedHosts ?? [];\n\tif (network?.allowDefaultHosts === false) return configured;\n\treturn [...new Set([...resolveDefaultAllowedHosts(), ...configured])];\n}\n\nconst SAFE_CHILD_ENVIRONMENT_KEYS = new Set([\n\t\"PATH\",\n\t\"LANG\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TERM\",\n\t\"COLORTERM\",\n\t\"NO_COLOR\",\n\t\"FORCE_COLOR\",\n\t\"TSX_TSCONFIG_PATH\",\n\t\"APEX_CODE_OFFLINE\",\n\t\"APEX_CODE_SKIP_VERSION_CHECK\",\n\t\"APEX_CODE_EXPERIMENTAL\",\n\t\"APEX_CODE_STARTUP_BENCHMARK\",\n\t\"APEX_CODE_TIMING\",\n\t\"APEX_CODE_CLEAR_ON_SHRINK\",\n\t\"APEX_CODE_HARDWARE_CURSOR\",\n\t\"APEX_CODE_MODEL_CATALOG_URL\",\n\t\"APEX_CODE_SHARE_VIEWER_URL\",\n\t\"VISUAL\",\n\t\"EDITOR\",\n]);\n\n// Provider API keys are explicit credential inputs, unlike arbitrary ambient variables.\n// Keep this list in sync with the documented provider environment-variable reference.\nconst SAFE_PROVIDER_CREDENTIAL_KEYS = new Set([\n\t\"ANTHROPIC_API_KEY\",\n\t\"ANTHROPIC_AUTH_TOKEN\",\n\t\"ANTHROPIC_OAUTH_TOKEN\",\n\t\"ANT_LING_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_BASE_URL\",\n\t\"AZURE_OPENAI_RESOURCE_NAME\",\n\t\"AZURE_OPENAI_API_VERSION\",\n\t\"AZURE_OPENAI_DEPLOYMENT_NAME_MAP\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"GROQ_API_KEY\",\n\t\"CEREBRAS_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"FIREWORKS_API_KEY\",\n\t\"TOGETHER_API_KEY\",\n\t\"BASETEN_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"AI_GATEWAY_API_KEY\",\n\t\"ZAI_API_KEY\",\n\t\"ZAI_CODING_CN_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"MOONSHOT_API_KEY\",\n\t\"OPENCODE_API_KEY\",\n\t\"KIMI_API_KEY\",\n\t\"CLOUDFLARE_API_KEY\",\n\t\"CLOUDFLARE_ACCOUNT_ID\",\n\t\"CLOUDFLARE_GATEWAY_ID\",\n\t\"QWEN_TOKEN_PLAN_API_KEY\",\n\t\"QWEN_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_AMS_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_SGP_API_KEY\",\n\t\"AWS_PROFILE\",\n\t\"AWS_ACCESS_KEY_ID\",\n\t\"AWS_SECRET_ACCESS_KEY\",\n\t\"AWS_BEARER_TOKEN_BEDROCK\",\n\t\"AWS_REGION\",\n]);\n\nfunction buildChildEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n\treturn Object.fromEntries(\n\t\tObject.entries(environment).filter(\n\t\t\t([key]) => SAFE_CHILD_ENVIRONMENT_KEYS.has(key) || SAFE_PROVIDER_CREDENTIAL_KEYS.has(key),\n\t\t),\n\t);\n}\n\n/**\n * Drop empty files left in the child's tools directory by a previous launch.\n *\n * A projected tool is bind-mounted over a file there, and bwrap materialises that\n * mountpoint as an empty file on the host which outlives the namespace. If the host\n * tool later disappears, nothing is projected over the stub and the child would\n * otherwise find a 0-byte file where its binary should be. A real downloaded binary\n * is never empty, so size is a safe discriminator.\n */\nfunction clearStaleToolMountpoints(toolsDirectory: string): void {\n\tfor (const entry of readdirSync(toolsDirectory, { withFileTypes: true })) {\n\t\tif (!entry.isFile()) continue;\n\t\tconst entryPath = join(toolsDirectory, entry.name);\n\t\tif (statSync(entryPath).size === 0) {\n\t\t\trmSync(entryPath, { force: true });\n\t\t}\n\t}\n}\n\n/**\n * Allocate agent-owned state under the sole writable workspace mount. The child does\n * not inherit a host home or a host session/config directory, preventing the sandbox\n * from presenting a write boundary while its own state quietly escapes it.\n */\nexport function buildSandboxedCliLaunch(options: {\n\tworkspace: string;\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n}): SandboxedCliLaunch {\n\tconst stateDirectory = join(options.workspace, \".apex-code\", \"sandbox-state\");\n\tconst agentDirectory = join(options.workspace, \".apex-code\", \"sandbox-agent\");\n\tconst sessionDirectory = join(options.workspace, \".apex-code\", \"sandbox-sessions\");\n\t// Mirrors getBinDir() as the child will compute it from APEX_CODE_CODING_AGENT_DIR,\n\t// so a projected tool lands exactly where the child's own lookup already checks.\n\tconst toolsDirectory = join(agentDirectory, \"bin\");\n\tconst xdgDirectories = {\n\t\tXDG_CONFIG_HOME: join(stateDirectory, \"config\"),\n\t\tXDG_CACHE_HOME: join(stateDirectory, \"cache\"),\n\t\tXDG_DATA_HOME: join(stateDirectory, \"data\"),\n\t\tXDG_STATE_HOME: join(stateDirectory, \"state\"),\n\t};\n\tfor (const directory of [\n\t\tstateDirectory,\n\t\tagentDirectory,\n\t\tsessionDirectory,\n\t\ttoolsDirectory,\n\t\t...Object.values(xdgDirectories),\n\t]) {\n\t\tmkdirSync(directory, { recursive: true });\n\t}\n\tclearStaleToolMountpoints(toolsDirectory);\n\tconst childEnvironment = buildChildEnvironment(options.environment);\n\tconst readOnlyPaths = [...(options.readOnlyPaths ?? [])];\n\tconst readOnlyFiles = options.authPath ? [options.authPath] : [];\n\tconst readOnlyBinaries = (options.toolBinaries ?? []).map((binary) => ({\n\t\tsource: binary.path,\n\t\tdestination: join(toolsDirectory, binary.name),\n\t}));\n\treturn {\n\t\tcommand: options.command,\n\t\targs: [...options.args],\n\t\tpolicy: { workspace: options.workspace, allowedHosts: options.allowedHosts ?? [] },\n\t\treadOnlyPaths,\n\t\treadOnlyFiles,\n\t\treadOnlyBinaries,\n\t\tenvironment: {\n\t\t\t...childEnvironment,\n\t\t\t...(options.authPath ? { APEX_CODE_AUTH_PATH: options.authPath } : {}),\n\t\t\tAPEX_CODE_CODING_AGENT_DIR: agentDirectory,\n\t\t\tAPEX_CODE_CODING_AGENT_SESSION_DIR: sessionDirectory,\n\t\t\tHOME: stateDirectory,\n\t\t\tTMPDIR: stateDirectory,\n\t\t\t...xdgDirectories,\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"cli-launch.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/cli-launch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAGnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAKrD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAKvE;AAED,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACxD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;CACxC;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAK1G;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC9B,gCAAgC;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,oCAAoC;IACpC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,oEAAoE;AACpE,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,cAAc,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACnD;AA4CD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,sBAAsB,CAY/F;AAmGD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;IAC/B,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B,GAAG,kBAAkB,CAsDrB","sourcesContent":["import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from \"node:fs\";\nimport { join, sep } from \"node:path\";\nimport type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { SettingsManager } from \"../settings-manager.ts\";\nimport { resolveDefaultAllowedHosts } from \"./default-hosts.ts\";\nimport type { SandboxLaunch } from \"./supervisor.ts\";\n\nconst NON_SESSION_COMMANDS = new Set([\"auth\", \"config\", \"install\", \"remove\", \"uninstall\", \"update\", \"list\"]);\nconst METADATA_FLAGS = new Set([\"--version\", \"-v\", \"--export\", \"--list-models\"]);\n\n/**\n * OS containment is the normal startup path for every command that can construct an\n * agent session. Commands that only inspect or maintain host configuration do not\n * create a runtime and therefore remain outside this child boundary.\n */\nexport function requiresSandboxedChild(args: readonly string[]): boolean {\n\tif (NON_SESSION_COMMANDS.has(args[0] ?? \"\")) return false;\n\tif (args.some((argument) => METADATA_FLAGS.has(argument))) return false;\n\tif (args.includes(\"--help\") || args.includes(\"-h\")) return false;\n\treturn true;\n}\n\nexport interface SandboxedCliLaunch extends SandboxLaunch {\n\treadonly environment: NodeJS.ProcessEnv;\n}\n\n/**\n * Read supervisor policy only from global settings; project settings are untrusted here.\n *\n * The built-in provider hosts are added unless explicitly refused, because a deny-all\n * default made a fresh install unable to reach any model while giving the user no way to\n * learn which host to permit. Configured hosts are additive on top, and\n * `allowDefaultHosts: false` restores the strict behaviour for anyone who wants it.\n */\nexport function resolveSupervisorAllowedHosts(cwd: string, agentDir: string): readonly string[] | undefined {\n\tconst network = SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getNetworkSettings();\n\tconst configured = network?.allowedHosts ?? [];\n\tif (network?.allowDefaultHosts === false) return configured;\n\treturn [...new Set([...resolveDefaultAllowedHosts(), ...configured])];\n}\n\n/**\n * The host's two user-scope skill roots, each present only when it exists on the\n * host. Kept as two named slots rather than one list because\n * `core/package-manager.ts` discovers them in different modes -- root `.md` files\n * count as skills under `agentSkills` (\"pi\" mode) and are ignored under\n * `agentsHomeSkills` (\"agents\" mode), per `docs/skills.md` -- and a flat list of 0-2\n * paths cannot tell the child which root a lone survivor was.\n */\nexport interface HostSkillPaths {\n\t/** Host `<agentDir>/skills`. */\n\treadonly agentSkills?: string;\n\t/** Host `<home>/.agents/skills`. */\n\treadonly agentsHomeSkills?: string;\n}\n\n/** A candidate skill root that exists but was excluded, and why. */\nexport interface HostSkillPathRefusal {\n\treadonly root: keyof HostSkillPaths;\n\treadonly path: string;\n\treadonly reason: string;\n}\n\nexport interface ResolvedHostSkillPaths {\n\treadonly paths: HostSkillPaths;\n\treadonly refusals: readonly HostSkillPathRefusal[];\n}\n\n/**\n * True when `candidate` is the host home directory itself, or an ancestor of it.\n * Both paths must already be resolved (`realpathSync`), so a symlink can't disguise\n * either side. Mounting such a candidate read-only would re-expose the entire home\n * tree the sandbox's `--tmpfs /home` (Linux) / `(deny file-read* USER_HOME)` (macOS)\n * deliberately hides -- SSH keys, cloud credentials, other projects -- not just a\n * skills subtree.\n */\nfunction isHomeOrAncestorOfHome(candidate: string, home: string): boolean {\n\tif (candidate === home) return true;\n\tconst prefix = candidate.endsWith(sep) ? candidate : candidate + sep;\n\treturn home.startsWith(prefix);\n}\n\n/** Resolve one candidate root: absent, refused (symlinked onto the host home), or usable. */\nfunction resolveHostSkillRoot(\n\troot: keyof HostSkillPaths,\n\tcandidate: string,\n\thomeDir: string,\n): { path?: string; refusal?: HostSkillPathRefusal } {\n\tif (!existsSync(candidate)) return {};\n\tlet realCandidate: string;\n\tlet realHome: string;\n\ttry {\n\t\trealCandidate = realpathSync(candidate);\n\t\trealHome = realpathSync(homeDir);\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\treturn { refusal: { root, path: candidate, reason: `could not resolve real path: ${message}` } };\n\t}\n\tif (isHomeOrAncestorOfHome(realCandidate, realHome)) {\n\t\treturn {\n\t\t\trefusal: {\n\t\t\t\troot,\n\t\t\t\tpath: candidate,\n\t\t\t\treason: \"resolves to the host home directory or an ancestor of it\",\n\t\t\t},\n\t\t};\n\t}\n\treturn { path: candidate };\n}\n\n/**\n * Resolve the host's user-scope skill directories, before the sandbox exists to hide\n * them. Read only from the runtime environment and the host agent directory --\n * never from project files -- matching ADR 0016's rule that supervisor policy is\n * trust-first. Mirrors `core/package-manager.ts`'s own user-scope roots so the two\n * sides agree on where a skill lives. A candidate that resolves (directly or via a\n * symlink) onto the host home or an ancestor of it is refused rather than mounted --\n * see `isHomeOrAncestorOfHome` -- and reported so the caller can surface a startup\n * diagnostic instead of silently mounting or silently skipping it.\n */\nexport function resolveHostSkillPaths(agentDir: string, homeDir: string): ResolvedHostSkillPaths {\n\tconst agentSkills = resolveHostSkillRoot(\"agentSkills\", join(agentDir, \"skills\"), homeDir);\n\tconst agentsHomeSkills = resolveHostSkillRoot(\"agentsHomeSkills\", join(homeDir, \".agents\", \"skills\"), homeDir);\n\treturn {\n\t\tpaths: {\n\t\t\t...(agentSkills.path ? { agentSkills: agentSkills.path } : {}),\n\t\t\t...(agentsHomeSkills.path ? { agentsHomeSkills: agentsHomeSkills.path } : {}),\n\t\t},\n\t\trefusals: [agentSkills.refusal, agentsHomeSkills.refusal].filter(\n\t\t\t(refusal): refusal is HostSkillPathRefusal => refusal !== undefined,\n\t\t),\n\t};\n}\n\nconst SAFE_CHILD_ENVIRONMENT_KEYS = new Set([\n\t\"PATH\",\n\t\"LANG\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TERM\",\n\t\"COLORTERM\",\n\t\"NO_COLOR\",\n\t\"FORCE_COLOR\",\n\t\"TSX_TSCONFIG_PATH\",\n\t\"APEX_CODE_OFFLINE\",\n\t\"APEX_CODE_SKIP_VERSION_CHECK\",\n\t\"APEX_CODE_EXPERIMENTAL\",\n\t\"APEX_CODE_STARTUP_BENCHMARK\",\n\t\"APEX_CODE_TIMING\",\n\t\"APEX_CODE_CLEAR_ON_SHRINK\",\n\t\"APEX_CODE_HARDWARE_CURSOR\",\n\t\"APEX_CODE_MODEL_CATALOG_URL\",\n\t\"APEX_CODE_SHARE_VIEWER_URL\",\n\t\"VISUAL\",\n\t\"EDITOR\",\n]);\n\n// Provider API keys are explicit credential inputs, unlike arbitrary ambient variables.\n// Keep this list in sync with the documented provider environment-variable reference.\nconst SAFE_PROVIDER_CREDENTIAL_KEYS = new Set([\n\t\"ANTHROPIC_API_KEY\",\n\t\"ANTHROPIC_AUTH_TOKEN\",\n\t\"ANTHROPIC_OAUTH_TOKEN\",\n\t\"ANT_LING_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_BASE_URL\",\n\t\"AZURE_OPENAI_RESOURCE_NAME\",\n\t\"AZURE_OPENAI_API_VERSION\",\n\t\"AZURE_OPENAI_DEPLOYMENT_NAME_MAP\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"GROQ_API_KEY\",\n\t\"CEREBRAS_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"FIREWORKS_API_KEY\",\n\t\"TOGETHER_API_KEY\",\n\t\"BASETEN_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"AI_GATEWAY_API_KEY\",\n\t\"ZAI_API_KEY\",\n\t\"ZAI_CODING_CN_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"MOONSHOT_API_KEY\",\n\t\"OPENCODE_API_KEY\",\n\t\"KIMI_API_KEY\",\n\t\"CLOUDFLARE_API_KEY\",\n\t\"CLOUDFLARE_ACCOUNT_ID\",\n\t\"CLOUDFLARE_GATEWAY_ID\",\n\t\"QWEN_TOKEN_PLAN_API_KEY\",\n\t\"QWEN_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_AMS_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_SGP_API_KEY\",\n\t\"AWS_PROFILE\",\n\t\"AWS_ACCESS_KEY_ID\",\n\t\"AWS_SECRET_ACCESS_KEY\",\n\t\"AWS_BEARER_TOKEN_BEDROCK\",\n\t\"AWS_REGION\",\n]);\n\nfunction buildChildEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n\treturn Object.fromEntries(\n\t\tObject.entries(environment).filter(\n\t\t\t([key]) => SAFE_CHILD_ENVIRONMENT_KEYS.has(key) || SAFE_PROVIDER_CREDENTIAL_KEYS.has(key),\n\t\t),\n\t);\n}\n\n/**\n * Drop empty files left in the child's tools directory by a previous launch.\n *\n * A projected tool is bind-mounted over a file there, and bwrap materialises that\n * mountpoint as an empty file on the host which outlives the namespace. If the host\n * tool later disappears, nothing is projected over the stub and the child would\n * otherwise find a 0-byte file where its binary should be. A real downloaded binary\n * is never empty, so size is a safe discriminator.\n */\nfunction clearStaleToolMountpoints(toolsDirectory: string): void {\n\tfor (const entry of readdirSync(toolsDirectory, { withFileTypes: true })) {\n\t\tif (!entry.isFile()) continue;\n\t\tconst entryPath = join(toolsDirectory, entry.name);\n\t\tif (statSync(entryPath).size === 0) {\n\t\t\trmSync(entryPath, { force: true });\n\t\t}\n\t}\n}\n\n/**\n * Allocate agent-owned state under the sole writable workspace mount. The child does\n * not inherit a host home or a host session/config directory, preventing the sandbox\n * from presenting a write boundary while its own state quietly escapes it.\n */\nexport function buildSandboxedCliLaunch(options: {\n\tworkspace: string;\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\t/**\n\t * Host user-scope skill directories, pre-filtered by the caller to those that\n\t * exist and pass the host-home escape check (SKILL.4). Mounted read-only at their\n\t * original host location -- Seatbelt cannot remap a path, so this must hold for\n\t * both backends -- and named to the child via `APEX_CODE_SKILL_PATH_*` so its\n\t * discovery can find them under the sandbox's own repointed `HOME`/agent dir.\n\t */\n\tskillPaths?: HostSkillPaths;\n}): SandboxedCliLaunch {\n\tconst stateDirectory = join(options.workspace, \".apex-code\", \"sandbox-state\");\n\tconst agentDirectory = join(options.workspace, \".apex-code\", \"sandbox-agent\");\n\tconst sessionDirectory = join(options.workspace, \".apex-code\", \"sandbox-sessions\");\n\t// Mirrors getBinDir() as the child will compute it from APEX_CODE_CODING_AGENT_DIR,\n\t// so a projected tool lands exactly where the child's own lookup already checks.\n\tconst toolsDirectory = join(agentDirectory, \"bin\");\n\tconst xdgDirectories = {\n\t\tXDG_CONFIG_HOME: join(stateDirectory, \"config\"),\n\t\tXDG_CACHE_HOME: join(stateDirectory, \"cache\"),\n\t\tXDG_DATA_HOME: join(stateDirectory, \"data\"),\n\t\tXDG_STATE_HOME: join(stateDirectory, \"state\"),\n\t};\n\tfor (const directory of [\n\t\tstateDirectory,\n\t\tagentDirectory,\n\t\tsessionDirectory,\n\t\ttoolsDirectory,\n\t\t...Object.values(xdgDirectories),\n\t]) {\n\t\tmkdirSync(directory, { recursive: true });\n\t}\n\tclearStaleToolMountpoints(toolsDirectory);\n\tconst childEnvironment = buildChildEnvironment(options.environment);\n\tconst { agentSkills, agentsHomeSkills } = options.skillPaths ?? {};\n\tconst skillMountPaths = [agentSkills, agentsHomeSkills].filter((path): path is string => path !== undefined);\n\tconst readOnlyPaths = [...(options.readOnlyPaths ?? []), ...skillMountPaths];\n\tconst readOnlyFiles = options.authPath ? [options.authPath] : [];\n\tconst readOnlyBinaries = (options.toolBinaries ?? []).map((binary) => ({\n\t\tsource: binary.path,\n\t\tdestination: join(toolsDirectory, binary.name),\n\t}));\n\treturn {\n\t\tcommand: options.command,\n\t\targs: [...options.args],\n\t\tpolicy: { workspace: options.workspace, allowedHosts: options.allowedHosts ?? [] },\n\t\treadOnlyPaths,\n\t\treadOnlyFiles,\n\t\treadOnlyBinaries,\n\t\tenvironment: {\n\t\t\t...childEnvironment,\n\t\t\t...(options.authPath ? { APEX_CODE_AUTH_PATH: options.authPath } : {}),\n\t\t\t// After childEnvironment: these come only from the supervisor's own\n\t\t\t// resolution, never from the invoking shell, so their value always wins over\n\t\t\t// anything of the same name that childEnvironment's allowlist let through.\n\t\t\t...(agentSkills ? { APEX_CODE_SKILL_PATH_AGENT: agentSkills } : {}),\n\t\t\t...(agentsHomeSkills ? { APEX_CODE_SKILL_PATH_AGENTS_HOME: agentsHomeSkills } : {}),\n\t\t\tAPEX_CODE_CODING_AGENT_DIR: agentDirectory,\n\t\t\tAPEX_CODE_CODING_AGENT_SESSION_DIR: sessionDirectory,\n\t\t\tHOME: stateDirectory,\n\t\t\tTMPDIR: stateDirectory,\n\t\t\t...xdgDirectories,\n\t\t},\n\t};\n}\n"]}
@@ -1,5 +1,5 @@
1
- import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
3
  import { SettingsManager } from "../settings-manager.js";
4
4
  import { resolveDefaultAllowedHosts } from "./default-hosts.js";
5
5
  const NON_SESSION_COMMANDS = new Set(["auth", "config", "install", "remove", "uninstall", "update", "list"]);
@@ -33,6 +33,66 @@ export function resolveSupervisorAllowedHosts(cwd, agentDir) {
33
33
  return configured;
34
34
  return [...new Set([...resolveDefaultAllowedHosts(), ...configured])];
35
35
  }
36
+ /**
37
+ * True when `candidate` is the host home directory itself, or an ancestor of it.
38
+ * Both paths must already be resolved (`realpathSync`), so a symlink can't disguise
39
+ * either side. Mounting such a candidate read-only would re-expose the entire home
40
+ * tree the sandbox's `--tmpfs /home` (Linux) / `(deny file-read* USER_HOME)` (macOS)
41
+ * deliberately hides -- SSH keys, cloud credentials, other projects -- not just a
42
+ * skills subtree.
43
+ */
44
+ function isHomeOrAncestorOfHome(candidate, home) {
45
+ if (candidate === home)
46
+ return true;
47
+ const prefix = candidate.endsWith(sep) ? candidate : candidate + sep;
48
+ return home.startsWith(prefix);
49
+ }
50
+ /** Resolve one candidate root: absent, refused (symlinked onto the host home), or usable. */
51
+ function resolveHostSkillRoot(root, candidate, homeDir) {
52
+ if (!existsSync(candidate))
53
+ return {};
54
+ let realCandidate;
55
+ let realHome;
56
+ try {
57
+ realCandidate = realpathSync(candidate);
58
+ realHome = realpathSync(homeDir);
59
+ }
60
+ catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ return { refusal: { root, path: candidate, reason: `could not resolve real path: ${message}` } };
63
+ }
64
+ if (isHomeOrAncestorOfHome(realCandidate, realHome)) {
65
+ return {
66
+ refusal: {
67
+ root,
68
+ path: candidate,
69
+ reason: "resolves to the host home directory or an ancestor of it",
70
+ },
71
+ };
72
+ }
73
+ return { path: candidate };
74
+ }
75
+ /**
76
+ * Resolve the host's user-scope skill directories, before the sandbox exists to hide
77
+ * them. Read only from the runtime environment and the host agent directory --
78
+ * never from project files -- matching ADR 0016's rule that supervisor policy is
79
+ * trust-first. Mirrors `core/package-manager.ts`'s own user-scope roots so the two
80
+ * sides agree on where a skill lives. A candidate that resolves (directly or via a
81
+ * symlink) onto the host home or an ancestor of it is refused rather than mounted --
82
+ * see `isHomeOrAncestorOfHome` -- and reported so the caller can surface a startup
83
+ * diagnostic instead of silently mounting or silently skipping it.
84
+ */
85
+ export function resolveHostSkillPaths(agentDir, homeDir) {
86
+ const agentSkills = resolveHostSkillRoot("agentSkills", join(agentDir, "skills"), homeDir);
87
+ const agentsHomeSkills = resolveHostSkillRoot("agentsHomeSkills", join(homeDir, ".agents", "skills"), homeDir);
88
+ return {
89
+ paths: {
90
+ ...(agentSkills.path ? { agentSkills: agentSkills.path } : {}),
91
+ ...(agentsHomeSkills.path ? { agentsHomeSkills: agentsHomeSkills.path } : {}),
92
+ },
93
+ refusals: [agentSkills.refusal, agentsHomeSkills.refusal].filter((refusal) => refusal !== undefined),
94
+ };
95
+ }
36
96
  const SAFE_CHILD_ENVIRONMENT_KEYS = new Set([
37
97
  "PATH",
38
98
  "LANG",
@@ -152,7 +212,9 @@ export function buildSandboxedCliLaunch(options) {
152
212
  }
153
213
  clearStaleToolMountpoints(toolsDirectory);
154
214
  const childEnvironment = buildChildEnvironment(options.environment);
155
- const readOnlyPaths = [...(options.readOnlyPaths ?? [])];
215
+ const { agentSkills, agentsHomeSkills } = options.skillPaths ?? {};
216
+ const skillMountPaths = [agentSkills, agentsHomeSkills].filter((path) => path !== undefined);
217
+ const readOnlyPaths = [...(options.readOnlyPaths ?? []), ...skillMountPaths];
156
218
  const readOnlyFiles = options.authPath ? [options.authPath] : [];
157
219
  const readOnlyBinaries = (options.toolBinaries ?? []).map((binary) => ({
158
220
  source: binary.path,
@@ -168,6 +230,11 @@ export function buildSandboxedCliLaunch(options) {
168
230
  environment: {
169
231
  ...childEnvironment,
170
232
  ...(options.authPath ? { APEX_CODE_AUTH_PATH: options.authPath } : {}),
233
+ // After childEnvironment: these come only from the supervisor's own
234
+ // resolution, never from the invoking shell, so their value always wins over
235
+ // anything of the same name that childEnvironment's allowlist let through.
236
+ ...(agentSkills ? { APEX_CODE_SKILL_PATH_AGENT: agentSkills } : {}),
237
+ ...(agentsHomeSkills ? { APEX_CODE_SKILL_PATH_AGENTS_HOME: agentsHomeSkills } : {}),
171
238
  APEX_CODE_CODING_AGENT_DIR: agentDirectory,
172
239
  APEX_CODE_CODING_AGENT_SESSION_DIR: sessionDirectory,
173
240
  HOME: stateDirectory,
@@ -1 +1 @@
1
- {"version":3,"file":"cli-launch.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-launch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAGhE,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;AAC7G,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC;AAEjF;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAAuB,EAAW;IACxE,IAAI,oBAAoB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,KAAK,CAAC;IAC1D,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACxE,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACjE,OAAO,IAAI,CAAC;AAAA,CACZ;AAMD;;;;;;;GAOG;AACH,MAAM,UAAU,6BAA6B,CAAC,GAAW,EAAE,QAAgB,EAAiC;IAC3G,MAAM,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,GAAG,EAAE,QAAQ,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,CAAC,kBAAkB,EAAE,CAAC;IACtG,MAAM,UAAU,GAAG,OAAO,EAAE,YAAY,IAAI,EAAE,CAAC;IAC/C,IAAI,OAAO,EAAE,iBAAiB,KAAK,KAAK;QAAE,OAAO,UAAU,CAAC;IAC5D,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,0BAA0B,EAAE,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;AAAA,CACtE;AAED,MAAM,2BAA2B,GAAG,IAAI,GAAG,CAAC;IAC3C,MAAM;IACN,MAAM;IACN,QAAQ;IACR,UAAU;IACV,MAAM;IACN,WAAW;IACX,UAAU;IACV,aAAa;IACb,mBAAmB;IACnB,mBAAmB;IACnB,8BAA8B;IAC9B,wBAAwB;IACxB,6BAA6B;IAC7B,kBAAkB;IAClB,2BAA2B;IAC3B,2BAA2B;IAC3B,6BAA6B;IAC7B,4BAA4B;IAC5B,QAAQ;IACR,QAAQ;CACR,CAAC,CAAC;AAEH,wFAAwF;AACxF,sFAAsF;AACtF,MAAM,6BAA6B,GAAG,IAAI,GAAG,CAAC;IAC7C,mBAAmB;IACnB,sBAAsB;IACtB,uBAAuB;IACvB,kBAAkB;IAClB,gBAAgB;IAChB,sBAAsB;IACtB,uBAAuB;IACvB,4BAA4B;IAC5B,0BAA0B;IAC1B,kCAAkC;IAClC,kBAAkB;IAClB,gBAAgB;IAChB,gBAAgB;IAChB,cAAc;IACd,kBAAkB;IAClB,aAAa;IACb,mBAAmB;IACnB,kBAAkB;IAClB,iBAAiB;IACjB,oBAAoB;IACpB,oBAAoB;IACpB,aAAa;IACb,uBAAuB;IACvB,iBAAiB;IACjB,iBAAiB;IACjB,kBAAkB;IAClB,kBAAkB;IAClB,cAAc;IACd,oBAAoB;IACpB,uBAAuB;IACvB,uBAAuB;IACvB,yBAAyB;IACzB,4BAA4B;IAC5B,gBAAgB;IAChB,8BAA8B;IAC9B,+BAA+B;IAC/B,+BAA+B;IAC/B,aAAa;IACb,mBAAmB;IACnB,uBAAuB;IACvB,0BAA0B;IAC1B,YAAY;CACZ,CAAC,CAAC;AAEH,SAAS,qBAAqB,CAAC,WAA8B,EAAqB;IACjF,OAAO,MAAM,CAAC,WAAW,CACxB,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,MAAM,CACjC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,2BAA2B,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,6BAA6B,CAAC,GAAG,CAAC,GAAG,CAAC,CACzF,CACD,CAAC;AAAA,CACF;AAED;;;;;;;;GAQG;AACH,SAAS,yBAAyB,CAAC,cAAsB,EAAQ;IAChE,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAC1E,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE;YAAE,SAAS;QAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACnD,IAAI,QAAQ,CAAC,SAAS,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACpC,MAAM,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACpC,CAAC;IACF,CAAC;AAAA,CACD;AAED;;;;GAIG;AACH,MAAM,UAAU,uBAAuB,CAAC,OASvC,EAAsB;IACtB,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,eAAe,CAAC,CAAC;IAC9E,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,eAAe,CAAC,CAAC;IAC9E,MAAM,gBAAgB,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,kBAAkB,CAAC,CAAC;IACnF,oFAAoF;IACpF,iFAAiF;IACjF,MAAM,cAAc,GAAG,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;IACnD,MAAM,cAAc,GAAG;QACtB,eAAe,EAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,CAAC;QAC/C,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC;QAC7C,aAAa,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC;QAC3C,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC;KAC7C,CAAC;IACF,KAAK,MAAM,SAAS,IAAI;QACvB,cAAc;QACd,cAAc;QACd,gBAAgB;QAChB,cAAc;QACd,GAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC;KAChC,EAAE,CAAC;QACH,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,CAAC;IACD,yBAAyB,CAAC,cAAc,CAAC,CAAC;IAC1C,MAAM,gBAAgB,GAAG,qBAAqB,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACpE,MAAM,aAAa,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,CAAC;IACzD,MAAM,aAAa,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACjE,MAAM,gBAAgB,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACtE,MAAM,EAAE,MAAM,CAAC,IAAI;QACnB,WAAW,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,IAAI,CAAC;KAC9C,CAAC,CAAC,CAAC;IACJ,OAAO;QACN,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;QACvB,MAAM,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,IAAI,EAAE,EAAE;QAClF,aAAa;QACb,aAAa;QACb,gBAAgB;QAChB,WAAW,EAAE;YACZ,GAAG,gBAAgB;YACnB,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,mBAAmB,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACtE,0BAA0B,EAAE,cAAc;YAC1C,kCAAkC,EAAE,gBAAgB;YACpD,IAAI,EAAE,cAAc;YACpB,MAAM,EAAE,cAAc;YACtB,GAAG,cAAc;SACjB;KACD,CAAC;AAAA,CACF","sourcesContent":["import { mkdirSync, readdirSync, rmSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { SettingsManager } from \"../settings-manager.ts\";\nimport { resolveDefaultAllowedHosts } from \"./default-hosts.ts\";\nimport type { SandboxLaunch } from \"./supervisor.ts\";\n\nconst NON_SESSION_COMMANDS = new Set([\"auth\", \"config\", \"install\", \"remove\", \"uninstall\", \"update\", \"list\"]);\nconst METADATA_FLAGS = new Set([\"--version\", \"-v\", \"--export\", \"--list-models\"]);\n\n/**\n * OS containment is the normal startup path for every command that can construct an\n * agent session. Commands that only inspect or maintain host configuration do not\n * create a runtime and therefore remain outside this child boundary.\n */\nexport function requiresSandboxedChild(args: readonly string[]): boolean {\n\tif (NON_SESSION_COMMANDS.has(args[0] ?? \"\")) return false;\n\tif (args.some((argument) => METADATA_FLAGS.has(argument))) return false;\n\tif (args.includes(\"--help\") || args.includes(\"-h\")) return false;\n\treturn true;\n}\n\nexport interface SandboxedCliLaunch extends SandboxLaunch {\n\treadonly environment: NodeJS.ProcessEnv;\n}\n\n/**\n * Read supervisor policy only from global settings; project settings are untrusted here.\n *\n * The built-in provider hosts are added unless explicitly refused, because a deny-all\n * default made a fresh install unable to reach any model while giving the user no way to\n * learn which host to permit. Configured hosts are additive on top, and\n * `allowDefaultHosts: false` restores the strict behaviour for anyone who wants it.\n */\nexport function resolveSupervisorAllowedHosts(cwd: string, agentDir: string): readonly string[] | undefined {\n\tconst network = SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getNetworkSettings();\n\tconst configured = network?.allowedHosts ?? [];\n\tif (network?.allowDefaultHosts === false) return configured;\n\treturn [...new Set([...resolveDefaultAllowedHosts(), ...configured])];\n}\n\nconst SAFE_CHILD_ENVIRONMENT_KEYS = new Set([\n\t\"PATH\",\n\t\"LANG\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TERM\",\n\t\"COLORTERM\",\n\t\"NO_COLOR\",\n\t\"FORCE_COLOR\",\n\t\"TSX_TSCONFIG_PATH\",\n\t\"APEX_CODE_OFFLINE\",\n\t\"APEX_CODE_SKIP_VERSION_CHECK\",\n\t\"APEX_CODE_EXPERIMENTAL\",\n\t\"APEX_CODE_STARTUP_BENCHMARK\",\n\t\"APEX_CODE_TIMING\",\n\t\"APEX_CODE_CLEAR_ON_SHRINK\",\n\t\"APEX_CODE_HARDWARE_CURSOR\",\n\t\"APEX_CODE_MODEL_CATALOG_URL\",\n\t\"APEX_CODE_SHARE_VIEWER_URL\",\n\t\"VISUAL\",\n\t\"EDITOR\",\n]);\n\n// Provider API keys are explicit credential inputs, unlike arbitrary ambient variables.\n// Keep this list in sync with the documented provider environment-variable reference.\nconst SAFE_PROVIDER_CREDENTIAL_KEYS = new Set([\n\t\"ANTHROPIC_API_KEY\",\n\t\"ANTHROPIC_AUTH_TOKEN\",\n\t\"ANTHROPIC_OAUTH_TOKEN\",\n\t\"ANT_LING_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_BASE_URL\",\n\t\"AZURE_OPENAI_RESOURCE_NAME\",\n\t\"AZURE_OPENAI_API_VERSION\",\n\t\"AZURE_OPENAI_DEPLOYMENT_NAME_MAP\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"GROQ_API_KEY\",\n\t\"CEREBRAS_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"FIREWORKS_API_KEY\",\n\t\"TOGETHER_API_KEY\",\n\t\"BASETEN_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"AI_GATEWAY_API_KEY\",\n\t\"ZAI_API_KEY\",\n\t\"ZAI_CODING_CN_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"MOONSHOT_API_KEY\",\n\t\"OPENCODE_API_KEY\",\n\t\"KIMI_API_KEY\",\n\t\"CLOUDFLARE_API_KEY\",\n\t\"CLOUDFLARE_ACCOUNT_ID\",\n\t\"CLOUDFLARE_GATEWAY_ID\",\n\t\"QWEN_TOKEN_PLAN_API_KEY\",\n\t\"QWEN_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_AMS_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_SGP_API_KEY\",\n\t\"AWS_PROFILE\",\n\t\"AWS_ACCESS_KEY_ID\",\n\t\"AWS_SECRET_ACCESS_KEY\",\n\t\"AWS_BEARER_TOKEN_BEDROCK\",\n\t\"AWS_REGION\",\n]);\n\nfunction buildChildEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n\treturn Object.fromEntries(\n\t\tObject.entries(environment).filter(\n\t\t\t([key]) => SAFE_CHILD_ENVIRONMENT_KEYS.has(key) || SAFE_PROVIDER_CREDENTIAL_KEYS.has(key),\n\t\t),\n\t);\n}\n\n/**\n * Drop empty files left in the child's tools directory by a previous launch.\n *\n * A projected tool is bind-mounted over a file there, and bwrap materialises that\n * mountpoint as an empty file on the host which outlives the namespace. If the host\n * tool later disappears, nothing is projected over the stub and the child would\n * otherwise find a 0-byte file where its binary should be. A real downloaded binary\n * is never empty, so size is a safe discriminator.\n */\nfunction clearStaleToolMountpoints(toolsDirectory: string): void {\n\tfor (const entry of readdirSync(toolsDirectory, { withFileTypes: true })) {\n\t\tif (!entry.isFile()) continue;\n\t\tconst entryPath = join(toolsDirectory, entry.name);\n\t\tif (statSync(entryPath).size === 0) {\n\t\t\trmSync(entryPath, { force: true });\n\t\t}\n\t}\n}\n\n/**\n * Allocate agent-owned state under the sole writable workspace mount. The child does\n * not inherit a host home or a host session/config directory, preventing the sandbox\n * from presenting a write boundary while its own state quietly escapes it.\n */\nexport function buildSandboxedCliLaunch(options: {\n\tworkspace: string;\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n}): SandboxedCliLaunch {\n\tconst stateDirectory = join(options.workspace, \".apex-code\", \"sandbox-state\");\n\tconst agentDirectory = join(options.workspace, \".apex-code\", \"sandbox-agent\");\n\tconst sessionDirectory = join(options.workspace, \".apex-code\", \"sandbox-sessions\");\n\t// Mirrors getBinDir() as the child will compute it from APEX_CODE_CODING_AGENT_DIR,\n\t// so a projected tool lands exactly where the child's own lookup already checks.\n\tconst toolsDirectory = join(agentDirectory, \"bin\");\n\tconst xdgDirectories = {\n\t\tXDG_CONFIG_HOME: join(stateDirectory, \"config\"),\n\t\tXDG_CACHE_HOME: join(stateDirectory, \"cache\"),\n\t\tXDG_DATA_HOME: join(stateDirectory, \"data\"),\n\t\tXDG_STATE_HOME: join(stateDirectory, \"state\"),\n\t};\n\tfor (const directory of [\n\t\tstateDirectory,\n\t\tagentDirectory,\n\t\tsessionDirectory,\n\t\ttoolsDirectory,\n\t\t...Object.values(xdgDirectories),\n\t]) {\n\t\tmkdirSync(directory, { recursive: true });\n\t}\n\tclearStaleToolMountpoints(toolsDirectory);\n\tconst childEnvironment = buildChildEnvironment(options.environment);\n\tconst readOnlyPaths = [...(options.readOnlyPaths ?? [])];\n\tconst readOnlyFiles = options.authPath ? [options.authPath] : [];\n\tconst readOnlyBinaries = (options.toolBinaries ?? []).map((binary) => ({\n\t\tsource: binary.path,\n\t\tdestination: join(toolsDirectory, binary.name),\n\t}));\n\treturn {\n\t\tcommand: options.command,\n\t\targs: [...options.args],\n\t\tpolicy: { workspace: options.workspace, allowedHosts: options.allowedHosts ?? [] },\n\t\treadOnlyPaths,\n\t\treadOnlyFiles,\n\t\treadOnlyBinaries,\n\t\tenvironment: {\n\t\t\t...childEnvironment,\n\t\t\t...(options.authPath ? { APEX_CODE_AUTH_PATH: options.authPath } : {}),\n\t\t\tAPEX_CODE_CODING_AGENT_DIR: agentDirectory,\n\t\t\tAPEX_CODE_CODING_AGENT_SESSION_DIR: sessionDirectory,\n\t\t\tHOME: stateDirectory,\n\t\t\tTMPDIR: stateDirectory,\n\t\t\t...xdgDirectories,\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"cli-launch.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-launch.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from \"node:fs\";\nimport { join, sep } from \"node:path\";\nimport type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { SettingsManager } from \"../settings-manager.ts\";\nimport { resolveDefaultAllowedHosts } from \"./default-hosts.ts\";\nimport type { SandboxLaunch } from \"./supervisor.ts\";\n\nconst NON_SESSION_COMMANDS = new Set([\"auth\", \"config\", \"install\", \"remove\", \"uninstall\", \"update\", \"list\"]);\nconst METADATA_FLAGS = new Set([\"--version\", \"-v\", \"--export\", \"--list-models\"]);\n\n/**\n * OS containment is the normal startup path for every command that can construct an\n * agent session. Commands that only inspect or maintain host configuration do not\n * create a runtime and therefore remain outside this child boundary.\n */\nexport function requiresSandboxedChild(args: readonly string[]): boolean {\n\tif (NON_SESSION_COMMANDS.has(args[0] ?? \"\")) return false;\n\tif (args.some((argument) => METADATA_FLAGS.has(argument))) return false;\n\tif (args.includes(\"--help\") || args.includes(\"-h\")) return false;\n\treturn true;\n}\n\nexport interface SandboxedCliLaunch extends SandboxLaunch {\n\treadonly environment: NodeJS.ProcessEnv;\n}\n\n/**\n * Read supervisor policy only from global settings; project settings are untrusted here.\n *\n * The built-in provider hosts are added unless explicitly refused, because a deny-all\n * default made a fresh install unable to reach any model while giving the user no way to\n * learn which host to permit. Configured hosts are additive on top, and\n * `allowDefaultHosts: false` restores the strict behaviour for anyone who wants it.\n */\nexport function resolveSupervisorAllowedHosts(cwd: string, agentDir: string): readonly string[] | undefined {\n\tconst network = SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getNetworkSettings();\n\tconst configured = network?.allowedHosts ?? [];\n\tif (network?.allowDefaultHosts === false) return configured;\n\treturn [...new Set([...resolveDefaultAllowedHosts(), ...configured])];\n}\n\n/**\n * The host's two user-scope skill roots, each present only when it exists on the\n * host. Kept as two named slots rather than one list because\n * `core/package-manager.ts` discovers them in different modes -- root `.md` files\n * count as skills under `agentSkills` (\"pi\" mode) and are ignored under\n * `agentsHomeSkills` (\"agents\" mode), per `docs/skills.md` -- and a flat list of 0-2\n * paths cannot tell the child which root a lone survivor was.\n */\nexport interface HostSkillPaths {\n\t/** Host `<agentDir>/skills`. */\n\treadonly agentSkills?: string;\n\t/** Host `<home>/.agents/skills`. */\n\treadonly agentsHomeSkills?: string;\n}\n\n/** A candidate skill root that exists but was excluded, and why. */\nexport interface HostSkillPathRefusal {\n\treadonly root: keyof HostSkillPaths;\n\treadonly path: string;\n\treadonly reason: string;\n}\n\nexport interface ResolvedHostSkillPaths {\n\treadonly paths: HostSkillPaths;\n\treadonly refusals: readonly HostSkillPathRefusal[];\n}\n\n/**\n * True when `candidate` is the host home directory itself, or an ancestor of it.\n * Both paths must already be resolved (`realpathSync`), so a symlink can't disguise\n * either side. Mounting such a candidate read-only would re-expose the entire home\n * tree the sandbox's `--tmpfs /home` (Linux) / `(deny file-read* USER_HOME)` (macOS)\n * deliberately hides -- SSH keys, cloud credentials, other projects -- not just a\n * skills subtree.\n */\nfunction isHomeOrAncestorOfHome(candidate: string, home: string): boolean {\n\tif (candidate === home) return true;\n\tconst prefix = candidate.endsWith(sep) ? candidate : candidate + sep;\n\treturn home.startsWith(prefix);\n}\n\n/** Resolve one candidate root: absent, refused (symlinked onto the host home), or usable. */\nfunction resolveHostSkillRoot(\n\troot: keyof HostSkillPaths,\n\tcandidate: string,\n\thomeDir: string,\n): { path?: string; refusal?: HostSkillPathRefusal } {\n\tif (!existsSync(candidate)) return {};\n\tlet realCandidate: string;\n\tlet realHome: string;\n\ttry {\n\t\trealCandidate = realpathSync(candidate);\n\t\trealHome = realpathSync(homeDir);\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\treturn { refusal: { root, path: candidate, reason: `could not resolve real path: ${message}` } };\n\t}\n\tif (isHomeOrAncestorOfHome(realCandidate, realHome)) {\n\t\treturn {\n\t\t\trefusal: {\n\t\t\t\troot,\n\t\t\t\tpath: candidate,\n\t\t\t\treason: \"resolves to the host home directory or an ancestor of it\",\n\t\t\t},\n\t\t};\n\t}\n\treturn { path: candidate };\n}\n\n/**\n * Resolve the host's user-scope skill directories, before the sandbox exists to hide\n * them. Read only from the runtime environment and the host agent directory --\n * never from project files -- matching ADR 0016's rule that supervisor policy is\n * trust-first. Mirrors `core/package-manager.ts`'s own user-scope roots so the two\n * sides agree on where a skill lives. A candidate that resolves (directly or via a\n * symlink) onto the host home or an ancestor of it is refused rather than mounted --\n * see `isHomeOrAncestorOfHome` -- and reported so the caller can surface a startup\n * diagnostic instead of silently mounting or silently skipping it.\n */\nexport function resolveHostSkillPaths(agentDir: string, homeDir: string): ResolvedHostSkillPaths {\n\tconst agentSkills = resolveHostSkillRoot(\"agentSkills\", join(agentDir, \"skills\"), homeDir);\n\tconst agentsHomeSkills = resolveHostSkillRoot(\"agentsHomeSkills\", join(homeDir, \".agents\", \"skills\"), homeDir);\n\treturn {\n\t\tpaths: {\n\t\t\t...(agentSkills.path ? { agentSkills: agentSkills.path } : {}),\n\t\t\t...(agentsHomeSkills.path ? { agentsHomeSkills: agentsHomeSkills.path } : {}),\n\t\t},\n\t\trefusals: [agentSkills.refusal, agentsHomeSkills.refusal].filter(\n\t\t\t(refusal): refusal is HostSkillPathRefusal => refusal !== undefined,\n\t\t),\n\t};\n}\n\nconst SAFE_CHILD_ENVIRONMENT_KEYS = new Set([\n\t\"PATH\",\n\t\"LANG\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TERM\",\n\t\"COLORTERM\",\n\t\"NO_COLOR\",\n\t\"FORCE_COLOR\",\n\t\"TSX_TSCONFIG_PATH\",\n\t\"APEX_CODE_OFFLINE\",\n\t\"APEX_CODE_SKIP_VERSION_CHECK\",\n\t\"APEX_CODE_EXPERIMENTAL\",\n\t\"APEX_CODE_STARTUP_BENCHMARK\",\n\t\"APEX_CODE_TIMING\",\n\t\"APEX_CODE_CLEAR_ON_SHRINK\",\n\t\"APEX_CODE_HARDWARE_CURSOR\",\n\t\"APEX_CODE_MODEL_CATALOG_URL\",\n\t\"APEX_CODE_SHARE_VIEWER_URL\",\n\t\"VISUAL\",\n\t\"EDITOR\",\n]);\n\n// Provider API keys are explicit credential inputs, unlike arbitrary ambient variables.\n// Keep this list in sync with the documented provider environment-variable reference.\nconst SAFE_PROVIDER_CREDENTIAL_KEYS = new Set([\n\t\"ANTHROPIC_API_KEY\",\n\t\"ANTHROPIC_AUTH_TOKEN\",\n\t\"ANTHROPIC_OAUTH_TOKEN\",\n\t\"ANT_LING_API_KEY\",\n\t\"OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_API_KEY\",\n\t\"AZURE_OPENAI_BASE_URL\",\n\t\"AZURE_OPENAI_RESOURCE_NAME\",\n\t\"AZURE_OPENAI_API_VERSION\",\n\t\"AZURE_OPENAI_DEPLOYMENT_NAME_MAP\",\n\t\"DEEPSEEK_API_KEY\",\n\t\"NVIDIA_API_KEY\",\n\t\"GEMINI_API_KEY\",\n\t\"GROQ_API_KEY\",\n\t\"CEREBRAS_API_KEY\",\n\t\"XAI_API_KEY\",\n\t\"FIREWORKS_API_KEY\",\n\t\"TOGETHER_API_KEY\",\n\t\"BASETEN_API_KEY\",\n\t\"OPENROUTER_API_KEY\",\n\t\"AI_GATEWAY_API_KEY\",\n\t\"ZAI_API_KEY\",\n\t\"ZAI_CODING_CN_API_KEY\",\n\t\"MISTRAL_API_KEY\",\n\t\"MINIMAX_API_KEY\",\n\t\"MOONSHOT_API_KEY\",\n\t\"OPENCODE_API_KEY\",\n\t\"KIMI_API_KEY\",\n\t\"CLOUDFLARE_API_KEY\",\n\t\"CLOUDFLARE_ACCOUNT_ID\",\n\t\"CLOUDFLARE_GATEWAY_ID\",\n\t\"QWEN_TOKEN_PLAN_API_KEY\",\n\t\"QWEN_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_CN_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_AMS_API_KEY\",\n\t\"XIAOMI_TOKEN_PLAN_SGP_API_KEY\",\n\t\"AWS_PROFILE\",\n\t\"AWS_ACCESS_KEY_ID\",\n\t\"AWS_SECRET_ACCESS_KEY\",\n\t\"AWS_BEARER_TOKEN_BEDROCK\",\n\t\"AWS_REGION\",\n]);\n\nfunction buildChildEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n\treturn Object.fromEntries(\n\t\tObject.entries(environment).filter(\n\t\t\t([key]) => SAFE_CHILD_ENVIRONMENT_KEYS.has(key) || SAFE_PROVIDER_CREDENTIAL_KEYS.has(key),\n\t\t),\n\t);\n}\n\n/**\n * Drop empty files left in the child's tools directory by a previous launch.\n *\n * A projected tool is bind-mounted over a file there, and bwrap materialises that\n * mountpoint as an empty file on the host which outlives the namespace. If the host\n * tool later disappears, nothing is projected over the stub and the child would\n * otherwise find a 0-byte file where its binary should be. A real downloaded binary\n * is never empty, so size is a safe discriminator.\n */\nfunction clearStaleToolMountpoints(toolsDirectory: string): void {\n\tfor (const entry of readdirSync(toolsDirectory, { withFileTypes: true })) {\n\t\tif (!entry.isFile()) continue;\n\t\tconst entryPath = join(toolsDirectory, entry.name);\n\t\tif (statSync(entryPath).size === 0) {\n\t\t\trmSync(entryPath, { force: true });\n\t\t}\n\t}\n}\n\n/**\n * Allocate agent-owned state under the sole writable workspace mount. The child does\n * not inherit a host home or a host session/config directory, preventing the sandbox\n * from presenting a write boundary while its own state quietly escapes it.\n */\nexport function buildSandboxedCliLaunch(options: {\n\tworkspace: string;\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\t/**\n\t * Host user-scope skill directories, pre-filtered by the caller to those that\n\t * exist and pass the host-home escape check (SKILL.4). Mounted read-only at their\n\t * original host location -- Seatbelt cannot remap a path, so this must hold for\n\t * both backends -- and named to the child via `APEX_CODE_SKILL_PATH_*` so its\n\t * discovery can find them under the sandbox's own repointed `HOME`/agent dir.\n\t */\n\tskillPaths?: HostSkillPaths;\n}): SandboxedCliLaunch {\n\tconst stateDirectory = join(options.workspace, \".apex-code\", \"sandbox-state\");\n\tconst agentDirectory = join(options.workspace, \".apex-code\", \"sandbox-agent\");\n\tconst sessionDirectory = join(options.workspace, \".apex-code\", \"sandbox-sessions\");\n\t// Mirrors getBinDir() as the child will compute it from APEX_CODE_CODING_AGENT_DIR,\n\t// so a projected tool lands exactly where the child's own lookup already checks.\n\tconst toolsDirectory = join(agentDirectory, \"bin\");\n\tconst xdgDirectories = {\n\t\tXDG_CONFIG_HOME: join(stateDirectory, \"config\"),\n\t\tXDG_CACHE_HOME: join(stateDirectory, \"cache\"),\n\t\tXDG_DATA_HOME: join(stateDirectory, \"data\"),\n\t\tXDG_STATE_HOME: join(stateDirectory, \"state\"),\n\t};\n\tfor (const directory of [\n\t\tstateDirectory,\n\t\tagentDirectory,\n\t\tsessionDirectory,\n\t\ttoolsDirectory,\n\t\t...Object.values(xdgDirectories),\n\t]) {\n\t\tmkdirSync(directory, { recursive: true });\n\t}\n\tclearStaleToolMountpoints(toolsDirectory);\n\tconst childEnvironment = buildChildEnvironment(options.environment);\n\tconst { agentSkills, agentsHomeSkills } = options.skillPaths ?? {};\n\tconst skillMountPaths = [agentSkills, agentsHomeSkills].filter((path): path is string => path !== undefined);\n\tconst readOnlyPaths = [...(options.readOnlyPaths ?? []), ...skillMountPaths];\n\tconst readOnlyFiles = options.authPath ? [options.authPath] : [];\n\tconst readOnlyBinaries = (options.toolBinaries ?? []).map((binary) => ({\n\t\tsource: binary.path,\n\t\tdestination: join(toolsDirectory, binary.name),\n\t}));\n\treturn {\n\t\tcommand: options.command,\n\t\targs: [...options.args],\n\t\tpolicy: { workspace: options.workspace, allowedHosts: options.allowedHosts ?? [] },\n\t\treadOnlyPaths,\n\t\treadOnlyFiles,\n\t\treadOnlyBinaries,\n\t\tenvironment: {\n\t\t\t...childEnvironment,\n\t\t\t...(options.authPath ? { APEX_CODE_AUTH_PATH: options.authPath } : {}),\n\t\t\t// After childEnvironment: these come only from the supervisor's own\n\t\t\t// resolution, never from the invoking shell, so their value always wins over\n\t\t\t// anything of the same name that childEnvironment's allowlist let through.\n\t\t\t...(agentSkills ? { APEX_CODE_SKILL_PATH_AGENT: agentSkills } : {}),\n\t\t\t...(agentsHomeSkills ? { APEX_CODE_SKILL_PATH_AGENTS_HOME: agentsHomeSkills } : {}),\n\t\t\tAPEX_CODE_CODING_AGENT_DIR: agentDirectory,\n\t\t\tAPEX_CODE_CODING_AGENT_SESSION_DIR: sessionDirectory,\n\t\t\tHOME: stateDirectory,\n\t\t\tTMPDIR: stateDirectory,\n\t\t\t...xdgDirectories,\n\t\t},\n\t};\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import type { HostToolBinary } from "../../utils/tools-manager.ts";
2
+ import { type HostSkillPaths } from "./cli-launch.ts";
2
3
  import { type SandboxBackend } from "./supervisor.ts";
3
4
  import { SandboxViolationStore } from "./violations.ts";
4
5
  export interface CliSandboxDependencies {
@@ -22,6 +23,7 @@ export declare function launchSandboxedCli(options: {
22
23
  readOnlyPaths?: readonly string[];
23
24
  authPath?: string;
24
25
  toolBinaries?: readonly HostToolBinary[];
26
+ skillPaths?: HostSkillPaths;
25
27
  dependencies?: Partial<CliSandboxDependencies>;
26
28
  }): Promise<number>;
27
29
  //# sourceMappingURL=cli-supervisor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-supervisor.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAKnE,OAAO,EAA2B,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,OAAO,EAAE;QAAE,cAAc,EAAE,qBAAqB,CAAA;KAAE,KAAK,cAAc,CAAC;IACtF,MAAM,EAAE;QAAE,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;CAC5C;AAeD;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC,YAAY,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;CAC/C,GAAG,OAAO,CAAC,MAAM,CAAC,CAkClB","sourcesContent":["import type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { buildSandboxedCliLaunch } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tconst launch = buildSandboxedCliLaunch({\n\t\tworkspace: policyResult.policy.workspace,\n\t\tcommand: options.command,\n\t\targs: options.args,\n\t\tenvironment: options.environment,\n\t\tallowedHosts: options.allowedHosts,\n\t\treadOnlyPaths: options.readOnlyPaths,\n\t\tauthPath: options.authPath,\n\t\ttoolBinaries: options.toolBinaries,\n\t});\n\ttry {\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tawait supervisor.close();\n\t}\n}\n"]}
1
+ {"version":3,"file":"cli-supervisor.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAA2B,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAI/E,OAAO,EAA2B,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,OAAO,EAAE;QAAE,cAAc,EAAE,qBAAqB,CAAA;KAAE,KAAK,cAAc,CAAC;IACtF,MAAM,EAAE;QAAE,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;CAC5C;AAeD;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;CAC/C,GAAG,OAAO,CAAC,MAAM,CAAC,CAmClB","sourcesContent":["import type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { buildSandboxedCliLaunch, type HostSkillPaths } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tskillPaths?: HostSkillPaths;\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tconst launch = buildSandboxedCliLaunch({\n\t\tworkspace: policyResult.policy.workspace,\n\t\tcommand: options.command,\n\t\targs: options.args,\n\t\tenvironment: options.environment,\n\t\tallowedHosts: options.allowedHosts,\n\t\treadOnlyPaths: options.readOnlyPaths,\n\t\tauthPath: options.authPath,\n\t\ttoolBinaries: options.toolBinaries,\n\t\tskillPaths: options.skillPaths,\n\t});\n\ttry {\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tawait supervisor.close();\n\t}\n}\n"]}
@@ -39,6 +39,7 @@ export async function launchSandboxedCli(options) {
39
39
  readOnlyPaths: options.readOnlyPaths,
40
40
  authPath: options.authPath,
41
41
  toolBinaries: options.toolBinaries,
42
+ skillPaths: options.skillPaths,
42
43
  });
43
44
  try {
44
45
  return await supervisor.launch(launch);
@@ -1 +1 @@
1
- {"version":3,"file":"cli-supervisor.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAuB,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAOxD;;6CAE6C;AAC7C,SAAS,2BAA2B,CAAC,OAAkD,EAAkB;IACxG,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;IAC7E,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;AAAA,CAC1C;AAED,MAAM,mBAAmB,GAA2B;IACnD,aAAa,EAAE,2BAA2B;IAC1C,MAAM,EAAE,OAAO,CAAC,MAAM;CACtB,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,OAUxC,EAAmB;IACnB,MAAM,YAAY,GAAG,EAAE,GAAG,mBAAmB,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IACzE,MAAM,YAAY,GAAG,mBAAmB,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAC/G,IAAI,YAAY,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACrC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,0DAA0D,YAAY,CAAC,MAAM,IAAI,CAAC,CAAC;QAC7G,OAAO,CAAC,CAAC;IACV,CAAC;IACD,MAAM,cAAc,GAAG,IAAI,qBAAqB,EAAE,CAAC;IACnD,MAAM,OAAO,GAAG,YAAY,CAAC,aAAa,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC;IAC/D,MAAM,UAAU,GAAG,uBAAuB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;IACrF,MAAM,MAAM,GAAG,uBAAuB,CAAC;QACtC,SAAS,EAAE,YAAY,CAAC,MAAM,CAAC,SAAS;QACxC,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,WAAW,EAAE,OAAO,CAAC,WAAW;QAChC,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,aAAa,EAAE,OAAO,CAAC,aAAa;QACpC,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;KAClC,CAAC,CAAC;IACH,IAAI,CAAC;QACJ,OAAO,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAC;QACvF,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,OAAO,IAAI,CAAC,CAAC;QACjD,OAAO,CAAC,CAAC;IACV,CAAC;YAAS,CAAC;QACV,KAAK,MAAM,SAAS,IAAI,cAAc,CAAC,IAAI,EAAE,EAAE,CAAC;YAC/C,YAAY,CAAC,MAAM,CAAC,KAAK,CACxB,sBAAsB,SAAS,CAAC,IAAI,MAAM,SAAS,CAAC,OAAO,QAAM,SAAS,CAAC,MAAM,IAAI,CACrF,CAAC;QACH,CAAC;QACD,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;AAAA,CACD","sourcesContent":["import type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { buildSandboxedCliLaunch } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tconst launch = buildSandboxedCliLaunch({\n\t\tworkspace: policyResult.policy.workspace,\n\t\tcommand: options.command,\n\t\targs: options.args,\n\t\tenvironment: options.environment,\n\t\tallowedHosts: options.allowedHosts,\n\t\treadOnlyPaths: options.readOnlyPaths,\n\t\tauthPath: options.authPath,\n\t\ttoolBinaries: options.toolBinaries,\n\t});\n\ttry {\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tawait supervisor.close();\n\t}\n}\n"]}
1
+ {"version":3,"file":"cli-supervisor.js","sourceRoot":"","sources":["../../../src/core/sandbox/cli-supervisor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAuB,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAuB,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAOxD;;6CAE6C;AAC7C,SAAS,2BAA2B,CAAC,OAAkD,EAAkB;IACxG,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;IAC7E,OAAO,yBAAyB,CAAC,OAAO,CAAC,CAAC;AAAA,CAC1C;AAED,MAAM,mBAAmB,GAA2B;IACnD,aAAa,EAAE,2BAA2B;IAC1C,MAAM,EAAE,OAAO,CAAC,MAAM;CACtB,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,OAWxC,EAAmB;IACnB,MAAM,YAAY,GAAG,EAAE,GAAG,mBAAmB,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IACzE,MAAM,YAAY,GAAG,mBAAmB,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAC/G,IAAI,YAAY,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACrC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,0DAA0D,YAAY,CAAC,MAAM,IAAI,CAAC,CAAC;QAC7G,OAAO,CAAC,CAAC;IACV,CAAC;IACD,MAAM,cAAc,GAAG,IAAI,qBAAqB,EAAE,CAAC;IACnD,MAAM,OAAO,GAAG,YAAY,CAAC,aAAa,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC;IAC/D,MAAM,UAAU,GAAG,uBAAuB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;IACrF,MAAM,MAAM,GAAG,uBAAuB,CAAC;QACtC,SAAS,EAAE,YAAY,CAAC,MAAM,CAAC,SAAS;QACxC,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,WAAW,EAAE,OAAO,CAAC,WAAW;QAChC,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,aAAa,EAAE,OAAO,CAAC,aAAa;QACpC,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,UAAU,EAAE,OAAO,CAAC,UAAU;KAC9B,CAAC,CAAC;IACH,IAAI,CAAC;QACJ,OAAO,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAC;QACvF,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,OAAO,IAAI,CAAC,CAAC;QACjD,OAAO,CAAC,CAAC;IACV,CAAC;YAAS,CAAC;QACV,KAAK,MAAM,SAAS,IAAI,cAAc,CAAC,IAAI,EAAE,EAAE,CAAC;YAC/C,YAAY,CAAC,MAAM,CAAC,KAAK,CACxB,sBAAsB,SAAS,CAAC,IAAI,MAAM,SAAS,CAAC,OAAO,QAAM,SAAS,CAAC,MAAM,IAAI,CACrF,CAAC;QACH,CAAC;QACD,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;AAAA,CACD","sourcesContent":["import type { HostToolBinary } from \"../../utils/tools-manager.ts\";\nimport { buildSandboxedCliLaunch, type HostSkillPaths } from \"./cli-launch.ts\";\nimport { createLinuxSandboxBackend } from \"./linux-backend.ts\";\nimport { createMacosSandboxBackend } from \"./macos-backend.ts\";\nimport { createSandboxPolicy } from \"./policy.ts\";\nimport { createSandboxSupervisor, type SandboxBackend } from \"./supervisor.ts\";\nimport { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface CliSandboxDependencies {\n\tcreateBackend: (options: { violationStore: SandboxViolationStore }) => SandboxBackend;\n\tstderr: { write(message: string): boolean };\n}\n\n/** Routes to the platform adapter for the running OS; each adapter self-reports\n * `unavailable` on any other platform, so this only needs to pick the one whose\n * enforcement is actually reachable here. */\nfunction createDefaultSandboxBackend(options: { violationStore: SandboxViolationStore }): SandboxBackend {\n\tif (process.platform === \"darwin\") return createMacosSandboxBackend(options);\n\treturn createLinuxSandboxBackend(options);\n}\n\nconst defaultDependencies: CliSandboxDependencies = {\n\tcreateBackend: createDefaultSandboxBackend,\n\tstderr: process.stderr,\n};\n\n/**\n * Start a normal CLI runtime beneath the whole-process boundary. The caller returns\n * the result directly so the outer process never continues into `main()` afterward.\n */\nexport async function launchSandboxedCli(options: {\n\tcommand: string;\n\targs: readonly string[];\n\tenvironment: NodeJS.ProcessEnv;\n\tworkspace: string;\n\tallowedHosts?: readonly string[];\n\treadOnlyPaths?: readonly string[];\n\tauthPath?: string;\n\ttoolBinaries?: readonly HostToolBinary[];\n\tskillPaths?: HostSkillPaths;\n\tdependencies?: Partial<CliSandboxDependencies>;\n}): Promise<number> {\n\tconst dependencies = { ...defaultDependencies, ...options.dependencies };\n\tconst policyResult = createSandboxPolicy({ workspace: options.workspace, allowedHosts: options.allowedHosts });\n\tif (policyResult.kind === \"invalid\") {\n\t\tdependencies.stderr.write(`Error: OS sandbox is not enforcing this agent session: ${policyResult.reason}\\n`);\n\t\treturn 1;\n\t}\n\tconst violationStore = new SandboxViolationStore();\n\tconst backend = dependencies.createBackend({ violationStore });\n\tconst supervisor = createSandboxSupervisor({ backend, policy: policyResult.policy });\n\tconst launch = buildSandboxedCliLaunch({\n\t\tworkspace: policyResult.policy.workspace,\n\t\tcommand: options.command,\n\t\targs: options.args,\n\t\tenvironment: options.environment,\n\t\tallowedHosts: options.allowedHosts,\n\t\treadOnlyPaths: options.readOnlyPaths,\n\t\tauthPath: options.authPath,\n\t\ttoolBinaries: options.toolBinaries,\n\t\tskillPaths: options.skillPaths,\n\t});\n\ttry {\n\t\treturn await supervisor.launch(launch);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : \"Failed to start OS sandbox.\";\n\t\tdependencies.stderr.write(`Error: ${message}\\n`);\n\t\treturn 1;\n\t} finally {\n\t\tfor (const violation of violationStore.list()) {\n\t\t\tdependencies.stderr.write(\n\t\t\t\t`Sandbox violation (${violation.kind}): ${violation.command} — ${violation.detail}\\n`,\n\t\t\t);\n\t\t}\n\t\tawait supervisor.close();\n\t}\n}\n"]}
@@ -34,14 +34,26 @@ export interface LoadSkillsFromDirOptions {
34
34
  */
35
35
  export declare function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult;
36
36
  /**
37
- * Format skills for inclusion in a system prompt.
38
- * Uses XML format per Agent Skills standard.
39
- * See: https://agentskills.io/integrate-skills
40
- *
41
- * Skills with disableModelInvocation=true are excluded from the prompt
42
- * (they can only be invoked explicitly via /skill:name commands).
37
+ * Derive a `/skill:<token>` command token from a skill's frontmatter `name`, which
38
+ * `docs/skills.md` § Validation deliberately allows to contain characters a slash
39
+ * command cannot (spaces, capitals) -- lenient loading is a considered divergence
40
+ * from the Agent Skills standard, not a gap to close, so the skill still loads and
41
+ * only its command token changes here. Identity for an already command-safe name
42
+ * (`^[a-z0-9-]+$` with no leading/trailing/consecutive hyphen, matching
43
+ * `validateName`'s own rule), so this is a no-op for the common case.
44
+ */
45
+ export declare function slugifySkillCommandName(name: string): string;
46
+ /**
47
+ * Initial catalog budget (ADR 0021). Measured against a real 115-skill library: name
48
+ * and full description cost 6,742 tokens against 128 tokens of prefix headroom, so the
49
+ * catalog carries names only and stops at this budget rather than growing with the
50
+ * user's library size. SKILL.8 re-measures this against the enforced production
51
+ * prefix and finalizes it -- matching `ENFORCED_PRODUCTION_PREFIX_BUDGET`'s own
52
+ * precedent of "fixed by measurement, not assumed" -- so this starting value is not
53
+ * final.
43
54
  */
44
- export declare function formatSkillsForPrompt(skills: Skill[]): string;
55
+ export declare const SKILL_CATALOG_PREFIX_BUDGET_TOKENS = 600;
56
+ export declare function formatSkillsForPrompt(skills: Skill[], budgetTokens?: number): string;
45
57
  export interface LoadSkillsOptions {
46
58
  /** Working directory for project-local skills. */
47
59
  cwd: string;
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/core/skills.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA2D9E,MAAM,WAAW,gBAAgB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,KAAK;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,UAAU,CAAC;IACvB,sBAAsB,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,WAAW,EAAE,kBAAkB,EAAE,CAAC;CAClC;AA2CD,MAAM,WAAW,wBAAwB;IACxC,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;CACf;AA0BD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,gBAAgB,CAGrF;AA4JD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,CA0B7D;AAWD,MAAM,WAAW,iBAAiB;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0CAA0C;IAC1C,eAAe,EAAE,OAAO,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAoGvE","sourcesContent":["import { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore from \"ignore\";\nimport { basename, dirname, join, relative, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME, getAgentDir } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { canonicalizePath, resolvePath } from \"../utils/paths.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/** Max name length per spec */\nconst MAX_NAME_LENGTH = 64;\n\n/** Max description length per spec */\nconst MAX_DESCRIPTION_LENGTH = 1024;\n\nconst IGNORE_FILE_NAMES = [\".gitignore\", \".ignore\", \".fdignore\"];\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\nfunction toPosixPath(p: string): string {\n\treturn p.split(sep).join(\"/\");\n}\n\nfunction prefixIgnorePattern(line: string, prefix: string): string | null {\n\tconst trimmed = line.trim();\n\tif (!trimmed) return null;\n\tif (trimmed.startsWith(\"#\") && !trimmed.startsWith(\"\\\\#\")) return null;\n\n\tlet pattern = line;\n\tlet negated = false;\n\n\tif (pattern.startsWith(\"!\")) {\n\t\tnegated = true;\n\t\tpattern = pattern.slice(1);\n\t} else if (pattern.startsWith(\"\\\\!\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tif (pattern.startsWith(\"/\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tconst prefixed = prefix ? `${prefix}${pattern}` : pattern;\n\treturn negated ? `!${prefixed}` : prefixed;\n}\n\nfunction addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {\n\tconst relativeDir = relative(rootDir, dir);\n\tconst prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : \"\";\n\n\tfor (const filename of IGNORE_FILE_NAMES) {\n\t\tconst ignorePath = join(dir, filename);\n\t\tif (!existsSync(ignorePath)) continue;\n\t\ttry {\n\t\t\tconst content = readFileSync(ignorePath, \"utf-8\");\n\t\t\tconst patterns = content\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.map((line) => prefixIgnorePattern(line, prefix))\n\t\t\t\t.filter((line): line is string => Boolean(line));\n\t\t\tif (patterns.length > 0) {\n\t\t\t\tig.add(patterns);\n\t\t\t}\n\t\t} catch {}\n\t}\n}\n\nexport interface SkillFrontmatter {\n\tname?: string;\n\tdescription?: string;\n\t\"disable-model-invocation\"?: boolean;\n\t[key: string]: unknown;\n}\n\nexport interface Skill {\n\tname: string;\n\tdescription: string;\n\tfilePath: string;\n\tbaseDir: string;\n\tsourceInfo: SourceInfo;\n\tdisableModelInvocation: boolean;\n}\n\nexport interface LoadSkillsResult {\n\tskills: Skill[];\n\tdiagnostics: ResourceDiagnostic[];\n}\n\n/**\n * Validate skill name per Agent Skills spec.\n * Returns array of validation error messages (empty if valid).\n */\nfunction validateName(name: string): string[] {\n\tconst errors: string[] = [];\n\n\tif (name.length > MAX_NAME_LENGTH) {\n\t\terrors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);\n\t}\n\n\tif (!/^[a-z0-9-]+$/.test(name)) {\n\t\terrors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);\n\t}\n\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) {\n\t\terrors.push(`name must not start or end with a hyphen`);\n\t}\n\n\tif (name.includes(\"--\")) {\n\t\terrors.push(`name must not contain consecutive hyphens`);\n\t}\n\n\treturn errors;\n}\n\n/**\n * Validate description per Agent Skills spec.\n */\nfunction validateDescription(description: string | undefined): string[] {\n\tconst errors: string[] = [];\n\n\tif (!description || description.trim() === \"\") {\n\t\terrors.push(\"description is required\");\n\t} else if (description.length > MAX_DESCRIPTION_LENGTH) {\n\t\terrors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);\n\t}\n\n\treturn errors;\n}\n\nexport interface LoadSkillsFromDirOptions {\n\t/** Directory to scan for skills */\n\tdir: string;\n\t/** Source identifier for these skills */\n\tsource: string;\n}\n\nfunction createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {\n\tswitch (source) {\n\t\tcase \"user\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"project\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"path\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tdefault:\n\t\t\treturn createSyntheticSourceInfo(filePath, { source, baseDir });\n\t}\n}\n\n/**\n * Load skills from a directory.\n *\n * Discovery rules:\n * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further\n * - otherwise, load direct .md children in the root\n * - recurse into subdirectories to find SKILL.md\n */\nexport function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {\n\tconst { dir, source } = options;\n\treturn loadSkillsFromDirInternal(dir, source, true);\n}\n\nfunction loadSkillsFromDirInternal(\n\tdir: string,\n\tsource: string,\n\tincludeRootFiles: boolean,\n\tignoreMatcher?: IgnoreMatcher,\n\trootDir?: string,\n): LoadSkillsResult {\n\tconst skills: Skill[] = [];\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn { skills, diagnostics };\n\t}\n\n\tconst root = rootDir ?? dir;\n\tconst ig = ignoreMatcher ?? ignore();\n\taddIgnoreRules(ig, dir, root);\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name !== \"SKILL.md\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tisFile = statSync(fullPath).isFile();\n\t\t\t\t} catch {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tif (!isFile || ig.ignores(relPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t\treturn { skills, diagnostics };\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name.startsWith(\".\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip node_modules to avoid scanning dependencies\n\t\t\tif (entry.name === \"node_modules\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a directory and follow them\n\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisDirectory = stats.isDirectory();\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tconst ignorePath = isDirectory ? `${relPath}/` : relPath;\n\t\t\tif (ig.ignores(ignorePath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (isDirectory) {\n\t\t\t\tconst subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root);\n\t\t\t\tskills.push(...subResult.skills);\n\t\t\t\tdiagnostics.push(...subResult.diagnostics);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!isFile || !includeRootFiles || !entry.name.endsWith(\".md\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t}\n\t} catch {}\n\n\treturn { skills, diagnostics };\n}\n\nfunction loadSkillFromFile(\n\tfilePath: string,\n\tsource: string,\n): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);\n\t\tconst skillDir = dirname(filePath);\n\t\tconst parentDirName = basename(skillDir);\n\n\t\t// Validate description\n\t\tconst descErrors = validateDescription(frontmatter.description);\n\t\tfor (const error of descErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Use name from frontmatter, or fall back to parent directory name\n\t\tconst name = frontmatter.name || parentDirName;\n\n\t\t// Validate name\n\t\tconst nameErrors = validateName(name);\n\t\tfor (const error of nameErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Still load the skill even with warnings (unless description is completely missing)\n\t\tif (!frontmatter.description || frontmatter.description.trim() === \"\") {\n\t\t\treturn { skill: null, diagnostics };\n\t\t}\n\n\t\treturn {\n\t\t\tskill: {\n\t\t\t\tname,\n\t\t\t\tdescription: frontmatter.description,\n\t\t\t\tfilePath,\n\t\t\t\tbaseDir: skillDir,\n\t\t\t\tsourceInfo: createSkillSourceInfo(filePath, skillDir, source),\n\t\t\t\tdisableModelInvocation: frontmatter[\"disable-model-invocation\"] === true,\n\t\t\t},\n\t\t\tdiagnostics,\n\t\t};\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : \"failed to parse skill file\";\n\t\tdiagnostics.push({ type: \"warning\", message, path: filePath });\n\t\treturn { skill: null, diagnostics };\n\t}\n}\n\n/**\n * Format skills for inclusion in a system prompt.\n * Uses XML format per Agent Skills standard.\n * See: https://agentskills.io/integrate-skills\n *\n * Skills with disableModelInvocation=true are excluded from the prompt\n * (they can only be invoked explicitly via /skill:name commands).\n */\nexport function formatSkillsForPrompt(skills: Skill[]): string {\n\tconst visibleSkills = skills.filter((s) => !s.disableModelInvocation);\n\n\tif (visibleSkills.length === 0) {\n\t\treturn \"\";\n\t}\n\n\tconst lines = [\n\t\t\"\\n\\nThe following skills provide specialized instructions for specific tasks.\",\n\t\t\"Use the read tool to load a skill's file when the task matches its description.\",\n\t\t\"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\",\n\t\t\"\",\n\t\t\"<available_skills>\",\n\t];\n\n\tfor (const skill of visibleSkills) {\n\t\tlines.push(\" <skill>\");\n\t\tlines.push(` <name>${escapeXml(skill.name)}</name>`);\n\t\tlines.push(` <description>${escapeXml(skill.description)}</description>`);\n\t\tlines.push(` <location>${escapeXml(skill.filePath)}</location>`);\n\t\tlines.push(\" </skill>\");\n\t}\n\n\tlines.push(\"</available_skills>\");\n\n\treturn lines.join(\"\\n\");\n}\n\nfunction escapeXml(str: string): string {\n\treturn str\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&apos;\");\n}\n\nexport interface LoadSkillsOptions {\n\t/** Working directory for project-local skills. */\n\tcwd: string;\n\t/** Agent config directory for global skills. */\n\tagentDir: string;\n\t/** Explicit skill paths (files or directories) */\n\tskillPaths: string[];\n\t/** Include default skills directories. */\n\tincludeDefaults: boolean;\n}\n\n/**\n * Load skills from all configured locations.\n * Returns skills and any validation diagnostics.\n */\nexport function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {\n\tconst { agentDir, skillPaths, includeDefaults } = options;\n\n\t// Resolve agentDir - if not provided, use default from config\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());\n\n\tconst skillMap = new Map<string, Skill>();\n\tconst realPathSet = new Set<string>();\n\tconst allDiagnostics: ResourceDiagnostic[] = [];\n\tconst collisionDiagnostics: ResourceDiagnostic[] = [];\n\n\tfunction addSkills(result: LoadSkillsResult) {\n\t\tallDiagnostics.push(...result.diagnostics);\n\t\tfor (const skill of result.skills) {\n\t\t\t// Resolve symlinks to detect duplicate files\n\t\t\tconst realPath = canonicalizePath(skill.filePath);\n\n\t\t\t// Skip silently if we've already loaded this exact file (via symlink)\n\t\t\tif (realPathSet.has(realPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst existing = skillMap.get(skill.name);\n\t\t\tif (existing) {\n\t\t\t\tcollisionDiagnostics.push({\n\t\t\t\t\ttype: \"collision\",\n\t\t\t\t\tmessage: `name \"${skill.name}\" collision`,\n\t\t\t\t\tpath: skill.filePath,\n\t\t\t\t\tcollision: {\n\t\t\t\t\t\tresourceType: \"skill\",\n\t\t\t\t\t\tname: skill.name,\n\t\t\t\t\t\twinnerPath: existing.filePath,\n\t\t\t\t\t\tloserPath: skill.filePath,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tskillMap.set(skill.name, skill);\n\t\t\t\trealPathSet.add(realPath);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (includeDefaults) {\n\t\taddSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, \"skills\"), \"user\", true));\n\t\taddSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\"), \"project\", true));\n\t}\n\n\tconst userSkillsDir = join(resolvedAgentDir, \"skills\");\n\tconst projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSource = (resolvedPath: string): \"user\" | \"project\" | \"path\" => {\n\t\tif (!includeDefaults) {\n\t\t\tif (isUnderPath(resolvedPath, userSkillsDir)) return \"user\";\n\t\t\tif (isUnderPath(resolvedPath, projectSkillsDir)) return \"project\";\n\t\t}\n\t\treturn \"path\";\n\t};\n\n\tfor (const rawPath of skillPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path does not exist\", path: resolvedPath });\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tconst source = getSource(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\taddSkills(loadSkillsFromDirInternal(resolvedPath, source, true));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst result = loadSkillFromFile(resolvedPath, source);\n\t\t\t\tif (result.skill) {\n\t\t\t\t\taddSkills({ skills: [result.skill], diagnostics: result.diagnostics });\n\t\t\t\t} else {\n\t\t\t\t\tallDiagnostics.push(...result.diagnostics);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path is not a markdown file\", path: resolvedPath });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read skill path\";\n\t\t\tallDiagnostics.push({ type: \"warning\", message, path: resolvedPath });\n\t\t}\n\t}\n\n\treturn {\n\t\tskills: Array.from(skillMap.values()),\n\t\tdiagnostics: [...allDiagnostics, ...collisionDiagnostics],\n\t};\n}\n"]}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/core/skills.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA2D9E,MAAM,WAAW,gBAAgB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,KAAK;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,UAAU,CAAC;IACvB,sBAAsB,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,WAAW,EAAE,kBAAkB,EAAE,CAAC;CAClC;AA2CD,MAAM,WAAW,wBAAwB;IACxC,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;CACf;AA0BD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,gBAAgB,CAGrF;AA4JD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO5D;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kCAAkC,MAAM,CAAC;AA0BtD,wBAAgB,qBAAqB,CACpC,MAAM,EAAE,KAAK,EAAE,EACf,YAAY,GAAE,MAA2C,GACvD,MAAM,CA0CR;AAWD,MAAM,WAAW,iBAAiB;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0CAA0C;IAC1C,eAAe,EAAE,OAAO,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAoGvE","sourcesContent":["import { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore from \"ignore\";\nimport { basename, dirname, join, relative, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME, getAgentDir } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { canonicalizePath, resolvePath } from \"../utils/paths.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/** Max name length per spec */\nconst MAX_NAME_LENGTH = 64;\n\n/** Max description length per spec */\nconst MAX_DESCRIPTION_LENGTH = 1024;\n\nconst IGNORE_FILE_NAMES = [\".gitignore\", \".ignore\", \".fdignore\"];\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\nfunction toPosixPath(p: string): string {\n\treturn p.split(sep).join(\"/\");\n}\n\nfunction prefixIgnorePattern(line: string, prefix: string): string | null {\n\tconst trimmed = line.trim();\n\tif (!trimmed) return null;\n\tif (trimmed.startsWith(\"#\") && !trimmed.startsWith(\"\\\\#\")) return null;\n\n\tlet pattern = line;\n\tlet negated = false;\n\n\tif (pattern.startsWith(\"!\")) {\n\t\tnegated = true;\n\t\tpattern = pattern.slice(1);\n\t} else if (pattern.startsWith(\"\\\\!\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tif (pattern.startsWith(\"/\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tconst prefixed = prefix ? `${prefix}${pattern}` : pattern;\n\treturn negated ? `!${prefixed}` : prefixed;\n}\n\nfunction addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {\n\tconst relativeDir = relative(rootDir, dir);\n\tconst prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : \"\";\n\n\tfor (const filename of IGNORE_FILE_NAMES) {\n\t\tconst ignorePath = join(dir, filename);\n\t\tif (!existsSync(ignorePath)) continue;\n\t\ttry {\n\t\t\tconst content = readFileSync(ignorePath, \"utf-8\");\n\t\t\tconst patterns = content\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.map((line) => prefixIgnorePattern(line, prefix))\n\t\t\t\t.filter((line): line is string => Boolean(line));\n\t\t\tif (patterns.length > 0) {\n\t\t\t\tig.add(patterns);\n\t\t\t}\n\t\t} catch {}\n\t}\n}\n\nexport interface SkillFrontmatter {\n\tname?: string;\n\tdescription?: string;\n\t\"disable-model-invocation\"?: boolean;\n\t[key: string]: unknown;\n}\n\nexport interface Skill {\n\tname: string;\n\tdescription: string;\n\tfilePath: string;\n\tbaseDir: string;\n\tsourceInfo: SourceInfo;\n\tdisableModelInvocation: boolean;\n}\n\nexport interface LoadSkillsResult {\n\tskills: Skill[];\n\tdiagnostics: ResourceDiagnostic[];\n}\n\n/**\n * Validate skill name per Agent Skills spec.\n * Returns array of validation error messages (empty if valid).\n */\nfunction validateName(name: string): string[] {\n\tconst errors: string[] = [];\n\n\tif (name.length > MAX_NAME_LENGTH) {\n\t\terrors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);\n\t}\n\n\tif (!/^[a-z0-9-]+$/.test(name)) {\n\t\terrors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);\n\t}\n\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) {\n\t\terrors.push(`name must not start or end with a hyphen`);\n\t}\n\n\tif (name.includes(\"--\")) {\n\t\terrors.push(`name must not contain consecutive hyphens`);\n\t}\n\n\treturn errors;\n}\n\n/**\n * Validate description per Agent Skills spec.\n */\nfunction validateDescription(description: string | undefined): string[] {\n\tconst errors: string[] = [];\n\n\tif (!description || description.trim() === \"\") {\n\t\terrors.push(\"description is required\");\n\t} else if (description.length > MAX_DESCRIPTION_LENGTH) {\n\t\terrors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);\n\t}\n\n\treturn errors;\n}\n\nexport interface LoadSkillsFromDirOptions {\n\t/** Directory to scan for skills */\n\tdir: string;\n\t/** Source identifier for these skills */\n\tsource: string;\n}\n\nfunction createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {\n\tswitch (source) {\n\t\tcase \"user\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"project\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"path\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tdefault:\n\t\t\treturn createSyntheticSourceInfo(filePath, { source, baseDir });\n\t}\n}\n\n/**\n * Load skills from a directory.\n *\n * Discovery rules:\n * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further\n * - otherwise, load direct .md children in the root\n * - recurse into subdirectories to find SKILL.md\n */\nexport function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {\n\tconst { dir, source } = options;\n\treturn loadSkillsFromDirInternal(dir, source, true);\n}\n\nfunction loadSkillsFromDirInternal(\n\tdir: string,\n\tsource: string,\n\tincludeRootFiles: boolean,\n\tignoreMatcher?: IgnoreMatcher,\n\trootDir?: string,\n): LoadSkillsResult {\n\tconst skills: Skill[] = [];\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn { skills, diagnostics };\n\t}\n\n\tconst root = rootDir ?? dir;\n\tconst ig = ignoreMatcher ?? ignore();\n\taddIgnoreRules(ig, dir, root);\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name !== \"SKILL.md\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tisFile = statSync(fullPath).isFile();\n\t\t\t\t} catch {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tif (!isFile || ig.ignores(relPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t\treturn { skills, diagnostics };\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name.startsWith(\".\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip node_modules to avoid scanning dependencies\n\t\t\tif (entry.name === \"node_modules\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a directory and follow them\n\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisDirectory = stats.isDirectory();\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tconst ignorePath = isDirectory ? `${relPath}/` : relPath;\n\t\t\tif (ig.ignores(ignorePath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (isDirectory) {\n\t\t\t\tconst subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root);\n\t\t\t\tskills.push(...subResult.skills);\n\t\t\t\tdiagnostics.push(...subResult.diagnostics);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!isFile || !includeRootFiles || !entry.name.endsWith(\".md\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t}\n\t} catch {}\n\n\treturn { skills, diagnostics };\n}\n\nfunction loadSkillFromFile(\n\tfilePath: string,\n\tsource: string,\n): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);\n\t\tconst skillDir = dirname(filePath);\n\t\tconst parentDirName = basename(skillDir);\n\n\t\t// Validate description\n\t\tconst descErrors = validateDescription(frontmatter.description);\n\t\tfor (const error of descErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Use name from frontmatter, or fall back to parent directory name\n\t\tconst name = frontmatter.name || parentDirName;\n\n\t\t// Validate name\n\t\tconst nameErrors = validateName(name);\n\t\tfor (const error of nameErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Still load the skill even with warnings (unless description is completely missing)\n\t\tif (!frontmatter.description || frontmatter.description.trim() === \"\") {\n\t\t\treturn { skill: null, diagnostics };\n\t\t}\n\n\t\treturn {\n\t\t\tskill: {\n\t\t\t\tname,\n\t\t\t\tdescription: frontmatter.description,\n\t\t\t\tfilePath,\n\t\t\t\tbaseDir: skillDir,\n\t\t\t\tsourceInfo: createSkillSourceInfo(filePath, skillDir, source),\n\t\t\t\tdisableModelInvocation: frontmatter[\"disable-model-invocation\"] === true,\n\t\t\t},\n\t\t\tdiagnostics,\n\t\t};\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : \"failed to parse skill file\";\n\t\tdiagnostics.push({ type: \"warning\", message, path: filePath });\n\t\treturn { skill: null, diagnostics };\n\t}\n}\n\n/**\n * Derive a `/skill:<token>` command token from a skill's frontmatter `name`, which\n * `docs/skills.md` § Validation deliberately allows to contain characters a slash\n * command cannot (spaces, capitals) -- lenient loading is a considered divergence\n * from the Agent Skills standard, not a gap to close, so the skill still loads and\n * only its command token changes here. Identity for an already command-safe name\n * (`^[a-z0-9-]+$` with no leading/trailing/consecutive hyphen, matching\n * `validateName`'s own rule), so this is a no-op for the common case.\n */\nexport function slugifySkillCommandName(name: string): string {\n\tconst slug = name\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9-]+/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/^-|-$/g, \"\");\n\treturn slug || \"skill\";\n}\n\n/**\n * Initial catalog budget (ADR 0021). Measured against a real 115-skill library: name\n * and full description cost 6,742 tokens against 128 tokens of prefix headroom, so the\n * catalog carries names only and stops at this budget rather than growing with the\n * user's library size. SKILL.8 re-measures this against the enforced production\n * prefix and finalizes it -- matching `ENFORCED_PRODUCTION_PREFIX_BUDGET`'s own\n * precedent of \"fixed by measurement, not assumed\" -- so this starting value is not\n * final.\n */\nexport const SKILL_CATALOG_PREFIX_BUDGET_TOKENS = 600;\n\n/** Same chars/4 heuristic `estimateTokens` uses for messages (compaction.ts), applied to plain text. */\nfunction estimateTextTokens(text: string): number {\n\treturn Math.ceil(text.length / 4);\n}\n\n/**\n * Format a budget-bounded skill catalog for inclusion in a system prompt (ADR 0021).\n *\n * Carries names only, alphabetically ordered, added until `budgetTokens` is spent --\n * unlike every other prefix contributor, the catalog is sized by the user's skill\n * library rather than by the product, so a token budget bounds it rather than a\n * fixed schema. Descriptions resolve on demand through the `skill_search` tool\n * (`core/tools/skill-search.ts`, SKILL.7); content still loads through `read`,\n * unchanged. Once one name fails to fit, every remaining name (in sorted order) is\n * counted as omitted rather than skipping ahead to a shorter one that might fit --\n * the catalog is always a clean prefix of the sorted list, not a best-fit selection.\n *\n * Skills with `disableModelInvocation=true` are excluded (invocable only via\n * `/skill:<token>`, per `slugifySkillCommandName`).\n */\nfunction omittedCommentLine(omittedCount: number): string {\n\treturn ` <!-- ${omittedCount} more skill${omittedCount === 1 ? \"\" : \"s\"} omitted for space; call skill_search to find them -->`;\n}\n\nexport function formatSkillsForPrompt(\n\tskills: Skill[],\n\tbudgetTokens: number = SKILL_CATALOG_PREFIX_BUDGET_TOKENS,\n): string {\n\tconst visibleSkills = skills.filter((s) => !s.disableModelInvocation);\n\tif (visibleSkills.length === 0) {\n\t\treturn \"\";\n\t}\n\n\tconst sortedNames = visibleSkills.map((s) => s.name).sort((a, b) => a.localeCompare(b));\n\tconst header = [\n\t\t\"\",\n\t\t\"\",\n\t\t\"The following skill names are available. Each provides specialized instructions for a specific task.\",\n\t\t\"Call skill_search with a name or a query to see a skill's description, then use the read tool to load its file when the task matches.\",\n\t\t\"\",\n\t\t\"<available_skills>\",\n\t].join(\"\\n\");\n\tconst footer = \"</available_skills>\";\n\tconst nameLines = sortedNames.map((name) => ` <name>${escapeXml(name)}</name>`);\n\n\tconst fullText = [header, ...nameLines, footer].join(\"\\n\");\n\tif (estimateTextTokens(fullText) <= budgetTokens) {\n\t\treturn fullText;\n\t}\n\n\t// The full catalog doesn't fit: at least one name will be omitted, so the\n\t// returned text must also carry the omitted-count comment line, and that line's\n\t// own cost has to be reserved up front. Checking each candidate only against\n\t// header+names+footer (no comment line) would let the comment line's own cost\n\t// push the final assembled text over budgetTokens once it's appended after the\n\t// loop -- reserving worst-case (every name omitted, the widest possible count)\n\t// keeps the reservation a safe upper bound regardless of the actual cutoff.\n\tconst availableForNames = budgetTokens - estimateTextTokens(omittedCommentLine(sortedNames.length));\n\n\tconst includedLines: string[] = [];\n\tlet cutoffIndex = 0;\n\tfor (; cutoffIndex < sortedNames.length; cutoffIndex++) {\n\t\tconst candidateText = [header, ...includedLines, nameLines[cutoffIndex], footer].join(\"\\n\");\n\t\tif (estimateTextTokens(candidateText) > availableForNames) break;\n\t\tincludedLines.push(nameLines[cutoffIndex]);\n\t}\n\n\tconst omittedCount = sortedNames.length - cutoffIndex;\n\treturn [header, ...includedLines, omittedCommentLine(omittedCount), footer].join(\"\\n\");\n}\n\nfunction escapeXml(str: string): string {\n\treturn str\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&apos;\");\n}\n\nexport interface LoadSkillsOptions {\n\t/** Working directory for project-local skills. */\n\tcwd: string;\n\t/** Agent config directory for global skills. */\n\tagentDir: string;\n\t/** Explicit skill paths (files or directories) */\n\tskillPaths: string[];\n\t/** Include default skills directories. */\n\tincludeDefaults: boolean;\n}\n\n/**\n * Load skills from all configured locations.\n * Returns skills and any validation diagnostics.\n */\nexport function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {\n\tconst { agentDir, skillPaths, includeDefaults } = options;\n\n\t// Resolve agentDir - if not provided, use default from config\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());\n\n\tconst skillMap = new Map<string, Skill>();\n\tconst realPathSet = new Set<string>();\n\tconst allDiagnostics: ResourceDiagnostic[] = [];\n\tconst collisionDiagnostics: ResourceDiagnostic[] = [];\n\n\tfunction addSkills(result: LoadSkillsResult) {\n\t\tallDiagnostics.push(...result.diagnostics);\n\t\tfor (const skill of result.skills) {\n\t\t\t// Resolve symlinks to detect duplicate files\n\t\t\tconst realPath = canonicalizePath(skill.filePath);\n\n\t\t\t// Skip silently if we've already loaded this exact file (via symlink)\n\t\t\tif (realPathSet.has(realPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst existing = skillMap.get(skill.name);\n\t\t\tif (existing) {\n\t\t\t\tcollisionDiagnostics.push({\n\t\t\t\t\ttype: \"collision\",\n\t\t\t\t\tmessage: `name \"${skill.name}\" collision`,\n\t\t\t\t\tpath: skill.filePath,\n\t\t\t\t\tcollision: {\n\t\t\t\t\t\tresourceType: \"skill\",\n\t\t\t\t\t\tname: skill.name,\n\t\t\t\t\t\twinnerPath: existing.filePath,\n\t\t\t\t\t\tloserPath: skill.filePath,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tskillMap.set(skill.name, skill);\n\t\t\t\trealPathSet.add(realPath);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (includeDefaults) {\n\t\taddSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, \"skills\"), \"user\", true));\n\t\taddSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\"), \"project\", true));\n\t}\n\n\tconst userSkillsDir = join(resolvedAgentDir, \"skills\");\n\tconst projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSource = (resolvedPath: string): \"user\" | \"project\" | \"path\" => {\n\t\tif (!includeDefaults) {\n\t\t\tif (isUnderPath(resolvedPath, userSkillsDir)) return \"user\";\n\t\t\tif (isUnderPath(resolvedPath, projectSkillsDir)) return \"project\";\n\t\t}\n\t\treturn \"path\";\n\t};\n\n\tfor (const rawPath of skillPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path does not exist\", path: resolvedPath });\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tconst source = getSource(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\taddSkills(loadSkillsFromDirInternal(resolvedPath, source, true));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst result = loadSkillFromFile(resolvedPath, source);\n\t\t\t\tif (result.skill) {\n\t\t\t\t\taddSkills({ skills: [result.skill], diagnostics: result.diagnostics });\n\t\t\t\t} else {\n\t\t\t\t\tallDiagnostics.push(...result.diagnostics);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path is not a markdown file\", path: resolvedPath });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read skill path\";\n\t\t\tallDiagnostics.push({ type: \"warning\", message, path: resolvedPath });\n\t\t}\n\t}\n\n\treturn {\n\t\tskills: Array.from(skillMap.values()),\n\t\tdiagnostics: [...allDiagnostics, ...collisionDiagnostics],\n\t};\n}\n"]}
@@ -247,34 +247,92 @@ function loadSkillFromFile(filePath, source) {
247
247
  }
248
248
  }
249
249
  /**
250
- * Format skills for inclusion in a system prompt.
251
- * Uses XML format per Agent Skills standard.
252
- * See: https://agentskills.io/integrate-skills
250
+ * Derive a `/skill:<token>` command token from a skill's frontmatter `name`, which
251
+ * `docs/skills.md` § Validation deliberately allows to contain characters a slash
252
+ * command cannot (spaces, capitals) -- lenient loading is a considered divergence
253
+ * from the Agent Skills standard, not a gap to close, so the skill still loads and
254
+ * only its command token changes here. Identity for an already command-safe name
255
+ * (`^[a-z0-9-]+$` with no leading/trailing/consecutive hyphen, matching
256
+ * `validateName`'s own rule), so this is a no-op for the common case.
257
+ */
258
+ export function slugifySkillCommandName(name) {
259
+ const slug = name
260
+ .toLowerCase()
261
+ .replace(/[^a-z0-9-]+/g, "-")
262
+ .replace(/-+/g, "-")
263
+ .replace(/^-|-$/g, "");
264
+ return slug || "skill";
265
+ }
266
+ /**
267
+ * Initial catalog budget (ADR 0021). Measured against a real 115-skill library: name
268
+ * and full description cost 6,742 tokens against 128 tokens of prefix headroom, so the
269
+ * catalog carries names only and stops at this budget rather than growing with the
270
+ * user's library size. SKILL.8 re-measures this against the enforced production
271
+ * prefix and finalizes it -- matching `ENFORCED_PRODUCTION_PREFIX_BUDGET`'s own
272
+ * precedent of "fixed by measurement, not assumed" -- so this starting value is not
273
+ * final.
274
+ */
275
+ export const SKILL_CATALOG_PREFIX_BUDGET_TOKENS = 600;
276
+ /** Same chars/4 heuristic `estimateTokens` uses for messages (compaction.ts), applied to plain text. */
277
+ function estimateTextTokens(text) {
278
+ return Math.ceil(text.length / 4);
279
+ }
280
+ /**
281
+ * Format a budget-bounded skill catalog for inclusion in a system prompt (ADR 0021).
253
282
  *
254
- * Skills with disableModelInvocation=true are excluded from the prompt
255
- * (they can only be invoked explicitly via /skill:name commands).
283
+ * Carries names only, alphabetically ordered, added until `budgetTokens` is spent --
284
+ * unlike every other prefix contributor, the catalog is sized by the user's skill
285
+ * library rather than by the product, so a token budget bounds it rather than a
286
+ * fixed schema. Descriptions resolve on demand through the `skill_search` tool
287
+ * (`core/tools/skill-search.ts`, SKILL.7); content still loads through `read`,
288
+ * unchanged. Once one name fails to fit, every remaining name (in sorted order) is
289
+ * counted as omitted rather than skipping ahead to a shorter one that might fit --
290
+ * the catalog is always a clean prefix of the sorted list, not a best-fit selection.
291
+ *
292
+ * Skills with `disableModelInvocation=true` are excluded (invocable only via
293
+ * `/skill:<token>`, per `slugifySkillCommandName`).
256
294
  */
257
- export function formatSkillsForPrompt(skills) {
295
+ function omittedCommentLine(omittedCount) {
296
+ return ` <!-- ${omittedCount} more skill${omittedCount === 1 ? "" : "s"} omitted for space; call skill_search to find them -->`;
297
+ }
298
+ export function formatSkillsForPrompt(skills, budgetTokens = SKILL_CATALOG_PREFIX_BUDGET_TOKENS) {
258
299
  const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
259
300
  if (visibleSkills.length === 0) {
260
301
  return "";
261
302
  }
262
- const lines = [
263
- "\n\nThe following skills provide specialized instructions for specific tasks.",
264
- "Use the read tool to load a skill's file when the task matches its description.",
265
- "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
303
+ const sortedNames = visibleSkills.map((s) => s.name).sort((a, b) => a.localeCompare(b));
304
+ const header = [
305
+ "",
306
+ "",
307
+ "The following skill names are available. Each provides specialized instructions for a specific task.",
308
+ "Call skill_search with a name or a query to see a skill's description, then use the read tool to load its file when the task matches.",
266
309
  "",
267
310
  "<available_skills>",
268
- ];
269
- for (const skill of visibleSkills) {
270
- lines.push(" <skill>");
271
- lines.push(` <name>${escapeXml(skill.name)}</name>`);
272
- lines.push(` <description>${escapeXml(skill.description)}</description>`);
273
- lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
274
- lines.push(" </skill>");
311
+ ].join("\n");
312
+ const footer = "</available_skills>";
313
+ const nameLines = sortedNames.map((name) => ` <name>${escapeXml(name)}</name>`);
314
+ const fullText = [header, ...nameLines, footer].join("\n");
315
+ if (estimateTextTokens(fullText) <= budgetTokens) {
316
+ return fullText;
317
+ }
318
+ // The full catalog doesn't fit: at least one name will be omitted, so the
319
+ // returned text must also carry the omitted-count comment line, and that line's
320
+ // own cost has to be reserved up front. Checking each candidate only against
321
+ // header+names+footer (no comment line) would let the comment line's own cost
322
+ // push the final assembled text over budgetTokens once it's appended after the
323
+ // loop -- reserving worst-case (every name omitted, the widest possible count)
324
+ // keeps the reservation a safe upper bound regardless of the actual cutoff.
325
+ const availableForNames = budgetTokens - estimateTextTokens(omittedCommentLine(sortedNames.length));
326
+ const includedLines = [];
327
+ let cutoffIndex = 0;
328
+ for (; cutoffIndex < sortedNames.length; cutoffIndex++) {
329
+ const candidateText = [header, ...includedLines, nameLines[cutoffIndex], footer].join("\n");
330
+ if (estimateTextTokens(candidateText) > availableForNames)
331
+ break;
332
+ includedLines.push(nameLines[cutoffIndex]);
275
333
  }
276
- lines.push("</available_skills>");
277
- return lines.join("\n");
334
+ const omittedCount = sortedNames.length - cutoffIndex;
335
+ return [header, ...includedLines, omittedCommentLine(omittedCount), footer].join("\n");
278
336
  }
279
337
  function escapeXml(str) {
280
338
  return str