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
@@ -3,9 +3,94 @@ import path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { logger } from '../../utils/logger.js';
5
5
  import { resolveRoot } from '../../paths.js';
6
- import { containsHClassReference, containsLClassReference, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, } from '../protected-paths.js';
6
+ import { containsHClassReference, containsLClassReference, checkProtectedPathAccess, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, resolveProtectedCandidateWithoutFinalSymlink, } from '../protected-paths.js';
7
7
  import { classifyEvolcoreShellCommand, parseCodexToolCommandArgv, parseBoundedOutputShellCommand, parseLiteralShellArgv, unwrapCodexShellCommandArgv, } from './ec-command-parser.js';
8
8
  import { analyzeReadonlyShellQuery, } from './readonly-shell-query.js';
9
+ /** Resolve the session-owned temporary root supplied by the execution host. */
10
+ export function resolveManagedTempDir(env = process.env) {
11
+ const raw = typeof env.TMPDIR === 'string' ? env.TMPDIR.trim() : '';
12
+ if (!raw || !path.isAbsolute(raw))
13
+ return undefined;
14
+ const resolved = resolveProtectedCandidate(raw);
15
+ // A process-wide shared temporary root is not a session capability. The
16
+ // runner must register a private subdirectory before `$TMPDIR` is trusted.
17
+ if (['/tmp', '/var/tmp', '/dev/shm'].includes(resolved))
18
+ return undefined;
19
+ return resolved;
20
+ }
21
+ function substituteTmpDir(value, root) {
22
+ if (!root)
23
+ return value;
24
+ return value
25
+ .replace(/\$\{TMPDIR\}/g, root)
26
+ .replace(/\$TMPDIR\b/g, root);
27
+ }
28
+ /**
29
+ * A temp path is valid only when its canonical existing ancestor remains below
30
+ * the current session's TMPDIR. This also closes symlink and `..` escapes.
31
+ */
32
+ export function isManagedTempPath(value, options = {}) {
33
+ const root = resolveManagedTempDir(options.env);
34
+ if (!root || typeof value !== 'string' || !value.trim())
35
+ return false;
36
+ const candidate = resolveProtectedCandidate(substituteTmpDir(value, root), options.cwd);
37
+ return isSameOrDescendant(candidate, root);
38
+ }
39
+ export function expandManagedTempPath(value, options = {}) {
40
+ return substituteTmpDir(value, resolveManagedTempDir(options.env));
41
+ }
42
+ const WRITE_INTENT_RE = /(?:^|[\s;&|])(?:mkdir|mktemp|touch|tee|cp|mv|install|chmod|chown|ln|rm)(?:[\s;&|]|$)|(?:^|[\s])(?:\d>>?|>>?)\s*/i;
43
+ const TEMP_TOKEN_RE = /(?:\$TMPDIR|\$\{TMPDIR\}|\/tmp(?:\/|$)|\/var\/tmp(?:\/|$)|\/dev\/shm(?:\/|$))[^\s'";&|<>)]*/g;
44
+ /**
45
+ * Inspect shell write targets that look like temporary paths. Ordinary
46
+ * project writes are left to the existing workspace/H-class policy; any
47
+ * explicit /tmp-style target must be contained by TMPDIR.
48
+ */
49
+ export function checkManagedTempShellCommand(command, options = {}) {
50
+ if (!WRITE_INTENT_RE.test(command))
51
+ return { kind: 'none' };
52
+ const root = resolveManagedTempDir(options.env);
53
+ const normalized = substituteTmpDir(command, root);
54
+ const candidates = [...normalized.matchAll(TEMP_TOKEN_RE)].map(match => match[0].replace(/[.,:]+$/, ''));
55
+ if (candidates.length === 0)
56
+ return { kind: 'none' };
57
+ if (!root) {
58
+ return { kind: 'deny', paths: candidates, reason: 'TMPDIR is unset or not an absolute managed path' };
59
+ }
60
+ const outside = candidates.filter(candidate => !isManagedTempPath(candidate, {
61
+ ...options,
62
+ env: { ...(options.env ?? process.env), TMPDIR: root },
63
+ }));
64
+ if (outside.length > 0) {
65
+ return { kind: 'deny', paths: outside, reason: 'temporary write target is outside the session-managed TMPDIR' };
66
+ }
67
+ return { kind: 'allow', paths: candidates };
68
+ }
69
+ /**
70
+ * Replace only already-validated temporary path operands before a second
71
+ * policy pass. This prevents a harmless `$TMPDIR/CA/root` name from looking
72
+ * like the real EvolCore CA directory while preserving any unrelated H-class
73
+ * operand in the same command.
74
+ */
75
+ export function maskManagedTempShellPaths(command, options = {}) {
76
+ const check = checkManagedTempShellCommand(command, options);
77
+ if (check.kind !== 'allow')
78
+ return command;
79
+ const root = resolveManagedTempDir(options.env);
80
+ const normalized = substituteTmpDir(command, root);
81
+ let index = 0;
82
+ return normalized.replace(TEMP_TOKEN_RE, token => {
83
+ const candidate = token.replace(/[.,:]+$/, '');
84
+ if (!check.paths.includes(candidate) || !isManagedTempPath(candidate, {
85
+ ...options,
86
+ env: { ...(options.env ?? process.env), TMPDIR: root },
87
+ })) {
88
+ return token;
89
+ }
90
+ index += 1;
91
+ return `__managed_tmp_${index}__`;
92
+ });
93
+ }
9
94
  // 绝对禁止命令(所有角色、所有权限模式下都禁止,不可授权)
10
95
  // 这些是系统级破坏操作,任何情况下都不应该允许
11
96
  const ABSOLUTE_FORBIDDEN = [
@@ -320,9 +405,50 @@ function containsNetStopCommand(command, depth = 0) {
320
405
  });
321
406
  }
