context-mode 1.0.163 → 1.0.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/security.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve, sep } from "node:path";
3
3
  import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js";
4
4
  // ==============================================================================
5
5
  // Pattern Parsing
@@ -367,6 +367,21 @@ export function readBashPolicies(projectDir, globalSettingsPath) {
367
367
  * Each inner array contains the extracted glob strings.
368
368
  */
369
369
  export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
370
+ return readToolPermissionPatterns(toolName, "deny", projectDir, globalSettingsPath);
371
+ }
372
+ /**
373
+ * Read `permissions.{deny|allow}` globs for a tool from every settings file in
374
+ * precedence order (project local → project shared → adapter globals).
375
+ *
376
+ * Generalizes the original deny-only reader so the project-boundary guard
377
+ * (#852) can consult the SAME `permissions.allow` rules the user already
378
+ * maintains for the host's `Read` tool — instead of inventing a context-mode-
379
+ * specific opt-out env that would rot into dead code. A user who legitimately
380
+ * needs an out-of-project read expresses it once, in the host config, e.g.
381
+ * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
382
+ * context-mode honor it.
383
+ */
384
+ export function readToolPermissionPatterns(toolName, kind, projectDir, globalSettingsPath) {
370
385
  const result = [];
371
386
  const extractGlobs = (path) => {
372
387
  let raw;
@@ -383,11 +398,11 @@ export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
383
398
  catch {
384
399
  return null;
385
400
  }
386
- const deny = parsed?.permissions?.deny;
387
- if (!Array.isArray(deny))
401
+ const entries = parsed?.permissions?.[kind];
402
+ if (!Array.isArray(entries))
388
403
  return [];
389
404
  const globs = [];
390
- for (const entry of deny) {
405
+ for (const entry of entries) {
391
406
  if (typeof entry !== "string")
392
407
  continue;
393
408
  const tp = parseToolPattern(entry);
@@ -552,6 +567,125 @@ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.
552
567
  return { denied: false };
553
568
  }
554
569
  // ==============================================================================
570
+ // Project-Boundary Containment (Issue #852)
571
+ // ==============================================================================
572
+ /**
573
+ * Pure, algorithmic (no-regex) test: does `filePath` resolve to a location
574
+ * inside `projectRoot`?
575
+ *
576
+ * Issue #852 — `ctx_execute_file` previously fed its `path` argument straight
577
+ * into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute*
578
+ * argument win outright, an agent could read any file on the host
579
+ * (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and
580
+ * `../` traversal escaped just as easily. Claude Code's harness sandbox cannot
581
+ * inspect MCP input params, so the user approving the MCP call could not see
582
+ * that the path escaped the workspace. This guard re-anchors the path to the
583
+ * project boundary.
584
+ *
585
+ * Containment is decided on the *resolved* form. When the file (or its parent
586
+ * chain) exists, the symlink-canonical form is ALSO required to stay inside —
587
+ * this closes the symlink-escape class (a project-local `safe.log` whose
588
+ * realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`.
589
+ *
590
+ * A path equal to the project root itself counts as inside. Comparison is
591
+ * case-insensitive on Windows/macOS to match those filesystems' semantics.
592
+ *
593
+ * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
594
+ * caller's fail-open posture is preserved when the root cannot be resolved.
595
+ */
596
+ export function isPathInsideProject(filePath, projectRoot, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
597
+ if (!projectRoot)
598
+ return true;
599
+ const root = resolve(projectRoot);
600
+ const lexical = resolve(projectRoot, filePath);
601
+ const within = (root, candidate) => {
602
+ let a = root;
603
+ let b = candidate;
604
+ if (caseInsensitive) {
605
+ a = a.toLowerCase();
606
+ b = b.toLowerCase();
607
+ }
608
+ if (a === b)
609
+ return true;
610
+ // `path.relative` is pure string arithmetic — no regex. A candidate inside
611
+ // the root yields a relative path that neither starts with `..` (escapes
612
+ // upward) nor is absolute (a different drive/root on Windows that cannot be
613
+ // expressed relatively).
614
+ const rel = relative(a, b);
615
+ if (rel === "")
616
+ return true;
617
+ if (rel === ".." || rel.startsWith(".." + sep))
618
+ return false;
619
+ if (isAbsoluteRel(rel))
620
+ return false;
621
+ return true;
622
+ };
623
+ // Lexical containment is the primary gate.
624
+ if (!within(root, lexical))
625
+ return false;
626
+ // Defense-in-depth: when the path (or a parent) is a symlink that points
627
+ // outside the project, the canonical form must ALSO stay inside. Best-effort
628
+ // — a not-yet-created file (ENOENT) falls back to the lexical decision above.
629
+ try {
630
+ const canonicalRoot = realpathSync(root);
631
+ const canonical = realpathSync(lexical);
632
+ if (!within(canonicalRoot, canonical))
633
+ return false;
634
+ }
635
+ catch {
636
+ /* file does not exist yet / realpath failed — lexical decision stands */
637
+ }
638
+ return true;
639
+ }
640
+ /** Pure helper: is a `path.relative` result an absolute path? (no regex) */
641
+ function isAbsoluteRel(rel) {
642
+ if (rel.startsWith("/"))
643
+ return true; // POSIX absolute
644
+ // Windows drive-absolute: "C:\..." or "C:/..."
645
+ if (rel.length >= 3 && rel[1] === ":" && (rel[2] === "\\" || rel[2] === "/")) {
646
+ const c = rel.charCodeAt(0);
647
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
648
+ }
649
+ return false;
650
+ }
651
+ /**
652
+ * Decide whether `filePath` may be processed, given the project boundary AND
653
+ * the user's existing host `Read(...)` allow rules.
654
+ *
655
+ * Decision order:
656
+ * 1. Inside the project root → allowed (the common case; no config needed).
657
+ * 2. Outside the project, but matching a `permissions.allow` `Read(...)` glob
658
+ * the user already configured for the host → allowed. This is the
659
+ * principled escape hatch: a deliberate out-of-project read is expressed
660
+ * ONCE in the host config the user already maintains, reusing the same
661
+ * mechanism Claude Code itself uses to whitelist a path outside the
662
+ * sandbox — no context-mode-specific opt-out env that would rot into
663
+ * dead code.
664
+ * 3. Outside the project, no allow match → denied (closes the #852 escape).
665
+ *
666
+ * `allowGlobs` has the same per-settings-file shape as the deny globs returned
667
+ * by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses
668
+ * `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is
669
+ * identical to the deny path — one matcher, no divergence.
670
+ *
671
+ * Fail-open on an unknown project root (boundary cannot be computed) so the
672
+ * guard never blocks legitimate in-project work when resolution fails.
673
+ */
674
+ export function evaluateProjectContainment(filePath, projectRoot, allowGlobs = [], caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
675
+ if (isPathInsideProject(filePath, projectRoot, caseInsensitive)) {
676
+ return { allowed: true, reason: "inside" };
677
+ }
678
+ // Outside the project — permit only if the user explicitly allowed this path
679
+ // for the host Read tool. `evaluateFilePath` returns `denied:true` when a glob
680
+ // MATCHES, so a match here means "explicitly allowed".
681
+ if (allowGlobs.some((g) => g.length > 0)) {
682
+ const matched = evaluateFilePath(filePath, allowGlobs, caseInsensitive, projectRoot);
683
+ if (matched.denied)
684
+ return { allowed: true, reason: "allow-rule" };
685
+ }
686
+ return { allowed: false, reason: "outside" };
687
+ }
688
+ // ==============================================================================
555
689
  // Shell-Escape Scanner
556
690
  // ==============================================================================
557
691
  // Regex patterns that detect shell-escape calls in non-shell languages.
package/build/server.js CHANGED
@@ -14,10 +14,10 @@ import { PolyglotExecutor } from "./executor.js";
14
14
  import { runPool } from "./runPool.js";
15
15
  import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
16
16
  import { composeFetchCacheKey } from "./fetch-cache.js";
17
- import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
17
+ import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
18
18
  import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
19
19
  import { classifyNonZeroExit } from "./exit-classify.js";
20
- import { startLifecycleGuard } from "./lifecycle.js";
20
+ import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
21
21
  import { charSafePrefix } from "./truncate.js";
22
22
  import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
23
23
  import { purgeSession } from "./session/purge.js";
@@ -235,6 +235,10 @@ server.registerTool = (...args) => {
235
235
  };
236
236
  function wrapToolHandler(name, handler) {
237
237
  return async (toolArgs) => {
238
+ // #854: mark a tool call in-flight so the bridge-child idle reaper never
239
+ // shuts the server down mid-execution during a long ctx_execute/batch that
240
+ // emits no further inbound messages. Symmetric end in finally (success+error).
241
+ noteRequestStart();
238
242
  try {
239
243
  return await handler(toolArgs);
240
244
  }
@@ -252,6 +256,9 @@ function wrapToolHandler(name, handler) {
252
256
  }
253
257
  throw err;
254
258
  }
259
+ finally {
260
+ noteRequestEnd();
261
+ }
255
262
  };
256
263
  }
257
264
  // Issue #637 — when suppression is active, install the empty tools/list handler
@@ -811,6 +818,9 @@ function healCacheMidSession() {
811
818
  catch { /* best effort */ }
812
819
  }
813
820
  function trackResponse(toolName, response) {
821
+ // #854: a response is activity too — refresh the bridge-child idle clock so a
822
+ // chatty/streaming call keeps its server alive even between inbound frames.
823
+ noteMcpActivity();
814
824
  // Mid-session cache heal — one-shot, first tool call
815
825
  healCacheMidSession();
816
826
  // Prepend version outdated warning if needed
@@ -1034,6 +1044,50 @@ function checkNonShellDenyPolicy(code, language, toolName) {
1034
1044
  }
1035
1045
  return null;
1036
1046
  }
1047
+ /**
1048
+ * Issue #852 — project-boundary containment for `ctx_execute_file`.
1049
+ *
1050
+ * The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
1051
+ * user approving a `ctx_execute_file` call cannot see that its `path` escapes
1052
+ * the workspace. This guard refuses a `path` that resolves outside the project
1053
+ * root (absolute escape, `../` traversal, or symlink-out), restoring the
1054
+ * boundary the host believes it is enforcing.
1055
+ *
1056
+ * Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
1057
+ * expressed in the SAME host config the user already maintains: a
1058
+ * `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
1059
+ * mechanism Claude Code uses to whitelist a path outside its sandbox, so the
1060
+ * grant lives in one place and stays meaningful instead of rotting into a
1061
+ * context-mode-only env flag nobody sets.
1062
+ *
1063
+ * Fail-open on resolver failure (consistent with the other deny checks): if the
1064
+ * project root cannot be resolved, containment evaluates as "inside" and the
1065
+ * path is allowed through rather than spuriously blocking legitimate work.
1066
+ */
1067
+ function checkProjectBoundary(filePath, toolName) {
1068
+ try {
1069
+ const projectDir = getProjectDir();
1070
+ const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
1071
+ const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
1072
+ if (verdict.allowed)
1073
+ return null;
1074
+ return trackResponse(toolName, {
1075
+ content: [{
1076
+ type: "text",
1077
+ text: `File access blocked: "${filePath}" resolves outside the project root ` +
1078
+ `(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
1079
+ `cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
1080
+ `To intentionally process a file outside the project, add a host allow rule, ` +
1081
+ `e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
1082
+ }],
1083
+ isError: true,
1084
+ });
1085
+ }
1086
+ catch {
1087
+ // Fail-open — resolver failure must not block legitimate in-project work.
1088
+ }
1089
+ return null;
1090
+ }
1037
1091
  /**
1038
1092
  * Check a file path against Read deny patterns.
1039
1093
  * Returns an error ToolResult if denied, or null if allowed.
@@ -1388,7 +1442,9 @@ export async function runBatchCommands(commands, opts, executor) {
1388
1442
  // Tool: execute
1389
1443
  // ─────────────────────────────────────────────────────────
1390
1444
  server.registerTool("ctx_execute", {
1391
- title: "Execute Code",
1445
+ // #852: surface code execution in the host approval prompt's title (the
1446
+ // only server-controlled field the MCP permission UI renders besides args).
1447
+ title: "Run code in a sandbox (executes the supplied code)",
1392
1448
  // #846: runs arbitrary code in a sandbox with full network access.
1393
1449
  annotations: {
1394
1450
  readOnlyHint: false,
@@ -1741,7 +1797,10 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
1741
1797
  // Tool: execute_file
1742
1798
  // ─────────────────────────────────────────────────────────
1743
1799
  server.registerTool("ctx_execute_file", {
1744
- title: "Execute File Processing",
1800
+ // #852: the host's MCP approval prompt renders only the tool name/title +
1801
+ // raw args — the title is the one server-controlled signal, so make it
1802
+ // unambiguously announce code execution + file read for the reviewer.
1803
+ title: "Run code over a file (executes code, reads the given path)",
1745
1804
  // #846: runs arbitrary code over a file in a sandbox with full network access.
1746
1805
  annotations: {
1747
1806
  readOnlyHint: false,
@@ -1803,6 +1862,12 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1803
1862
  "returns only matching sections via BM25 search instead of truncated output."),
1804
1863
  }),
1805
1864
  }, async ({ path, language, code, timeout, intent }) => {
1865
+ // Security (#852): confine the processed file to the project root so
1866
+ // ctx_execute_file cannot be used to escape the host's sandbox/permission
1867
+ // controls. Runs before the deny-glob check — boundary first, then policy.
1868
+ const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
1869
+ if (boundaryDenied)
1870
+ return boundaryDenied;
1806
1871
  // Security: check file path against Read deny patterns
1807
1872
  const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
1808
1873
  if (pathDenied)
@@ -4384,6 +4449,10 @@ async function main() {
4384
4449
  startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
4385
4450
  const transport = new StdioServerTransport();
4386
4451
  await server.connect(transport);
4452
+ // #854: refresh the bridge-child idle clock on each inbound MCP message so an
4453
+ // abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
4454
+ // of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
4455
+ attachMcpActivityTap(transport);
4387
4456
  // Write MCP readiness sentinel (#230)
4388
4457
  try {
4389
4458
  writeFileSync(mcpSentinel, String(process.pid));
@@ -1439,6 +1439,72 @@ const ROLE_MIN_CHARS = 8;
1439
1439
  const ROLE_MAX_CHARS = 120;
1440
1440
  const TWO_LEXICAL_TOKENS_PATTERN = /\p{L}+\s+\p{L}+/u;
1441
1441
  const CONTINUOUS_LETTER_RUN_PATTERN = /\p{L}{6,}/u;
1442
+ // Issue #856 — persona / standing-directive cue gate.
1443
+ //
1444
+ // The structural test below ("two lexical tokens OR a 6-codepoint letter run,
1445
+ // 8..120 chars, no '?', no clause separator") is intentionally coarse and
1446
+ // matches ANY short declarative sentence. That let casual conversational
1447
+ // acknowledgements ("that's fine for now", "go with the second option") freeze
1448
+ // as a priority-3 `role`, which the Pi adapter then re-injected as a standing
1449
+ // behavioral_directive every turn → do-nothing loop.
1450
+ //
1451
+ // A genuine role/behavioral prompt always LEADS with a persona declaration
1452
+ // ("You are X", "Tu es X", "あなたは…", "你是…") or a standing-directive verb
1453
+ // ("always respond…", "act as…"). Casual phrases never do, so we require that
1454
+ // cue as a NECESSARY condition. This preserves legitimate role persistence
1455
+ // (issue #535 multilingual corpus) while killing the casual-phrase loop.
1456
+ //
1457
+ // ALGORITHMIC ONLY — pure lowercase + prefix membership, no regex (project
1458
+ // hard rule). Multilingual openers are matched by `startsWith` on the
1459
+ // normalized first clause; leading conversational filler tokens are stripped
1460
+ // by array operations before the check.
1461
+ const ROLE_FILLER_TOKENS = new Set([
1462
+ "ok", "okay", "sure", "yeah", "yep", "yup", "alright", "fine",
1463
+ "well", "so", "hmm", "right", "please",
1464
+ ]);
1465
+ // Second-person persona openers across the supported-language corpus
1466
+ // (issue #535 multilingual role test set) plus common English persona framings.
1467
+ const ROLE_PERSONA_PREFIXES = [
1468
+ "you are", "you're", "your role", "you will be", "you act", "you will act",
1469
+ "act as", "act like", "behave as", "behave like", "imagine you", "pretend you",
1470
+ "assume the role", "take the role", "play the role", "respond as",
1471
+ "tu es", "tu est", "vous etes", "vous êtes", // French
1472
+ "sen ", "siz ", // Turkish (Sen kıdemli…)
1473
+ "eres ", "tú eres", "usted es", // Spanish (Eres…)
1474
+ "ты ", "вы ", // Russian (Ты опытный…)
1475
+ "あなたは", "君は", "お前は", "あなたが", // Japanese (あなたは…)
1476
+ "你是", "您是", // Chinese (你是…)
1477
+ "तुम ", "आप ", "तू ", // Hindi (तुम…)
1478
+ "أنت ", "انت ", "أنتَ ", // Arabic (أنت…)
1479
+ ];
1480
+ // Standing-directive verb openers — imperative behavioral rules that should
1481
+ // persist ("always respond in TypeScript", "never use emojis").
1482
+ const ROLE_DIRECTIVE_PREFIXES = [
1483
+ "always ", "never ", "respond ", "reply ", "answer ", "speak ",
1484
+ "write ", "prefer ", "format ", "output ", "communicate ", "use only ",
1485
+ ];
1486
+ function hasRoleCue(firstClause) {
1487
+ const lower = firstClause.toLowerCase().trim();
1488
+ if (!lower)
1489
+ return false;
1490
+ // Strip leading conversational filler tokens via array ops (no regex).
1491
+ const tokens = lower.split(" ").filter((t) => t.length > 0);
1492
+ while (tokens.length > 0 && ROLE_FILLER_TOKENS.has(tokens[0])) {
1493
+ tokens.shift();
1494
+ }
1495
+ const normalized = tokens.join(" ");
1496
+ if (!normalized)
1497
+ return false;
1498
+ for (const prefix of ROLE_PERSONA_PREFIXES) {
1499
+ if (normalized.startsWith(prefix))
1500
+ return true;
1501
+ }
1502
+ for (const prefix of ROLE_DIRECTIVE_PREFIXES) {
1503
+ if (normalized.startsWith(prefix))
1504
+ return true;
1505
+ }
1506
+ return false;
1507
+ }
1442
1508
  function looksLikeRole(trimmed) {
1443
1509
  // Role prompts are persona-prefix shaped: the FIRST SENTENCE declares the
1444
1510
  // role (e.g. "You are a senior backend engineer. <long context...>").
@@ -1457,6 +1523,10 @@ function looksLikeRole(trimmed) {
1457
1523
  const codepointLength = [...firstClause].length;
1458
1524
  if (codepointLength < ROLE_MIN_CHARS || codepointLength > ROLE_MAX_CHARS)
1459
1525
  return false;
1526
+ // Issue #856 — require a persona / standing-directive cue so casual
1527
+ // conversational acknowledgements do not freeze as a role directive.
1528
+ if (!hasRoleCue(firstClause))
1529
+ return false;
1460
1530
  return (TWO_LEXICAL_TOKENS_PATTERN.test(firstClause) ||
1461
1531
  CONTINUOUS_LETTER_RUN_PATTERN.test(firstClause));
1462
1532
  }