@evomap/evolver-core 2.0.0-beta.2 → 2.0.0-beta.22

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 (240) hide show
  1. package/assets/gep/genes.jsonl +5 -5
  2. package/dist/algo/bans.d.ts +10 -1
  3. package/dist/algo/bans.js +56 -6
  4. package/dist/algo/candidateAssembly.d.ts +25 -2
  5. package/dist/algo/candidateAssembly.js +171 -26
  6. package/dist/algo/capabilityCandidates.js +10 -0
  7. package/dist/algo/conversationSniffer.js +25 -8
  8. package/dist/algo/cycleEngine.d.ts +63 -5
  9. package/dist/algo/cycleEngine.js +320 -42
  10. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  11. package/dist/algo/evolutionEvent.d.ts +26 -0
  12. package/dist/algo/evolutionEvent.js +32 -0
  13. package/dist/algo/exploration.d.ts +7 -0
  14. package/dist/algo/exploration.js +16 -3
  15. package/dist/algo/geneHealth.d.ts +36 -3
  16. package/dist/algo/geneHealth.js +47 -4
  17. package/dist/algo/geneIntake.d.ts +46 -1
  18. package/dist/algo/geneIntake.js +121 -10
  19. package/dist/algo/geneSelection.d.ts +126 -6
  20. package/dist/algo/geneSelection.js +479 -36
  21. package/dist/algo/index.d.ts +6 -1
  22. package/dist/algo/index.js +6 -1
  23. package/dist/algo/kautoProjection.d.ts +41 -0
  24. package/dist/algo/kautoProjection.js +95 -0
  25. package/dist/algo/kautoValidator.d.ts +68 -0
  26. package/dist/algo/kautoValidator.js +256 -0
  27. package/dist/algo/memoryGraph.d.ts +62 -0
  28. package/dist/algo/memoryGraph.js +86 -0
  29. package/dist/algo/orchestrator.d.ts +17 -1
  30. package/dist/algo/orchestrator.js +30 -4
  31. package/dist/algo/publishEligibility.d.ts +34 -0
  32. package/dist/algo/publishEligibility.js +52 -0
  33. package/dist/algo/solidify.d.ts +11 -2
  34. package/dist/algo/solidify.js +37 -7
  35. package/dist/algo/ucb1.d.ts +53 -0
  36. package/dist/algo/ucb1.js +156 -0
  37. package/dist/assetrepair/hubRejection.d.ts +12 -0
  38. package/dist/assetrepair/hubRejection.js +109 -0
  39. package/dist/assetrepair/index.d.ts +2 -0
  40. package/dist/assetrepair/index.js +2 -0
  41. package/dist/assetrepair/repair.d.ts +33 -0
  42. package/dist/assetrepair/repair.js +155 -0
  43. package/dist/assetstore/assetSidecarRecords.d.ts +28 -0
  44. package/dist/assetstore/assetSidecarRecords.js +384 -0
  45. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  46. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  47. package/dist/assetstore/assetStoreHealth.d.ts +81 -0
  48. package/dist/assetstore/assetStoreHealth.js +319 -0
  49. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  50. package/dist/assetstore/assetStoreLayout.js +6 -0
  51. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  52. package/dist/assetstore/assetStoreStorage.js +336 -0
  53. package/dist/assetstore/assetSyncLedger.d.ts +91 -1
  54. package/dist/assetstore/assetSyncLedger.js +718 -59
  55. package/dist/assetstore/foreignJsonlSource.d.ts +48 -0
  56. package/dist/assetstore/foreignJsonlSource.js +150 -0
  57. package/dist/assetstore/index.d.ts +5 -0
  58. package/dist/assetstore/index.js +5 -0
  59. package/dist/assetstore/learningHistory.js +3 -3
  60. package/dist/assetstore/localAssetStoreSnapshot.d.ts +51 -0
  61. package/dist/assetstore/localAssetStoreSnapshot.js +329 -0
  62. package/dist/assetstore/localJsonl.d.ts +12 -1
  63. package/dist/assetstore/localJsonl.js +251 -42
  64. package/dist/assetstore/provenance.d.ts +93 -4
  65. package/dist/assetstore/provenance.js +368 -84
  66. package/dist/assetstore/provider.d.ts +63 -0
  67. package/dist/assetstore/provider.js +97 -6
  68. package/dist/assetstore/reviewFilter.d.ts +19 -1
  69. package/dist/assetstore/reviewFilter.js +39 -1
  70. package/dist/assetstore/reviewLedger.d.ts +8 -2
  71. package/dist/assetstore/reviewLedger.js +71 -45
  72. package/dist/assetstore/unionReadStore.d.ts +25 -0
  73. package/dist/assetstore/unionReadStore.js +119 -0
  74. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  75. package/dist/benchmark/antiGeneBenchmark.js +4 -3
  76. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  77. package/dist/benchmark/antiGeneRollout.js +4 -3
  78. package/dist/benchmark/index.d.ts +3 -1
  79. package/dist/benchmark/index.js +3 -1
  80. package/dist/benchmark/selectionFlatAbstention.d.ts +152 -0
  81. package/dist/benchmark/selectionFlatAbstention.js +481 -0
  82. package/dist/benchmark/triggerShift.d.ts +62 -0
  83. package/dist/benchmark/triggerShift.js +106 -0
  84. package/dist/bootstrap/envFingerprint.d.ts +9 -0
  85. package/dist/bootstrap/envFingerprint.js +5 -0
  86. package/dist/bootstrap/index.d.ts +3 -1
  87. package/dist/bootstrap/index.js +3 -1
  88. package/dist/bootstrap/lifecycleBootstrap.d.ts +111 -0
  89. package/dist/bootstrap/lifecycleBootstrap.js +433 -0
  90. package/dist/bootstrap/v1EnvCompat.d.ts +113 -0
  91. package/dist/bootstrap/v1EnvCompat.js +303 -0
  92. package/dist/events/eventArchive.d.ts +2 -0
  93. package/dist/events/eventArchive.js +13 -3
  94. package/dist/events/eventSchema.d.ts +7 -7
  95. package/dist/events/eventStore.d.ts +2 -0
  96. package/dist/events/eventStore.js +5 -1
  97. package/dist/events/ingest.d.ts +2 -1
  98. package/dist/events/ingest.js +15 -0
  99. package/dist/events/paths.d.ts +12 -10
  100. package/dist/events/paths.js +24 -20
  101. package/dist/events/public.d.ts +2 -2
  102. package/dist/events/public.js +2 -2
  103. package/dist/events/reports.d.ts +2 -0
  104. package/dist/events/reports.js +4 -0
  105. package/dist/exec/autoExec.d.ts +68 -4
  106. package/dist/exec/autoExec.js +392 -33
  107. package/dist/exec/autonomousCycle.d.ts +31 -4
  108. package/dist/exec/autonomousCycle.js +71 -13
  109. package/dist/exec/claudeBridge.d.ts +80 -15
  110. package/dist/exec/claudeBridge.js +845 -76
  111. package/dist/exec/executionBinding.d.ts +414 -0
  112. package/dist/exec/executionBinding.js +588 -0
  113. package/dist/exec/index.d.ts +1 -0
  114. package/dist/exec/index.js +1 -0
  115. package/dist/exec/openPrRegistry.d.ts +8 -2
  116. package/dist/exec/openPrRegistry.js +32 -22
  117. package/dist/exec/prompt.js +14 -1
  118. package/dist/exec/proofOfWork.d.ts +1 -1
  119. package/dist/exec/proofOfWork.js +2 -2
  120. package/dist/exec/runnerRegistry.d.ts +153 -36
  121. package/dist/exec/runnerRegistry.js +848 -65
  122. package/dist/exec/selfPr.js +1 -7
  123. package/dist/feedback/envelope.d.ts +61 -0
  124. package/dist/feedback/envelope.js +168 -0
  125. package/dist/feedback/index.d.ts +1 -0
  126. package/dist/feedback/index.js +1 -0
  127. package/dist/hooks/hooks.js +1 -0
  128. package/dist/hub/assetCallLog.d.ts +35 -1
  129. package/dist/hub/assetCallLog.js +124 -1
  130. package/dist/hub/bindings.d.ts +8 -1
  131. package/dist/hub/bindings.js +29 -8
  132. package/dist/hub/capability.d.ts +130 -4
  133. package/dist/hub/conversationDistiller.d.ts +19 -0
  134. package/dist/hub/conversationDistiller.js +115 -37
  135. package/dist/hub/fake.d.ts +3 -2
  136. package/dist/hub/fake.js +2 -1
  137. package/dist/hub/index.d.ts +1 -0
  138. package/dist/hub/index.js +1 -0
  139. package/dist/hub/questionGenerator.d.ts +5 -1
  140. package/dist/hub/questionGenerator.js +8 -6
  141. package/dist/hub/recipeCompose.d.ts +27 -0
  142. package/dist/hub/recipeCompose.js +90 -0
  143. package/dist/hub/sanitize.js +122 -7
  144. package/dist/index.d.ts +6 -1
  145. package/dist/index.js +7 -1
  146. package/dist/issueReporter/index.d.ts +156 -0
  147. package/dist/issueReporter/index.js +1688 -0
  148. package/dist/mailbox/dispatch.d.ts +1 -1
  149. package/dist/mailbox/dispatch.js +22 -6
  150. package/dist/mailbox/envelope.d.ts +7 -1
  151. package/dist/mailbox/envelope.js +9 -2
  152. package/dist/mailbox/ipcServer.d.ts +12 -2
  153. package/dist/mailbox/ipcServer.js +183 -13
  154. package/dist/mailbox/store.d.ts +89 -3
  155. package/dist/mailbox/store.js +895 -41
  156. package/dist/modelCompatibility.d.ts +164 -0
  157. package/dist/modelCompatibility.js +309 -0
  158. package/dist/observers/valueDigestObserver.d.ts +9 -0
  159. package/dist/observers/valueDigestObserver.js +35 -2
  160. package/dist/ops/cleanup.js +1 -1
  161. package/dist/ops/evolutionGraphProjection.d.ts +20 -0
  162. package/dist/ops/evolutionGraphProjection.js +315 -0
  163. package/dist/ops/index.d.ts +2 -1
  164. package/dist/ops/index.js +2 -1
  165. package/dist/ops/savingsCore.js +1 -2
  166. package/dist/ops/selfUpdate.d.ts +18 -1
  167. package/dist/ops/selfUpdate.js +88 -23
  168. package/dist/ops/valueOutreach.d.ts +3 -1
  169. package/dist/ops/valueOutreach.js +5 -1
  170. package/dist/personality/schema.d.ts +24 -24
  171. package/dist/schema/evolutionGraph.d.ts +784 -0
  172. package/dist/schema/evolutionGraph.js +187 -0
  173. package/dist/schema/index.d.ts +1 -0
  174. package/dist/schema/index.js +1 -0
  175. package/dist/schema/proofOfWork.d.ts +125 -6
  176. package/dist/schema/proofOfWork.js +102 -4
  177. package/dist/schema/signal.d.ts +3 -3
  178. package/dist/schema/signal.js +1 -1
  179. package/dist/shadow/shadowHub.js +1 -0
  180. package/dist/signals/curriculum.d.ts +55 -0
  181. package/dist/signals/curriculum.js +202 -0
  182. package/dist/signals/cycleHistoryFromEvents.js +17 -8
  183. package/dist/signals/expand.d.ts +15 -1
  184. package/dist/signals/expand.js +169 -1
  185. package/dist/signals/extractor.d.ts +2 -2
  186. package/dist/signals/extractor.js +31 -6
  187. package/dist/signals/index.d.ts +4 -1
  188. package/dist/signals/index.js +4 -1
  189. package/dist/signals/metaSignals.d.ts +4 -0
  190. package/dist/signals/metaSignals.js +42 -0
  191. package/dist/signals/scopeVocabulary.d.ts +75 -0
  192. package/dist/signals/scopeVocabulary.js +91 -0
  193. package/dist/signals/signalGate.js +1 -1
  194. package/dist/signals/taskDomain.d.ts +22 -0
  195. package/dist/signals/taskDomain.js +43 -0
  196. package/dist/strategy/constraintAblation.d.ts +64 -0
  197. package/dist/strategy/constraintAblation.js +2820 -0
  198. package/dist/strategy/constraintAblationPredicates.d.ts +31 -0
  199. package/dist/strategy/constraintAblationPredicates.js +339 -0
  200. package/dist/strategy/index.d.ts +2 -1
  201. package/dist/strategy/index.js +2 -1
  202. package/dist/trace/index.d.ts +3 -1
  203. package/dist/trace/index.js +3 -1
  204. package/dist/trace/learningTrace.d.ts +216 -0
  205. package/dist/trace/learningTrace.js +298 -0
  206. package/dist/trace/proxyTurns.d.ts +31 -0
  207. package/dist/trace/proxyTurns.js +137 -0
  208. package/dist/trace/trajectory.d.ts +8 -0
  209. package/dist/trace/trajectory.js +14 -2
  210. package/dist/util/fetchPort.d.ts +1 -0
  211. package/dist/util/fetchPort.js +11 -0
  212. package/dist/util/fileLock.d.ts +113 -7
  213. package/dist/util/fileLock.js +1035 -80
  214. package/dist/util/index.d.ts +3 -1
  215. package/dist/util/index.js +2 -1
  216. package/dist/verify/index.d.ts +2 -1
  217. package/dist/verify/index.js +1 -1
  218. package/dist/verify/sandboxRunner.d.ts +30 -0
  219. package/dist/verify/sandboxRunner.js +370 -27
  220. package/dist/verify/sandboxedValidation.d.ts +16 -2
  221. package/dist/verify/sandboxedValidation.js +200 -22
  222. package/dist/verify/validation.d.ts +35 -4
  223. package/dist/verify/validation.js +164 -16
  224. package/dist/wire/geneHints.d.ts +88 -16
  225. package/dist/wire/geneHints.js +124 -15
  226. package/dist/wire/index.d.ts +8 -3
  227. package/dist/wire/index.js +2 -2
  228. package/dist/wire/schemaGate.d.ts +21 -0
  229. package/dist/wire/schemaGate.js +119 -10
  230. package/dist/workflow/dsl.d.ts +24 -3
  231. package/dist/workflow/dsl.js +4 -0
  232. package/dist/workflow/engine.d.ts +5 -1
  233. package/dist/workflow/engine.js +3 -0
  234. package/dist/workflow/index.d.ts +3 -1
  235. package/dist/workflow/index.js +3 -1
  236. package/dist/workflow/runtime.d.ts +110 -0
  237. package/dist/workflow/runtime.js +1298 -0
  238. package/dist/workflow/stateStore.d.ts +172 -0
  239. package/dist/workflow/stateStore.js +1044 -0
  240. package/package.json +12 -4