322
407
  function queryPathOperands(analysis) {
323
- return analysis.kind === 'proven-readonly' ? analysis.ir.pathOperands : [];
408
+ if (analysis.kind !== 'proven-readonly')
409
+ return [];
410
+ return analysis.ir.pipelines.flatMap(pipeline => pipeline.flatMap(command => command.pathOperands.map(operand => ({
411
+ operand,
412
+ accessKind: command.accessKind ?? (operand.access === 'recursive' ? 'enumerate' : 'content'),
413
+ }))));
324
414
  }
325
- function shellOperandMatchesClass(operand, className, options) {
415
+ function shellOperandMatchesClass(operand, className, options, accessKind = operand.access === 'recursive' ? 'enumerate' : 'content') {
416
+ // `$TMPDIR` is a private session namespace, so names such as `CA` or
417
+ // `config.json` inside it are ordinary temporary data rather than paths in
418
+ // the EvolCore root. Resolve the candidate first so a symlink escape does
419
+ // not inherit this exception.
420
+ if (options.managedTempDir) {
421
+ const managedTemp = resolveProtectedCandidate(options.managedTempDir);
422
+ const candidate = accessKind === 'metadata' && operand.access === 'exact'
423
+ ? resolveProtectedCandidateWithoutFinalSymlink(operand.value, options.cwd)
424
+ : resolveProtectedCandidate(operand.value, options.cwd);
425
+ const workspace = options.cwd ? resolveProtectedCandidate(options.cwd) : undefined;
426
+ if (isSameOrDescendant(candidate, managedTemp)
427
+ && (!workspace || !isSameOrDescendant(candidate, workspace))) {
428
+ return false;
429
+ }
430
+ }
431
+ if (className === 'h' && accessKind === 'metadata' && operand.access === 'exact') {
432
+ // Filesystem sandboxes currently expose only path-wide H-class deny
433
+ // rules. A runner that cannot provide an action-aware metadata channel
434
+ // must keep the policy decision aligned with that lower-level boundary,
435
+ // but ordinary workspace metadata remains safe and should stay usable.
436
+ const metadata = checkProtectedPathAccess(operand.value, 'metadata', {
437
+ ...options,
438
+ // stat/lstat must inspect the final symlink itself, never its target.
439
+ followFinalSymlink: false,
440
+ });
441
+ if (options.allowProtectedMetadata === false) {
442
+ return metadata.pathClass === 'h-file' || metadata.pathClass === 'h-directory';
443
+ }
444
+ // The no-follow decision is authoritative for metadata. In particular,
445
+ // an ordinary symlink whose target is H-class must remain inspectable as a
446
+ // link; falling through to isHClassPath() would follow it and reject the
447
+ // otherwise safe lstat/stat operation.
448
+ if (metadata.pathClass !== 'unknown' && metadata.allowed) {
449
+ return false;
450
+ }
451
+ }
326
452
  const reference = className === 'h' ? containsHClassReference(operand.value) : containsLClassReference(operand.value);
327
453
  if (reference)
328
454
  return true;
@@ -352,7 +478,7 @@ function hClassReadScopeIncludesProtectedPath(value, options) {
352
478
  return getExistingHClassMaskTargets(root).some(target => isSameOrDescendant(target.path, scope));
353
479
  }
354
480
  function analyzeShellProtectedOperands(command, options) {
355
- const analysis = analyzeReadonlyShellQuery(command);
481
+ const analysis = analyzeReadonlyShellQuery(command, { managedTempDir: options.managedTempDir });
356
482
  if (analysis.kind === 'unproven') {
357
483
  return {
358
484
  analysis,
@@ -363,8 +489,8 @@ function analyzeShellProtectedOperands(command, options) {
363
489
  const operands = queryPathOperands(analysis);
364
490
  return {
365
491
  analysis,
366
- hClass: operands.some(operand => shellOperandMatchesClass(operand, 'h', options)),
367
- lClass: operands.some(operand => shellOperandMatchesClass(operand, 'l', options)),
492
+ hClass: operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'h', options, accessKind)),
493
+ lClass: operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'l', options, accessKind)),
368
494
  };
369
495
  }
370
496
  function protectedReadToolPaths(toolName, input) {
@@ -444,11 +570,21 @@ function protectedReadToolStaysInWorkspace(toolName, input, projectPath) {
444
570
  }
445
571
  return true;
446
572
  }
447
- function readonlyShellStaysInWorkspace(analysis, projectPath) {
573
+ function readonlyShellStaysInWorkspace(analysis, projectPath, managedTempDir) {
448
574
  if (analysis.kind !== 'proven-readonly')
449
575
  return false;
450
576
  const workspace = resolveProtectedCandidate(projectPath);
451
- return analysis.ir.pathOperands.every(operand => isSameOrDescendant(resolveProtectedCandidate(operand.value, projectPath), workspace));
577
+ const managedTemp = managedTempDir ? resolveProtectedCandidate(managedTempDir) : undefined;
578
+ return analysis.ir.pipelines.flatMap(pipeline => pipeline.flatMap(command => command.pathOperands.map(operand => ({
579
+ operand,
580
+ accessKind: command.accessKind ?? (operand.access === 'recursive' ? 'enumerate' : 'content'),
581
+ })))).every(({ operand, accessKind }) => (() => {
582
+ const candidate = accessKind === 'metadata' && operand.access === 'exact'
583
+ ? resolveProtectedCandidateWithoutFinalSymlink(operand.value, projectPath)
584
+ : resolveProtectedCandidate(operand.value, projectPath);
585
+ return isSameOrDescendant(candidate, workspace)
586
+ || (!!managedTemp && isSameOrDescendant(candidate, managedTemp));
587
+ })());
452
588
  }
453
589
  /**
454
590
  * 只读模式检查(用于 PreToolUse hook 和 canUseTool callback)。普通
@@ -463,50 +599,61 @@ export function checkReadonly(toolName, input, projectPath, context) {
463
599
  const paths = protectedReadToolPaths(toolName, input);
464
600
  const pathOptions = { cwd: projectPath, root: context?.root };
465
601
  if (!protectedReadToolStaysInWorkspace(toolName, input, projectPath)) {
466
- return { behavior: 'deny', message: '🔒 只读模式:文件读取范围不能越出当前项目目录' };
602
+ return { behavior: 'deny', message: '🔒 只读模式:文件读取范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
467
603
  }
468
604
  if (paths.some(filePath => containsHClassReference(filePath) || isHClassPath(filePath, pathOptions))) {
469
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
605
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
470
606
  }
471
607
  if (protectedReadToolMatchesHClass(toolName, input, pathOptions)) {
472
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
608
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
473
609
  }
474
610
  if (protectedReadToolMatchesLClass(toolName, input, pathOptions)) {
475
- return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径' };
611
+ return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
476
612
  }
477
613
  return { behavior: 'allow' };
478
614
  }
479
615
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') {
480
616
  const filePath = (input.file_path || input.notebook_path);
481
617
  logger.warn(`[ReadonlyCheck] 🔒 File write blocked: tool=${toolName} path=${filePath} project=${projectPath} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
482
- return { behavior: 'deny', message: '🔒 只读模式:禁止写入或修改文件' };
618
+ return { behavior: 'deny', message: '🔒 只读模式:禁止写入或修改文件', policyCode: 'readonly_file_write' };
483
619
  }
484
620
  if (toolName === 'Bash') {
485
621
  const cmd = input.command || '';
486
- const protectedOperands = analyzeShellProtectedOperands(cmd, { cwd: projectPath, root: context?.root });
622
+ // A managed session must provide its runner-registered temp root. For
623
+ // direct policy callers without a session, the process TMPDIR remains a
624
+ // useful compatibility fallback after the shared-root check above.
625
+ const managedTempDir = context?.sessionId
626
+ ? context.managedTempDir
627
+ : context?.managedTempDir ?? resolveManagedTempDir();
628
+ const protectedOperands = analyzeShellProtectedOperands(cmd, {
629
+ cwd: projectPath,
630
+ root: context?.root,
631
+ managedTempDir,
632
+ allowProtectedMetadata: context?.allowProtectedMetadata,
633
+ });
487
634
  if (protectedOperands.analysis.kind === 'proven-readonly'
488
635
  && !protectedOperands.hClass
489
636
  && !protectedOperands.lClass
490
- && readonlyShellStaysInWorkspace(protectedOperands.analysis, projectPath)) {
637
+ && readonlyShellStaysInWorkspace(protectedOperands.analysis, projectPath, managedTempDir)) {
491
638
  return { behavior: 'allow' };
492
639
  }
493
640
  if (protectedOperands.hClass) {
494
641
  logger.warn(`[ReadonlyCheck] 🔒 H-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
495
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
642
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
496
643
  }
497
644
  if (protectedOperands.lClass) {
498
645
  logger.warn(`[ReadonlyCheck] 🔒 L-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
499
- return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径' };
646
+ return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
500
647
  }
501
648
  if (protectedOperands.analysis.kind === 'proven-readonly') {
502
- return { behavior: 'deny', message: '🔒 只读模式:Shell 查询范围不能越出当前项目目录' };
649
+ return { behavior: 'deny', message: '🔒 只读模式:Shell 查询范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
503
650
  }
504
651
  const cmdPreview = cmd.length > 80 ? cmd.substring(0, 80) + '...' : cmd;
505
652
  logger.warn(`[ReadonlyCheck] 🔒 Bash blocked by default: cmd="${cmdPreview}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
506
- return { behavior: 'deny', message: '🔒 只读模式:Shell 仅允许受限、可证明只读的普通路径查询' };
653
+ return { behavior: 'deny', message: '🔒 只读模式:Shell 仅允许受限、可证明只读的普通路径查询', policyCode: 'readonly_shell_unproven' };
507
654
  }
508
655
  logger.warn(`[ReadonlyCheck] 🔒 Unknown tool blocked: tool=${toolName} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
509
- return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName} 已拒绝执行` };
656
+ return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName} 已拒绝执行`, policyCode: 'readonly_tool_unavailable' };
510
657
  }
511
658
  const NON_FILESYSTEM_METADATA_TOOLS = new Set([
512
659
  'Agent',
@@ -752,9 +899,35 @@ export function checkHClassWrite(toolName, input, context) {
752
899
  }
753
900
  else if (toolName === 'Bash') {
754
901
  const command = typeof input.command === 'string' ? input.command : '';
755
- const protectedOperands = analyzeShellProtectedOperands(command, {
902
+ // Keep this H-class pass aligned with the shared preflight pass. A
903
+ // managed session must not fall back to the daemon's process-wide TMPDIR;
904
+ // without a registered root, explicit temporary paths fail closed.
905
+ const tempOptions = {
906
+ cwd: context?.projectPath,
907
+ env: context?.sessionId
908
+ ? { ...process.env, TMPDIR: context.managedTempDir ?? '' }
909
+ : context?.managedTempDir !== undefined
910
+ ? { ...process.env, TMPDIR: context.managedTempDir }
911
+ : process.env,
912
+ };
913
+ const tempCheck = checkManagedTempShellCommand(command, tempOptions);
914
+ if (tempCheck.kind === 'deny') {
915
+ return {
916
+ behavior: 'deny',
917
+ message: '🔒 临时文件必须位于当前会话的 $TMPDIR 内',
918
+ };
919
+ }
920
+ // A managed temp write is intentionally allowed to contain names such as
921
+ // CA/root, but only that validated operand is masked. Any other protected
922
+ // operand in a mixed command must still be rejected.
923
+ const policyCommand = tempCheck.kind === 'allow'
924
+ ? maskManagedTempShellPaths(command, tempOptions)
925
+ : command;
926
+ const protectedOperands = analyzeShellProtectedOperands(policyCommand, {
756
927
  cwd: context?.projectPath,
757
928
  root: context?.root,
929
+ managedTempDir: context?.managedTempDir,
930
+ allowProtectedMetadata: context?.allowProtectedMetadata,
758
931
  });
759
932
  if (protectedOperands.hClass) {
760
933
  logger.warn(`[H-Class Protection] 🔒 Protected filesystem operand in shell command: tool=${toolName} ` +
@@ -905,7 +1078,10 @@ export function checkLClassWrite(toolName, input, context) {
905
1078
  else if (toolName === 'Bash') {
906
1079
  const command = typeof input.command === 'string' ? input.command : '';
907
1080
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
908
- const protectedOperands = analyzeShellProtectedOperands(command, pathOptions);
1081
+ const protectedOperands = analyzeShellProtectedOperands(command, {
1082
+ ...pathOptions,
1083
+ managedTempDir: context?.managedTempDir,
1084
+ });
909
1085
  const lClassDenied = protectedOperands.lClass
910
1086
  && (context?.permissionMode === 'readonly' || protectedOperands.analysis.kind === 'unproven');
911
1087
  if (lClassDenied) {
@@ -992,14 +1168,46 @@ export function checkBlacklist(toolName, input) {
992
1168
  // 默认允许
993
1169
  return { behavior: 'allow', updatedInput: input };
994
1170
  }
1171
+ function managedTempOptions(context) {
1172
+ return {
1173
+ cwd: context.projectPath,
1174
+ // A managed session must never inherit the daemon's unrelated TMPDIR.
1175
+ env: context.sessionId
1176
+ ? { ...process.env, TMPDIR: context.managedTempDir ?? '' }
1177
+ : context.managedTempDir !== undefined
1178
+ ? { ...process.env, TMPDIR: context.managedTempDir }
1179
+ : process.env,
1180
+ };
1181
+ }
995
1182
  function validateBoundedOutputPath(command, context) {
996
1183
  const outputPath = command.outputPath;
997
1184
  if (!outputPath)
998
1185
  return undefined;
999
1186
  if (context.permissionMode === 'readonly')
1000
1187
  return '🔒 只读模式:禁止将命令输出写入文件';
1001
- if (path.isAbsolute(outputPath))
1002
- return '🔒 输出文件必须使用项目目录内的相对路径';
1188
+ const tempOptions = managedTempOptions(context);
1189
+ const expandedOutputPath = expandManagedTempPath(outputPath, tempOptions);
1190
+ if (path.isAbsolute(expandedOutputPath)) {
1191
+ // A literal `/tmp/...` path is not proof that the caller is using this
1192
+ // session's managed workspace. Only an explicit `$TMPDIR` reference may
1193
+ // opt into the absolute temporary-path branch; the runner owns the value
1194
+ // of TMPDIR and validates its containment below.
1195
+ const explicitManagedTemp = /^\$(?:\{TMPDIR\}|TMPDIR)(?:[\\/]|$)/.test(outputPath);
1196
+ if (!explicitManagedTemp || !isManagedTempPath(outputPath, tempOptions)) {
1197
+ return '🔒 输出文件必须使用项目目录内的相对路径,或当前会话的 $TMPDIR';
1198
+ }
1199
+ const tempTarget = resolveProtectedCandidate(expandedOutputPath);
1200
+ if (fs.existsSync(tempTarget))
1201
+ return '🔒 临时安全输出只允许创建新文件,不能覆盖或追加现有文件';
1202
+ try {
1203
+ if (!fs.statSync(path.dirname(tempTarget)).isDirectory())
1204
+ return '🔒 临时输出文件的父目录无效';
1205
+ }
1206
+ catch {
1207
+ return '🔒 临时输出文件的父目录必须已经存在';
1208
+ }
1209
+ return undefined;
1210
+ }
1003
1211
  const workspace = resolveProtectedCandidate(context.workspacePath ?? context.projectPath);
1004
1212
  const cwd = resolveProtectedCandidate(context.projectPath);
1005
1213
  if (!isSameOrDescendant(cwd, workspace))
@@ -1033,7 +1241,7 @@ function quotePosixShellArg(value) {
1033
1241
  function prepareBoundedOutputInput(input, command, kind, context) {
1034
1242
  const validationError = validateBoundedOutputPath(command, context);
1035
1243
  if (validationError)
1036
- return { behavior: 'deny', input, message: validationError };
1244
+ return { behavior: 'deny', input, message: validationError, policyCode: 'bounded_output_invalid' };
1037
1245
  const helperPath = context.safeOutputHelperPath;
1038
1246
  if (!helperPath) {
1039
1247
  return {
@@ -1047,7 +1255,7 @@ function prepareBoundedOutputInput(input, command, kind, context) {
1047
1255
  throw new Error('not a file');
1048
1256
  }
1049
1257
  catch {
1050
- return { behavior: 'deny', input, message: '🔒 安全输出 helper 不可用,命令已拒绝' };
1258
+ return { behavior: 'deny', input, message: '🔒 安全输出 helper 不可用,命令已拒绝', policyCode: 'bounded_output_helper_unavailable' };
1051
1259
  }
1052
1260
  const payload = Buffer.from(JSON.stringify({
1053
1261
  v: 1,
@@ -1058,7 +1266,9 @@ function prepareBoundedOutputInput(input, command, kind, context) {
1058
1266
  argv: command.argv,
1059
1267
  mergeStderr: command.mergeStderr,
1060
1268
  ...(command.headArgs ? { headArgs: command.headArgs } : {}),
1061
- ...(command.outputPath ? { outputPath: command.outputPath } : {}),
1269
+ ...(command.outputPath
1270
+ ? { outputPath: expandManagedTempPath(command.outputPath, managedTempOptions(context)) }
1271
+ : {}),
1062
1272
  }), 'utf8').toString('base64url');
1063
1273
  const rewrittenCommand = `${quotePosixShellArg(process.execPath)} ${quotePosixShellArg(helperPath)} ${quotePosixShellArg(payload)}`;
1064
1274
  return {
@@ -1071,6 +1281,15 @@ function prepareBoundedOutputInput(input, command, kind, context) {
1071
1281
  export function evaluateToolPreflight(toolName, input, context) {
1072
1282
  if (toolName === 'Bash') {
1073
1283
  const command = typeof input.command === 'string' ? input.command : '';
1284
+ const tempCheck = checkManagedTempShellCommand(command, managedTempOptions(context));
1285
+ if (tempCheck.kind === 'deny') {
1286
+ return {
1287
+ behavior: 'deny',
1288
+ input,
1289
+ message: `🔒 临时文件必须位于当前会话的 $TMPDIR 内:${tempCheck.reason}`,
1290
+ policyCode: 'temporary_path_outside_tmpdir',
1291
+ };
1292
+ }
1074
1293
  const explicitCommandArgv = Array.isArray(input.commandArgv)
1075
1294
  && input.commandArgv.every(value => typeof value === 'string')
1076
1295
  ? input.commandArgv
@@ -1129,40 +1348,28 @@ export function evaluateToolPreflight(toolName, input, context) {
1129
1348
  return prepareBoundedOutputInput(input, ecCommand.command, 'ec', context);
1130
1349
  }
1131
1350
  if (ecCommand.kind === 'composite') {
1132
- // Older app-server approval callbacks may provide only the rendered
1133
- // `/bin/bash -lc ...` command string. A composite inside that carrier
1134
- // is not eligible for a delegation ticket, but owner bypass still
1135
- // handles it as an ordinary shell request. Structured `commandArgv`
1136
- // callbacks remain fail-closed below because their command boundary is
1137
- // authoritative.
1138
- const legacyRenderedCarrier = !explicitCommandArgv && wrappedCommand !== undefined;
1139
- const allowLegacyBypassComposition = legacyRenderedCarrier
1140
- && context.permissionMode === 'bypass'
1141
- && ecCommand.issue === 'shell-composition';
1142
- if (!allowLegacyBypassComposition) {
1143
- if (ecCommand.issue === 'unsafe-expansion') {
1144
- return {
1145
- behavior: 'deny',
1146
- input,
1147
- message: '🔒 EC 双引号正文包含未转义的 Shell 展开;请将反引号写成 \\`,将 $()、${}、$变量中的 $ 写成 \\$。Shell 传给 ec 时会还原为原文字面量',
1148
- policyCode: 'ec_shell_unsafe_expansion',
1149
- };
1150
- }
1151
- if (ecCommand.issue === 'invalid-quote') {
1152
- return {
1153
- behavior: 'deny',
1154
- input,
1155
- message: '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"',
1156
- policyCode: 'ec_shell_invalid_quote',
1157
- };
1158
- }
1351
+ if (ecCommand.issue === 'unsafe-expansion') {
1352
+ return {
1353
+ behavior: 'deny',
1354
+ input,
1355
+ message: '🔒 EC 双引号正文包含未转义的 Shell 展开;请将反引号写成 \\`,将 $()、${}、$变量中的 $ 写成 \\$。Shell 传给 ec 时会还原为原文字面量',
1356
+ policyCode: 'ec_shell_unsafe_expansion',
1357
+ };
1358
+ }
1359
+ if (ecCommand.issue === 'invalid-quote') {
1159
1360
  return {
1160
1361
  behavior: 'deny',
1161
1362
  input,
1162
- message: '🔒 EC 命令调用被拒绝:一次 Bash/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要先用 `command -v`/`ec aid` 探测,也不要使用 `&&`、`||`、`;`、`|`、重定向、变量展开、子 shell、`sh -c` 或 `bash -lc` 拼接其它命令。',
1163
- policyCode: 'ec_shell_composite_command',
1363
+ message: '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"',
1364
+ policyCode: 'ec_shell_invalid_quote',
1164
1365
  };
1165
1366
  }
1367
+ return {
1368
+ behavior: 'deny',
1369
+ input,
1370
+ message: '🔒 EC 命令调用被拒绝:一次 Bash/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要先用 `command -v`/`ec aid` 探测,也不要使用 `&&`、`||`、`;`、`|`、重定向、变量展开、子 shell、`sh -c` 或 `bash -lc` 拼接其它命令。',
1371
+ policyCode: 'ec_shell_composite_command',
1372
+ };
1166
1373
  }
1167
1374
  const boundedOutput = parseBoundedOutputShellCommand(command);
1168
1375
  const hostProcessCommand = boundedOutput
@@ -1190,6 +1397,8 @@ export function evaluateToolPreflight(toolName, input, context) {
1190
1397
  projectPath: context.projectPath,
1191
1398
  workspacePath: context.workspacePath,
1192
1399
  root: context.root,
1400
+ managedTempDir: context.managedTempDir,
1401
+ allowProtectedMetadata: context.allowProtectedMetadata,
1193
1402
  });
1194
1403
  if (hClass.behavior === 'deny') {
1195
1404
  return { behavior: 'deny', input: checkedInput, message: hClass.message, policyCode: 'h_class_protection' };
@@ -1203,6 +1412,7 @@ export function evaluateToolPreflight(toolName, input, context) {
1203
1412
  projectPath: context.projectPath,
1204
1413
  workspacePath: context.workspacePath,
1205
1414
  root: context.root,
1415
+ managedTempDir: context.managedTempDir,
1206
1416
  });
1207
1417
  if (lClass.behavior === 'deny') {
1208
1418
  return { behavior: 'deny', input: checkedInput, message: lClass.message, policyCode: 'l_class_protection' };
@@ -38,6 +38,20 @@ const H_CLASS_REFERENCE_PATTERNS = [
38
38
  /(?:^|[/\\\s"'=<>])data[\\/]triggers(?:[\\/]|$|[\s"';&|<>)},])/,
39
39
  /(?:^|[/\\\s"'=<>])(?:[^/\\\s"']+\.json_|[^/\\\s"']+\.json\.migrated|defaults_\d+\.json)(?=$|[\s"';&|<>)},])/,
40
40
  ];
41
+ // H-class directory roots are kept separately from the full path matcher so
42
+ // an exact metadata query can distinguish a protected directory from a
43
+ // protected file. Descendants inherit the directory classification unless an
44
+ // existing regular file gives us a more precise answer.
45
+ const H_CLASS_DIRECTORY_RELATIVE_PATTERNS = [
46
+ /^backups\/config(?:\/|$)/,
47
+ /^\.snapshots(?:\/|$)/,
48
+ /^CA(?:\/|$)/,
49
+ /^(?:AIDs|aids)\/[^/]+\/(?:cert|keys|private)(?:\/|$)/,
50
+ /^data\/(?:triggers|sessions|outbox|handoff|channel-state)(?:\/|$)/,
51
+ /^data\/daemon\/(?:control|migrations)(?:\/|$)/,
52
+ /^agents\/[^/]+\/sessions(?:\/|$)/,
53
+ /^agents\/[^/]+\/data\/(?:channels|handoff)(?:\/|$)/,
54
+ ];
41
55
  const L_CLASS_RELATIVE_PATTERNS = [
42
56
  /^agents\/[^/]+\/triggers(?:\/|$)/,
43
57
  /^agents\/[^/]+\/roles(?:\/|$)/,
@@ -68,6 +82,22 @@ function resolveThroughExistingAncestor(value) {
68
82
  return absolute;
69
83
  }
70
84
  }
85
+ /**
86
+ * H-class file names are normally leaves, but treating their descendants as
87
+ * protected as well is important when a malformed or externally-created
88
+ * directory uses a reserved file name (for example `config.json/child`).
89
+ * Such a path cannot expose content from the expected file, and fail-closed
90
+ * classification avoids turning that unusual filesystem state into a bypass.
91
+ */
92
+ function matchesHClassRelative(relative) {
93
+ const segments = relative.split('/').filter(Boolean);
94
+ for (let end = segments.length; end > 0; end--) {
95
+ if (H_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(segments.slice(0, end).join('/')))) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
71
101
  export function isSameOrDescendant(candidate, parent) {
72
102
  const normalizedCandidate = normalizeForComparison(candidate);
73
103
  const normalizedParent = normalizeForComparison(parent);
@@ -85,14 +115,89 @@ export function isHClassPath(value, options = {}) {
85
115
  : path.resolve(options.cwd ?? process.cwd(), value);
86
116
  if (isSameOrDescendant(lexicalCandidate, root)) {
87
117
  const lexicalRelative = path.relative(root, lexicalCandidate).split(path.sep).join('/');
88
- if (H_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(lexicalRelative)))
118
+ if (matchesHClassRelative(lexicalRelative))
89
119
  return true;
90
120
  }
91
121
  const candidate = resolveProtectedCandidate(value, options.cwd);
92
122
  if (!isSameOrDescendant(candidate, root))
93
123
  return false;
94
124
  const relative = path.relative(root, candidate).split(path.sep).join('/');
95
- return H_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(relative));
125
+ return matchesHClassRelative(relative);
126
+ }
127
+ function hasPathSyntax(value) {
128
+ return /[\0\r\n$`*?\[\]{}<>|;&]/.test(value);
129
+ }
130
+ function lexicalCandidate(value, cwd = process.cwd()) {
131
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(cwd, value);
132
+ }
133
+ function resolveWithoutFinalSymlink(value, cwd = process.cwd()) {
134
+ const absolute = lexicalCandidate(value, cwd);
135
+ const basename = path.basename(absolute);
136
+ const parent = resolveThroughExistingAncestor(path.dirname(absolute));
137
+ return basename ? path.join(parent, basename) : parent;
138
+ }
139
+ /** Resolve path ancestors while preserving the final symlink as a literal node. */
140
+ export function resolveProtectedCandidateWithoutFinalSymlink(value, cwd = process.cwd()) {
141
+ return resolveWithoutFinalSymlink(value, cwd);
142
+ }
143
+ function relativeWithinRoot(value, root) {
144
+ if (!isSameOrDescendant(value, root))
145
+ return undefined;
146
+ return path.relative(root, value).split(path.sep).join('/');
147
+ }
148
+ /**
149
+ * Classify one literal filesystem path without interpreting shell syntax.
150
+ * `followFinalSymlink=false` is used by stat/lstat policy so metadata queries
151
+ * describe the link itself rather than exposing the protected target.
152
+ */
153
+ export function classifyProtectedPath(value, options = {}) {
154
+ if (typeof value !== 'string' || !value.trim() || hasPathSyntax(value))
155
+ return 'unknown';
156
+ const root = resolveThroughExistingAncestor(options.root ?? resolveRoot());
157
+ const lexical = resolveWithoutFinalSymlink(value, options.cwd);
158
+ const followFinalSymlink = options.followFinalSymlink !== false;
159
+ const candidate = followFinalSymlink ? resolveProtectedCandidate(value, options.cwd) : lexical;
160
+ const relative = relativeWithinRoot(candidate, root);
161
+ if (relative === undefined)
162
+ return 'ordinary';
163
+ const hMatch = matchesHClassRelative(relative);
164
+ if (hMatch) {
165
+ let directory = H_CLASS_DIRECTORY_RELATIVE_PATTERNS.some(pattern => pattern.test(relative));
166
+ try {
167
+ const statTarget = followFinalSymlink ? candidate : lexical;
168
+ directory = fs.lstatSync(statTarget).isDirectory();
169
+ }
170
+ catch {
171
+ // Missing paths below a protected directory inherit that directory's
172
+ // class; missing exact H-class filenames remain H-class files.
173
+ }
174
+ return directory ? 'h-directory' : 'h-file';
175
+ }
176
+ const lMatch = L_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(relative));
177
+ if (lMatch)
178
+ return 'l-path';
179
+ return 'ordinary';
180
+ }
181
+ /** Apply the H/L path-class × access-action matrix to one literal path. */
182
+ export function checkProtectedPathAccess(value, accessKind, options = {}) {
183
+ const pathClass = classifyProtectedPath(value, options);
184
+ if (pathClass === 'unknown')
185
+ return { pathClass, accessKind, allowed: false };
186
+ if (pathClass === 'h-file' || pathClass === 'h-directory') {
187
+ return { pathClass, accessKind, allowed: accessKind === 'metadata' };
188
+ }
189
+ if (pathClass === 'l-path') {
190
+ return {
191
+ pathClass,
192
+ accessKind,
193
+ allowed: accessKind !== 'write' && accessKind !== 'grant',
194
+ };
195
+ }
196
+ return {
197
+ pathClass,
198
+ accessKind,
199
+ allowed: accessKind !== 'write' && accessKind !== 'grant',
200
+ };
96
201
  }
97
202
  export function containsHClassReference(value) {
98
203
  const normalized = value.replace(/\\/g, '/');