context-mode 1.0.88 → 1.0.90

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 (132) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +184 -60
  6. package/build/adapters/antigravity/index.d.ts +3 -5
  7. package/build/adapters/antigravity/index.js +7 -35
  8. package/build/adapters/base.d.ts +27 -0
  9. package/build/adapters/base.js +59 -0
  10. package/build/adapters/claude-code/index.d.ts +9 -25
  11. package/build/adapters/claude-code/index.js +27 -141
  12. package/build/adapters/claude-code-base.d.ts +49 -0
  13. package/build/adapters/claude-code-base.js +113 -0
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +21 -14
  16. package/build/adapters/codex/hooks.js +22 -15
  17. package/build/adapters/codex/index.d.ts +6 -10
  18. package/build/adapters/codex/index.js +13 -43
  19. package/build/adapters/copilot-base.d.ts +78 -0
  20. package/build/adapters/copilot-base.js +281 -0
  21. package/build/adapters/cursor/index.d.ts +3 -5
  22. package/build/adapters/cursor/index.js +6 -34
  23. package/build/adapters/detect.d.ts +7 -0
  24. package/build/adapters/detect.js +57 -56
  25. package/build/adapters/gemini-cli/index.d.ts +3 -5
  26. package/build/adapters/gemini-cli/index.js +7 -35
  27. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  28. package/build/adapters/jetbrains-copilot/config.js +8 -0
  29. package/build/adapters/jetbrains-copilot/hooks.d.ts +51 -0
  30. package/build/adapters/jetbrains-copilot/hooks.js +82 -0
  31. package/build/adapters/jetbrains-copilot/index.d.ts +24 -0
  32. package/build/adapters/jetbrains-copilot/index.js +119 -0
  33. package/build/adapters/kiro/hooks.d.ts +14 -0
  34. package/build/adapters/kiro/hooks.js +23 -0
  35. package/build/adapters/kiro/index.d.ts +3 -5
  36. package/build/adapters/kiro/index.js +10 -38
  37. package/build/adapters/openclaw/index.d.ts +3 -4
  38. package/build/adapters/openclaw/index.js +6 -22
  39. package/build/adapters/opencode/index.d.ts +2 -3
  40. package/build/adapters/opencode/index.js +5 -16
  41. package/build/adapters/qwen-code/index.d.ts +39 -0
  42. package/build/adapters/qwen-code/index.js +199 -0
  43. package/build/adapters/types.d.ts +1 -1
  44. package/build/adapters/vscode-copilot/index.d.ts +16 -46
  45. package/build/adapters/vscode-copilot/index.js +29 -320
  46. package/build/adapters/zed/index.d.ts +3 -5
  47. package/build/adapters/zed/index.js +7 -35
  48. package/build/cli.js +113 -47
  49. package/build/lifecycle.d.ts +23 -0
  50. package/build/lifecycle.js +54 -13
  51. package/build/opencode-plugin.d.ts +19 -7
  52. package/build/opencode-plugin.js +19 -7
  53. package/build/pi-extension.js +24 -7
  54. package/build/runtime.js +24 -9
  55. package/build/security.d.ts +17 -1
  56. package/build/security.js +40 -6
  57. package/build/server.js +129 -21
  58. package/build/session/analytics.d.ts +8 -7
  59. package/build/session/analytics.js +95 -75
  60. package/build/session/db.d.ts +10 -1
  61. package/build/session/db.js +67 -8
  62. package/build/session/extract.js +10 -2
  63. package/build/session/project-attribution.d.ts +73 -0
  64. package/build/session/project-attribution.js +231 -0
  65. package/build/store.d.ts +7 -0
  66. package/build/store.js +117 -18
  67. package/build/truncate.d.ts +6 -0
  68. package/build/truncate.js +51 -29
  69. package/build/types.d.ts +8 -0
  70. package/cli.bundle.mjs +157 -136
  71. package/configs/antigravity/GEMINI.md +31 -36
  72. package/configs/claude-code/CLAUDE.md +31 -37
  73. package/configs/codex/AGENTS.md +35 -49
  74. package/configs/cursor/context-mode.mdc +24 -25
  75. package/configs/gemini-cli/GEMINI.md +30 -36
  76. package/configs/jetbrains-copilot/copilot-instructions.md +59 -0
  77. package/configs/jetbrains-copilot/hooks.json +16 -0
  78. package/configs/jetbrains-copilot/mcp.json +8 -0
  79. package/configs/kilo/AGENTS.md +30 -36
  80. package/configs/kiro/KIRO.md +30 -36
  81. package/configs/kiro/agent.json +1 -1
  82. package/configs/openclaw/AGENTS.md +30 -36
  83. package/configs/opencode/AGENTS.md +30 -36
  84. package/configs/pi/AGENTS.md +31 -36
  85. package/configs/qwen-code/QWEN.md +63 -0
  86. package/configs/vscode-copilot/copilot-instructions.md +30 -36
  87. package/configs/zed/AGENTS.md +31 -36
  88. package/hooks/codex/posttooluse.mjs +7 -7
  89. package/hooks/codex/pretooluse.mjs +3 -3
  90. package/hooks/codex/sessionstart.mjs +2 -1
  91. package/hooks/core/formatters.mjs +24 -0
  92. package/hooks/core/routing.mjs +40 -15
  93. package/hooks/core/tool-naming.mjs +2 -0
  94. package/hooks/cursor/posttooluse.mjs +7 -7
  95. package/hooks/cursor/pretooluse.mjs +3 -3
  96. package/hooks/cursor/sessionstart.mjs +2 -1
  97. package/hooks/cursor/stop.mjs +2 -2
  98. package/hooks/ensure-deps.mjs +22 -10
  99. package/hooks/gemini-cli/aftertool.mjs +8 -8
  100. package/hooks/gemini-cli/beforetool.mjs +3 -2
  101. package/hooks/gemini-cli/precompress.mjs +2 -2
  102. package/hooks/gemini-cli/sessionstart.mjs +12 -4
  103. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  104. package/hooks/jetbrains-copilot/precompact.mjs +54 -0
  105. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  106. package/hooks/jetbrains-copilot/sessionstart.mjs +119 -0
  107. package/hooks/kiro/posttooluse.mjs +6 -7
  108. package/hooks/kiro/pretooluse.mjs +3 -2
  109. package/hooks/posttooluse.mjs +8 -8
  110. package/hooks/precompact.mjs +3 -4
  111. package/hooks/pretooluse.mjs +43 -20
  112. package/hooks/routing-block.mjs +35 -33
  113. package/hooks/session-attribution.bundle.mjs +1 -0
  114. package/hooks/session-db.bundle.mjs +27 -8
  115. package/hooks/session-extract.bundle.mjs +2 -1
  116. package/hooks/session-helpers.mjs +44 -3
  117. package/hooks/session-loaders.mjs +37 -0
  118. package/hooks/session-snapshot.bundle.mjs +14 -14
  119. package/hooks/sessionstart.mjs +5 -5
  120. package/hooks/userpromptsubmit.mjs +26 -9
  121. package/hooks/vscode-copilot/posttooluse.mjs +8 -8
  122. package/hooks/vscode-copilot/precompact.mjs +2 -2
  123. package/hooks/vscode-copilot/pretooluse.mjs +3 -2
  124. package/hooks/vscode-copilot/sessionstart.mjs +2 -2
  125. package/insight/server.mjs +262 -32
  126. package/insight/src/lib/api.ts +2 -1
  127. package/insight/src/routes/index.tsx +16 -3
  128. package/insight/src/routes/search.tsx +1 -1
  129. package/openclaw.plugin.json +1 -1
  130. package/package.json +11 -2
  131. package/server.bundle.mjs +117 -99
  132. package/skills/ctx-insight/SKILL.md +1 -1
