evolcore 0.0.12 → 0.0.14

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 (116) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/codex-managed-hook.mjs +4 -1
  3. package/bin/ec-safe-output.js +26 -7
  4. package/bin/install-codex-managed-hooks.mjs +201 -0
  5. package/dist/agents/claude-runner.js +105 -11
  6. package/dist/agents/codex-app-server-client.js +176 -7
  7. package/dist/agents/codex-runner.js +248 -84
  8. package/dist/agents/ecagent-runner.js +21 -2
  9. package/dist/agents/gemini-runner.js +9 -4
  10. package/dist/aun/aid/control-aid.js +36 -13
  11. package/dist/aun/aid/store.js +13 -7
  12. package/dist/aun/group-fs-download.js +56 -0
  13. package/dist/aun/group-identity.js +1 -2
  14. package/dist/aun/msg/group.js +11 -33
  15. package/dist/aun/msg/index.js +1 -2
  16. package/dist/aun/msg/managed-operation.js +176 -55
  17. package/dist/aun/msg/upload.js +16 -1
  18. package/dist/aun/outbox.js +31 -2
  19. package/dist/aun/rpc/client.js +66 -0
  20. package/dist/aun/rpc/index.js +1 -2
  21. package/dist/aun/storage/{upload.js → client.js} +11 -2
  22. package/dist/aun/storage/index.js +1 -2
  23. package/dist/channels/aun.js +514 -150
  24. package/dist/channels/daemon.js +2 -0
  25. package/dist/cli/agent.js +9 -96
  26. package/dist/cli/aun-commands.js +1 -1
  27. package/dist/cli/cli-argv.js +10 -0
  28. package/dist/cli/config.js +9 -17
  29. package/dist/cli/ctl-command.js +1 -1
  30. package/dist/cli/daemon-commands.js +39 -24
  31. package/dist/cli/fs-command.js +65 -19
  32. package/dist/cli/init.js +77 -8
  33. package/dist/cli/queue-command.js +42 -0
  34. package/dist/cli/restart-monitor.js +3 -14
  35. package/dist/cli/task-context.js +172 -0
  36. package/dist/cli/trigger-command.js +7 -0
  37. package/dist/config/access-policy.js +1 -2
  38. package/dist/config/builtin-roles.js +7 -0
  39. package/dist/config/config-manager.js +27 -3
  40. package/dist/config/contact-book-store.js +7 -2
  41. package/dist/config/peer-role-resolver.js +6 -2
  42. package/dist/config/role-migration-startup.js +53 -0
  43. package/dist/config/role-store.js +1 -1
  44. package/dist/core/auth/agent-delegation.js +1 -1
  45. package/dist/core/auth/auth-gateway.js +30 -1
  46. package/dist/core/auth/authorization-audit.js +47 -0
  47. package/dist/core/auth/operation-authorizer.js +1 -0
  48. package/dist/core/auth/operation-catalog.js +10 -1
  49. package/dist/core/bootstrap-messages.js +9 -2
  50. package/dist/core/bootstrap-service.js +8 -1
  51. package/dist/core/command/command-handler.js +201 -51
  52. package/dist/core/command/menu-catalog.js +4 -0
  53. package/dist/core/command/menu-handler.js +85 -18
  54. package/dist/core/command/menu-protocol.js +23 -2
  55. package/dist/core/command/slash-handler.js +58 -10
  56. package/dist/core/event-catalog.js +8 -0
  57. package/dist/core/handoff/runtime.js +12 -1
  58. package/dist/core/handoff/store.js +5 -1
  59. package/dist/core/interaction-router.js +10 -0
  60. package/dist/core/message/im-renderer.js +34 -1
  61. package/dist/core/message/message-bridge.js +68 -13
  62. package/dist/core/message/message-log.js +2 -0
  63. package/dist/core/message/message-queue.js +267 -39
  64. package/dist/core/message/message-utils.js +81 -4
  65. package/dist/core/message/response-engine.js +242 -48
  66. package/dist/core/message/stream-debouncer.js +10 -2
  67. package/dist/core/permission/approval-gateway.js +1 -0
  68. package/dist/core/permission/ec-command-parser.js +649 -18
  69. package/dist/core/permission/index.js +1 -1
  70. package/dist/core/permission/mode.js +15 -0
  71. package/dist/core/permission/readonly-shell-query.js +148 -8
  72. package/dist/core/permission/sandbox-runtime.js +21 -0
  73. package/dist/core/permission/tool-policy.js +266 -56
  74. package/dist/core/protected-paths.js +107 -2
  75. package/dist/core/runtime-lock.js +101 -0
  76. package/dist/core/session/session-manager.js +21 -2
  77. package/dist/core/session/session-mapper.js +20 -1
  78. package/dist/eck/detect.js +18 -1
  79. package/dist/eck/group-rules-sync.js +15 -44
  80. package/dist/index.js +191 -89
  81. package/dist/ipc.js +16 -3
  82. package/dist/paths.js +3 -0
  83. package/dist/response-system/engines/v1/proactive-flow.js +92 -8
  84. package/dist/response-system/modes/single-session/index.js +3 -0
  85. package/dist/trigger/feedback.js +64 -11
  86. package/dist/trigger/history.js +42 -7
  87. package/dist/trigger/manager.js +21 -1
  88. package/dist/trigger/parser.js +14 -2
  89. package/dist/trigger/scheduler.js +1 -0
  90. package/dist/trigger/validation.js +67 -6
  91. package/dist/utils/ecweb-supervisor.js +332 -0
  92. package/dist/utils/error-utils.js +17 -0
  93. package/dist/utils/logger.js +37 -4
  94. package/kits/docs/evolcore/trigger.md +17 -1
  95. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  96. package/kits/schemas/role-config.schema.1.json +1 -0
  97. package/kits/templates/roles/admin.json +7 -0
  98. package/kits/templates/roles/member.json +8 -1
  99. package/kits/templates/roles/visitor.json +2 -0
  100. package/package.json +1 -1
  101. package/dist/aun/msg/payload-type.js +0 -27
  102. package/dist/aun/rpc/caller.js +0 -42
  103. package/dist/aun/rpc/connection.js +0 -25
  104. package/dist/aun/storage/manage.js +0 -10
  105. package/dist/config/access-policy-domain.js +0 -18
  106. package/dist/config/config-batch-get.js +0 -11
  107. package/dist/config/owner-policy.js +0 -4
  108. package/dist/config/role-config-v4-startup.js +0 -32
  109. package/dist/config/role-config-v5-startup.js +0 -27
  110. package/dist/core/auth/trigger-authorization.js +0 -15
  111. package/dist/core/interaction-registration.js +0 -10
  112. package/dist/core/permission/execution-sandbox.js +0 -16
  113. package/dist/core/relation/peer-key.js +0 -1
  114. package/dist/core/session/session-key.js +0 -24
  115. package/dist/eck/baseagent-caps.js +0 -18
  116. package/dist/eck/rules-loader.js +0 -28
