@gajae-code/coding-agent 0.18.8 → 0.18.10

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 (113) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/support.d.ts +0 -14
  4. package/dist/types/commands/mcp-serve.d.ts +4 -0
  5. package/dist/types/discovery/mcp-json.d.ts +1 -0
  6. package/dist/types/eval/py/kernel.d.ts +18 -0
  7. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  8. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  9. package/dist/types/extensibility/skill-management.d.ts +1 -0
  10. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  11. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  12. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  14. package/dist/types/lsp/config.d.ts +8 -5
  15. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  16. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  17. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
  18. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  19. package/dist/types/sdk/broker/broker.d.ts +6 -1
  20. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  22. package/dist/types/session/session-storage.d.ts +7 -0
  23. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  24. package/dist/types/task/isolation-dir.d.ts +26 -0
  25. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  26. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  27. package/dist/types/tools/github-side-effect.d.ts +22 -0
  28. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  29. package/package.json +7 -7
  30. package/src/async/endpoint-id.ts +26 -0
  31. package/src/async/job-manager.ts +19 -10
  32. package/src/async/support.ts +0 -25
  33. package/src/capability/index.ts +14 -16
  34. package/src/cli/doctor/repairs.ts +4 -1
  35. package/src/cli/doctor-supervisor.ts +3 -0
  36. package/src/cli/public-command-registry.ts +28 -6
  37. package/src/commands/mcp-serve.ts +7 -0
  38. package/src/commands/sdk.ts +453 -415
  39. package/src/config/file-lock.ts +9 -11
  40. package/src/config/model-profile-activation.ts +5 -5
  41. package/src/coordinator-mcp/server.ts +2 -0
  42. package/src/customization/inventory.ts +1 -13
  43. package/src/dap/config.ts +2 -2
  44. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  45. package/src/discovery/builtin.ts +3 -1
  46. package/src/discovery/helpers.ts +12 -9
  47. package/src/discovery/mcp-json.ts +20 -12
  48. package/src/eval/py/executor.ts +459 -77
  49. package/src/eval/py/kernel.ts +79 -13
  50. package/src/eval/py/tool-bridge.ts +4 -0
  51. package/src/export/html/html-export-destination.ts +16 -0
  52. package/src/export/html/index.ts +2 -6
  53. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  54. package/src/extensibility/extensions/runner.ts +11 -1
  55. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  56. package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
  57. package/src/extensibility/skill-management.ts +3 -0
  58. package/src/gjc-runtime/launch-worktree.ts +56 -5
  59. package/src/gjc-runtime/psmux-detect.ts +28 -4
  60. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  61. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  62. package/src/gjc-runtime/state-writer.ts +22 -8
  63. package/src/gjc-runtime/tmux-common.ts +8 -5
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/config.ts +12 -63
  66. package/src/memories/index.ts +88 -7
  67. package/src/modes/components/hook-editor.ts +1 -1
  68. package/src/modes/components/hook-selector.ts +1 -1
  69. package/src/modes/controllers/input-controller.ts +2 -2
  70. package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
  71. package/src/modes/controllers/selector-controller.ts +3 -7
  72. package/src/modes/controllers/todo-command-controller.ts +1 -1
  73. package/src/modes/interactive-mode.ts +1 -1
  74. package/src/runtime-mcp/client.ts +4 -2
  75. package/src/runtime-mcp/json-rpc.ts +2 -1
  76. package/src/runtime-mcp/manager.ts +29 -11
  77. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  78. package/src/runtime-mcp/oauth-flow.ts +11 -4
  79. package/src/runtime-mcp/oauth-public-url.ts +8 -2
  80. package/src/runtime-mcp/pool.ts +6 -0
  81. package/src/runtime-mcp/project-host-values.ts +46 -0
  82. package/src/runtime-mcp/transports/http.ts +2 -1
  83. package/src/sdk/acp/adapter.ts +2 -0
  84. package/src/sdk/broker/broker.ts +6 -0
  85. package/src/sdk/broker/doctor-restart.ts +4 -0
  86. package/src/sdk/broker/ensure.ts +29 -2
  87. package/src/sdk/host/session-runtime.test.ts +58 -9
  88. package/src/sdk/host/session-runtime.ts +65 -35
  89. package/src/sdk/router/session-router.ts +7 -6
  90. package/src/sdk/session.ts +2 -7
  91. package/src/session/agent-session.ts +59 -17
  92. package/src/session/artifacts.ts +3 -2
  93. package/src/session/internal/managed-session-scope.ts +403 -81
  94. package/src/session/internal/managed-session-storage.ts +125 -17
  95. package/src/session/session-manager.ts +12 -0
  96. package/src/session/session-storage.ts +30 -1
  97. package/src/skill-state/active-state.ts +13 -0
  98. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  99. package/src/slash-commands/builtin-registry.ts +1 -1
  100. package/src/slash-commands/helpers/mcp.ts +8 -4
  101. package/src/task/isolation-dir.ts +143 -0
  102. package/src/task/worktree.ts +16 -7
  103. package/src/tools/ast-edit-write-path.ts +68 -0
  104. package/src/tools/ast-edit.ts +7 -2
  105. package/src/tools/browser/cdp-url.ts +18 -0
  106. package/src/tools/browser.ts +8 -1
  107. package/src/tools/gh.ts +10 -0
  108. package/src/tools/github-side-effect.ts +112 -0
  109. package/src/tools/monitor-mutation.ts +27 -0
  110. package/src/tools/monitor.ts +7 -0
  111. package/src/tools/python.ts +7 -6
  112. package/src/utils/external-editor.ts +3 -2
  113. package/src/web/insane/url-guard.ts +2 -1