@@ -17,6 +17,29 @@ export interface LifecycleGuardOptions {
17
17
  /** Injectable parent-alive check (for testing). Default: ppid-based check. */
18
18
  isParentAlive?: () => boolean;
19
19
  }
20
+ /** Injectable dependencies for {@link makeDefaultIsParentAlive}. */
21
+ export interface IsParentAliveDeps {
22
+ /** Read the current ppid. Default: `() => process.ppid`. */
23
+ getPpid?: () => number;
24
+ /** Read the grandparent ppid. Default: ps-based POSIX probe, NaN on Windows. */
25
+ readGrandparentPpid?: () => number;
26
+ }
27
+ /**
28
+ * Build a parent-liveness check that handles the npm-exec wrapper case (#311).
29
+ *
30
+ * A plain ppid comparison misses Claude Code sessions launched via
31
+ * `start.mjs → npm exec → context-mode server`: when Claude Code dies,
32
+ * `start.mjs` reparents to init but `npm exec` stays alive, so the server's
33
+ * direct ppid never changes. We additionally check whether the grandparent
34
+ * process has been reparented to init (PID 1). When the original grandparent
35
+ * was already 1 (daemonized startup) the check is skipped, and on Windows
36
+ * where there's no cheap `ps` equivalent we also skip — so this change is
37
+ * strictly additive to the previous behavior.
38
+ *
39
+ * Exported for unit-testing with injected readers. Production code uses
40
+ * {@link defaultIsParentAlive} (captured once at module load).
41
+ */
42
+ export declare function makeDefaultIsParentAlive(deps?: IsParentAliveDeps): () => boolean;
20
43
  /**
21
44
  * Start the lifecycle guard. Returns a cleanup function.
22
45
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
@@ -9,23 +9,64 @@
9
9
  *
10
10
  * Cross-platform: macOS, Linux, Windows.
11
11
  */