@@ -7,22 +7,23 @@
7
7
  */
8
8
  import { requestDangerousCommandPermission } from '../core/permission/approval-gateway.js';
9
9
  import { checkReadonly, checkDangerousCommand, evaluateToolPreflight } from '../core/permission/tool-policy.js';
10
- import { normalizePermissionMode } from '../core/permission/mode.js';
11
- import { resolvePhaseOneExecutionSandbox } from '../core/permission/execution-sandbox.js';
10
+ import { normalizePermissionMode, resolvePhaseOneExecutionSandbox } from '../core/permission/mode.js';
12
11
  import { buildCodexProtectedFilesystemRules, isSameOrDescendant, resolveProtectedCandidate } from '../core/protected-paths.js';
13
12
  import { CodexAppServerClient } from './codex-app-server-client.js';
14
13
  import { resolveOpenaiConfig } from './baseagent.js';
15
14
  import { logger } from '../utils/logger.js';
16
15
  import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
16
+ import { auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
17
17
  import { isRetryableError } from '../utils/error-utils.js';
18
18
  import { renderActionAsText } from '../core/interaction-router.js';
19
19
  import { buildEnvelope, sendInteractionPayload } from '../core/message/message-utils.js';
20
20
  import { resolveCodexCapabilityThreadConfigForProject } from '../core/capability/capability-manager.js';
21
21
  import { AGENT_DELEGATION_TOKEN_ENV, hashDelegatedCommandArgv } from '../core/auth/agent-delegation.js';
22
22
  import { sanitizeShellExecutionEnvironment } from '../core/permission/shell-environment.js';
23
- import { classifyEvolcoreShellCommand, parseLiteralShellArgv, unwrapCodexShellCommandArgv } from '../core/permission/ec-command-parser.js';
23
+ import { classifyEvolcoreShellCommand, containsLiteralManagedTmpDirOutsideSendContent, parseLiteralShellArgv, unwrapCodexShellCommandArgv, } from '../core/permission/ec-command-parser.js';
24
24
  import { compareVersions } from '../utils/npm-ops.js';
25
25
  import { resolvePaths, resolveRoot } from '../paths.js';
26
+ import { ensureProcessManagedTempDir } from '../cli/task-context.js';
26
27
  import { buildSessionTurnList } from '../core/session/session-turns.js';
27
28
  import { execFileSync } from 'child_process';
28
29
  import { execCodexCliSync, resolveCodexCliPath } from '../utils/codex-cli.js';
@@ -76,6 +77,39 @@ class AsyncEventQueue {
76
77
  }
77
78
  const managedTempDirsForExitCleanup = new Map();
78
79
  let managedTempExitCleanupRegistered = false;
80
+ function isTrustedManagedTempDirectory(directory, parent) {
81
+ try {
82
+ const parentPath = path.resolve(parent);
83
+ const directoryPath = path.resolve(directory);
84
+ // The session directory must be a direct child of the private parent.
85
+ // This lexical check is required before canonicalization so a replaced
86
+ // parent symlink cannot turn an old session path into another tree.
87
+ if (path.dirname(directoryPath) !== parentPath)
88
+ return false;
89
+ const trustedDirectory = (candidate) => {
90
+ const stat = fs.lstatSync(candidate);
91
+ if (!stat.isDirectory() || stat.isSymbolicLink())
92
+ return false;
93
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
94
+ return false;
95
+ return (stat.mode & 0o077) === 0;
96
+ };
97
+ if (!trustedDirectory(parentPath) || !trustedDirectory(directoryPath))
98
+ return false;
99
+ const canonicalParent = resolveProtectedCandidate(parentPath);
100
+ const canonicalDirectory = resolveProtectedCandidate(directoryPath);
101
+ // Keep the canonical and lexical paths identical after the lstat checks;
102
+ // this rejects symlink replacement in either node or an existing ancestor.
103
+ return canonicalParent === parentPath
104
+ && canonicalDirectory === directoryPath
105
+ && canonicalDirectory !== canonicalParent
106
+ && path.dirname(canonicalDirectory) === canonicalParent
107
+ && isSameOrDescendant(canonicalDirectory, canonicalParent);
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
79
113
  function registerManagedTempDirForExitCleanup(directory, parent) {
80
114
  managedTempDirsForExitCleanup.set(directory, parent);
81
115
  if (managedTempExitCleanupRegistered)
@@ -84,10 +118,8 @@ function registerManagedTempDirForExitCleanup(directory, parent) {
84
118
  process.once('exit', () => {
85
119
  for (const [candidatePath, parentPath] of managedTempDirsForExitCleanup) {
86
120
  try {
87
- const parentCandidate = resolveProtectedCandidate(parentPath);
88
- const candidate = resolveProtectedCandidate(candidatePath);
89
- if (candidate !== parentCandidate && isSameOrDescendant(candidate, parentCandidate)) {
90
- fs.rmSync(candidate, { recursive: true, force: true });
121
+ if (isTrustedManagedTempDirectory(candidatePath, parentPath)) {
122
+ fs.rmSync(path.resolve(candidatePath), { recursive: true, force: true });
91
123
  }
92
124
  }
93
125
  catch {
@@ -121,7 +153,7 @@ const CODEX_DELEGATION_CARRIER_METADATA_KEY = '__codexDelegationCarrierV1';
121
153
  let codexCatalogCache = null;
122
154
  // The permission bridge is version-locked to the audited approval schema and
123
155
  // managed PreToolUse hook behavior used by proactive first-tool enforcement.
124
- export const MIN_CODEX_CLI_VERSION = '0.147.0';
156
+ export const MIN_CODEX_CLI_VERSION = '0.144.1';
125
157
  export function parseCodexCliVersion(output) {
126
158
  const match = output.match(/\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/);
127
159
  return match?.[1] ?? null;
@@ -144,19 +176,18 @@ export function getCodexCliVersion() {
144
176
  }
145
177
  export function getCodexAppServerAvailability() {
146
178
  if (!resolveCodexCliPath()) {
147
- const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
148
- return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
179
+ return { available: false, reason: '未检测到 Codex CLI。' };
149
180
  }
150
181
  const version = getCodexCliVersion();
151
- const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
152
182
  if (!version) {
153
- return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
183
+ return { available: false, reason: '无法读取 Codex CLI 版本。' };
154
184
  }
155
185
  if (!isCodexCliVersionSupported(version)) {
156
186
  return {
157
187
  available: false,
158
188
  version,
159
- reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。${upgradeHint}`,
189
+ reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。请升级 Codex CLI:npm install -g @openai/codex@latest`,
190
+ requiresUpgrade: true,
160
191
  };
161
192
  }
162
193
  try {
@@ -168,7 +199,7 @@ export function getCodexAppServerAvailability() {
168
199
  return { available: true, version };
169
200
  }
170
201
  catch {
171
- return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。${upgradeHint}` };
202
+ return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。` };
172
203
  }
173
204
  }
174
205
  export function isCodexAppServerAvailable() {
@@ -208,6 +239,9 @@ export function getCodexEfforts(model) {
208
239
  // ── Codex Runner ──
209
240
  export class CodexRunner {
210
241
  name = 'codex';
242
+ // turn/start persists a new user turn before runQuery resolves. App-server
243
+ // has no retry-in-place API for a completed or failed turn.
244
+ retryInputSemantics = 'at_most_once';
211
245
  capabilities;
212
246
  model;
213
247
  effort;
@@ -282,51 +316,82 @@ export class CodexRunner {
282
316
  createDelegationCarrier() {
283
317
  return randomBytes(32).toString('base64url');
284
318
  }
285
- approvedLiteralEvolcoreCommandArgv(command) {
319
+ approvedLiteralEvolcoreCommandArgv(command, managedTempDir) {
286
320
  const classification = classifyEvolcoreShellCommand(command);
287
321
  if (classification.kind === 'bounded-output')
288
322
  return classification.command.argv;
289
323
  if (classification.kind !== 'literal')
290
324
  return undefined;
291
- const argv = parseLiteralShellArgv(command);
325
+ // Keep the approval/delegation parser aligned with the preflight parser:
326
+ // the session-managed `$TMPDIR` token is a permitted EC path reference.
327
+ const argv = parseLiteralShellArgv(command, {
328
+ allowManagedTmpDir: true,
329
+ ...(managedTempDir ? { managedTempDir } : {}),
330
+ });
331
+ if (argv && containsLiteralManagedTmpDirOutsideSendContent(argv))
332
+ return undefined;
292
333
  return argv?.[0] === 'ec' ? argv : undefined;
293
334
  }
294
- approvedEvolcoreCommandArgv(toolInput) {
335
+ approvedEvolcoreCommandArgv(toolInput, managedTempDir) {
295
336
  const explicitArgv = Array.isArray(toolInput.commandArgv)
296
337
  && toolInput.commandArgv.every(value => typeof value === 'string')
297
338
  ? toolInput.commandArgv
298
339
  : undefined;
299
- if (explicitArgv?.[0] === 'ec')
300
- return explicitArgv;
340
+ if (explicitArgv?.[0] === 'ec') {
341
+ return explicitArgv.some(value => value.includes('\0'))
342
+ || containsLiteralManagedTmpDirOutsideSendContent(explicitArgv)
343
+ ? undefined
344
+ : explicitArgv;
345
+ }
301
346
  const explicitWrappedCommand = explicitArgv
302
347
  ? unwrapCodexShellCommandArgv(explicitArgv)
303
348
  : undefined;
304
349
  if (explicitWrappedCommand !== undefined) {
305
- return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand);
350
+ return this.approvedLiteralEvolcoreCommandArgv(explicitWrappedCommand, managedTempDir);
306
351
  }
307
352
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
308
- const directArgv = this.approvedLiteralEvolcoreCommandArgv(command);
353
+ const directArgv = this.approvedLiteralEvolcoreCommandArgv(command, managedTempDir);
309
354
  if (directArgv)
310
355
  return directArgv;
311
- const wrappedArgv = parseLiteralShellArgv(command);
356
+ const wrappedArgv = parseLiteralShellArgv(command, {
357
+ allowManagedTmpDir: true,
358
+ ...(managedTempDir ? { managedTempDir } : {}),
359
+ });
312
360
  const wrappedCommand = wrappedArgv
313
361
  ? unwrapCodexShellCommandArgv(wrappedArgv)
314
362
  : undefined;
315
363
  return wrappedCommand === undefined
316
364
  ? undefined
317
- : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand);
365
+ : this.approvedLiteralEvolcoreCommandArgv(wrappedCommand, managedTempDir);
318
366
  }
319
- armApprovedDelegationCommand(sessionId, toolInput) {
367
+ isManagedEvolcoreCommandIntent(toolInput) {
368
+ if (Array.isArray(toolInput.commandArgv)) {
369
+ const rawArgv = toolInput.commandArgv;
370
+ if (rawArgv[0] === 'ec')
371
+ return true;
372
+ if (!rawArgv.every(value => typeof value === 'string'))
373
+ return false;
374
+ const wrappedCommand = unwrapCodexShellCommandArgv(rawArgv);
375
+ return wrappedCommand !== undefined
376
+ && classifyEvolcoreShellCommand(wrappedCommand).kind !== 'none';
377
+ }
378
+ const command = typeof toolInput.command === 'string' ? toolInput.command : '';
379
+ return classifyEvolcoreShellCommand(command).kind !== 'none';
380
+ }
381
+ armApprovedDelegationCommand(sessionId, argv) {
320
382
  const threadId = this.activeSessions.get(sessionId);
321
383
  const carrierToken = threadId ? this.threadDelegationCarriers.get(threadId) : undefined;
322
- const argv = this.approvedEvolcoreCommandArgv(toolInput);
323
- if (!carrierToken || !argv)
324
- return false;
325
384
  const commandHash = hashDelegatedCommandArgv(argv);
326
385
  const arm = this.permissionContexts.get(sessionId)?.armApprovedDelegationCommand;
327
- if (!commandHash || !arm)
386
+ if (!carrierToken || !commandHash || !arm)
328
387
  return false;
329
- return arm(carrierToken, commandHash);
388
+ try {
389
+ return arm(carrierToken, commandHash);
390
+ }
391
+ catch (error) {
392
+ logger.warn(`[CodexRunner] delegation arm callback failed: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
393
+ return false;
394
+ }
330
395
  }
331
396
  async readThreadDelegationCarrier(appServer, threadId) {
332
397
  try {
@@ -750,6 +815,8 @@ export class CodexRunner {
750
815
  ...(mode !== 'readonly' && managedTempDir ? {
751
816
  [managedTempDir]: 'write',
752
817
  [path.join(managedTempDir, '**')]: 'write',
818
+ [path.join(path.dirname(managedTempDir), 'evolcore-locks')]: 'write',
819
+ [path.join(path.dirname(managedTempDir), 'evolcore-locks', '**')]: 'write',
753
820
  } : {}),
754
821
  },
755
822
  network,
@@ -789,6 +856,17 @@ export class CodexRunner {
789
856
  }
790
857
  setSendPrompt(fn) { this.sendPromptFn = fn; }
791
858
  setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
859
+ async assertProactiveHookAvailability(sessionId, appServer) {
860
+ if (this.permissionContexts.get(sessionId)?.chatmode !== 'proactive')
861
+ return;
862
+ if (process.platform === 'win32') {
863
+ await appServer.assertManagedHooksAvailable();
864
+ return;
865
+ }
866
+ if (process.platform !== 'linux') {
867
+ throw new Error('Codex proactive managed PreToolUse enforcement requires Linux or an installed Windows managed hook');
868
+ }
869
+ }
792
870
  async evaluatePreToolUse(threadId, toolName, toolInput) {
793
871
  const sessionKey = this.findSessionKeyByThread(threadId);
794
872
  const activeThread = this.activeSessions.get(sessionKey);
@@ -953,9 +1031,6 @@ export class CodexRunner {
953
1031
  }
954
1032
  async runQueryLocked(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride, runtimeEnv) {
955
1033
  let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
956
- if (this.permissionContexts.get(sessionId)?.chatmode === 'proactive' && process.platform !== 'linux') {
957
- throw new Error('Codex proactive managed PreToolUse enforcement currently requires Linux');
958
- }
959
1034
  const resumingThread = !!agentSessionId;
960
1035
  const callModel = modelOverride?.model || this.model;
961
1036
  const callEffort = modelOverride?.effortMode === 'model_default'
@@ -975,6 +1050,7 @@ export class CodexRunner {
975
1050
  const effectiveApprovalPolicy = callApprovalPolicy;
976
1051
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
977
1052
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, callMode);
1053
+ await this.assertProactiveHookAvailability(sessionId, appServer);
978
1054
  const managedTempInstruction = this.managedTempInstruction(sessionId);
979
1055
  const developerInstructions = [systemPromptAppend, managedTempInstruction].filter(Boolean).join('\n\n') || undefined;
980
1056
  let knownDelegationCarrier = agentSessionId
@@ -1044,7 +1120,7 @@ export class CodexRunner {
1044
1120
  const controller = new AbortController();
1045
1121
  this.activeAbortControllers.set(sessionId, controller);
1046
1122
  const tempFiles = [];
1047
- const input = this.buildAppServerInput(prompt, images, tempFiles);
1123
+ const input = this.buildAppServerInput(prompt, images, tempFiles, sessionId, runtimeEnv);
1048
1124
  const queue = new AsyncEventQueue();
1049
1125
  controller.signal.addEventListener('abort', () => queue.end(), { once: true });
1050
1126
  const state = {
@@ -1259,6 +1335,7 @@ export class CodexRunner {
1259
1335
  const executionSandbox = this.buildExecutionSandboxOptions(_sessionId, compactMode, _projectPath);
1260
1336
  const capabilityConfig = await this.resolveCapabilityThreadConfig(_projectPath);
1261
1337
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, _projectPath, capabilityConfig, compactMode);
1338
+ await this.assertProactiveHookAvailability(_sessionId, appServer);
1262
1339
  const compactDelegationCarrier = this.threadDelegationCarriers.get(agentSessionId)
1263
1340
  ?? this.createDelegationCarrier();
1264
1341
  await appServer.threadResume(agentSessionId, _projectPath, {
@@ -1422,6 +1499,8 @@ export class CodexRunner {
1422
1499
  const capabilityConfig = await this.resolveCapabilityThreadConfig(projectPath);
1423
1500
  const appServer = this.getAppServerClient();
1424
1501
  const externalToolConfig = await this.resolveExternalToolApprovalConfig(appServer, projectPath, capabilityConfig, mode);
1502
+ if (sessionKey)
1503
+ await this.assertProactiveHookAvailability(sessionKey, appServer);
1425
1504
  const delegationCarrier = this.createDelegationCarrier();
1426
1505
  const response = await appServer.threadFork(agentSessionId, projectPath, title, {
1427
1506
  model: this.model,
@@ -1614,31 +1693,77 @@ export class CodexRunner {
1614
1693
  catch (error) {
1615
1694
  logger.warn(`[CodexRunner] failed to record policy-hook denial: session=${sessionKey} tool=${toolName} error=${error instanceof Error ? error.message : String(error)}`);
1616
1695
  }
1617
- return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1696
+ const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1697
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'policy', policyResult.policyCode ?? 'session_policy_hook');
1698
+ return response;
1618
1699
  }
1619
1700
  const summary = this.summarizeAppServerRequest(request.method, params);
1620
1701
  const reason = params.reason || params.decisionReason || undefined;
1621
1702
  const workspacePath = this.resolvePermissionWorkspacePath(params);
1622
1703
  if (!workspacePath) {
1623
1704
  logger.warn(`[CodexRunner] approval denied because thread workspace is unknown: method=${request.method} thread=${params.threadId ?? params.conversationId ?? '<missing>'}`);
1624
- return this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1705
+ const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1706
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', 'approval_workspace_unknown');
1707
+ return response;
1625
1708
  }
1626
1709
  const operationCwd = this.resolvePermissionOperationCwd(params, workspacePath, toolName);
1627
1710
  const sessionMode = this.chatModes.get(sessionKey) ?? this.currentMode;
1628
1711
  logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${sessionMode} tool=${toolName} summary=${summary}`);
1712
+ const isCommandApproval = request.method === 'item/commandExecution/requestApproval'
1713
+ || request.method === 'execCommandApproval';
1714
+ const managedEcIntent = isCommandApproval && this.isManagedEvolcoreCommandIntent(toolInput);
1715
+ const approvedCommand = isCommandApproval
1716
+ ? this.approvedEvolcoreCommandArgv(toolInput, this.getManagedTempDir(sessionKey))
1717
+ : undefined;
1718
+ const denyInfrastructure = async (policyCode, message) => {
1719
+ logger.warn(`[CodexRunner] ${message}: session=${sessionKey} requestId=${request.id ?? '<missing>'}`);
1720
+ try {
1721
+ await permissionContext?.recordExecutionAnomaly?.({
1722
+ code: 'operation_blocked',
1723
+ severity: 'warning',
1724
+ phase: 'execution',
1725
+ occurredAt: Date.now(),
1726
+ toolName,
1727
+ ...(request.id !== undefined ? { requestId: String(request.id) } : {}),
1728
+ policyCode,
1729
+ decisionSource: 'infrastructure',
1730
+ agentAid: permissionContext?.selfAid,
1731
+ sessionId: sessionKey,
1732
+ permissionMode: normalizePermissionMode(sessionMode).mode,
1733
+ summary: summarizeToolInputForAudit(toolName, toolInput).slice(0, 512),
1734
+ effect: 'operation_skipped',
1735
+ });
1736
+ }
1737
+ catch (error) {
1738
+ logger.warn(`[CodexRunner] failed to record infrastructure approval denial: session=${sessionKey} code=${policyCode} error=${error instanceof Error ? error.message : String(error)}`);
1739
+ }
1740
+ const response = this.toAppServerApprovalResponse(request.method, 'deny', toolInput, unattended);
1741
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, 'deny', 'infrastructure', policyCode);
1742
+ return response;
1743
+ };
1744
+ if (managedEcIntent && !approvedCommand) {
1745
+ return await denyInfrastructure('ec_command_not_canonical', 'EC command approval denied because no canonical argv could be derived');
1746
+ }
1629
1747
  try {
1630
1748
  const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, workspacePath, operationCwd, request.id);
1631
- if (decision !== 'deny'
1632
- && (request.method === 'item/commandExecution/requestApproval' || request.method === 'execCommandApproval')
1633
- && this.approvedEvolcoreCommandArgv(toolInput)) {
1634
- if (!this.armApprovedDelegationCommand(sessionKey, toolInput)) {
1635
- // Approval policy and delegation authority are separate boundaries.
1636
- // If no ticket can be armed, the CLI still fails closed at IPC token
1637
- // validation instead of changing an otherwise valid tool decision.
1638
- logger.warn(`[CodexRunner] EC command approved without a task-bound delegation ticket: session=${sessionKey}`);
1749
+ if (decision !== 'deny' && managedEcIntent && approvedCommand) {
1750
+ const carrierThreadIds = [
1751
+ this.activeSessions.get(sessionKey),
1752
+ typeof params.threadId === 'string' ? params.threadId : undefined,
1753
+ typeof params.conversationId === 'string' ? params.conversationId : undefined,
1754
+ ];
1755
+ const hasDelegationCarrier = carrierThreadIds.some(threadId => !!threadId && this.threadDelegationCarriers.has(threadId));
1756
+ const canArmDelegation = typeof permissionContext?.armApprovedDelegationCommand === 'function';
1757
+ // A carrier-bearing thread is managed even if its permission context
1758
+ // was not wired with an arm callback; it must fail closed here rather
1759
+ // than reaching daemon IPC with an unarmed carrier.
1760
+ if ((hasDelegationCarrier || canArmDelegation)
1761
+ && !this.armApprovedDelegationCommand(sessionKey, approvedCommand)) {
1762
+ return await denyInfrastructure('delegation_not_armed', 'EC command approval failed to arm delegation');
1639
1763
  }
1640
1764
  }
1641
1765
  const response = this.toAppServerApprovalResponse(request.method, decision, toolInput, unattended);
1766
+ this.logAppServerApprovalAudit(request, sessionKey, toolName, decision === 'deny' ? 'deny' : 'allow', decision === 'deny' ? (sessionMode === 'auto' || sessionMode === 'readonly' ? 'policy' : 'approval') : 'approval');
1642
1767
  logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
1643
1768
  return response;
1644
1769
  }
@@ -1648,6 +1773,18 @@ export class CodexRunner {
1648
1773
  throw error;
1649
1774
  }
1650
1775
  }
1776
+ logAppServerApprovalAudit(request, sessionKey, toolName, decision, decisionSource, policyCode) {
1777
+ logger.info(`[CodexRunner] app-server approval audit ${JSON.stringify({
1778
+ requestId: request.id !== undefined ? String(request.id) : undefined,
1779
+ method: request.method,
1780
+ sessionId: sessionKey,
1781
+ toolName,
1782
+ decision,
1783
+ decisionSource,
1784
+ ...(policyCode ? { policyCode } : {}),
1785
+ executed: false,
1786
+ })}`);
1787
+ }
1651
1788
  classifyMcpApprovalLabel(label) {
1652
1789
  const normalized = label.trim().toLowerCase().replace(/[\s_-]+/g, ' ');
1653
1790
  const negative = /\b(decline|deny|reject|cancel|no|block|stop)\b|do not|don't|not\s+(?:accept|approve|allow)|拒绝|不同意|不允许|取消|否|停止/i.test(normalized);
@@ -2058,10 +2195,15 @@ export class CodexRunner {
2058
2195
  channel: permCtx?.channel,
2059
2196
  peerId: permCtx?.userId,
2060
2197
  role: permCtx?.role,
2198
+ allowProtectedMetadata: false,
2199
+ // Revalidate ownership, mode, and parent containment at the final
2200
+ // readonly decision. The map entry alone is stale if the directory was
2201
+ // replaced between preflight and the approval callback.
2202
+ managedTempDir: this.getManagedTempDir(sessionKey),
2061
2203
  } : undefined;
2062
2204
  if (toolName === 'Bash')
2063
2205
  return checkReadonly(toolName, input, projectPath, readonlyContext);
2064
- return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝' };
2206
+ return { behavior: 'deny', message: '🔒 只读模式:权限升级和文件写入请求已拒绝', policyCode: 'readonly_permission_or_write' };
2065
2207
  }
2066
2208
  permissionProfiles(input) {
2067
2209
  return [input.permissions, input.additionalPermissions]
@@ -2174,6 +2316,22 @@ export class CodexRunner {
2174
2316
  const rawMode = this.chatModes.get(sessionKey) ?? this.currentMode;
2175
2317
  const mode = normalizePermissionMode(rawMode).mode;
2176
2318
  const recordBlockedOperation = async (policyCode, operationInput = toolInput) => {
2319
+ const summary = summarizeToolInputForAudit(toolName, operationInput).slice(0, 512);
2320
+ auditToolPreflightDenial({
2321
+ toolName,
2322
+ policyCode,
2323
+ reason: 'policy denied',
2324
+ summary,
2325
+ sessionId: sessionKey,
2326
+ agentAid: permissionContext?.selfAid,
2327
+ permissionMode: mode,
2328
+ channel: permissionContext?.channel,
2329
+ actorId: permissionContext?.userId,
2330
+ role: permissionContext?.role,
2331
+ selfAid: permissionContext?.selfAid,
2332
+ requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
2333
+ taskId: permissionContext?.taskId,
2334
+ });
2177
2335
  try {
2178
2336
  await permissionContext?.recordExecutionAnomaly?.({
2179
2337
  code: 'operation_blocked',
@@ -2183,7 +2341,10 @@ export class CodexRunner {
2183
2341
  toolName,
2184
2342
  ...(typeof requestId === 'string' || typeof requestId === 'number' ? { requestId: String(requestId) } : {}),
2185
2343
  policyCode,
2186
- summary: summarizeToolInputForAudit(toolName, operationInput).slice(0, 512),
2344
+ decisionSource: 'policy',
2345
+ agentAid: permissionContext?.selfAid,
2346
+ permissionMode: mode,
2347
+ summary,
2187
2348
  effect: 'operation_skipped',
2188
2349
  });
2189
2350
  }
@@ -2193,6 +2354,11 @@ export class CodexRunner {
2193
2354
  };
2194
2355
  const preflight = evaluateToolPreflight(toolName, toolInput, {
2195
2356
  sessionId: sessionKey,
2357
+ // An unavailable registered root must fail closed; the policy helper
2358
+ // distinguishes this explicit empty value from runners without a
2359
+ // session-temp capability.
2360
+ managedTempDir: this.getManagedTempDir(sessionKey) ?? '',
2361
+ allowProtectedMetadata: false,
2196
2362
  selfAid: permissionContext?.selfAid,
2197
2363
  channel: permissionContext?.channel,
2198
2364
  userId: permissionContext?.userId,
@@ -2202,6 +2368,8 @@ export class CodexRunner {
2202
2368
  workspacePath,
2203
2369
  });
2204
2370
  if (preflight.behavior === 'deny') {
2371
+ logger.warn(`[CodexRunner] tool preflight denied: session=${sessionKey} tool=${toolName} ` +
2372
+ `policy=${preflight.policyCode ?? 'unknown'} reason=${preflight.message ?? 'policy denied'}`);
2205
2373
  if (preflight.policyCode) {
2206
2374
  await recordBlockedOperation(preflight.policyCode);
2207
2375
  }
@@ -2231,7 +2399,7 @@ export class CodexRunner {
2231
2399
  if (mode === 'readonly') {
2232
2400
  const readonly = this.checkCodexReadonly(toolName, checkedInput, operationCwd, sessionKey);
2233
2401
  if (readonly.behavior === 'deny') {
2234
- await recordBlockedOperation('readonly_mode', checkedInput);
2402
+ await recordBlockedOperation(readonly.policyCode ?? 'readonly_mode', checkedInput);
2235
2403
  return 'deny';
2236
2404
  }
2237
2405
  return 'allow';
@@ -2468,11 +2636,15 @@ export class CodexRunner {
2468
2636
  this.onCompactStart = callback;
2469
2637
  }
2470
2638
  // ── Event stream transformation ──
2471
- buildAppServerInput(prompt, images, tempFiles) {
2639
+ buildAppServerInput(prompt, images, tempFiles, sessionId, runtimeEnv) {
2472
2640
  const input = [{ type: 'text', text: prompt, text_elements: [] }];
2473
2641
  if (!images?.length)
2474
2642
  return input;
2475
- const tmpDir = os.tmpdir();
2643
+ const tmpDir = runtimeEnv?.EVOLCORE_SESSION_RUNTIME_DIR
2644
+ ?? (sessionId ? this.getManagedTempDir(sessionId) : undefined)
2645
+ ?? process.env.TMPDIR;
2646
+ if (!tmpDir || !path.isAbsolute(tmpDir))
2647
+ throw new Error('managed TMPDIR is unset or not absolute');
2476
2648
  for (let i = 0; i < images.length; i++) {
2477
2649
  const img = images[i];
2478
2650
  const ext = MIME_EXT[img.mimeType || ''] || '.jpg';
@@ -2636,19 +2808,18 @@ export class CodexRunner {
2636
2808
  *reconcileOpenAppServerToolCalls(state, turnId) {
2637
2809
  if (state.openToolCalls.size === 0)
2638
2810
  return;
2639
- const callIds = [...state.openToolCalls.keys()];
2811
+ const openCalls = [...state.openToolCalls.entries()];
2812
+ const callIds = openCalls.map(([callId]) => callId);
2640
2813
  logger.warn(`[CodexRunner] Turn completed with ${callIds.length} open tool call(s): ` +
2641
2814
  `thread=${state.threadId} turn=${turnId || state.turnId || 'unknown'} calls=${callIds.join(',')}`);
2642
- for (const [callId, name] of state.openToolCalls) {
2643
- yield {
2644
- type: 'tool_result',
2645
- name,
2646
- result: '',
2647
- isError: true,
2648
- error: 'Codex app-server ended the turn without an item/completed notification',
2649
- callId,
2650
- };
2651
- }
2815
+ yield {
2816
+ type: 'notice',
2817
+ severity: 'warn',
2818
+ subtype: 'missing-item-completed',
2819
+ message: 'Turn completed before tool completion was received',
2820
+ closedToolCallIds: callIds,
2821
+ missingItems: openCalls.map(([itemId, toolName]) => ({ itemId, toolName })),
2822
+ };
2652
2823
  state.openToolCalls.clear();
2653
2824
  }
2654
2825
  *mapAppServerItemStarted(item, state) {
@@ -2962,7 +3133,8 @@ export class CodexRunner {
2962
3133
  }
2963
3134
  managedTempParent() {
2964
3135
  const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
2965
- return path.join(resolveProtectedCandidate(os.tmpdir()), `evolcore-codex-${uid}`);
3136
+ const base = ensureProcessManagedTempDir();
3137
+ return path.join(resolveProtectedCandidate(base), `evolcore-codex-${uid}`);
2966
3138
  }
2967
3139
  ensurePrivateDirectory(directory) {
2968
3140
  try {
@@ -2999,6 +3171,7 @@ export class CodexRunner {
2999
3171
  try {
3000
3172
  const parent = this.managedTempParent();
3001
3173
  this.ensurePrivateDirectory(parent);
3174
+ this.ensurePrivateDirectory(path.join(parent, 'evolcore-locks'));
3002
3175
  // Include a per-runner nonce so a daemon restart never reuses contents
3003
3176
  // left behind by an unclean shutdown for the same EvolCore session id.
3004
3177
  const digest = createHash('sha256')
@@ -3019,39 +3192,28 @@ export class CodexRunner {
3019
3192
  }
3020
3193
  }
3021
3194
  isTrustedSessionTempDir(directory) {
3022
- try {
3023
- const rawStat = fs.lstatSync(directory);
3024
- if (!rawStat.isDirectory() || rawStat.isSymbolicLink())
3025
- return false;
3026
- const parent = resolveProtectedCandidate(this.managedTempParent());
3027
- const canonical = resolveProtectedCandidate(directory);
3028
- for (const candidate of [parent, canonical]) {
3029
- const stat = fs.lstatSync(candidate);
3030
- if (!stat.isDirectory() || stat.isSymbolicLink())
3031
- return false;
3032
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid())
3033
- return false;
3034
- if ((stat.mode & 0o077) !== 0)
3035
- return false;
3036
- }
3037
- return canonical !== parent && isSameOrDescendant(canonical, parent);
3038
- }
3039
- catch {
3040
- return false;
3041
- }
3195
+ return isTrustedManagedTempDirectory(directory, this.managedTempParent());
3196
+ }
3197
+ getManagedTempDir(sessionId) {
3198
+ const directory = this.ensureSessionTempDir(sessionId);
3199
+ if (!directory || !this.isTrustedSessionTempDir(directory))
3200
+ return undefined;
3201
+ return resolveProtectedCandidate(directory);
3042
3202
  }
3043
3203
  cleanupSessionTempDir(sessionId) {
3044
3204
  const directory = this.sessionTempDirs.get(sessionId);
3045
3205
  this.sessionTempDirs.delete(sessionId);
3046
3206
  if (!directory)
3047
3207
  return;
3048
- const parent = resolveProtectedCandidate(this.managedTempParent());
3049
- const candidate = resolveProtectedCandidate(directory);
3050
- if (candidate === parent || !isSameOrDescendant(candidate, parent)) {
3208
+ // Reuse the same ownership, mode, lexical-parent, and canonicalization
3209
+ // checks used at execution time. If the path changed, leave it in place
3210
+ // rather than recursively removing an attacker-selected target.
3211
+ if (!this.isTrustedSessionTempDir(directory)) {
3051
3212
  managedTempDirsForExitCleanup.delete(directory);
3052
3213
  logger.warn(`[CodexRunner] refused to clean unmanaged TMPDIR: session=${sessionId} path=${directory}`);
3053
3214
  return;
3054
3215
  }
3216
+ const candidate = path.resolve(directory);
3055
3217
  try {
3056
3218
  fs.rmSync(candidate, { recursive: true, force: true });
3057
3219
  managedTempDirsForExitCleanup.delete(directory);
@@ -3063,7 +3225,7 @@ export class CodexRunner {
3063
3225
  managedTempInstruction(sessionId) {
3064
3226
  if (!this.ensureSessionTempDir(sessionId))
3065
3227
  return undefined;
3066
- return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。';
3228
+ return '临时文件必须使用环境变量 `$TMPDIR`。它指向当前 EvolCore 会话的受管临时工作区;不要直接使用 `/tmp`,也不要使用其他会话的临时目录。当前是可持久化 thread 的恢复轮次,早先上下文中的 `/tmp/evolcore-codex-*` 绝对路径可能已经失效;不要重用旧路径。若文件不存在,请先在当前 `$TMPDIR` 重新创建,再继续执行后续 `ec` 命令。';
3067
3229
  }
3068
3230
  buildEvolcoreShellEnvironmentConfig(sessionId, delegationCarrier) {
3069
3231
  const managedTempDir = this.ensureSessionTempDir(sessionId);
@@ -3072,6 +3234,8 @@ export class CodexRunner {
3072
3234
  set: {
3073
3235
  EVOLCORE_SESSION_ID: sessionId,
3074
3236
  EVOLCORE_HOME: resolveRoot(),
3237
+ ...(managedTempDir ? { EVOLCORE_SESSION_RUNTIME_DIR: managedTempDir } : {}),
3238
+ ...(managedTempDir ? { EVOLCORE_RUNTIME_LOCK_DIR: path.join(path.dirname(managedTempDir), 'evolcore-locks') } : {}),
3075
3239
  ...(delegationCarrier ? { [AGENT_DELEGATION_TOKEN_ENV]: delegationCarrier } : {}),
3076
3240
  ...(managedTempDir ? { TMPDIR: managedTempDir } : {}),
3077
3241
  },