@@ -1280,8 +1280,8 @@ export function retainManagedDirectoryAuthority(
1280
1280
  expected?: { dev: bigint; ino: bigint },
1281
1281
  ): RecoveryFsRoot | undefined {
1282
1282
  assertManagedDirectoryRoot(root);
1283
- managedRelativePath(root, directory);
1284
- const resolved = path.resolve(directory);
1283
+ const resolved = canonicalizeManagedPathWithinRoot(root.canonicalPath, directory);
1284
+ const relative = managedRelativePath(root, resolved).join("/");
1285
1285
  if (process.platform !== "linux") return undefined;
1286
1286
  const named = fs.lstatSync(resolved, { bigint: true });
1287
1287
  if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("Managed directory authority is unavailable");
@@ -1297,7 +1297,6 @@ export function retainManagedDirectoryAuthority(
1297
1297
  retainedRoot.identity.ino !== root.ino.toString()
1298
1298
  )
1299
1299
  throw new Error("Managed root authority changed");
1300
- const relative = path.relative(root.canonicalPath, resolved).split(path.sep).join("/");
1301
1300
  const rootRecovery = rootAuthority.recoveryReaperMetrics();
1302
1301
  const rootRecoveryUnhealthy = !rootRecovery.ok || Boolean(rootRecovery.code);
1303
1302
  if (rootRecoveryUnhealthy)
@@ -1366,14 +1365,79 @@ function ensureManagedRoot(root: ManagedDirectoryRoot): void {
1366
1365
  assertManagedDirectoryRoot(root);
1367
1366
  }
1368
1367
 
1369
- function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
1370
- const relative = path.relative(root.canonicalPath, path.resolve(pathname));
1368
+ /** Resolve aliases above a managed root without following descendant symlinks. */
1369
+ function canonicalizeManagedPathWithinRoot(rootPath: string, pathname: string): string {
1370
+ const canonicalRootPath = path.resolve(rootPath);
1371
+ const namedRoot = fs.lstatSync(canonicalRootPath);
1372
+ if (!namedRoot.isDirectory() || namedRoot.isSymbolicLink())
1373
+ throw new Error(`Managed root authority changed: ${canonicalRootPath}`);
1374
+ const resolvedPath = path.resolve(pathname);
1375
+ const filesystemRoot = path.parse(resolvedPath).root;
1376
+ const components = path.relative(filesystemRoot, resolvedPath).split(path.sep).filter(Boolean);
1377
+ let currentPath = filesystemRoot;
1378
+ let reachedManagedRoot = currentPath === canonicalRootPath;
1379
+ if (reachedManagedRoot) currentPath = canonicalRootPath;
1380
+
1381
+ for (let index = 0; index < components.length; index++) {
1382
+ const component = components[index]!;
1383
+ const nextPath = path.join(currentPath, component);
1384
+ if (!reachedManagedRoot) {
1385
+ let canonicalPrefix: string;
1386
+ try {
1387
+ canonicalPrefix = fs.realpathSync.native(nextPath);
1388
+ } catch (error) {
1389
+ const code = (error as NodeJS.ErrnoException).code;
1390
+ if (code === "ENOENT" || code === "ENOTDIR")
1391
+ throw new Error(`Managed path escapes configured root: ${pathname}`, { cause: error });
1392
+ throw error;
1393
+ }
1394
+ if (canonicalPrefix === canonicalRootPath) {
1395
+ reachedManagedRoot = true;
1396
+ currentPath = canonicalRootPath;
1397
+ continue;
1398
+ }
1399
+ currentPath = nextPath;
1400
+ continue;
1401
+ }
1402
+
1403
+ let named: fs.Stats;
1404
+ try {
1405
+ named = fs.lstatSync(nextPath);
1406
+ } catch (error) {
1407
+ const code = (error as NodeJS.ErrnoException).code;
1408
+ if (code === "ENOENT") return path.join(currentPath, ...components.slice(index));
1409
+ throw error;
1410
+ }
1411
+ if (named.isSymbolicLink()) throw new Error(`Managed path contains symlink: ${nextPath}`);
1412
+ if (index < components.length - 1 && !named.isDirectory())
1413
+ throw new Error(`Managed path component is not a directory: ${nextPath}`);
1414
+ currentPath = nextPath;
1415
+ }
1416
+
1417
+ if (!reachedManagedRoot) throw new Error(`Managed path escapes configured root: ${pathname}`);
1418
+ return currentPath;
1419
+ }
1420
+
1421
+ function relativeWithinManagedRoot(rootPath: string, pathname: string): readonly string[] {
1422
+ const canonicalPath = canonicalizeManagedPathWithinRoot(rootPath, pathname);
1423
+ const relative = path.relative(rootPath, canonicalPath);
1424
+ if (process.platform === "win32") {
1425
+ const canonicalRoot = path.win32.normalize(rootPath);
1426
+ const canonicalCandidate = path.win32.normalize(canonicalPath);
1427
+ const rootPrefix = canonicalRoot.endsWith(path.win32.sep) ? canonicalRoot : `${canonicalRoot}${path.win32.sep}`;
1428
+ if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(rootPrefix))
1429
+ throw new Error(`Managed path escapes configured root: ${pathname}`);
1430
+ }
1371
1431
  if (relative === "") return [];
1372
1432
  if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
1373
1433
  throw new Error(`Managed path escapes configured root: ${pathname}`);
1374
1434
  return relative.split(path.sep);
1375
1435
  }