12
+ import { execFileSync } from "node:child_process";
13
+ /** Read grandparent PID via `ps -o ppid= -p $PPID`. Returns NaN on failure or Windows. */
14
+ function readGrandparentPpidImpl() {
15
+ if (process.platform === "win32")
16
+ return NaN;
17
+ const ppid = process.ppid;
18
+ if (!ppid || ppid <= 1)
19
+ return NaN;
20
+ try {
21
+ const out = execFileSync("ps", ["-o", "ppid=", "-p", String(ppid)], {
22
+ encoding: "utf-8",
23
+ timeout: 2000,
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ }).trim();
26
+ const n = parseInt(out, 10);
27
+ return Number.isFinite(n) ? n : NaN;
28
+ }
29
+ catch {
30
+ return NaN;
31
+ }
32
+ }
12
33
  /**
13
- * Default parent liveness check.
14
- * Compares current ppid against the original — if it changed (reparented to
15
- * init/launchd/systemd), parent is dead. This is more reliable than
16
- * kill(ppid, 0) which succeeds for PID 1 on all platforms.
34
+ * Build a parent-liveness check that handles the npm-exec wrapper case (#311).
35
+ *
36
+ * A plain ppid comparison misses Claude Code sessions launched via
37
+ * `start.mjs npm exec context-mode server`: when Claude Code dies,
38
+ * `start.mjs` reparents to init but `npm exec` stays alive, so the server's
39
+ * direct ppid never changes. We additionally check whether the grandparent
40
+ * process has been reparented to init (PID 1). When the original grandparent
41
+ * was already 1 (daemonized startup) the check is skipped, and on Windows
42
+ * where there's no cheap `ps` equivalent we also skip — so this change is
43
+ * strictly additive to the previous behavior.
17
44
  *
18
- * On Windows, ppid becomes 0 when parent exits.
45
+ * Exported for unit-testing with injected readers. Production code uses
46
+ * {@link defaultIsParentAlive} (captured once at module load).
19
47
  */