@@ -11,19 +11,20 @@
11
11
  // SAFETY: default-OFF. The factory throws ExecBridgeDisabledError unless explicitly enabled (opts.enabled)
12
12
  // or EVOLVE_EXEC_BRIDGE === '1'. Wiring it in by accident must fail loudly rather than silently spawn an
13
13
  // autonomous agent.
14
- import { resolve as resolvePath, sep, join as joinPath } from 'node:path';
15
- import { tmpdir } from 'node:os';
16
- import { writeFileSync } from 'node:fs';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { basename, dirname, resolve as resolvePath, sep, join as joinPath } from 'node:path';
16
+ import { homedir, tmpdir } from 'node:os';
17
+ import { closeSync, existsSync, lstatSync, mkdtempSync, openSync, readdirSync, realpathSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
17
18
  import { renderExecPrompt } from './prompt.js';
18
19
  import { parseGitShortstat, gitDiffProof } from './proofOfWork.js';
19
20
  // Policy enforcement core (#107): checkPolicy runs the always-on global guards (blast hard cap +
20
21
  // protected paths + destructive deletes) on EVERY exec — gene or not — plus the per-gene constraints when a
21
22
  // gene supplies them. It supersedes the old gene-gated `checkChangeConstraints` call (the no-gene-no-guard hole).
22
23
  import { checkPolicy, summarizeViolations } from './policy/index.js';
23
- import { spawnCapture, getRunnerSpec, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
24
+ import { spawnCapture, SpawnCaptureFinalizeError, getRunnerSpec, validateAgentSessionResume, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
24
25
  // Re-export the runner layer so existing importers of ./claudeBridge.js (and the `exec` namespace) keep their
25
26
  // surface after the #91-6 split — the registry simply has a clearer home now.
26
- export { resolveSpawnCommand, spawnCapture, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, } from './runnerRegistry.js';
27
+ export { resolveSpawnCommand, spawnCapture, DEFAULT_MAX_CAPTURE_BYTES, MAX_AGENT_SESSION_ID_CHARS, AgentSessionResumeError, validateAgentSessionResume, UnboundedSkipPermissionsError, UnsupportedCodexPermissionOptionsError, UnsupportedCursorAllowedToolsError, UnsupportedCursorSkipPermissionsError, UnsupportedCursorWorkspaceTrustError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, hasBoundedClaudeFileAccess, CLAUDE_SAFE_AUTONOMOUS_TOOLS, geminiRunnerArgs, makeGeminiHeadlessRunner, classifyGeminiRunnerResult, } from './runnerRegistry.js';
27
28
  export class ExecBridgeDisabledError extends Error {
28
29
  constructor() {
29
30
  super('exec bridge is disabled — set EVOLVE_EXEC_BRIDGE=1 or pass { enabled: true } to enable agent execution');
@@ -41,12 +42,11 @@ export class ExecBridgeForbiddenError extends Error {
41
42
  * Thrown when a FULL-ACCESS (or unverified) agent run is requested without worktree isolation. The throwaway
42
43
  * worktree is the containment WE control — allowedRoots gates the cwd but cannot stop an auto-approved or
43
44
  * unsandboxed process writing/running outside it. Gated runners:
44
- * - codex with skipPermissions `--sandbox danger-full-access` (no inner OS sandbox). Without skip it stays
45
- * workspace-write, so only the skip path is gated.
45
+ * - codex permission overrides are refused by the runner because it has no enforceable per-tool allowlist.
46
46
  * - cursor default → gated UNCONDITIONALLY. cursor-agent base `-p` already documents write+shell access, cursor
47
47
  * skipPermissions is rejected by the runner layer, and the scaffold remains unverified (#66/#181) — so default
48
48
  * cursor still needs the wrapper worktree rather than risk mutating the real tree.
49
- * claude is exempt: its skip is bounded by --allowedTools (finding #80).
49
+ * Claude is fail-closed below because a tool-name allowlist does not constrain absolute filesystem paths.
50
50
  */
51
51
  export class UnsandboxedFullAccessRequiresIsolationError extends Error {
52
52
  constructor() {
@@ -54,11 +54,243 @@ export class UnsandboxedFullAccessRequiresIsolationError extends Error {
54
54
  this.name = 'UnsandboxedFullAccessRequiresIsolationError';
55
55
  }
56
56
  }
57
- /** Whether `child` is the same as, or nested under, `root` (both resolved to absolute paths). */
58
- function isWithinRoot(child, root) {
59
- const c = resolvePath(child);
60
- const r = resolvePath(root);
61
- return c === r || c.startsWith(r.endsWith(sep) ? r : r + sep);
57
+ export class WorkspaceTrustRequiresIsolationError extends Error {
58
+ code = 'WORKSPACE_TRUST_REQUIRES_ISOLATION';
59
+ constructor() {
60
+ super('workspaceTrust=isolated-worktree requires isolation=worktree');
61
+ this.name = 'WorkspaceTrustRequiresIsolationError';
62
+ }
63
+ }
64
+ export class UnsupportedCursorBuiltInRunnerError extends Error {
65
+ constructor() {
66
+ super('built-in cursor autonomous execution is unsupported until host filesystem and network containment is verified');
67
+ this.name = 'UnsupportedCursorBuiltInRunnerError';
68
+ }
69
+ }
70
+ export class UnsupportedClaudeBuiltInRunnerError extends Error {
71
+ constructor() {
72
+ super('built-in claude autonomous execution is unsupported until host filesystem and network containment is verified');
73
+ this.name = 'UnsupportedClaudeBuiltInRunnerError';
74
+ }
75
+ }
76
+ export class UnsupportedCodexBuiltInRunnerError extends Error {
77
+ constructor() {
78
+ super('built-in codex autonomous execution is unsupported because workspace-write does not contain host filesystem reads');
79
+ this.name = 'UnsupportedCodexBuiltInRunnerError';
80
+ }
81
+ }
82
+ export class UnsafeWorktreePathError extends Error {
83
+ constructor(reason) {
84
+ super(`worktree isolation refused: ${reason}`);
85
+ this.name = 'UnsafeWorktreePathError';
86
+ }
87
+ }
88
+ function errorCode(error) {
89
+ return typeof error === 'object' && error !== null && 'code' in error
90
+ ? String(error.code)
91
+ : undefined;
92
+ }
93
+ function assertPathAbsent(path) {
94
+ try {
95
+ lstatSync(path);
96
+ }
97
+ catch (error) {
98
+ if (errorCode(error) === 'ENOENT')
99
+ return;
100
+ throw error;
101
+ }
102
+ throw new UnsafeWorktreePathError('reserved destination already exists');
103
+ }
104
+ function reserveWorktreePath() {
105
+ // The random 0700 container is atomically created by the OS. The git destination remains absent inside it,
106
+ // so `git worktree add` cannot adopt an attacker-precreated path from the shared temp directory.
107
+ const container = mkdtempSync(joinPath(tmpdir(), 'evolver-wt-'));
108
+ const containerStat = lstatSync(container);
109
+ if (containerStat.isSymbolicLink() || !containerStat.isDirectory()) {
110
+ throw new UnsafeWorktreePathError('temporary reservation is not a real directory');
111
+ }
112
+ const workDir = joinPath(container, 'worktree');
113
+ assertPathAbsent(workDir);
114
+ return {
115
+ container,
116
+ containerDev: containerStat.dev,
117
+ containerIno: containerStat.ino,
118
+ workDir,
119
+ expectedRealPath: joinPath(realpathSync(container), 'worktree'),
120
+ };
121
+ }
122
+ function verifyWorktreePath(reservation) {
123
+ const stat = lstatSync(reservation.workDir);
124
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
125
+ throw new UnsafeWorktreePathError('git worktree destination is not a real directory');
126
+ }
127
+ if (realpathSync(reservation.workDir) !== reservation.expectedRealPath) {
128
+ throw new UnsafeWorktreePathError('git worktree destination resolves outside its reservation');
129
+ }
130
+ return { dev: stat.dev, ino: stat.ino };
131
+ }
132
+ function worktreePathStillOwned(reservation, identity) {
133
+ try {
134
+ const stat = lstatSync(reservation.workDir);
135
+ return !stat.isSymbolicLink()
136
+ && stat.isDirectory()
137
+ && stat.dev === identity.dev
138
+ && stat.ino === identity.ino
139
+ && realpathSync(reservation.workDir) === reservation.expectedRealPath;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function verifyManagedCursorWorktree(path, expectedName, repoCwd, git, managedRoot) {
146
+ const resolvedPath = resolvePath(path);
147
+ const cursorRoot = realpathSync(managedRoot);
148
+ if (resolvedPath !== path) {
149
+ throw new UnsafeWorktreePathError('Cursor reported a non-canonical worktree path');
150
+ }
151
+ const realPath = realpathSync(resolvedPath);
152
+ if (!isWithinRoot(realPath, cursorRoot)) {
153
+ throw new UnsafeWorktreePathError('Cursor reported a worktree outside its managed root');
154
+ }
155
+ if (basename(realPath) !== expectedName) {
156
+ throw new UnsafeWorktreePathError('Cursor reported an unexpected worktree name');
157
+ }
158
+ const stat = lstatSync(resolvedPath);
159
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
160
+ throw new UnsafeWorktreePathError('Cursor worktree is not a real directory');
161
+ }
162
+ const [repoCommonDir, worktreeCommonDir] = await Promise.all([
163
+ git(['rev-parse', '--path-format=absolute', '--git-common-dir'], repoCwd),
164
+ git(['rev-parse', '--path-format=absolute', '--git-common-dir'], realPath),
165
+ ]);
166
+ if (realpathSync(repoCommonDir.trim()) !== realpathSync(worktreeCommonDir.trim())) {
167
+ throw new UnsafeWorktreePathError('Cursor worktree belongs to a different repository');
168
+ }
169
+ return { path: realPath, dev: stat.dev, ino: stat.ino };
170
+ }
171
+ async function cleanupManagedCursorWorktree(identity, git, repoCwd) {
172
+ const stat = lstatSync(identity.path);
173
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) {
174
+ throw new UnsafeWorktreePathError('verified Cursor worktree path changed before cleanup');
175
+ }
176
+ await git(['worktree', 'remove', '--force', identity.path], repoCwd, undefined, { processSignalMode: 'ignore' });
177
+ assertPathAbsent(identity.path);
178
+ }
179
+ async function discoverManagedCursorWorktree(expectedName, repoCwd, git, managedRoot) {
180
+ const cursorRoot = managedRoot;
181
+ if (!existsSync(cursorRoot))
182
+ return undefined;
183
+ const verified = [];
184
+ let validationError;
185
+ for (const entry of readdirSync(cursorRoot, { withFileTypes: true })) {
186
+ if (!entry.isDirectory())
187
+ continue;
188
+ const candidate = joinPath(cursorRoot, entry.name, expectedName);
189
+ if (!existsSync(candidate))
190
+ continue;
191
+ try {
192
+ verified.push(await verifyManagedCursorWorktree(candidate, expectedName, repoCwd, git, cursorRoot));
193
+ }
194
+ catch (error) {
195
+ // Unverified same-name paths are never read or removed.
196
+ validationError ??= error;
197
+ }
198
+ }
199
+ if (verified.length > 1) {
200
+ throw new UnsafeWorktreePathError('multiple verified Cursor worktrees matched the generated name');
201
+ }
202
+ if (verified.length === 0 && validationError)
203
+ throw validationError;
204
+ return verified[0];
205
+ }
206
+ async function resolveManagedCursorWorktree(reportedPath, expectedName, repoCwd, git, managedRoot) {
207
+ if (!reportedPath)
208
+ return discoverManagedCursorWorktree(expectedName, repoCwd, git, managedRoot);
209
+ try {
210
+ return await verifyManagedCursorWorktree(reportedPath, expectedName, repoCwd, git, managedRoot);
211
+ }
212
+ catch (reportedPathError) {
213
+ const discovered = await discoverManagedCursorWorktree(expectedName, repoCwd, git, managedRoot);
214
+ if (discovered)
215
+ return discovered;
216
+ throw reportedPathError;
217
+ }
218
+ }
219
+ function removeEmptyReservation(reservation) {
220
+ try {
221
+ const stat = lstatSync(reservation.container);
222
+ if (stat.isSymbolicLink() || !stat.isDirectory()
223
+ || stat.dev !== reservation.containerDev || stat.ino !== reservation.containerIno)
224
+ return;
225
+ rmdirSync(reservation.container);
226
+ }
227
+ catch (error) {
228
+ // Never recurse through a path that may have been replaced. Empty, owned reservations are the only thing
229
+ // removed directly; non-empty or already-gone containers are intentionally left for safe diagnosis.
230
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(errorCode(error) ?? ''))
231
+ throw error;
232
+ }
233
+ }
234
+ async function cleanupWorktreeReservation(reservation, identity, git, repoCwd) {
235
+ let cleanupIdentity = identity;
236
+ if (!cleanupIdentity) {
237
+ try {
238
+ cleanupIdentity = verifyWorktreePath(reservation);
239
+ }
240
+ catch (error) {
241
+ if (errorCode(error) !== 'ENOENT')
242
+ throw error;
243
+ // `git worktree add` may register metadata before failing without creating the destination. Give Git a
244
+ // signal-shielded chance to prune that partial registration, then reclaim only the still-empty container.
245
+ try {
246
+ await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
247
+ }
248
+ catch { /* no child exists, so an unregistered cleanup failure is harmless */ }
249
+ removeEmptyReservation(reservation);
250
+ return;
251
+ }
252
+ }
253
+ if (cleanupIdentity) {
254
+ if (!worktreePathStillOwned(reservation, cleanupIdentity)) {
255
+ throw new UnsafeWorktreePathError('verified worktree path changed before cleanup');
256
+ }
257
+ await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
258
+ assertPathAbsent(reservation.workDir);
259
+ }
260
+ removeEmptyReservation(reservation);
261
+ }
262
+ /** Resolve existing path components so an allowlisted symlink cannot redirect execution outside its root. */
263
+ function canonicalPath(path) {
264
+ let current = resolvePath(path);
265
+ const missing = [];
266
+ while (true) {
267
+ try {
268
+ return resolvePath(realpathSync(current), ...missing.reverse());
269
+ }
270
+ catch (error) {
271
+ const code = error.code;
272
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
273
+ return undefined;
274
+ const parent = dirname(current);
275
+ if (parent === current)
276
+ return undefined;
277
+ missing.push(basename(current));
278
+ current = parent;
279
+ }
280
+ }
281
+ }
282
+ /** Whether `child` is the same as, or nested under, `root`, including filesystem symlink resolution. */
283
+ export function isWithinRoot(child, root) {
284
+ return canonicalPathWithinRoots(child, [root]) !== undefined;
285
+ }
286
+ function canonicalPathWithinRoots(child, roots) {
287
+ const c = canonicalPath(child);
288
+ if (!c)
289
+ return undefined;
290
+ return roots.some((root) => {
291
+ const r = canonicalPath(root);
292
+ return Boolean(r && (c === r || c.startsWith(r.endsWith(sep) ? r : r + sep)));
293
+ }) ? c : undefined;
62
294
  }
63
295
  /**
64
296
  * Sensitive env keys to strip before spawning an agent/tool (finding #39.2): evolver/hub/cloud secrets must
@@ -76,6 +308,11 @@ const ALLOW_ENV_KEYS = new Set([
76
308
  'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TZ', 'PWD', 'HOSTNAME',
77
309
  'TMPDIR', 'TMP', 'TEMP', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
78
310
  'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
311
+ // Node's built-in fetch (undici) does NOT honor HTTP(S)_PROXY unless NODE_USE_ENV_PROXY=1 is set (Node 20+).
312
+ // An agent CLI that uses global fetch (e.g. the gemini CLI on system Node) is otherwise unreachable behind a
313
+ // proxy even though the proxy vars above are allowed. This is a neutral runtime knob, not a secret — pair it
314
+ // with the proxy vars so a proxied agent can actually reach its API. Absent (unset) → no-op.
315
+ 'NODE_USE_ENV_PROXY',
79
316
  // Windows OS essentials (absent on POSIX → no-op there): a process can't run, resolve PATHEXT shims, or find
80
317
  // its own per-user config/auth dir without these. They are OS runtime vars, NOT app secrets — the secret
81
318
  // env (NODE_SECRET/AWS_*/GH_TOKEN/vendor keys) is still denied by the whitelist. Their absence is why an
@@ -124,49 +361,284 @@ export function scrubAgentEnv(env, opts = {}) {
124
361
  }
125
362
  return out;
126
363
  }
127
- /** Default git runner: spawn `git <args>` in cwd, return stdout (empty string on error). Env scrubbed — git never needs evolver/hub secrets. */
128
- export const defaultGitRunner = async (args, cwd) => {
364
+ class ExecBridgeRunCancelledError extends Error {
365
+ constructor() {
366
+ super('exec bridge run cancelled');
367
+ this.name = 'ExecBridgeRunCancelledError';
368
+ }
369
+ }
370
+ /**
371
+ * 绑定执行不能接受无法收到权威截止时间取消信号的旧验证器或写入器。
372
+ * AbortSignal 本身是协作式的;先拒绝不暴露该信号的回调,避免旧 hook
373
+ * 被静默地当作无界工作运行。
374
+ */
375
+ export class ExecBridgeCancellationContractError extends Error {
376
+ constructor(hook) {
377
+ super(`bound execution ${hook} must accept AbortSignal so deadline cancellation can be enforced`);
378
+ this.name = 'ExecBridgeCancellationContractError';
379
+ }
380
+ }
381
+ class ExecBridgeRunTimedOutError extends Error {
382
+ constructor() {
383
+ super('exec bridge run timed out');
384
+ this.name = 'ExecBridgeRunTimedOutError';
385
+ }
386
+ }
387
+ /**
388
+ * 将绑定运行时限制应用到完整 bridge 生命周期,而不仅是 agent 子进程。验证和补丁持久化等每个操作都会
389
+ * 收到同一个派生信号。无法接受该信号的回调会在绑定运行启动前被拒绝(见
390
+ * ExecBridgeCancellationContractError),因此下面的 Promise.race 仅作为不遵守协作式取消约定时的最后
391
+ * 响应边界,而不是隐式放行旧 hook。
392
+ */
393
+ function createExecutionDeadline(parentSignal, maxRuntimeMs) {
394
+ const controller = new AbortController();
395
+ let expired = false;
396
+ let rejectInterrupt = () => { };
397
+ const interrupt = new Promise((_, reject) => { rejectInterrupt = reject; });
398
+ const onParentAbort = () => {
399
+ if (controller.signal.aborted)
400
+ return;
401
+ controller.abort(parentSignal?.reason ?? new ExecBridgeRunCancelledError());
402
+ rejectInterrupt(new ExecBridgeRunCancelledError());
403
+ };
404
+ const timer = maxRuntimeMs === undefined ? undefined : setTimeout(() => {
405
+ expired = true;
406
+ controller.abort(new ExecBridgeRunTimedOutError());
407
+ rejectInterrupt(new ExecBridgeRunTimedOutError());
408
+ }, Math.min(maxRuntimeMs, 2_147_483_647));
409
+ parentSignal?.addEventListener('abort', onParentAbort, { once: true });
410
+ if (parentSignal?.aborted)
411
+ onParentAbort();
412
+ return {
413
+ // 未配置本地运行时定时器时保留调用方显式传入的 parent signal,维持既有取消身份;绑定运行始终使用
414
+ // 带截止时间的派生信号。
415
+ signal: maxRuntimeMs === undefined && parentSignal ? parentSignal : controller.signal,
416
+ expired: () => expired,
417
+ assertActive: () => {
418
+ if (expired)
419
+ throw new ExecBridgeRunTimedOutError();
420
+ if (parentSignal?.aborted)
421
+ throw new ExecBridgeRunCancelledError();
422
+ },
423
+ run: async (operation) => {
424
+ if (expired)
425
+ throw new ExecBridgeRunTimedOutError();
426
+ if (parentSignal?.aborted)
427
+ throw new ExecBridgeRunCancelledError();
428
+ const value = await Promise.race([Promise.resolve().then(operation), interrupt]);
429
+ if (expired)
430
+ throw new ExecBridgeRunTimedOutError();
431
+ if (parentSignal?.aborted)
432
+ throw new ExecBridgeRunCancelledError();
433
+ return value;
434
+ },
435
+ dispose: () => {
436
+ if (timer)
437
+ clearTimeout(timer);
438
+ parentSignal?.removeEventListener('abort', onParentAbort);
439
+ },
440
+ };
441
+ }
442
+ class AgentRunBeforeManagedWorktreeError extends Error {
443
+ constructor() {
444
+ super('agent run failed before a managed worktree was created');
445
+ this.name = 'AgentRunBeforeManagedWorktreeError';
446
+ }
447
+ }
448
+ class GitProofError extends Error {
449
+ constructor(message) {
450
+ super(message);
451
+ this.name = 'GitProofError';
452
+ }
453
+ }
454
+ class GitOutputTruncatedError extends GitProofError {
455
+ constructor(bytes) {
456
+ super(`git output exceeded the capture limit (${String(bytes ?? 'unknown')} bytes)`);
457
+ this.name = 'GitOutputTruncatedError';
458
+ }
459
+ }
460
+ function cancelledExecutionResult(run) {
461
+ return {
462
+ outcome: { status: 'failed', score: 0.1, reason: 'execution cancelled' },
463
+ strongEvidence: false,
464
+ failureKind: 'cancelled',
465
+ exitCode: run?.exitCode ?? null,
466
+ ...(run ? { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output } : {}),
467
+ };
468
+ }
469
+ function timedOutExecutionResult(run) {
470
+ return {
471
+ outcome: { status: 'failed', score: 0.1, reason: 'execution timed out' },
472
+ strongEvidence: false,
473
+ failureKind: 'timeout',
474
+ exitCode: run?.exitCode ?? null,
475
+ ...(run ? { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output } : {}),
476
+ };
477
+ }
478
+ function failedProofExecutionResult(run, error, proofOfWork) {
479
+ return {
480
+ outcome: { status: 'failed', score: 0.1, reason: `execution proof failed: ${error.message}` },
481
+ ...(proofOfWork ? { proofOfWork } : {}),
482
+ strongEvidence: false,
483
+ failureKind: run.failureKind ?? 'runtime_error',
484
+ exitCode: run.exitCode ?? null,
485
+ sessionLog: run.error ? `${run.output}\n${run.error}` : run.output,
486
+ };
487
+ }
488
+ function failedAgentRunResult(run) {
489
+ return {
490
+ outcome: { status: 'failed', score: 0.1, reason: run.error ?? run.failureKind ?? 'agent run failed' },
491
+ strongEvidence: false,
492
+ ...(run.failureKind !== undefined ? { failureKind: run.failureKind } : {}),
493
+ ...(run.exitCode !== undefined ? { exitCode: run.exitCode } : {}),
494
+ sessionLog: run.error ? `${run.output}\n${run.error}` : run.output,
495
+ };
496
+ }
497
+ /** Default git runner: every incomplete command result fails closed. */
498
+ export const defaultGitRunner = async (args, cwd, signal, options) => {
499
+ let result;
129
500
  try {
130
- const r = await spawnCapture('git', args, { cwd, timeoutMs: 30_000, env: scrubAgentEnv(process.env) });
131
- return r.stdout;
501
+ result = await spawnCapture('git', args, {
502
+ cwd,
503
+ timeoutMs: 30_000,
504
+ env: scrubAgentEnv(process.env),
505
+ ...(signal ? { signal } : {}),
506
+ ...(options?.processSignalMode ? { processSignalMode: options.processSignalMode } : {}),
507
+ });
132
508
  }
133
509
  catch {
134
- return '';
510
+ throw new GitProofError('git command failed to start or capture output');
135
511
  }
512
+ if (result.termination === 'cancelled')
513
+ throw new ExecBridgeRunCancelledError();
514
+ if (result.termination === 'timeout')
515
+ throw new GitProofError('git command timed out');
516
+ if (result.stdoutTruncated)
517
+ throw new GitOutputTruncatedError(result.stdoutBytes);
518
+ if (result.code !== 0)
519
+ throw new GitProofError(`git command exited with code ${result.code}`);
520
+ return result.stdout;
136
521
  };
522
+ /** Stream a complete git patch to disk so large diffs never need to be retained in the Node heap. */
523
+ async function writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened) {
524
+ const result = await spawnCapture('git', args, {
525
+ cwd,
526
+ timeoutMs: 30_000,
527
+ env: scrubAgentEnv(process.env),
528
+ stdoutFile: destination,
529
+ ...(onDestinationOpened ? { onStdoutFileOpened: onDestinationOpened } : {}),
530
+ ...(signal ? { signal } : {}),
531
+ });
532
+ if (result.termination === 'cancelled')
533
+ throw new ExecBridgeRunCancelledError();
534
+ if (result.termination === 'timeout')
535
+ throw new GitProofError('git patch capture timed out');
536
+ if (result.code !== 0)
537
+ throw new GitProofError(`git patch capture exited with code ${result.code}`);
538
+ }
539
+ export const defaultGitPatchWriter = (args, cwd, destination, signal, onDestinationOpened) => (writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened));
540
+ function writePrivatePatchFile(path, patch) {
541
+ let fd;
542
+ let ownsFile = false;
543
+ try {
544
+ fd = openSync(path, 'wx', 0o600);
545
+ ownsFile = true;
546
+ writeFileSync(fd, patch, { encoding: 'utf8' });
547
+ closeSync(fd);
548
+ fd = undefined;
549
+ }
550
+ catch (error) {
551
+ if (fd !== undefined) {
552
+ try {
553
+ closeSync(fd);
554
+ }
555
+ catch { /* best-effort close before removing our artifact */ }
556
+ }
557
+ if (ownsFile) {
558
+ try {
559
+ rmSync(path, { force: true });
560
+ }
561
+ catch { /* preserve the persistence failure */ }
562
+ }
563
+ throw error;
564
+ }
565
+ }
137
566
  /**
138
567
  * Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
139
568
  * ExecBridgeDisabledError on first call unless enabled.
140
569
  */
141
- export function makeClaudeExecBridge(opts) {
570
+ export function makeClaudeExecBridge(opts, internal) {
571
+ if (internal?.cursorWorktreeRoot && !opts.agent) {
572
+ throw new Error('custom Cursor worktree root requires an injected agent');
573
+ }
574
+ const cursorWorktreeRoot = internal?.cursorWorktreeRoot ?? joinPath(homedir(), '.cursor', 'worktrees');
142
575
  const enabled = opts.enabled ?? (process.env['EVOLVE_EXEC_BRIDGE'] === '1');
143
- const spec = getRunnerSpec(opts.runner); // #66: claude (default, byte-identical) | codex
144
- const agent = opts.agent ?? spec.makeRunner(opts.agentOptions);
576
+ if (opts.agentOptions?.workspaceTrust === 'isolated-worktree' && opts.isolation !== 'worktree') {
577
+ throw new WorkspaceTrustRequiresIsolationError();
578
+ }
579
+ const runnerName = opts.runner ?? 'claude';
580
+ const unsupportedBuiltInClaude = runnerName === 'claude' && !opts.agent;
581
+ const unsupportedBuiltInCodex = runnerName === 'codex' && !opts.agent;
582
+ const spec = getRunnerSpec(runnerName); // #66: provider-neutral runner registry
583
+ const resume = opts.resume ? validateAgentSessionResume(opts.resume, spec.name) : undefined;
584
+ // Do not construct uncontained built-in runners. Keep the bridge factory side-effect free so
585
+ // default-OFF callers retain the documented first-call failure semantics.
586
+ const agent = opts.agent ?? (unsupportedBuiltInClaude || unsupportedBuiltInCodex
587
+ ? (() => {
588
+ if (unsupportedBuiltInCodex)
589
+ throw new UnsupportedCodexBuiltInRunnerError();
590
+ throw new UnsupportedClaudeBuiltInRunnerError();
591
+ })
592
+ : spec.makeRunner(opts.agentOptions));
145
593
  const git = opts.git ?? defaultGitRunner;
146
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
594
+ const gitPatchWriter = opts.gitPatchWriter ?? (git === defaultGitRunner ? defaultGitPatchWriter : undefined);
595
+ const timeoutMs = Math.min(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.executionLimits?.maxRuntimeMs ?? Number.MAX_SAFE_INTEGER);
596
+ // 绑定 patch writer 属于受保护的执行生命周期。没有第四个 AbortSignal 参数的旧三参数 writer 无法
597
+ // 观察我们传入的取消信号,不能让它在截止时间后继续运行。
598
+ if (opts.executionLimits && opts.gitPatchWriter && opts.gitPatchWriter.length < 4) {
599
+ throw new ExecBridgeCancellationContractError('gitPatchWriter');
600
+ }
601
+ if (opts.executionLimits && opts.validate && opts.validate.length < 4) {
602
+ throw new ExecBridgeCancellationContractError('validate');
603
+ }
147
604
  // secure by default; only THIS runner's auth env reaches it (claude never gets OPENAI_, codex never ANTHROPIC_, #66)
148
605
  const agentEnv = opts.scrubEnv === false ? undefined : scrubAgentEnv(process.env, { allowPrefixes: spec.envAllow.prefixes, ...(spec.envAllow.keys ? { allowKeys: spec.envAllow.keys } : {}) });
149
606
  // Fail-fast when a run that can write/execute against the tree with no inner sandbox we control is requested
150
607
  // without the throwaway worktree as containment (allowedRoots gates the cwd but can't stop a write outside it).
151
608
  // Only when WE build the runner — an injected `agent` bypasses the built-in runner:
152
- // - codex: only its skip path emits --sandbox danger-full-access; without skip it stays workspace-write.
609
+ // - codex: fail-closed above because workspace-write does not contain host reads.
153
610
  // - cursor: gated UNCONDITIONALLY. cursor-agent base `-p` already has write+shell access, its skipPermissions
154
611
  // path is refused by runnerRegistry, and the runner is an unverified scaffold (#66/#181), so we do not let
155
612
  // default cursor touch the real tree until run-verified. (Bugbot High #181)
156
- // claude is exempt its skip is bounded by --allowedTools (finding #80).
157
- const needsIsolation = opts.runner === 'cursor'
158
- || (opts.runner === 'codex' && opts.agentOptions?.skipPermissions === true);
613
+ // Built-in Claude and Codex are fail-closed above.
614
+ const needsIsolation = opts.runner === 'cursor' || opts.runner === 'gemini';
159
615
  if (!opts.agent && needsIsolation && opts.isolation !== 'worktree') {
160
616
  throw new UnsandboxedFullAccessRequiresIsolationError();
161
617
  }
618
+ if (opts.runner === 'cursor' && !opts.agent) {
619
+ throw new UnsupportedCursorBuiltInRunnerError();
620
+ }
162
621
  return async (mutation, decision) => {
163
622
  if (!enabled)
164
623
  throw new ExecBridgeDisabledError();
165
624
  // Deny-by-default guardrail: an autonomous agent may only edit allowlisted repos. Checked before the
166
625
  // agent is ever spawned, so a forbidden cwd never runs anything.
167
- if (opts.allowedRoots !== undefined && !opts.allowedRoots.some((root) => isWithinRoot(opts.cwd, root))) {
626
+ const repoCwd = opts.allowedRoots === undefined
627
+ ? opts.cwd
628
+ : canonicalPathWithinRoots(opts.cwd, opts.allowedRoots);
629
+ if (!repoCwd) {
168
630
  throw new ExecBridgeForbiddenError(opts.cwd);
169
631
  }
632
+ if (unsupportedBuiltInClaude)
633
+ throw new UnsupportedClaudeBuiltInRunnerError();
634
+ if (unsupportedBuiltInCodex)
635
+ throw new UnsupportedCodexBuiltInRunnerError();
636
+ const assertRepoCwdStillAllowed = () => {
637
+ if (opts.allowedRoots !== undefined
638
+ && canonicalPathWithinRoots(repoCwd, opts.allowedRoots) !== repoCwd) {
639
+ throw new ExecBridgeForbiddenError(opts.cwd);
640
+ }
641
+ };
170
642
  const resolveId = decision.selectedAssetId ?? decision.selectedGeneId;
171
643
  const resolved = resolveId && opts.resolveGene ? await opts.resolveGene(resolveId) : null;
172
644
  // Trust gate (finding #39.3): for unattended runs, only embed a gene we trust — an untrusted gene's strategy
@@ -180,63 +652,360 @@ export function makeClaudeExecBridge(opts) {
180
652
  // use-case ①: inject the personality style block from the state applySelectForRun just persisted.
181
653
  ...(opts.personality ? { personality: opts.personality.currentState() } : {}),
182
654
  });
183
- // Isolation: run in a throwaway git worktree so the agent's edits never touch the real working tree.
184
- const isolate = opts.isolation === 'worktree';
185
- const workDir = isolate ? joinPath(tmpdir(), `evolver-wt-${mutation.id}`) : opts.cwd;
186
- if (isolate)
187
- await git(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd);
655
+ assertRepoCwdStillAllowed();
656
+ const executionDeadline = createExecutionDeadline(opts.signal, opts.executionLimits?.maxRuntimeMs);
188
657
  try {
189
- const run = await agent(prompt, { cwd: workDir, timeoutMs, ...(agentEnv ? { env: agentEnv } : {}) });
190
- const stat = parseGitShortstat(await git(['diff', '--shortstat'], workDir));
191
- // ENFORCE policy against the ACTUAL diff (finding: prompt.ts only ADVISES the agent "touch at most N
192
- // file(s) / never modify X"; this is the hard gate). checkPolicy ALWAYS runs the global guards — the
193
- // system blast hard cap (EVOLVER_HARD_CAP_FILES/LINES), the critical-protected paths (.env, MEMORY.md,
194
- // package.json, the evolver skill, …), and destructive deletes of those paths — so a no-gene / no-
195
- // constraints run is no longer un-guarded. The gene's max_files/max_lines/forbidden_paths layer on top.
196
- // Any violation fails the cycle no matter what the agent did — even when validation would pass.
197
- const changedFiles = (await git(['diff', '--name-only'], workDir)).split('\n').map((s) => s.trim()).filter(Boolean);
198
- const numstat = await git(['diff', '--numstat'], workDir);
199
- const violations = checkPolicy({ stat, changedFiles, numstat, ...(gene?.constraints ? { constraints: gene.constraints } : {}) });
658
+ // Isolation: atomically reserve an unpredictable private temp container, then let git create the absent
659
+ // worktree path inside it. Cleanup is armed only after git succeeds and the resulting directory is verified.
660
+ const isolate = opts.isolation === 'worktree';
661
+ const managedCursorIsolation = isolate && opts.runner === 'cursor' && resume?.runner === 'cursor';
662
+ if (executionDeadline.expired())
663
+ return timedOutExecutionResult(undefined);
664
+ if (opts.signal?.aborted)
665
+ return cancelledExecutionResult(undefined);
666
+ const reservation = isolate ? reserveWorktreePath() : undefined;
667
+ let workDir = reservation?.workDir ?? repoCwd;
668
+ let worktreeIdentity;
669
+ let managedCursorIdentity;
670
+ const managedWorktreeName = managedCursorIsolation ? `evolver-${randomUUID()}` : undefined;
671
+ let result;
672
+ let observedRun;
200
673
  let patchRef;
201
- if (isolate && stat.files > 0) {
202
- // preserve the isolated edits as a patch (the worktree itself is removed); the real repo is untouched
203
- patchRef = joinPath(tmpdir(), `evolver-patch-${mutation.id}.diff`);
204
- writeFileSync(patchRef, await git(['diff'], workDir));
674
+ let ownsPatchRef = false;
675
+ let preservePatchRef = false;
676
+ let failedProof;
677
+ const proofGit = async (args, cwd, checkCancellationAfter = true) => {
678
+ executionDeadline.assertActive();
679
+ if (cwd === repoCwd)
680
+ assertRepoCwdStillAllowed();
681
+ try {
682
+ const output = await executionDeadline.run(() => git(args, cwd, executionDeadline.signal));
683
+ if (checkCancellationAfter)
684
+ executionDeadline.assertActive();
685
+ return output;
686
+ }
687
+ catch (error) {
688
+ if (error instanceof ExecBridgeRunCancelledError || error instanceof ExecBridgeRunTimedOutError || opts.signal?.aborted || executionDeadline.expired())
689
+ throw error;
690
+ if (error instanceof GitProofError)
691
+ throw error;
692
+ throw new GitProofError('git state proof failed');
693
+ }
694
+ };
695
+ try {
696
+ if (reservation) {
697
+ await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], repoCwd, false);
698
+ worktreeIdentity = verifyWorktreePath(reservation);
699
+ if (opts.signal?.aborted)
700
+ throw new ExecBridgeRunCancelledError();
701
+ }
702
+ if (!reservation)
703
+ assertRepoCwdStillAllowed();
704
+ const run = await executionDeadline.run(() => agent(prompt, {
705
+ cwd: workDir,
706
+ timeoutMs,
707
+ ...(agentEnv ? { env: agentEnv } : {}),
708
+ ...(executionDeadline.signal ? { signal: executionDeadline.signal } : {}),
709
+ ...(resume ? { resume } : {}),
710
+ ...(managedWorktreeName ? { managedWorktreeName } : {}),
711
+ }));
712
+ observedRun = run;
713
+ await executionDeadline.run(() => opts.executionObserver?.onToolDecision?.({
714
+ tool_name: 'agent_runner',
715
+ decision: 'allowed',
716
+ status: run.ok ? 'completed' : 'failed',
717
+ }, executionDeadline.signal));
718
+ if (managedWorktreeName) {
719
+ try {
720
+ assertRepoCwdStillAllowed();
721
+ managedCursorIdentity = await executionDeadline.run(() => resolveManagedCursorWorktree(run.managedWorktreePath, managedWorktreeName, repoCwd, git, cursorWorktreeRoot));
722
+ }
723
+ catch (error) {
724
+ if (error instanceof ExecBridgeRunTimedOutError || executionDeadline.expired())
725
+ throw new ExecBridgeRunTimedOutError();
726
+ if (run.failureKind === 'cancelled' || opts.signal?.aborted)
727
+ throw new ExecBridgeRunCancelledError();
728
+ if (!run.ok)
729
+ throw new AgentRunBeforeManagedWorktreeError();
730
+ if (error instanceof UnsafeWorktreePathError) {
731
+ throw new GitProofError(`Cursor managed worktree verification failed: ${error.message}`);
732
+ }
733
+ throw error;
734
+ }
735
+ if (!managedCursorIdentity) {
736
+ if (executionDeadline.expired())
737
+ throw new ExecBridgeRunTimedOutError();
738
+ if (run.failureKind === 'cancelled' || opts.signal?.aborted)
739
+ throw new ExecBridgeRunCancelledError();
740
+ if (!run.ok)
741
+ throw new AgentRunBeforeManagedWorktreeError();
742
+ throw new GitProofError('Cursor managed worktree could not be verified');
743
+ }
744
+ workDir = managedCursorIdentity.path;
745
+ }
746
+ // Learning trace (slice 2): one model.called per agent spawn — the headless runner is one opaque
747
+ // model-driven turn from the bridge's viewpoint (per-request fidelity arrives via the proxy's llm_turn
748
+ // records; recordLlmTurn folds those when a caller has them). Best-effort: never affects the result.
749
+ try {
750
+ opts.traceRecorder?.modelCalled({
751
+ ...(opts.runner !== undefined ? { provider: opts.runner } : { provider: 'claude' }),
752
+ ...(opts.agentOptions?.model !== undefined ? { model: opts.agentOptions.model } : {}),
753
+ ...(run.exitCode !== undefined && run.exitCode !== null ? { stopReason: `exit_${run.exitCode}` } : {}),
754
+ });
755
+ // Prefer an authoritative native session id from the runner when present. This covers Gemini and
756
+ // any future harness that reports a session id without also writing proxy llm_turn rows.
757
+ if (typeof run.sessionId === 'string' && run.sessionId.length > 0) {
758
+ try {
759
+ opts.traceRecorder?.bindSessionId(run.sessionId);
760
+ }
761
+ catch { /* observability only */ }
762
+ }
763
+ if (!run.ok) {
764
+ opts.traceRecorder?.toolFailed({
765
+ toolName: 'agent_runner',
766
+ error: run.error ?? run.failureKind ?? 'agent run failed',
767
+ });
768
+ }
769
+ }
770
+ catch { /* trace emission is observability only */ }
771
+ executionDeadline.assertActive();
772
+ // A worktree can contain three independent change surfaces after the agent exits: staged tracked changes,
773
+ // unstaged tracked changes, and untracked files. `git diff` alone sees only the second. In an isolated
774
+ // worktree it is safe to mark untracked files intent-to-add temporarily, which makes one `git diff HEAD`
775
+ // snapshot cover all three without staging their contents or disturbing the agent's existing staged state.
776
+ // Reset only those temporary index entries before validation so hooks observe the state the agent left.
777
+ const untrackedFiles = isolate
778
+ ? (await proofGit(['ls-files', '--others', '--exclude-standard', '-z'], workDir)).split('\0').filter(Boolean)
779
+ : [];
780
+ if (untrackedFiles.length > 0)
781
+ await proofGit(['add', '--intent-to-add', '--', ...untrackedFiles], workDir);
782
+ let stat;
783
+ let changedFiles;
784
+ let numstat;
785
+ let patch = '';
786
+ const resetIntentToAdd = async () => {
787
+ if (untrackedFiles.length > 0) {
788
+ await executionDeadline.run(() => git(['reset', '--quiet', '--', ...untrackedFiles], workDir, executionDeadline.signal));
789
+ }
790
+ };
791
+ try {
792
+ stat = parseGitShortstat(await proofGit(['diff', '--shortstat', 'HEAD'], workDir));
793
+ changedFiles = (await proofGit(['diff', '--name-only', 'HEAD'], workDir)).split('\n').map((s) => s.trim()).filter(Boolean);
794
+ numstat = await proofGit(['diff', '--numstat', 'HEAD'], workDir);
795
+ if (isolate && stat.files > 0) {
796
+ if (gitPatchWriter) {
797
+ if (workDir === repoCwd)
798
+ assertRepoCwdStillAllowed();
799
+ const patchDestination = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
800
+ patchRef = patchDestination;
801
+ try {
802
+ await executionDeadline.run(() => gitPatchWriter(['diff', '--binary', '--full-index', 'HEAD'], workDir, patchDestination, executionDeadline.signal, () => { ownsPatchRef = true; }));
803
+ // A successful writer owns its result even when an older injected implementation ignores the
804
+ // optional callback. On rejection, only the callback can prove that a partial file is ours.
805
+ ownsPatchRef = true;
806
+ }
807
+ catch (error) {
808
+ if (error instanceof ExecBridgeRunCancelledError || error instanceof ExecBridgeRunTimedOutError || opts.signal?.aborted || executionDeadline.expired())
809
+ throw error;
810
+ if (error instanceof SpawnCaptureFinalizeError) {
811
+ if (error.result.termination === 'cancelled')
812
+ throw new ExecBridgeRunCancelledError();
813
+ if (error.result.termination === 'timeout')
814
+ throw new GitProofError('git patch capture timed out');
815
+ }
816
+ if (error instanceof GitProofError)
817
+ throw error;
818
+ throw new GitProofError('git patch capture failed');
819
+ }
820
+ }
821
+ else {
822
+ patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
823
+ }
824
+ }
825
+ }
826
+ catch (error) {
827
+ try {
828
+ await resetIntentToAdd();
829
+ }
830
+ catch (cleanupError) {
831
+ if (cleanupError instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
832
+ throw new ExecBridgeRunCancelledError();
833
+ }
834
+ if (cleanupError instanceof ExecBridgeRunTimedOutError || executionDeadline.expired()) {
835
+ throw new ExecBridgeRunTimedOutError();
836
+ }
837
+ // Preserve the primary proof failure for non-cancellation cleanup errors.
838
+ }
839
+ throw error;
840
+ }
841
+ try {
842
+ await resetIntentToAdd();
843
+ }
844
+ catch (error) {
845
+ if (error instanceof ExecBridgeRunCancelledError || error instanceof ExecBridgeRunTimedOutError || opts.signal?.aborted || executionDeadline.expired())
846
+ throw error;
847
+ if (patchRef && ownsPatchRef) {
848
+ failedProof = gitDiffProof(stat, patchRef);
849
+ preservePatchRef = true;
850
+ }
851
+ throw new GitProofError('git index cleanup failed');
852
+ }
853
+ executionDeadline.assertActive();
854
+ // ENFORCE policy against the ACTUAL diff (finding: prompt.ts only ADVISES the agent "touch at most N
855
+ // file(s) / never modify X"; this is the hard gate). checkPolicy ALWAYS runs the global guards — the
856
+ // system blast hard cap (EVOLVER_HARD_CAP_FILES/LINES), the critical-protected paths (.env, MEMORY.md,
857
+ // package.json, the evolver skill, …), and destructive deletes of those paths — so a no-gene / no-
858
+ // constraints run is no longer un-guarded. The gene's max_files/max_lines/forbidden_paths layer on top.
859
+ // Any violation fails the cycle no matter what the agent did — even when validation would pass.
860
+ const bindingConstraints = opts.executionLimits
861
+ ? { max_files: opts.executionLimits.maxFiles, max_lines: opts.executionLimits.maxLines }
862
+ : undefined;
863
+ const constraints = gene?.constraints && bindingConstraints
864
+ ? {
865
+ max_files: Math.min(gene.constraints.max_files ?? Number.MAX_SAFE_INTEGER, bindingConstraints.max_files ?? Number.MAX_SAFE_INTEGER),
866
+ max_lines: Math.min(gene.constraints.max_lines ?? Number.MAX_SAFE_INTEGER, bindingConstraints.max_lines ?? Number.MAX_SAFE_INTEGER),
867
+ ...(gene.constraints.forbidden_paths ? { forbidden_paths: gene.constraints.forbidden_paths } : {}),
868
+ }
869
+ : gene?.constraints ?? bindingConstraints;
870
+ const violations = checkPolicy({ stat, changedFiles, numstat, ...(constraints ? { constraints } : {}) });
871
+ await executionDeadline.run(() => opts.executionObserver?.onPolicyDecision?.({ version: 'policy.v1', allowed: violations.length === 0, violations }, executionDeadline.signal));
872
+ if (isolate && stat.files > 0 && !patchRef) {
873
+ // preserve the isolated edits as a patch (the worktree itself is removed); the real repo is untouched
874
+ const destination = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
875
+ try {
876
+ (opts.writePatchFile ?? writePrivatePatchFile)(destination, patch);
877
+ patchRef = destination;
878
+ ownsPatchRef = true;
879
+ }
880
+ catch (error) {
881
+ if (error instanceof GitProofError)
882
+ throw error;
883
+ throw new GitProofError('git patch persistence failed');
884
+ }
885
+ }
886
+ const proof = gitDiffProof(stat, patchRef);
887
+ if (proof.git_diff?.patch_ref) {
888
+ const patchReference = proof.git_diff.patch_ref;
889
+ await executionDeadline.run(() => opts.executionObserver?.onProofReference?.({ kind: proof.kind, ref: patchReference }, executionDeadline.signal));
890
+ }
891
+ // Success: prefer the authoritative validation hook; otherwise "agent succeeded AND produced a diff".
892
+ let passed = run.ok && stat.files > 0;
893
+ let score = passed ? 0.7 : run.ok ? 0.4 : 0.1; // ran-but-no-change is weak, not a clean failure
894
+ // Only validate a change that already respects the constraints — a constraint-violating diff is never
895
+ // a success regardless of what its tests say.
896
+ if (run.ok && stat.files > 0 && opts.validate && violations.length === 0) {
897
+ executionDeadline.assertActive();
898
+ const v = await executionDeadline.run(() => opts.validate(mutation, decision, workDir, executionDeadline.signal));
899
+ if (v.validator)
900
+ await executionDeadline.run(() => opts.executionObserver?.onValidatorDecision?.(v.validator, executionDeadline.signal));
901
+ else
902
+ await executionDeadline.run(() => opts.executionObserver?.onValidatorDecision?.({
903
+ id: 'validation', version: 'validation.v1', status: 'ran', passed: v.passed, score: v.score,
904
+ results: [], skipped: [], isolated: false,
905
+ }, executionDeadline.signal));
906
+ passed = v.passed;
907
+ score = v.score ?? (v.passed ? 0.9 : 0.2);
908
+ }
909
+ else if (opts.validate && violations.length > 0) {
910
+ await executionDeadline.run(() => opts.executionObserver?.onValidatorDecision?.({
911
+ id: 'validation', version: 'validation.v1', status: 'not_run', reason: 'policy_denied',
912
+ results: [], skipped: [], isolated: false,
913
+ }, executionDeadline.signal));
914
+ }
915
+ let reason;
916
+ if (violations.length > 0) {
917
+ passed = false;
918
+ score = Math.min(score, 0.1);
919
+ reason = summarizeViolations(violations);
920
+ }
921
+ preservePatchRef = patchRef !== undefined && ownsPatchRef;
922
+ const failureIdentity = !passed && stat.files > 0
923
+ ? { rootAttemptId: mutation.id, executionId: randomUUID() }
924
+ : undefined;
925
+ result = {
926
+ outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
927
+ proofOfWork: proof,
928
+ strongEvidence: passed && stat.files > 0,
929
+ ...(failureIdentity ? { failureIdentity } : {}),
930
+ ...(run.failureKind !== undefined ? { failureKind: run.failureKind } : {}),
931
+ ...(run.exitCode !== undefined ? { exitCode: run.exitCode } : {}),
932
+ ...(run.sessionId ? { nativeSessionId: run.sessionId } : {}),
933
+ // On a FAILED outcome, hand the agent transcript (stdout + stderr) to the cycle engine as host-side
934
+ // triage context (#279): an empty transcript -> host_no_transcript, a provider-error string ->
935
+ // host_provider_error. Omitted on success (failure-only context; never persisted).
936
+ ...(passed ? {} : { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output }),
937
+ };
205
938
  }
206
- const proof = gitDiffProof(stat, patchRef);
207
- // Success: prefer the authoritative validation hook; otherwise "agent succeeded AND produced a diff".
208
- let passed = run.ok && stat.files > 0;
209
- let score = passed ? 0.7 : run.ok ? 0.4 : 0.1; // ran-but-no-change is weak, not a clean failure
210
- // Only validate a change that already respects the constraints — a constraint-violating diff is never
211
- // a success regardless of what its tests say.
212
- if (run.ok && opts.validate && violations.length === 0) {
213
- const v = await opts.validate(mutation, decision, workDir);
214
- passed = v.passed;
215
- score = v.score ?? (v.passed ? 0.9 : 0.2);
939
+ catch (error) {
940
+ if (error instanceof ExecBridgeRunTimedOutError || executionDeadline.expired()) {
941
+ result = timedOutExecutionResult(observedRun);
942
+ }
943
+ else if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
944
+ result = cancelledExecutionResult(observedRun);
945
+ }
946
+ else if (error instanceof AgentRunBeforeManagedWorktreeError && observedRun) {
947
+ result = failedAgentRunResult(observedRun);
948
+ }
949
+ else if (error instanceof GitProofError && observedRun) {
950
+ result = failedProofExecutionResult(observedRun, error, failedProof);
951
+ }
952
+ else {
953
+ if (managedCursorIdentity) {
954
+ try {
955
+ assertRepoCwdStillAllowed();
956
+ await cleanupManagedCursorWorktree(managedCursorIdentity, git, repoCwd);
957
+ }
958
+ catch {
959
+ // Preserve the primary execution error; the verified worktree remains diagnosable.
960
+ }
961
+ }
962
+ if (reservation) {
963
+ try {
964
+ assertRepoCwdStillAllowed();
965
+ await cleanupWorktreeReservation(reservation, worktreeIdentity, git, repoCwd);
966
+ }
967
+ catch {
968
+ // Preserve the primary execution error. The abandoned reservation remains private and diagnosable.
969
+ }
970
+ }
971
+ throw error;
972
+ }
216
973
  }
217
- let reason;
218
- if (violations.length > 0) {
219
- passed = false;
220
- score = Math.min(score, 0.1);
221
- reason = summarizeViolations(violations);
974
+ finally {
975
+ if (!preservePatchRef && ownsPatchRef && patchRef) {
976
+ try {
977
+ (opts.removePatchFile ?? ((path) => rmSync(path, { force: true })))(patchRef);
978
+ }
979
+ catch { /* best-effort cleanup must not replace the execution result or its primary failure */ }
980
+ }
222
981
  }
223
- return {
224
- outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
225
- proofOfWork: proof,
226
- strongEvidence: passed && stat.files > 0,
227
- // On a FAILED outcome, hand the agent transcript (stdout + stderr) to the cycle engine as host-side
228
- // triage context (#279): an empty transcript -> host_no_transcript, a provider-error string ->
229
- // host_provider_error. Omitted on success (failure-only context; never persisted).
230
- ...(passed ? {} : { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output }),
231
- };
232
- }
233
- finally {
234
- if (isolate) {
982
+ let cleanupError;
983
+ if (managedCursorIdentity) {
235
984
  try {
236
- await git(['worktree', 'remove', '--force', workDir], opts.cwd);
985
+ assertRepoCwdStillAllowed();
986
+ await cleanupManagedCursorWorktree(managedCursorIdentity, git, repoCwd);
987
+ }
988
+ catch (error) {
989
+ cleanupError = error;
237
990
  }
238
- catch { /* best-effort cleanup */ }
239
991
  }
992
+ if (reservation) {
993
+ try {
994
+ assertRepoCwdStillAllowed();
995
+ await cleanupWorktreeReservation(reservation, worktreeIdentity, git, repoCwd);
996
+ }
997
+ catch (error) {
998
+ cleanupError ??= error;
999
+ }
1000
+ }
1001
+ // A successful run must not hide abandoned edits/resources. Both cleanup paths are attempted first so one
1002
+ // failure cannot strand the other worktree. Failed and cancelled results retain their primary classification.
1003
+ if (cleanupError && result.outcome.status === 'success')
1004
+ throw cleanupError;
1005
+ return result;
1006
+ }
1007
+ finally {
1008
+ executionDeadline.dispose();
240
1009
  }
241
1010
  };
242
1011
  }