1376
1436
 
1437
+ function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
1438
+ return relativeWithinManagedRoot(root.canonicalPath, pathname);
1439
+ }
1440
+
1377
1441
  const PROCESS_START_ID = randomUUID();
1378
1442
 
1379
1443
  class ManagedSecurityError extends Error {
@@ -1538,16 +1602,46 @@ export class ManagedSessionDescendantStore {
1538
1602
  if (access === "read-only" && retained) throw new Error("managed_read_store_cannot_borrow_authority");
1539
1603
  if (access === "read-only" && !expectedSubtreeRoot)
1540
1604
  throw new Error("managed_read_store_requires_existing_identity");
1541
- managedRelativePath(root, baseDir);
1605
+ assertManagedDirectoryRoot(root);
1606
+ let canonicalBaseDir: string;
1607
+ try {
1608
+ canonicalBaseDir = canonicalizeManagedPathWithinRoot(root.canonicalPath, baseDir);
1609
+ } catch (error) {
1610
+ // If canonicalization fails with a symlink error on a retained path,
1611
+ // the binding has changed: the path no longer resolves to the original inode.
1612
+ if (retained && error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
1613
+ throw new Error("Managed descendant root binding changed");
1614
+ }
1615
+ throw error;
1616
+ }
1617
+ let canonicalAuthorityBaseDir: string;
1618
+ if (retained) {
1619
+ try {
1620
+ canonicalAuthorityBaseDir = canonicalizeManagedPathWithinRoot(
1621
+ root.canonicalPath,
1622
+ retained.authorityBaseDir,
1623
+ );
1624
+ } catch (error) {
1625
+ // If canonicalization fails with a symlink error on a retained path,
1626
+ // the binding has changed: the path no longer resolves to the original inode.
1627
+ if (error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
1628
+ throw new Error("Managed descendant root binding changed");
1629
+ }
1630
+ throw error;
1631
+ }
1632
+ } else {
1633
+ canonicalAuthorityBaseDir = canonicalBaseDir;
1634
+ }
1635
+ managedRelativePath(root, canonicalBaseDir);
1542
1636
 
1543
1637
  this.#root = root;
1544
- this.#baseDir = path.resolve(baseDir);
1638
+ this.#baseDir = canonicalBaseDir;
1545
1639
  this.#policy = policy ?? "default";
1546
1640
  this.#access = access;
1547
1641
  this.#profileAgentDir = profileAgentDir ?? root.canonicalPath;
1548
- this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
1642
+ this.#authorityBaseDir = canonicalAuthorityBaseDir;
1549
1643
  if (retained) {
1550
- const relative = path.relative(retained.authorityBaseDir, this.#baseDir).split(path.sep).join("/");
1644
+ const relative = relativeWithinManagedRoot(canonicalAuthorityBaseDir, this.#baseDir).join("/");
1551
1645
  // For the root case (authorityBaseDir === baseDir, relative === ""), the
1552
1646
  // stable identity() result carries the exact root dev/inode without
1553
1647
  // snapshotting the entire live session tree. snapshotManagedTree("")
@@ -2668,12 +2762,18 @@ export class ManagedSessionDescendantStore {
2668
2762
  canonicalFileId(rootBefore.ino) !== this.#subtreeRoot.ino
2669
2763
  )
2670
2764
  throw new Error("Managed descendant root binding changed");
2671
- let fd: number | undefined;
2765
+ let fd: number;
2672
2766
  try {
2673
2767
  fd = fs.openSync(
2674
2768
  resolved,
2675
2769
  fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
2676
2770
  );
2771
+ } catch (error) {
2772
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2773
+ throw error;
2774
+ }
2775
+ let descriptor: SessionStorageStat;
2776
+ try {
2677
2777
  const opened = fs.fstatSync(fd, { bigint: true });
2678
2778
  const named = fs.lstatSync(resolved, { bigint: true });
2679
2779
  if (
@@ -2695,7 +2795,7 @@ export class ManagedSessionDescendantStore {
2695
2795
  )
2696
2796
  throw new Error("Managed descendant root binding changed");
2697
2797
  this.#assertBound();
2698
- return managedAppendReceiptFromIdentity({
2798
+ descriptor = managedAppendReceiptFromIdentity({
2699
2799
  dev: canonicalFileId(opened.dev),
2700
2800
  ino: canonicalFileId(opened.ino),
2701
2801
  nlink: opened.nlink,
@@ -2704,11 +2804,13 @@ export class ManagedSessionDescendantStore {
2704
2804
  ctimeNs: opened.ctimeNs,
2705
2805
  }).descriptor;
2706
2806
  } catch (error) {
2707
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2807
+ try {
2808
+ fs.closeSync(fd);
2809
+ } catch {}
2708
2810
  throw error;
2709
- } finally {
2710
- if (fd !== undefined) fs.closeSync(fd);
2711
2811
  }
2812
+ fs.closeSync(fd);
2813
+ return descriptor;
2712
2814
  }
2713
2815
  const stat = this.#authority.stat(this.#relative(resolved));
2714
2816
  if (!stat.ok) {
@@ -2759,6 +2861,7 @@ export class ManagedSessionDescendantStore {
2759
2861
  resolved,
2760
2862
  fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
2761
2863
  );
2864
+ let snapshot: SessionStorageRangeSnapshot;
2762
2865
  try {
2763
2866
  const before = fs.fstatSync(fd, { bigint: true });
2764
2867
  if (!before.isFile() || before.nlink > 1n) throw new Error("source_changed");
@@ -2820,7 +2923,7 @@ export class ManagedSessionDescendantStore {
2820
2923
  )
2821
2924
  throw new Error("Managed descendant root binding changed");
2822
2925
  this.#assertBound();
2823
- return {
2926
+ snapshot = {
2824
2927
  bytes,
2825
2928
  stat: {
2826
2929
  dev: canonicalFileId(after.dev),
@@ -2834,9 +2937,14 @@ export class ManagedSessionDescendantStore {
2834
2937
  isFile: true,
2835
2938
  },
2836
2939
  };
2837
- } finally {
2838
- fs.closeSync(fd);
2940
+ } catch (error) {
2941
+ try {
2942
+ fs.closeSync(fd);
2943
+ } catch {}
2944
+ throw error;
2839
2945
  }
2946
+ fs.closeSync(fd);
2947
+ return snapshot;
2840
2948
  }
2841
2949
 
2842
2950
  /** Open an identity-bound stream lease; Darwin uses exact bounded range reads per chunk. */
@@ -17274,6 +17274,18 @@ export class SessionManager {
17274
17274
  if (observedPersistError && outcome.kind === "closed") {
17275
17275
  return { kind: "close_unknown", error: observedPersistError };
17276
17276
  }
17277
+ // Always release managed sidecar cache to prevent file handle leaks on Windows (#6566).
17278
+ // This must happen even if the close is retryable, because the sidecar cache holds
17279
+ // directory handles that prevent temp directory deletion during test cleanup.
17280
+ if (!observedPersistError && !this.#persistWriter && outcome.kind === "closed") {
17281
+ try {
17282
+ this.#releaseManagedSidecarCache();
17283
+ } catch (error) {
17284
+ logger.warn("Failed to release managed sidecar cache during closeStrict", {
17285
+ error: String(error),
17286
+ });
17287
+ }
17288
+ }
17277
17289
  return outcome;
17278
17290
  }
17279
17291
 
@@ -822,6 +822,34 @@ function secureOwnerOnlyFileDescriptor(
822
822
  if (!result.ok) throw new Error(`Owner-only security rejected ${pathname}: ${result.code}`);
823
823
  }
824
824
 
825
+ /**
826
+ * Replace an explicit session file. Creation mode `0600` does not install a
827
+ * Windows owner-only DACL, so this applies and verifies the same native path a
828
+ * primary transcript uses when it has no managed security context. POSIX mode
829
+ * stays `0600`.
830
+ */
831
+ export function writeExplicitOwnerOnlyTextSync(pathname: string, content: string): void {
832
+ const fd = fs.openSync(
833
+ pathname,
834
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0),
835
+ 0o600,
836
+ );
837
+ try {
838
+ secureOwnerOnlyFileDescriptor(pathname, fd, "apply", undefined);
839
+ fs.ftruncateSync(fd, 0);
840
+ const bytes = Buffer.from(content, "utf8");
841
+ let offset = 0;
842
+ while (offset < bytes.byteLength) {
843
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
844
+ if (written === 0) throw new Error("Short write");
845
+ offset += written;
846
+ }
847
+ secureOwnerOnlyFileDescriptor(pathname, fd, "verify", undefined);
848
+ } finally {
849
+ fs.closeSync(fd);
850
+ }
851
+ }
852
+
825
853
  /** Reject a symlink/junction/reparse component before a storage path is created or opened. */
826
854
  function assertNoReparsePath(pathname: string): void {
827
855
  const resolved = path.resolve(pathname);
@@ -2073,7 +2101,8 @@ export class FileSessionStorage implements SessionStorage {
2073
2101
 
2074
2102
  writeTextSync(fpath: string, content: string): void {
2075
2103
  this.ensureDirSync(path.dirname(fpath));
2076
- fs.writeFileSync(fpath, content);
2104
+ // New fork and branch transcripts are created here.
2105
+ writeExplicitOwnerOnlyTextSync(fpath, content);
2077
2106
  }
2078
2107
 
2079
2108
  readTextSync(fpath: string): string {
@@ -17,6 +17,7 @@ import {
17
17
  rebuildActiveSnapshot,
18
18
  removeActiveEntry,
19
19
  setActiveStateCacheInvalidator,
20
+ workflowEnvelopeChecksumStatus,
20
21
  writeActiveEntry,
21
22
  } from "../gjc-runtime/state-writer";
22
23
  import { getSkillManifest } from "../gjc-runtime/workflow-manifest";
@@ -519,6 +520,18 @@ async function readModeStatePhase(
519
520
  const phase = safeString(record.current_phase).trim();
520
521
  if (!phase) return undefined;
521
522
  if (record.active === false && !getSkillManifest("ralplan").canonicalOverrides.includes(phase)) return undefined;
523
+ const claimedSession = safeString(record.session_id).trim();
524
+ if (claimedSession && claimedSession !== sessionId) return undefined;
525
+ // A mode-state phase may replace the active entry only when the writer
526
+ // stamp still matches. A checksum mismatch is an out-of-band edit. An
527
+ // unsigned releasing phase is not a seal and must not paint "complete"
528
+ // onto a planning entry. Non-releasing canonical phases (final, handoff)
529
+ // still overlay, and a matching stamp still overlays a releasing phase.
530
+ const checksum = workflowEnvelopeChecksumStatus(record);
531
+ if (checksum === "mismatch") return undefined;
532
+ if (checksum === "absent" && getSkillManifest(skill).stopReleasingPhases.includes(phase.trim().toLowerCase())) {
533
+ return undefined;
534
+ }
522
535
  return phase;
523
536
  } catch {
524
537
  return undefined;
@@ -8,8 +8,10 @@ import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
8
8
  import { GJC_SESSION_PREFIX, modeStatePath as sessionModeStatePath } from "../gjc-runtime/session-layout";
9
9
  import { resolveGjcSessionForRead } from "../gjc-runtime/session-resolution";
10
10
  import { ModeStateSchema } from "../gjc-runtime/state-schema";
11
+ import { workflowEnvelopeChecksumStatus } from "../gjc-runtime/state-writer";
11
12
  import { getSkillManifest } from "../gjc-runtime/workflow-manifest";
12
13
  import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
14
+ import { isGithubReadOnlyArgs } from "../tools/github-side-effect";
13
15
  import { resolveToCwd } from "../tools/path-utils";
14
16
  import { ToolError } from "../tools/tool-errors";
15
17
  import { listActiveSkills, readVisibleSkillActiveState, type SkillActiveEntry } from "./active-state";
@@ -38,7 +40,14 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
38
40
  return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
39
41
  }
40
42
 
41
- const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
43
+ /** Tools whose execute path can mutate product state. `github` is included so side-effect ops share this guard. */
44
+ export const WORKFLOW_MUTATION_TOOL_NAMES = ["edit", "write", "ast_edit", "bash", "github"] as const;
45
+
46
+ const BLOCKED_TOOL_NAMES = new Set<string>(WORKFLOW_MUTATION_TOOL_NAMES);
47
+
48
+ export function isWorkflowMutationTool(name: string): boolean {
49
+ return BLOCKED_TOOL_NAMES.has(name);
50
+ }
42
51
  /**
43
52
  * Only `/dev/null` is exempt. `/dev/stdout`, `/dev/stderr`, and `/dev/fd/<n>` are descriptor
44
53
  * aliases: `exec 1<>src/product.ts; printf x >/dev/stdout` reaches a real repository file
@@ -317,10 +326,20 @@ function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill:
317
326
  * governs, so a stale planning entry can never block while an executor runs and
318
327
  * a resumed planning phase reliably re-blocks.
319
328
  *
320
- * Fail-open contract: a missing or invalid durable mode-state releases the block
321
- * (a corrupt state file must not lock all mutation), matching the guard's
322
- * historical behavior — this is intentionally looser than the Stop hook, which
323
- * fails closed for handoff-required skills.
329
+ * Fail-open contract: a missing or schema-invalid durable mode-state releases
330
+ * the block (a corrupt state file must not lock all mutation), matching the
331
+ * guard's historical behavior — this is intentionally looser than the Stop
332
+ * hook, which fails closed for handoff-required skills. A checksum mismatch is
333
+ * not that case: the file is still schema-valid, and the CLI already refuses
334
+ * it as an out-of-band edit. The guard keeps the active entry's phase instead
335
+ * of letting the forged `current_phase` or `active: false` release the block.
336
+ * An unsigned envelope may restate that entry, but it may not release a block
337
+ * the entry still holds. It may still name a stricter blocking phase: the
338
+ * pre-fix guard honored that phase, and dropping it would allow an edit the
339
+ * base rejected. A `session_id` or `thread_id` that does not match this
340
+ * session is the same kind of claim: it does not make the file someone else's,
341
+ * and it does not open the block. A matching `content_sha256` whose context
342
+ * also matches is the existing writer stamp and is trusted.
324
343
  */
325
344
  async function getActivePlanningSkill(
326
345
  cwd: string,
@@ -343,11 +362,73 @@ async function getActivePlanningSkill(
343
362
  const modeState = guardContext.modeStates.get(current.skill) ?? null;
344
363
 
345
364
  if (!modeState) return null;
346
- if (modeState.active !== true) return null;
347
- if (!modeStateMatchesContext(modeState, resolvedSessionId, threadId)) return null;
348
- const phase = String(modeState.current_phase ?? current.phase ?? "").trim();
349
- if (!isBlockingPlanningPhase(current.skill, phase)) return null;
350
- return { skill: current.skill, phase };
365
+ const activePhase = String(current.phase ?? "").trim();
366
+ const posture = planningPostureFromModeState(current.skill, activePhase, modeState, resolvedSessionId, threadId);
367
+ if (posture.tampered) {
368
+ warnInvalidModeState(
369
+ modeStatePath(cwd, current.skill, resolvedSessionId),
370
+ "out-of-band edit detected (content_sha256 mismatch)",
371
+ );
372
+ }
373
+ if (!posture.blocking) return null;
374
+ return { skill: current.skill, phase: posture.phase };
375
+ }
376
+
377
+ /**
378
+ * Decide whether `modeState` may release the planning block.
379
+ *
380
+ * The active entry is the seal. A mode-state file releases that seal only when
381
+ * its writer-stamped `content_sha256` matches and its session context matches,
382
+ * or when an unsigned file in the same context does not claim a looser posture
383
+ * than the entry. An unsigned file may still impose a stricter blocking phase.
384
+ * Checksum mismatch ignores the file. A context mismatch does not release the
385
+ * entry's block: the file was read from this session's path.
386
+ */
387
+ function planningPostureFromModeState(
388
+ skill: MutationGatedSkill,
389
+ activePhase: string,
390
+ modeState: ModeState,
391
+ sessionId?: string,
392
+ threadId?: string,
393
+ ): { blocking: boolean; phase: string; tampered: boolean } {
394
+ const status = workflowEnvelopeChecksumStatus(modeState);
395
+ if (status === "mismatch") {
396
+ return {
397
+ blocking: isBlockingPlanningPhase(skill, activePhase),
398
+ phase: activePhase,
399
+ tampered: true,
400
+ };
401
+ }
402
+ if (!modeStateMatchesContext(modeState, sessionId, threadId)) {
403
+ return {
404
+ blocking: isBlockingPlanningPhase(skill, activePhase),
405
+ phase: activePhase,
406
+ tampered: false,
407
+ };
408
+ }
409
+ if (status === "match") {
410
+ if (modeState.active !== true) return { blocking: false, phase: activePhase, tampered: false };
411
+ const phase = String(modeState.current_phase ?? activePhase).trim();
412
+ return { blocking: isBlockingPlanningPhase(skill, phase), phase, tampered: false };
413
+ }
414
+ if (!isBlockingPlanningPhase(skill, activePhase)) {
415
+ const stricterPhase = String(modeState.current_phase ?? "").trim();
416
+ if (modeState.active === true && stricterPhase && isBlockingPlanningPhase(skill, stricterPhase)) {
417
+ return { blocking: true, phase: stricterPhase, tampered: false };
418
+ }
419
+ return { blocking: false, phase: activePhase, tampered: false };
420
+ }
421
+ if (modeState.active !== true) return { blocking: true, phase: activePhase, tampered: false };
422
+ const modePhase = String(modeState.current_phase ?? "").trim();
423
+ if (
424
+ modePhase &&
425
+ !isBlockingPlanningPhase(skill, modePhase) &&
426
+ modePhase.trim().toLowerCase() !== activePhase.trim().toLowerCase()
427
+ ) {
428
+ return { blocking: true, phase: activePhase, tampered: false };
429
+ }
430
+ const phase = modePhase || activePhase;
431
+ return { blocking: isBlockingPlanningPhase(skill, phase), phase, tampered: false };
351
432
  }
352
433
 
353
434
  function normalizePosix(value: string): string {
@@ -1607,11 +1688,22 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1607
1688
  }
1608
1689
  return targets;
1609
1690
  }
1691
+ /**
1692
+ * Read-only github ops extract no mutation. Every other op fails closed during
1693
+ * planning: remote push, PR creation, and worktree/config writes are not a
1694
+ * filesystem path the planning allowlist can clear.
1695
+ */
1696
+ function extractGithubTargets(args: unknown): ExtractedTargets {
1697
+ if (isGithubReadOnlyArgs(args)) return { paths: [], unknown: false };
1698
+ return { paths: [], unknown: true, explicitMutation: true };
1699
+ }
1700
+
1610
1701
  function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
1611
1702
  if (tool.name === "write") return extractWriteTargets(args);
1612
1703
  if (tool.name === "ast_edit") return extractAstEditTargets(args);
1613
1704
  if (tool.name === "edit") return extractEditTargets(args, tool);
1614
1705
  if (tool.name === "bash") return extractBashTargets(args);
1706
+ if (tool.name === "github") return extractGithubTargets(args);
1615
1707
  return { paths: [], unknown: true };
1616
1708
  }
1617
1709
 
@@ -1050,7 +1050,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1050
1050
  runtime,
1051
1051
  );
1052
1052
  }
1053
- if (targetIds.some(targetId => targetId !== "default")) runtime.session.markUserModelSelection();
1054
1053
  // Preset shortcut: when the selector names a known model profile
1055
1054
  // (optionally `gajae-code/`-prefixed) and the target is implicit,
1056
1055
  // activate the profile immediately instead of treating the preset name
@@ -1113,6 +1112,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1113
1112
  throw new Error(`No API key for ${selection.model.provider}/${selection.model.id}`);
1114
1113
  }
1115
1114
  }
1115
+ if (includesRoleAgent && !includesDefault) runtime.session.markUserModelSelection();
1116
1116
 
1117
1117
  const overrides = runtime.settings.get("task.agentModelOverrides");
1118
1118
  const assignments = new Map<GjcModelAssignmentTargetId, string>();
@@ -9,6 +9,7 @@ import {
9
9
  updateMCPServer,
10
10
  } from "../../runtime-mcp/config-writer";
11
11
  import { MCPManager } from "../../runtime-mcp/manager";
12
+ import { nativeMcpSource } from "../../runtime-mcp/project-host-values";
12
13
  import { getSmitheryApiKey } from "../../runtime-mcp/smithery-auth";
13
14
  import { searchSmitheryRegistry } from "../../runtime-mcp/smithery-registry";
14
15
  import type { MCPServerConfig, MCPServerConnection } from "../../runtime-mcp/types";
@@ -199,6 +200,7 @@ async function withPreparedMcpConnection<T>(
199
200
  runtime: SlashCommandRuntime,
200
201
  name: string,
201
202
  config: MCPServerConfig,
203
+ scope: AcpMcpScope,
202
204
  fn: (connection: MCPServerConnection) => Promise<T>,
203
205
  ): Promise<T> {
204
206
  const manager = new MCPManager(runtime.cwd, null, {
@@ -207,7 +209,9 @@ async function withPreparedMcpConnection<T>(
207
209
  // Auth storage must be wired in before the prepared lease so OAuth-backed
208
210
  // servers can refresh credentials and inject Authorization headers.
209
211
  manager.setAuthStorage(runtime.session.modelRegistry.authStorage);
210
- return manager.withPreparedLease(name, config, lease => fn(lease.connectionForLease()));
212
+ return manager.withPreparedLease(name, config, lease => fn(lease.connectionForLease()), {
213
+ source: nativeMcpSource(scope, getMCPConfigPath(scope, runtime.cwd)),
214
+ });
211
215
  }
212
216
 
213
217
  async function collectConnectedMcpLines(
@@ -218,9 +222,9 @@ async function collectConnectedMcpLines(
218
222
  if (servers.length === 0) return undefined;
219
223
 
220
224
  const lines: string[] = [];
221
- for (const { name, config } of servers) {
225
+ for (const { name, config, scope } of servers) {
222
226
  try {
223
- const collected = await withPreparedMcpConnection(runtime, name, config, connection =>
227
+ const collected = await withPreparedMcpConnection(runtime, name, config, scope, connection =>
224
228
  collect(name, connection),
225
229
  );
226
230
  lines.push(...collected);
@@ -265,7 +269,7 @@ async function handleTestCommand(rest: string, runtime: SlashCommandRuntime): Pr
265
269
  if (!server) return usage(`Server "${name}" not found. Run /mcp list to see configured servers.`, runtime);
266
270
 
267
271
  try {
268
- return await withPreparedMcpConnection(runtime, name, server.config, async connection => {
272
+ return await withPreparedMcpConnection(runtime, name, server.config, server.scope, async connection => {
269
273
  const tools = await listTools(connection);
270
274
  const lines = [`Server "${name}" connected (${tools.length} tools).`];
271
275
  for (const tool of tools) lines.push(` - ${tool.name}`);