20
- const originalPpid = process.ppid;
21
- function defaultIsParentAlive() {
22
- const ppid = process.ppid;
23
- if (ppid !== originalPpid)
24
- return false;
25
- if (ppid === 0 || ppid === 1)
26
- return false;
27
- return true;
48
+ export function makeDefaultIsParentAlive(deps = {}) {
49
+ const getPpid = deps.getPpid ?? (() => process.ppid);
50
+ const readGp = deps.readGrandparentPpid ?? readGrandparentPpidImpl;
51
+ const originalPpid = getPpid();
52
+ const originalGrandparentPpid = readGp();
53
+ return () => {
54
+ const ppid = getPpid();
55
+ if (ppid !== originalPpid)
56
+ return false;
57
+ if (ppid === 0 || ppid === 1)
58
+ return false;
59
+ // Grandparent orphan check (#311): npm-exec wrappers stay alive past the
60
+ // session owner. If our grandparent is now PID 1 but wasn't at startup,
61
+ // the wrapping chain is orphaned and we should shut down.
62
+ if (!Number.isNaN(originalGrandparentPpid) && originalGrandparentPpid > 1) {
63
+ if (readGp() === 1)
64
+ return false;
65
+ }
66
+ return true;
67
+ };
28
68
  }
69
+ const defaultIsParentAlive = makeDefaultIsParentAlive();
29
70
  /**
30
71
  * Start the lifecycle guard. Returns a cleanup function.
31
72
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
@@ -1,12 +1,16 @@
1
1
  /**
2
- * OpenCode TypeScript plugin entry point for context-mode.
2
+ * OpenCode / KiloCode TypeScript plugin entry point for context-mode.
3
3
  *
4
4
  * Provides three hooks:
5
5
  * - tool.execute.before — Routing enforcement (deny/modify/passthrough)
6
6
  * - tool.execute.after — Session event capture
7
7
  * - experimental.session.compacting — Compaction snapshot generation
8
8
  *
9
- * Loaded by OpenCode via: import("context-mode/plugin").ContextModePlugin(ctx)
9
+ * KiloCode loads this via: import("context-mode") → expects default export
10
+ * with shape { server: (input) => Promise<Hooks> } (PluginModule).
11
+ *
12
+ * OpenCode loads this via: import("context-mode/plugin") → also supports
13
+ * the named export ContextModePlugin for backward compat.
10
14
  *
11
15
  * Constraints:
12
16
  * - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
@@ -14,9 +18,10 @@
14
18
  * - No routing file auto-write (avoid dirtying project trees)
15
19
  * - Session cleanup happens at plugin init (no SessionStart)
16
20
  */
17
- /** OpenCode plugin context passed to the factory function. */
21
+ /** KiloCode/OpenCode plugin input both platforms pass at least `directory`. */
18
22
  interface PluginContext {
19
23
  directory: string;
24
+ [key: string]: unknown;
20
25
  }
21
26
  /** OpenCode tool.execute.before — first parameter */
22
27
  interface BeforeHookInput {
@@ -51,12 +56,19 @@ interface CompactingHookOutput {
51
56
  prompt?: string;
52
57
  }
53
58
  /**
54
- * OpenCode plugin factory. Called once when OpenCode loads the plugin.
59
+ * Plugin factory. Called once when KiloCode/OpenCode loads the plugin.
55
60
  * Returns an object mapping hook event names to async handler functions.
56
- */
57
- export declare const ContextModePlugin: (ctx: PluginContext) => Promise<{
61
+ *
62
+ * KiloCode expects: export default { server: (input) => Promise<Hooks> }
63
+ * OpenCode expects: export const ContextModePlugin = (ctx) => Promise<Hooks>
64
+ */
65
+ declare function createContextModePlugin(ctx: PluginContext): Promise<{
58
66
  "tool.execute.before": (input: BeforeHookInput, output: BeforeHookOutput) => Promise<void>;
59
67
  "tool.execute.after": (input: AfterHookInput, output: AfterHookOutput) => Promise<void>;
60
68
  "experimental.session.compacting": (input: CompactingHookInput, output: CompactingHookOutput) => Promise<string>;
61
69
  }>;
62
- export {};
70
+ declare const _default: {
71
+ server: typeof createContextModePlugin;
72
+ };
73
+ export default _default;
74
+ export { createContextModePlugin as ContextModePlugin };
@@ -1,12 +1,16 @@
1
1
  /**
2
- * OpenCode TypeScript plugin entry point for context-mode.
2
+ * OpenCode / KiloCode TypeScript plugin entry point for context-mode.
3
3
  *
4
4
  * Provides three hooks:
5
5
  * - tool.execute.before — Routing enforcement (deny/modify/passthrough)
6
6
  * - tool.execute.after — Session event capture
7
7
  * - experimental.session.compacting — Compaction snapshot generation
8
8
  *
9
- * Loaded by OpenCode via: import("context-mode/plugin").ContextModePlugin(ctx)
9
+ * KiloCode loads this via: import("context-mode") → expects default export
10
+ * with shape { server: (input) => Promise<Hooks> } (PluginModule).
11
+ *
12
+ * OpenCode loads this via: import("context-mode/plugin") → also supports
13
+ * the named export ContextModePlugin for backward compat.
10
14
  *
11
15
  * Constraints:
12
16
  * - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
@@ -27,10 +31,13 @@ function getPlatform() {
27
31
  }
28
32
  // ── Plugin Factory ────────────────────────────────────────
29
33
  /**
30
- * OpenCode plugin factory. Called once when OpenCode loads the plugin.
34
+ * Plugin factory. Called once when KiloCode/OpenCode loads the plugin.
31
35
  * Returns an object mapping hook event names to async handler functions.
32
- */
33
- export const ContextModePlugin = async (ctx) => {
36
+ *
37
+ * KiloCode expects: export default { server: (input) => Promise<Hooks> }
38
+ * OpenCode expects: export const ContextModePlugin = (ctx) => Promise<Hooks>
39
+ */
40
+ async function createContextModePlugin(ctx) {
34
41
  // Resolve build dir from compiled JS location
35
42
  const adapter = new OpenCodeAdapter(getPlatform());
36
43
  const buildDir = dirname(fileURLToPath(import.meta.url));
@@ -52,7 +59,7 @@ export const ContextModePlugin = async (ctx) => {
52
59
  const toolInput = output.args ?? {};
53
60
  let decision;
54
61
  try {
55
- decision = routing.routePreToolUse(toolName, toolInput, projectDir, "opencode");
62
+ decision = routing.routePreToolUse(toolName, toolInput, projectDir, getPlatform());
56
63
  }
57
64
  catch {
58
65
  return; // Routing failure → allow passthrough
@@ -109,4 +116,9 @@ export const ContextModePlugin = async (ctx) => {
109
116
  }
110
117
  },
111
118
  };
112
- };
119
+ }
120
+ // ── Exports ──────────────────────────────────────────────
121
+ // KiloCode PluginModule: default export with { server } shape
122
+ // OpenCode compat: named export for direct import("context-mode/plugin")
123
+ export default { server: createContextModePlugin };
124
+ export { createContextModePlugin as ContextModePlugin };
@@ -110,6 +110,20 @@ function buildStatsText(db, sessionId) {
110
110
  return "context-mode stats unavailable (session DB error)";
111
111
  }
112
112
  }
113
+ function resolveCommandContext(argsOrCtx, ctx) {
114
+ if (ctx !== undefined)
115
+ return ctx;
116
+ if (argsOrCtx && typeof argsOrCtx === "object")
117
+ return argsOrCtx;
118
+ return undefined;
119
+ }
120
+ function handleCommandText(text, ctx) {
121
+ if (ctx?.hasUI) {
122
+ ctx.ui.notify(text, "info");
123
+ return;
124
+ }
125
+ return { text };
126
+ }
113
127
  // ── Extension entry point ────────────────────────────────
114
128
  /** Pi extension default export. Called once by Pi runtime with the extension API. */
115
129
  export default function piExtension(pi) {
@@ -300,16 +314,18 @@ export default function piExtension(pi) {
300
314
  // ── 8. Slash commands ──────────────────────────────────
301
315
  pi.registerCommand("ctx-stats", {
302
316
  description: "Show context-mode session statistics",
303
- handler: () => {
304
- if (!_db || !_sessionId) {
305
- return { text: "context-mode: no active session" };
306
- }
307
- return { text: buildStatsText(_db, _sessionId) };
317
+ handler: async (argsOrCtx, maybeCtx) => {
318
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
319
+ const text = !_db || !_sessionId
320
+ ? "context-mode: no active session"
321
+ : buildStatsText(_db, _sessionId);
322
+ return handleCommandText(text, ctx);
308
323
  },
309
324
  });
310
325
  pi.registerCommand("ctx-doctor", {
311
326
  description: "Run context-mode diagnostics",
312
- handler: () => {
327
+ handler: async (argsOrCtx, maybeCtx) => {
328
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
313
329
  const dbPath = getDBPath();
314
330
  const dbExists = existsSync(dbPath);
315
331
  const lines = [
@@ -334,7 +350,8 @@ export default function piExtension(pi) {
334
350
  lines.push("- DB query error");
335
351
  }
336
352
  }
337
- return { text: lines.join("\n") };
353
+ const text = lines.join("\n");
354
+ return handleCommandText(text, ctx);
338
355
  },
339
356
  });
340
357
  }
package/build/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  const isWindows = process.platform === "win32";
4
4
  function commandExists(cmd) {
@@ -14,10 +14,8 @@ function commandExists(cmd) {
14
14
  function bunExists() {
15
15
  if (commandExists("bun"))
16
16
  return true;
17
- // Bun installs to ~/.bun/bin which may not be in PATH in MCP server environments
18
- if (!isWindows) {
19
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
20
- if (home && existsSync(`${home}/.bun/bin/bun`))
17
+ for (const p of bunFallbackPaths()) {
18
+ if (existsSync(p))
21
19
  return true;
22
20
  }
23
21
  return false;
@@ -25,8 +23,24 @@ function bunExists() {
25
23
  function bunCommand() {
26
24
  if (commandExists("bun"))
27
25
  return "bun";
26
+ for (const p of bunFallbackPaths()) {
27
+ if (existsSync(p))
28
+ return p;
29
+ }
28
30
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
29
- return `${home}/.bun/bin/bun`;
31
+ return isWindows ? `${home}\\.bun\\bin\\bun.exe` : `${home}/.bun/bin/bun`;
32
+ }
33
+ /** Fallback paths where Bun may be installed but not on PATH. */
34
+ function bunFallbackPaths() {
35
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
36
+ if (isWindows) {
37
+ const localAppData = process.env.LOCALAPPDATA ?? "";
38
+ return [
39
+ ...(home ? [`${home}\\.bun\\bin\\bun.exe`] : []),
40
+ ...(localAppData ? [`${localAppData}\\bun\\bin\\bun.exe`] : []),
41
+ ];
42
+ }
43
+ return home ? [`${home}/.bun/bin/bun`] : [];
30
44
  }
31
45
  /**
32
46
  * On Windows, resolve the first non-WSL bash in PATH.
@@ -61,10 +75,11 @@ function resolveWindowsBash() {
61
75
  return null;
62
76
  }
63
77
  }
64
- function getVersion(cmd) {
78
+ function getVersion(cmd, args = ["--version"]) {
65
79
  try {
66
- return execSync(`${cmd} --version`, {
80
+ return execFileSync(cmd, args, {
67
81
  encoding: "utf-8",
82
+ shell: process.platform === "win32",
68
83
  stdio: ["pipe", "pipe", "pipe"],
69
84
  timeout: 5000,
70
85
  })
@@ -132,7 +147,7 @@ export function getRuntimeSummary(runtimes) {
132
147
  if (runtimes.ruby)
133
148
  lines.push(` Ruby: ${runtimes.ruby} (${getVersion(runtimes.ruby)})`);
134
149
  if (runtimes.go)
135
- lines.push(` Go: ${runtimes.go} (${getVersion(runtimes.go)})`);
150
+ lines.push(` Go: ${runtimes.go} (${getVersion(runtimes.go, ["version"])})`);
136
151
  if (runtimes.rust)
137
152
  lines.push(` Rust: ${runtimes.rust} (${getVersion(runtimes.rust)})`);
138
153
  if (runtimes.php)
@@ -104,8 +104,24 @@ export declare function evaluateCommandDenyOnly(command: string, policies: Secur
104
104
  *
105
105
  * Normalizes backslashes to forward slashes before matching so that
106
106
  * Windows paths work with Unix-style glob patterns.
107
+ *
108
+ * When `projectRoot` is supplied, the path is also matched in its
109
+ * fully-resolved absolute form **and** — when the file exists — in
110
+ * its canonical form (`fs.realpathSync`). This prevents two classes
111
+ * of bypass:
112
+ *
113
+ * 1. `..` traversal: a relative path like `../../.ssh/id_rsa` no
114
+ * longer evades absolute-path deny rules.
115
+ * 2. Symlink escape: a project-local path whose realpath points
116
+ * outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no
117
+ * longer evades absolute-path deny rules.
118
+ *
119
+ * realpath is best-effort: if the file does not exist yet (ENOENT)
120
+ * or the syscall fails for any reason, the lexical resolved form is
121
+ * still checked. This keeps the function usable for paths that will
122
+ * be created during execution.
107
123
  */
108
- export declare function evaluateFilePath(filePath: string, denyGlobs: string[][], caseInsensitive?: boolean): {
124
+ export declare function evaluateFilePath(filePath: string, denyGlobs: string[][], caseInsensitive?: boolean, projectRoot?: string): {
109
125
  denied: boolean;
110
126
  matchedPattern?: string;
111
127
  };
package/build/security.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  // ==============================================================================
@@ -358,14 +358,48 @@ export function evaluateCommandDenyOnly(command, policies, caseInsensitive = pro
358
358
  *
359
359
  * Normalizes backslashes to forward slashes before matching so that
360
360
  * Windows paths work with Unix-style glob patterns.
361
+ *
362
+ * When `projectRoot` is supplied, the path is also matched in its
363
+ * fully-resolved absolute form **and** — when the file exists — in
364
+ * its canonical form (`fs.realpathSync`). This prevents two classes
365
+ * of bypass:
366
+ *
367
+ * 1. `..` traversal: a relative path like `../../.ssh/id_rsa` no
368
+ * longer evades absolute-path deny rules.
369
+ * 2. Symlink escape: a project-local path whose realpath points
370
+ * outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no
371
+ * longer evades absolute-path deny rules.
372
+ *
373
+ * realpath is best-effort: if the file does not exist yet (ENOENT)
374
+ * or the syscall fails for any reason, the lexical resolved form is
375
+ * still checked. This keeps the function usable for paths that will
376
+ * be created during execution.
361
377
  */
362
- export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.platform === "win32") {
363
- // Normalize backslashes to forward slashes for cross-platform matching
364
- const normalized = filePath.replace(/\\/g, "/");
378
+ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.platform === "win32", projectRoot) {
379
+ const toForward = (path) => path.replace(/\\/g, "/");
380
+ // Match against the raw input, the lexically-resolved absolute path,
381
+ // and the canonical (symlink-resolved) path when the file exists.
382
+ // Deduplicated so absolute inputs and paths that don't cross symlinks
383
+ // don't pay the matching cost multiple times.
384
+ const candidates = new Set();
385
+ candidates.add(toForward(filePath));
386
+ if (projectRoot) {
387
+ const lexical = resolve(projectRoot, filePath);
388
+ candidates.add(toForward(lexical));
389
+ try {
390
+ candidates.add(toForward(realpathSync(lexical)));
391
+ }
392
+ catch {
393
+ // File does not exist yet, or realpath failed — rely on lexical form.
394
+ }
395
+ }
365
396
  for (const globs of denyGlobs) {
366
397
  for (const glob of globs) {
367
- if (fileGlobToRegex(glob, caseInsensitive).test(normalized)) {
368
- return { denied: true, matchedPattern: glob };
398
+ const regex = fileGlobToRegex(glob, caseInsensitive);
399
+ for (const candidate of candidates) {
400
+ if (regex.test(candidate)) {
401
+ return { denied: true, matchedPattern: glob };
402
+ }
369
403
  }
370
404
  }
371
405
  }