evolcore 0.0.19 → 0.0.21

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 (136) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +60 -9
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/baseagent.js +10 -6
  5. package/dist/agents/claude-runner.js +383 -111
  6. package/dist/agents/codex-app-server-client.js +10 -2
  7. package/dist/agents/codex-runner.js +405 -137
  8. package/dist/agents/ecagent-runner.js +174 -63
  9. package/dist/agents/gemini-runner.js +130 -30
  10. package/dist/agents/request-identity.js +25 -0
  11. package/dist/agents/runner-types.js +19 -0
  12. package/dist/aun/aid/agentmd.js +66 -2
  13. package/dist/aun/aid/identity.js +4 -1
  14. package/dist/aun/aid/index.js +1 -1
  15. package/dist/aun/msg/group.js +86 -9
  16. package/dist/aun/msg/history.js +213 -36
  17. package/dist/aun/msg/managed-operation.js +58 -9
  18. package/dist/aun/msg/p2p.js +26 -11
  19. package/dist/aun/outbox.js +295 -68
  20. package/dist/aun/service-proxy.js +43 -25
  21. package/dist/channels/aun.js +1004 -273
  22. package/dist/channels/daemon.js +6 -1
  23. package/dist/cli/agent-command.js +4 -3
  24. package/dist/cli/agent.js +66 -56
  25. package/dist/cli/aun-commands.js +177 -42
  26. package/dist/cli/command-log.js +10 -11
  27. package/dist/cli/contact.js +1 -0
  28. package/dist/cli/daemon-commands.js +81 -121
  29. package/dist/cli/index.js +1 -0
  30. package/dist/cli/init.js +76 -24
  31. package/dist/cli/restart-monitor.js +3 -3
  32. package/dist/cli/task-context.js +46 -0
  33. package/dist/cli/trigger-command.js +1 -1
  34. package/dist/cli/watch-logs.js +10 -3
  35. package/dist/config/builtin-roles.js +1 -0
  36. package/dist/config/config-field-policy.js +16 -5
  37. package/dist/config/config-manager.js +226 -24
  38. package/dist/config/config-operation-service.js +1 -2
  39. package/dist/config/contact-operation-service.js +32 -1
  40. package/dist/config/contact-request-service.js +44 -0
  41. package/dist/config/daemon-services.js +186 -0
  42. package/dist/config/gateway-config.js +29 -16
  43. package/dist/config/lifecycle.js +16 -5
  44. package/dist/config/role-service.js +54 -3
  45. package/dist/config/schema-migration.js +550 -0
  46. package/dist/config-store.js +161 -12
  47. package/dist/core/agent-application-service.js +279 -0
  48. package/dist/core/audit/log-integrity.js +102 -0
  49. package/dist/core/auth/agent-delegation.js +31 -1
  50. package/dist/core/auth/auth-gateway.js +33 -4
  51. package/dist/core/auth/authorization-audit.js +155 -4
  52. package/dist/core/auth/operation-authorizer.js +41 -1
  53. package/dist/core/auth/operation-catalog.js +9 -1
  54. package/dist/core/bootstrap-messages.js +2 -2
  55. package/dist/core/bootstrap-service.js +27 -38
  56. package/dist/core/causation/aun-association.js +7 -4
  57. package/dist/core/channel-loader.js +0 -2
  58. package/dist/core/command/agent-control.js +56 -16
  59. package/dist/core/command/command-handler.js +290 -44
  60. package/dist/core/command/connect-menu.js +3 -4
  61. package/dist/core/command/group-menu.js +5 -7
  62. package/dist/core/command/menu-handler.js +279 -80
  63. package/dist/core/command/role-menu.js +21 -11
  64. package/dist/core/command/slash-gate.js +85 -18
  65. package/dist/core/command/slash-handler.js +350 -32
  66. package/dist/core/event-catalog.js +5 -0
  67. package/dist/core/evolagent.js +9 -4
  68. package/dist/core/handoff/dispatcher.js +4 -0
  69. package/dist/core/handoff/runtime.js +10 -0
  70. package/dist/core/handoff/store.js +32 -9
  71. package/dist/core/inference/text-inference.js +7 -15
  72. package/dist/core/message/im-renderer.js +83 -84
  73. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  74. package/dist/core/message/message-bridge.js +124 -10
  75. package/dist/core/message/message-log.js +14 -7
  76. package/dist/core/message/message-queue.js +206 -16
  77. package/dist/core/message/message-utils.js +12 -5
  78. package/dist/core/message/response-engine.js +495 -72
  79. package/dist/core/message/send-receipt.js +1 -0
  80. package/dist/core/message/stream-debouncer.js +9 -2
  81. package/dist/core/model/model-catalog.js +23 -15
  82. package/dist/core/model/model-diagnostics.js +28 -10
  83. package/dist/core/permission/approval-gateway.js +180 -6
  84. package/dist/core/permission/ec-command-parser.js +465 -56
  85. package/dist/core/permission/mode.js +18 -3
  86. package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
  87. package/dist/core/permission/readonly-shell-query.js +263 -9
  88. package/dist/core/permission/sandbox-runtime.js +205 -13
  89. package/dist/core/permission/tool-policy.js +673 -62
  90. package/dist/core/relation/peer-identity.js +18 -0
  91. package/dist/core/session/session-fs-store.js +154 -5
  92. package/dist/core/session/session-manager.js +299 -30
  93. package/dist/core/session/session-renew.js +19 -12
  94. package/dist/core/session/session-turn-coordinator.js +11 -4
  95. package/dist/eck/kit-renderer.js +18 -9
  96. package/dist/index.js +283 -65
  97. package/dist/ipc.js +374 -24
  98. package/dist/paths.js +64 -7
  99. package/dist/response-system/context-builder.js +1 -7
  100. package/dist/trigger/anomaly-store.js +1 -0
  101. package/dist/trigger/feedback.js +56 -5
  102. package/dist/trigger/history.js +79 -4
  103. package/dist/trigger/legacy-session-history.js +2 -2
  104. package/dist/trigger/parser.js +3 -2
  105. package/dist/trigger/validation.js +6 -1
  106. package/dist/utils/atomic-write.js +27 -0
  107. package/dist/utils/ecweb-utils.js +16 -2
  108. package/dist/utils/error-utils.js +4 -1
  109. package/dist/utils/logger.js +21 -2
  110. package/dist/utils/process-tree-stats.js +24 -4
  111. package/dist/utils/process-tree-worker.js +31 -0
  112. package/dist/utils/project-path.js +1 -2
  113. package/dist/utils/stats.js +52 -18
  114. package/dist/utils/welcome.js +2 -2
  115. package/kits/docs/INDEX.md +1 -1
  116. package/kits/docs/evolcore/INDEX.md +1 -1
  117. package/kits/docs/evolcore/contact.md +7 -1
  118. package/kits/docs/evolcore/msg.md +16 -0
  119. package/kits/rules/01-overview.md +1 -1
  120. package/kits/rules/03-identity.md +1 -1
  121. package/kits/rules/05-venue.md +1 -1
  122. package/kits/schemas/_meta.json +10 -5
  123. package/kits/schemas/agent-config.schema.10.json +2 -1
  124. package/kits/schemas/agent-config.schema.11.json +421 -0
  125. package/kits/schemas/daemon.schema.5.json +135 -0
  126. package/kits/schemas/daemon.schema.6.json +131 -0
  127. package/kits/schemas/defaults.schema.5.json +119 -0
  128. package/kits/schemas/migrations/README.md +3 -1
  129. package/kits/schemas/relation-config.schema.8.json +13 -0
  130. package/kits/schemas/role-config.schema.1.json +1 -2
  131. package/kits/schemas/single-session.schema.3.json +32 -0
  132. package/kits/templates/roles/admin.json +1 -0
  133. package/kits/templates/roles/member.json +1 -0
  134. package/kits/templates/roles/visitor.json +1 -0
  135. package/package.json +7 -3
  136. package/skills/eclink/SKILL.md +2 -0
@@ -3,8 +3,8 @@ 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, checkProtectedPathAccess, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, resolveProtectedCandidateWithoutFinalSymlink, } from '../protected-paths.js';
7
- import { classifyEvolcoreShellCommand, parseCodexToolCommandArgv, parseBoundedOutputShellCommand, parseLiteralShellArgv, resolveCodexShellCarrierArgv, } from './ec-command-parser.js';
6
+ import { containsHClassReference, containsLClassReference, checkProtectedPathAccess, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, resolveProtectedCandidateWithoutFinalSymlink, } from './protected-paths.js';
7
+ import { classifyEvolcoreShellCommand, hasCodexCmdCarrierEcIntent, parseCodexToolCommandArgv, parseBoundedOutputShellCommand, parseLiteralShellArgv, resolveCodexShellCarrierArgv, resolveCodexShellCarrierString, } from './ec-command-parser.js';
8
8
  import { analyzeReadonlyShellQuery, } from './readonly-shell-query.js';
9
9
  /** Resolve the session-owned temporary root supplied by the execution host. */
10
10
  export function resolveManagedTempDir(env = process.env) {
@@ -412,6 +412,529 @@ function queryPathOperands(analysis) {
412
412
  accessKind: command.accessKind ?? (operand.access === 'recursive' ? 'enumerate' : 'content'),
413
413
  }))));
414
414
  }
415
+ const PROTECTED_BASENAME_LITERAL_RE = /^(?:daemon\.json|defaults\.json|config\.json|contact\.json|contact-operations\.jsonl|\.device_id|\.env|\.lock|daemon\.pid)$/i;
416
+ const SHELL_PATH_LITERAL_META_RE = /[\0\r\n$`*?\[\]{}<>|;&(),]/;
417
+ /**
418
+ * Extract literal operands immediately following an unquoted shell
419
+ * redirection operator. The full command remains unproven, but a redirect
420
+ * target is an explicit filesystem write/read operand and must retain H/L
421
+ * protection. Quoted prose and command text are deliberately ignored.
422
+ */
423
+ function literalShellRedirectOperands(command) {
424
+ const operands = [];
425
+ let quote = null;
426
+ let escaped = false;
427
+ let redirectPending = false;
428
+ let token = '';
429
+ const finishRedirect = () => {
430
+ if (redirectPending && token)
431
+ operands.push(token);
432
+ token = '';
433
+ redirectPending = false;
434
+ };
435
+ for (let index = 0; index < command.length; index++) {
436
+ const char = command[index];
437
+ if (escaped) {
438
+ if (redirectPending)
439
+ token += char;
440
+ escaped = false;
441
+ continue;
442
+ }
443
+ if (char === '\\') {
444
+ if (redirectPending)
445
+ token += char;
446
+ escaped = true;
447
+ continue;
448
+ }
449
+ if (quote === 'single') {
450
+ if (char === "'")
451
+ quote = null;
452
+ else if (redirectPending)
453
+ token += char;
454
+ continue;
455
+ }
456
+ if (quote === 'double') {
457
+ if (char === '"')
458
+ quote = null;
459
+ else if (redirectPending)
460
+ token += char;
461
+ continue;
462
+ }
463
+ if (char === "'" || char === '"') {
464
+ quote = char === "'" ? 'single' : 'double';
465
+ continue;
466
+ }
467
+ if (char === '>' || char === '<') {
468
+ // A preceding numeric token is an optional file descriptor, not part
469
+ // of the target. Repeated operators (`>>`) share one target.
470
+ token = '';
471
+ redirectPending = true;
472
+ if (command[index + 1] === char)
473
+ index++;
474
+ continue;
475
+ }
476
+ if (/\s/.test(char)) {
477
+ if (redirectPending && token)
478
+ finishRedirect();
479
+ continue;
480
+ }
481
+ if (/[;&|()]/.test(char)) {
482
+ finishRedirect();
483
+ continue;
484
+ }
485
+ if (redirectPending)
486
+ token += char;
487
+ }
488
+ finishRedirect();
489
+ return operands;
490
+ }
491
+ function looksLikeLiteralShellPath(value) {
492
+ return !!value
493
+ && !value.startsWith('-')
494
+ && !/\s/.test(value)
495
+ && !SHELL_PATH_LITERAL_META_RE.test(value)
496
+ && (value === '.' || value === '..' || value.includes('/') || value.includes('\\') || PROTECTED_BASENAME_LITERAL_RE.test(value));
497
+ }
498
+ function protectedClassPath(value, className, options) {
499
+ if (!looksLikeLiteralShellPath(value))
500
+ return undefined;
501
+ const matched = className === 'h'
502
+ ? (containsHClassReference(value) || isHClassPath(value, options))
503
+ : (containsLClassReference(value) || isLClassPath(value, options));
504
+ return matched ? resolveProtectedCandidate(value, options.cwd) : undefined;
505
+ }
506
+ function literalSedOperands(args) {
507
+ const values = [];
508
+ let hasExplicitProgram = false;
509
+ let skippedPositionalProgram = false;
510
+ let optionsEnded = false;
511
+ for (let index = 0; index < args.length; index++) {
512
+ const argument = args[index];
513
+ if (!optionsEnded && argument === '--') {
514
+ optionsEnded = true;
515
+ continue;
516
+ }
517
+ if (!optionsEnded && argument.startsWith('--')) {
518
+ if (argument === '--expression') {
519
+ hasExplicitProgram = true;
520
+ index++;
521
+ continue;
522
+ }
523
+ if (argument.startsWith('--expression=')) {
524
+ hasExplicitProgram = true;
525
+ continue;
526
+ }
527
+ if (argument === '--file') {
528
+ hasExplicitProgram = true;
529
+ if (args[index + 1])
530
+ values.push(args[++index]);
531
+ continue;
532
+ }
533
+ if (argument.startsWith('--file=')) {
534
+ hasExplicitProgram = true;
535
+ values.push(argument.slice(argument.indexOf('=') + 1));
536
+ continue;
537
+ }
538
+ continue;
539
+ }
540
+ if (!optionsEnded && argument.startsWith('-') && argument !== '-') {
541
+ const body = argument.slice(1);
542
+ for (let cursor = 0; cursor < body.length; cursor++) {
543
+ const option = body[cursor];
544
+ if (option !== 'e' && option !== 'f')
545
+ continue;
546
+ hasExplicitProgram = true;
547
+ const attached = body.slice(cursor + 1);
548
+ const value = attached || args[index + 1];
549
+ if (option === 'f' && value)
550
+ values.push(value);
551
+ if (!attached && value)
552
+ index++;
553
+ break;
554
+ }
555
+ continue;
556
+ }
557
+ if (!hasExplicitProgram && !skippedPositionalProgram) {
558
+ skippedPositionalProgram = true;
559
+ continue;
560
+ }
561
+ values.push(argument);
562
+ }
563
+ return values;
564
+ }
565
+ /**
566
+ * Recover paths from a small set of commands whose ordinary argv is a write
567
+ * target list. This is intentionally separate from grep/rg/git parsing: their
568
+ * first operands are patterns or revisions and must never be treated as
569
+ * filesystem paths merely because they contain a protected-looking string.
570
+ */
571
+ function literalMutationOperands(argv) {
572
+ const executable = commandName(argv[0]);
573
+ const args = argv.slice(1);
574
+ if (['mkdir', 'mktemp', 'touch', 'tee', 'cp', 'mv', 'install', 'chmod', 'chown', 'ln', 'rm'].includes(executable)) {
575
+ return args.filter(argument => argument !== '--' && !argument.startsWith('-'));
576
+ }
577
+ if (executable === 'sed')
578
+ return literalSedOperands(args);
579
+ if (executable === 'git') {
580
+ return literalGitOperands(argv);
581
+ }
582
+ return [];
583
+ }
584
+ /**
585
+ * Recover Git operands that are unambiguously filesystem paths. Git has many
586
+ * positional revision/search arguments, so scanning every argv value would
587
+ * turn a pickaxe string into a protected-path access. Keep global path options,
588
+ * explicit pathspecs after `--`, and the small set of subcommands/options that
589
+ * name an output or working directory.
590
+ */
591
+ function literalGitOperands(argv) {
592
+ const args = argv.slice(1);
593
+ const values = [];
594
+ const globalPathOptions = new Set(['-C', '--git-dir', '--work-tree', '--exec-path']);
595
+ const globalValueOptions = new Set(['-C', '--git-dir', '--work-tree', '--namespace', '--super-prefix']);
596
+ let index = 0;
597
+ let subcommand;
598
+ // Git global options precede the subcommand. Capture only options whose
599
+ // values are directories/files; skip the rest so config values are not
600
+ // mistaken for paths.
601
+ const pathConfigKey = (key) => /^(?:include(?:if\..+)?\.path|core\.(?:gitdir|worktree|excludesfile|attributesfile|hookspath))$/i.test(key);
602
+ while (index < args.length) {
603
+ const argument = args[index];
604
+ if (!argument.startsWith('-')) {
605
+ subcommand = argument.toLowerCase();
606
+ index++;
607
+ break;
608
+ }
609
+ const option = argument.split('=', 1)[0];
610
+ const shortAttached = !argument.startsWith('--') && argument.length > 2 && argument.startsWith('-C');
611
+ if (option === '-c') {
612
+ const config = argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : args[index + 1];
613
+ if (config) {
614
+ const separator = config.indexOf('=');
615
+ if (separator < 0 || pathConfigKey(config.slice(0, separator))) {
616
+ values.push(separator < 0 ? config : config.slice(separator + 1));
617
+ }
618
+ }
619
+ index += argument.includes('=') ? 1 : 2;
620
+ continue;
621
+ }
622
+ if (option === '--config-env') {
623
+ const config = argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : args[index + 1];
624
+ const separator = config?.indexOf('=') ?? -1;
625
+ const key = separator >= 0 ? config.slice(0, separator) : '';
626
+ const envName = separator >= 0 ? config.slice(separator + 1) : undefined;
627
+ if (envName && pathConfigKey(key) && process.env[envName])
628
+ values.push(process.env[envName]);
629
+ index += argument.includes('=') ? 1 : 2;
630
+ continue;
631
+ }
632
+ const execPathWithValue = option === '--exec-path' && argument.includes('=');
633
+ if ((globalPathOptions.has(option) && option !== '--exec-path') || execPathWithValue || shortAttached) {
634
+ const value = argument.includes('=')
635
+ ? argument.slice(argument.indexOf('=') + 1)
636
+ : shortAttached
637
+ ? argument.slice(2)
638
+ : args[index + 1];
639
+ if (value)
640
+ values.push(value);
641
+ }
642
+ if (!argument.includes('=') && globalValueOptions.has(option) && !shortAttached)
643
+ index++;
644
+ index++;
645
+ }
646
+ if (!subcommand)
647
+ return values;
648
+ const rest = args.slice(index);
649
+ const delimiter = rest.indexOf('--');
650
+ const beforeDelimiter = delimiter >= 0 ? rest.slice(0, delimiter) : rest;
651
+ if (delimiter >= 0) {
652
+ values.push(...rest.slice(delimiter + 1).filter(argument => !argument.startsWith('-')));
653
+ }
654
+ const positionalValues = (argsToScan, valueOptions, attachedShortOptions = new Set()) => {
655
+ const positionals = [];
656
+ for (let cursor = 0; cursor < argsToScan.length; cursor++) {
657
+ const argument = argsToScan[cursor];
658
+ if (!argument.startsWith('-')) {
659
+ positionals.push(argument);
660
+ continue;
661
+ }
662
+ const option = argument.split('=', 1)[0];
663
+ const attached = [...attachedShortOptions].some(entry => argument.startsWith(entry) && argument.length > entry.length);
664
+ if (valueOptions.has(option) && !argument.includes('=') && !attached)
665
+ cursor++;
666
+ }
667
+ return positionals;
668
+ };
669
+ const outputSubcommands = new Set(['archive', 'diff', 'log', 'show', 'format-patch', 'range-diff', 'shortlog']);
670
+ if (outputSubcommands.has(subcommand)) {
671
+ for (let cursor = 0; cursor < beforeDelimiter.length; cursor++) {
672
+ const argument = beforeDelimiter[cursor];
673
+ const option = argument.split('=', 1)[0];
674
+ const shortOutput = argument === '-o' || (!argument.startsWith('--') && argument.startsWith('-o') && argument.length > 2);
675
+ const longOutput = option === '--output' || option === '--output-directory';
676
+ if (!shortOutput && !longOutput)
677
+ continue;
678
+ const value = argument.includes('=')
679
+ ? argument.slice(argument.indexOf('=') + 1)
680
+ : shortOutput && argument.length > 2
681
+ ? argument.slice(2)
682
+ : beforeDelimiter[cursor + 1];
683
+ if (value)
684
+ values.push(value);
685
+ if (!argument.includes('=') && !(shortOutput && argument.length > 2))
686
+ cursor++;
687
+ }
688
+ }
689
+ // These subcommands have positional filesystem targets rather than
690
+ // revision/search operands. Conservative over-collection here preserves
691
+ // H/L protection for source and destination paths alike.
692
+ const positionalPathSubcommands = new Set(['clone', 'init', 'apply', 'am']);
693
+ if (positionalPathSubcommands.has(subcommand)) {
694
+ values.push(...beforeDelimiter.filter(argument => !argument.startsWith('-')));
695
+ }
696
+ else if (subcommand === 'worktree' || subcommand === 'bundle') {
697
+ const control = subcommand === 'worktree'
698
+ ? new Set(['add', 'move', 'remove', 'prune', 'lock', 'unlock', 'repair'])
699
+ : new Set(['create', 'verify-heads', 'list-heads', 'unbundle']);
700
+ values.push(...beforeDelimiter.filter(argument => !argument.startsWith('-') && !control.has(argument)));
701
+ }
702
+ // `git diff --no-index` compares two filesystem paths without the usual
703
+ // revision/pathspec ambiguity. Capture both operands even when the caller
704
+ // omitted the `--` delimiter.
705
+ if (subcommand === 'diff' && beforeDelimiter.includes('--no-index')) {
706
+ values.push(...beforeDelimiter.filter(argument => !argument.startsWith('-')));
707
+ }
708
+ else if (subcommand === 'diff' && delimiter < 0) {
709
+ // Without `--`, a path can be parsed by Git in the same position as a
710
+ // revision. Keep likely filesystem-shaped positionals in the protection
711
+ // pass, while skipping regex/text option values.
712
+ const textValueOptions = new Set([
713
+ '-I', '--ignore-matching-lines', '--anchored', '--word-diff-regex',
714
+ '--diff-algorithm', '--submodule', '--stat-width', '--stat-name-width',
715
+ '--stat-graph-width', '--stat-count', '--inter-hunk-context',
716
+ '--output', '--output-directory',
717
+ ]);
718
+ values.push(...positionalValues(beforeDelimiter, textValueOptions, new Set(['-I']))
719
+ .filter(looksLikeLiteralShellPath));
720
+ }
721
+ else if (subcommand === 'log' && delimiter < 0) {
722
+ const textValueOptions = new Set([
723
+ '-S', '-G', '-n', '--grep', '--author', '--committer', '--since', '--after',
724
+ '--until', '--before', '--format', '--pretty', '--max-count', '--skip',
725
+ '--decorate-refs',
726
+ ]);
727
+ values.push(...positionalValues(beforeDelimiter, textValueOptions, new Set(['-S', '-G', '-n']))
728
+ .filter(looksLikeLiteralShellPath));
729
+ }
730
+ // Several mutating Git subcommands accept directory/file options instead
731
+ // of positional targets. Keep these values in the H/L operand pass so a
732
+ // protected path cannot hide behind an option spelling.
733
+ const optionPathValues = new Set([
734
+ '--directory', '--include', '--build-fake-ancestor', '--separate-git-dir',
735
+ '--template', '--reference', '--reference-if-able', '--git-dir', '--work-tree',
736
+ ]);
737
+ if (['clone', 'init', 'apply', 'am', 'format-patch'].includes(subcommand)) {
738
+ for (let cursor = 0; cursor < beforeDelimiter.length; cursor++) {
739
+ const argument = beforeDelimiter[cursor];
740
+ const option = argument.split('=', 1)[0];
741
+ if (!optionPathValues.has(option))
742
+ continue;
743
+ const value = argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : beforeDelimiter[cursor + 1];
744
+ if (value)
745
+ values.push(value);
746
+ if (!argument.includes('='))
747
+ cursor++;
748
+ }
749
+ }
750
+ return values;
751
+ }
752
+ function literalReadonlyOperands(argv) {
753
+ const executable = commandName(argv[0]);
754
+ const args = argv.slice(1);
755
+ if (['cat', 'ls', 'head', 'sort', 'wc', 'stat', 'lstat'].includes(executable)) {
756
+ const valueOptions = new Set(['-c', '--format', '--printf', '-I', '-T', '-w', '--block-size', '--color', '--format', '--hide', '--ignore', '--key', '--parallel', '--sort']);
757
+ const values = [];
758
+ for (let index = 0; index < args.length; index++) {
759
+ const argument = args[index];
760
+ if (argument === '--') {
761
+ values.push(...args.slice(index + 1));
762
+ break;
763
+ }
764
+ if (argument.startsWith('-')) {
765
+ const option = argument.split('=', 1)[0];
766
+ if (valueOptions.has(option) && !argument.includes('=') && index + 1 < args.length)
767
+ index++;
768
+ continue;
769
+ }
770
+ values.push(argument);
771
+ }
772
+ return values;
773
+ }
774
+ if (['grep', 'rg'].includes(executable)) {
775
+ const values = [];
776
+ let patternSeen = false;
777
+ const textValueOptions = new Set(['-A', '-B', '-C', '-m', '-d', '-g', '--after-context', '--before-context', '--context', '--directories', '--exclude', '--exclude-dir', '--include', '--glob', '--pre-glob']);
778
+ const patternOptions = new Set(['-e', '--regexp']);
779
+ const pathValueOptions = new Set(['-f', '--file', '--ignore-file', '--exclude-from', '--pre', '--hostname-bin']);
780
+ const shortTextValueOptions = executable === 'grep'
781
+ ? new Set(['A', 'B', 'C', 'd', 'D', 'm'])
782
+ : new Set(['A', 'B', 'C', 'E', 'g', 'j', 'm', 'M', 'r', 't', 'T']);
783
+ for (let index = 0; index < args.length; index++) {
784
+ const argument = args[index];
785
+ if (argument === '--') {
786
+ values.push(...args.slice(index + 1));
787
+ break;
788
+ }
789
+ if (argument.startsWith('-')) {
790
+ if (!argument.startsWith('--') && argument !== '-') {
791
+ const body = argument.slice(1);
792
+ let consumedValue = false;
793
+ for (let cursor = 0; cursor < body.length; cursor++) {
794
+ const option = body[cursor];
795
+ if (option !== 'e' && option !== 'f' && !shortTextValueOptions.has(option))
796
+ continue;
797
+ const attached = body.slice(cursor + 1);
798
+ const value = attached || args[index + 1];
799
+ if (option === 'e')
800
+ patternSeen = true;
801
+ if (option === 'f') {
802
+ patternSeen = true;
803
+ if (value)
804
+ values.push(value);
805
+ }
806
+ if (!attached && value)
807
+ index++;
808
+ consumedValue = true;
809
+ break;
810
+ }
811
+ if (consumedValue)
812
+ continue;
813
+ }
814
+ const option = argument.split('=', 1)[0];
815
+ if (patternOptions.has(option)) {
816
+ patternSeen = true;
817
+ if (!argument.includes('=') && index + 1 < args.length)
818
+ index++;
819
+ continue;
820
+ }
821
+ if (pathValueOptions.has(option)) {
822
+ patternSeen ||= option === '-f' || option === '--file';
823
+ const value = argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : args[++index];
824
+ if (value)
825
+ values.push(value);
826
+ continue;
827
+ }
828
+ if (textValueOptions.has(option) && !argument.includes('=') && index + 1 < args.length)
829
+ index++;
830
+ continue;
831
+ }
832
+ if (!patternSeen)
833
+ patternSeen = true;
834
+ else
835
+ values.push(argument);
836
+ }
837
+ return values;
838
+ }
839
+ if (executable === 'find') {
840
+ const values = [];
841
+ for (const argument of args) {
842
+ if (argument === '--' || argument.startsWith('-') || ['!', '(', ')'].includes(argument))
843
+ break;
844
+ values.push(argument);
845
+ }
846
+ return values;
847
+ }
848
+ if (executable === 'sed')
849
+ return literalSedOperands(args);
850
+ return [];
851
+ }
852
+ /** Extract literal path strings from an explicit node/python-style eval only
853
+ * when the script calls a filesystem API. The script itself is not a shell
854
+ * path operand; this narrow check preserves H/L protection for common
855
+ * `node -e fs.writeFileSync/readFileSync('...')` carriers without scanning
856
+ * prose or arbitrary string arguments. */
857
+ function literalScriptFilesystemOperands(argv) {
858
+ const executable = commandName(argv[0]);
859
+ if (!['node', 'nodejs', 'python', 'python3', 'perl'].includes(executable))
860
+ return [];
861
+ const args = argv.slice(1);
862
+ const evalIndex = args.findIndex(argument => argument === '-e' || argument === '--eval' || argument === '-c');
863
+ const script = evalIndex >= 0 ? args[evalIndex + 1] : undefined;
864
+ if (!script || !/(?:writeFile|appendFile|truncate|unlink|rename|mkdir|rmdir|chmod|chown|symlink|link|copyFile|createWriteStream|readFile|readdir|realpath|lstat|stat|open\s*\()/.test(script)) {
865
+ return [];
866
+ }
867
+ const values = [];
868
+ const literalRe = /(['"])([^'"\\\r\n]+)\1/g;
869
+ for (const match of script.matchAll(literalRe)) {
870
+ if (match[2])
871
+ values.push(match[2]);
872
+ }
873
+ return values;
874
+ }
875
+ /**
876
+ * Recover only literal path-looking argv values when the structured readonly
877
+ * query parser cannot prove the complete command. This is deliberately not a
878
+ * raw-text search: regexps, log prose, comments and shell syntax are ignored.
879
+ * The normal readonly path still fails closed for an unproven command; this
880
+ * helper exists only so a non-readonly H/L check can retain protection for an
881
+ * explicit literal path in an otherwise unsupported command.
882
+ */
883
+ function literalProtectedShellOperand(command, className, options, depth = 0) {
884
+ for (const segment of splitShellCommandSegments(command)) {
885
+ for (const value of literalShellRedirectOperands(segment)) {
886
+ const matched = protectedClassPath(value, className, options);
887
+ if (matched)
888
+ return matched;
889
+ }
890
+ // A segment that is itself a proven readonly query already has precise
891
+ // path operands. Reusing that IR avoids treating patterns/revisions as
892
+ // paths and also lets a valid segment coexist with a protected one.
893
+ const segmentAnalysis = analyzeReadonlyShellQuery(segment);
894
+ if (segmentAnalysis.kind === 'proven-readonly') {
895
+ for (const operand of queryPathOperands(segmentAnalysis)) {
896
+ const matched = protectedClassPath(operand.operand.value, className, options);
897
+ if (matched)
898
+ return matched;
899
+ }
900
+ continue;
901
+ }
902
+ const argv = parseLiteralShellArgv(segment);
903
+ if (!argv || argv.length < 2)
904
+ continue;
905
+ for (const value of literalMutationOperands(argv)) {
906
+ const matched = protectedClassPath(value, className, options);
907
+ if (matched)
908
+ return matched;
909
+ }
910
+ for (const value of literalReadonlyOperands(argv)) {
911
+ const matched = protectedClassPath(value, className, options);
912
+ if (matched)
913
+ return matched;
914
+ }
915
+ for (const value of literalScriptFilesystemOperands(argv)) {
916
+ const matched = protectedClassPath(value, className, options);
917
+ if (matched)
918
+ return matched;
919
+ }
920
+ // Explicit nested shell carriers are parsed recursively. A nested query
921
+ // still goes through the same segment-aware rules; arbitrary unsupported
922
+ // shell composition remains unproven and is never authorized here.
923
+ if (depth < 2) {
924
+ const executable = path.posix.basename(argv[0].replace(/\\/g, '/'));
925
+ if (['bash', 'sh', 'dash', 'zsh'].includes(executable)) {
926
+ const commandIndex = argv.findIndex(value => value === '-c' || value === '-lc');
927
+ const nested = commandIndex >= 0 ? argv[commandIndex + 1] : undefined;
928
+ if (nested) {
929
+ const matched = literalProtectedShellOperand(nested, className, options, depth + 1);
930
+ if (matched)
931
+ return matched;
932
+ }
933
+ }
934
+ }
935
+ }
936
+ return undefined;
937
+ }
415
938
  function shellOperandMatchesClass(operand, className, options, accessKind = operand.access === 'recursive' ? 'enumerate' : 'content') {
416
939
  // `$TMPDIR` is a private session namespace, so names such as `CA` or
417
940
  // `config.json` inside it are ordinary temporary data rather than paths in
@@ -425,7 +948,7 @@ function shellOperandMatchesClass(operand, className, options, accessKind = oper
425
948
  const workspace = options.cwd ? resolveProtectedCandidate(options.cwd) : undefined;
426
949
  if (isSameOrDescendant(candidate, managedTemp)
427
950
  && (!workspace || !isSameOrDescendant(candidate, workspace))) {
428
- return false;
951
+ return undefined;
429
952
  }
430
953
  }
431
954
  if (className === 'h' && accessKind === 'metadata' && operand.access === 'exact') {
@@ -439,27 +962,31 @@ function shellOperandMatchesClass(operand, className, options, accessKind = oper
439
962
  followFinalSymlink: false,
440
963
  });
441
964
  if (options.allowProtectedMetadata === false) {
442
- return metadata.pathClass === 'h-file' || metadata.pathClass === 'h-directory';
965
+ return metadata.pathClass === 'h-file' || metadata.pathClass === 'h-directory'
966
+ ? resolveProtectedCandidate(operand.value, options.cwd)
967
+ : undefined;
443
968
  }
444
969
  // The no-follow decision is authoritative for metadata. In particular,
445
970
  // an ordinary symlink whose target is H-class must remain inspectable as a
446
971
  // link; falling through to isHClassPath() would follow it and reject the
447
972
  // otherwise safe lstat/stat operation.
448
973
  if (metadata.pathClass !== 'unknown' && metadata.allowed) {
449
- return false;
974
+ return undefined;
450
975
  }
451
976
  }
452
977
  const reference = className === 'h' ? containsHClassReference(operand.value) : containsLClassReference(operand.value);
453
978
  if (reference)
454
- return true;
979
+ return resolveProtectedCandidate(operand.value, options.cwd);
455
980
  if (operand.access === 'recursive') {
456
- return className === 'h'
981
+ const matched = className === 'h'
457
982
  ? hClassReadScopeIncludesProtectedPath(operand.value, options)
458
983
  : lClassGrantIncludesProtectedPath(operand.value, options);
984
+ return matched ? resolveProtectedCandidate(operand.value, options.cwd) : undefined;
459
985
  }
460
- return className === 'h'
986
+ const matched = className === 'h'
461
987
  ? isHClassPath(operand.value, options)
462
988
  : isLClassPath(operand.value, options);
989
+ return matched ? resolveProtectedCandidate(operand.value, options.cwd) : undefined;
463
990
  }
464
991
  /**
465
992
  * A read-only recursive query only needs to cover H-class targets that exist
@@ -480,25 +1007,55 @@ function hClassReadScopeIncludesProtectedPath(value, options) {
480
1007
  function analyzeShellProtectedOperands(command, options) {
481
1008
  const analysis = analyzeReadonlyShellQuery(command, { managedTempDir: options.managedTempDir });
482
1009
  if (analysis.kind === 'unproven') {
1010
+ const pathOptions = { cwd: options.cwd, root: options.root };
1011
+ const hClassPath = literalProtectedShellOperand(command, 'h', pathOptions);
1012
+ const lClassPath = literalProtectedShellOperand(command, 'l', pathOptions);
483
1013
  return {
484
1014
  analysis,
485
- hClass: containsHClassReference(command),
486
- lClass: containsLClassReference(command),
487
- pathHClass: false,
1015
+ hClass: !!hClassPath,
1016
+ lClass: !!lClassPath,
1017
+ pathHClass: !!hClassPath,
488
1018
  lockDiagnostic: false,
1019
+ hClassPath,
1020
+ lClassPath,
489
1021
  };
490
1022
  }
491
1023
  const operands = queryPathOperands(analysis);
492
- const lockDiagnostic = /(?:^|[\/\s'"`])(?:[^\s'"`/]*\.lock|EVOLCORE_RUNTIME_LOCK_DIR)(?=$|[\/\s'"`=])/i.test(command);
493
- const pathHClass = operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'h', options, accessKind));
1024
+ // Only parsed path operands can establish a lock-file diagnostic. Searching
1025
+ // the raw command would mistake a grep/rg pattern or report text for an
1026
+ // actual protected filesystem target.
1027
+ const lockDiagnostic = operands.some(({ operand }) => containsLockPathReference(operand.value))
1028
+ || hasFindLockPattern(analysis);
1029
+ const hClassPath = operands.map(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'h', options, accessKind)).find(Boolean);
1030
+ const lClassPath = operands.map(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'l', options, accessKind)).find(Boolean);
494
1031
  return {
495
1032
  analysis,
496
- hClass: lockDiagnostic || pathHClass,
497
- lClass: operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'l', options, accessKind)),
498
- pathHClass,
1033
+ hClass: lockDiagnostic || !!hClassPath,
1034
+ lClass: !!lClassPath,
1035
+ pathHClass: !!hClassPath,
499
1036
  lockDiagnostic,
1037
+ hClassPath,
1038
+ lClassPath,
500
1039
  };
501
1040
  }
1041
+ /** Detect a find pattern such as `-name '*.lock'` from parsed argv only. */
1042
+ function hasFindLockPattern(analysis) {
1043
+ if (analysis.kind !== 'proven-readonly')
1044
+ return false;
1045
+ return analysis.ir.pipelines.some(pipeline => pipeline.some(command => {
1046
+ if (command.executable !== 'find')
1047
+ return false;
1048
+ for (let index = 0; index < command.argv.length - 1; index++) {
1049
+ if (!['-name', '-iname', '-path', '-ipath', '-wholename'].includes(command.argv[index]))
1050
+ continue;
1051
+ if (/(?:^|[\\/])[^\\/]*\.lock(?:$|[\\/])/i.test(command.argv[index + 1]))
1052
+ return true;
1053
+ if (/^EVOLCORE_RUNTIME_LOCK_DIR$/i.test(command.argv[index + 1]))
1054
+ return true;
1055
+ }
1056
+ return false;
1057
+ }));
1058
+ }
502
1059
  function protectedReadToolPaths(toolName, input) {
503
1060
  if (toolName === 'Read') {
504
1061
  return typeof input.file_path === 'string' ? [input.file_path] : [];
@@ -513,33 +1070,43 @@ function protectedReadToolPaths(toolName, input) {
513
1070
  }
514
1071
  function protectedReadToolMatchesLClass(toolName, input, options) {
515
1072
  const paths = protectedReadToolPaths(toolName, input);
516
- if (paths.some(filePath => containsLClassReference(filePath) || isLClassPath(filePath, options))) {
517
- return true;
1073
+ const direct = paths.find(filePath => containsLClassReference(filePath) || isLClassPath(filePath, options));
1074
+ if (direct) {
1075
+ return resolveProtectedCandidate(direct, options.cwd);
518
1076
  }
519
1077
  if (toolName === 'Grep') {
520
1078
  const scope = typeof input.path === 'string' && input.path ? input.path : '.';
521
- return lClassGrantIncludesProtectedPath(scope, options);
1079
+ return lClassGrantIncludesProtectedPath(scope, options)
1080
+ ? resolveProtectedCandidate(scope, options.cwd)
1081
+ : undefined;
522
1082
  }
523
1083
  if (toolName === 'Glob') {
524
1084
  const scope = globReadScope(input, options);
525
- return lClassGrantIncludesProtectedPath(scope, { root: options.root });
1085
+ return lClassGrantIncludesProtectedPath(scope, { root: options.root })
1086
+ ? resolveProtectedCandidate(scope, options.cwd)
1087
+ : undefined;
526
1088
  }
527
- return false;
1089
+ return undefined;
528
1090
  }
529
1091
  function protectedReadToolMatchesHClass(toolName, input, options) {
530
1092
  const paths = protectedReadToolPaths(toolName, input);
531
- if (paths.some(filePath => containsHClassReference(filePath) || isHClassPath(filePath, options))) {
532
- return true;
1093
+ const direct = paths.find(filePath => containsHClassReference(filePath) || isHClassPath(filePath, options));
1094
+ if (direct) {
1095
+ return resolveProtectedCandidate(direct, options.cwd);
533
1096
  }
534
1097
  if (toolName === 'Grep') {
535
1098
  const scope = typeof input.path === 'string' && input.path ? input.path : '.';
536
- return hClassReadScopeIncludesProtectedPath(scope, options);
1099
+ return hClassReadScopeIncludesProtectedPath(scope, options)
1100
+ ? resolveProtectedCandidate(scope, options.cwd)
1101
+ : undefined;
537
1102
  }
538
1103
  if (toolName === 'Glob') {
539
1104
  const scope = globReadScope(input, options);
540
- return hClassReadScopeIncludesProtectedPath(scope, options);
1105
+ return hClassReadScopeIncludesProtectedPath(scope, options)
1106
+ ? resolveProtectedCandidate(scope, options.cwd)
1107
+ : undefined;
541
1108
  }
542
- return false;
1109
+ return undefined;
543
1110
  }
544
1111
  function globLiteralPrefix(pattern) {
545
1112
  const normalized = pattern.replace(/\\/g, '/');
@@ -646,14 +1213,17 @@ export function checkReadonly(toolName, input, projectPath, context) {
646
1213
  if (!protectedReadToolStaysInWorkspace(toolName, input, projectPath)) {
647
1214
  return { behavior: 'deny', message: '🔒 只读模式:文件读取范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
648
1215
  }
649
- if (paths.some(filePath => containsHClassReference(filePath) || isHClassPath(filePath, pathOptions))) {
650
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
1216
+ const directHPath = paths.find(filePath => containsHClassReference(filePath) || isHClassPath(filePath, pathOptions));
1217
+ if (directHPath) {
1218
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read', matchedPath: resolveProtectedCandidate(directHPath, projectPath) };
651
1219
  }
652
- if (protectedReadToolMatchesHClass(toolName, input, pathOptions)) {
653
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
1220
+ const hPath = protectedReadToolMatchesHClass(toolName, input, pathOptions);
1221
+ if (hPath) {
1222
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read', matchedPath: hPath };
654
1223
  }
655
- if (protectedReadToolMatchesLClass(toolName, input, pathOptions)) {
656
- return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
1224
+ const lPath = protectedReadToolMatchesLClass(toolName, input, pathOptions);
1225
+ if (lPath) {
1226
+ return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read', matchedPath: lPath };
657
1227
  }
658
1228
  return { behavior: 'allow' };
659
1229
  }
@@ -691,11 +1261,15 @@ export function checkReadonly(toolName, input, projectPath, context) {
691
1261
  }
692
1262
  if (protectedOperands.hClass) {
693
1263
  logger.warn(`[ReadonlyCheck] 🔒 H-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
694
- return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
1264
+ if (protectedOperands.analysis.kind === 'proven-readonly') {
1265
+ return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read', matchedPath: protectedOperands.hClassPath };
1266
+ }
695
1267
  }
696
1268
  if (protectedOperands.lClass) {
697
1269
  logger.warn(`[ReadonlyCheck] 🔒 L-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
698
- return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
1270
+ if (protectedOperands.analysis.kind === 'proven-readonly') {
1271
+ return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read', matchedPath: protectedOperands.lClassPath };
1272
+ }
699
1273
  }
700
1274
  if (protectedOperands.analysis.kind === 'proven-readonly') {
701
1275
  return { behavior: 'deny', message: '🔒 只读模式:Shell 查询范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
@@ -967,6 +1541,7 @@ export function checkHClassWrite(toolName, input, context) {
967
1541
  return {
968
1542
  behavior: 'deny',
969
1543
  message: '🔒 临时文件必须位于当前会话的 $TMPDIR 内',
1544
+ matchedPath: tempCheck.paths[0],
970
1545
  };
971
1546
  }
972
1547
  // A managed temp write is intentionally allowed to contain names such as
@@ -992,6 +1567,7 @@ export function checkHClassWrite(toolName, input, context) {
992
1567
  return {
993
1568
  behavior: 'deny',
994
1569
  message: '🔒 Shell 命令涉及受保护的 H 类配置/证书/快照路径,agent 不可直接操作',
1570
+ matchedPath: protectedOperands.hClassPath,
995
1571
  };
996
1572
  }
997
1573
  collectFilesystemGrantPaths(input.additionalPermissions, grantPaths, projectRootGrantSubpaths);
@@ -1010,14 +1586,17 @@ export function checkHClassWrite(toolName, input, context) {
1010
1586
  // filesystem operation is checked again when it reaches Read/Write/Bash,
1011
1587
  // FileChange, or PermissionGrant above.
1012
1588
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
1013
- if (['Read', 'Glob', 'Grep'].includes(toolName)
1014
- && protectedReadToolMatchesHClass(toolName, input, pathOptions)) {
1015
- logger.warn(`[H-Class Protection] 🔒 Protected filesystem read scope blocked: tool=${toolName} ` +
1016
- `session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
1017
- return {
1018
- behavior: 'deny',
1019
- message: '🔒 文件读取范围包含受保护的 H 类配置/证书/快照路径,agent 不可直接操作',
1020
- };
1589
+ if (['Read', 'Glob', 'Grep'].includes(toolName)) {
1590
+ const matchedReadPath = protectedReadToolMatchesHClass(toolName, input, pathOptions);
1591
+ if (matchedReadPath) {
1592
+ logger.warn(`[H-Class Protection] 🔒 Protected filesystem read scope blocked: tool=${toolName} ` +
1593
+ `session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
1594
+ return {
1595
+ behavior: 'deny',
1596
+ message: '🔒 文件读取范围包含受保护的 H 类配置/证书/快照路径,agent 不可直接操作',
1597
+ matchedPath: matchedReadPath,
1598
+ };
1599
+ }
1021
1600
  }
1022
1601
  if (requestsFilesystemRoot(input.permissions) || requestsFilesystemRoot(input.additionalPermissions)) {
1023
1602
  logger.warn(`[H-Class Protection] 🔒 Permission grant covers filesystem root: tool=${toolName} ` +
@@ -1036,6 +1615,7 @@ export function checkHClassWrite(toolName, input, context) {
1036
1615
  return {
1037
1616
  behavior: 'deny',
1038
1617
  message: '🔒 请求的文件系统权限范围包含受保护的 H 类路径,已拒绝授权',
1618
+ matchedPath: resolveProtectedCandidate(grantPath, pathOptions.cwd),
1039
1619
  };
1040
1620
  }
1041
1621
  }
@@ -1059,6 +1639,7 @@ export function checkHClassWrite(toolName, input, context) {
1059
1639
  return {
1060
1640
  behavior: 'deny',
1061
1641
  message: '🔒 请求的 project_roots 权限范围包含受保护的 H 类路径,已拒绝授权',
1642
+ matchedPath: resolveProtectedCandidate(candidate, permissionWorkspace),
1062
1643
  };
1063
1644
  }
1064
1645
  }
@@ -1093,7 +1674,8 @@ export function checkHClassWrite(toolName, input, context) {
1093
1674
  behavior: 'deny',
1094
1675
  message: `🔒 此文件受保护,agent 不可直接写入\n\n` +
1095
1676
  `文件:${filePath}\n` +
1096
- `类型:配置/快照/证书等系统关键文件${suggestion}`
1677
+ `类型:配置/快照/证书等系统关键文件${suggestion}`,
1678
+ matchedPath: resolveProtectedCandidate(filePath, pathOptions.cwd),
1097
1679
  };
1098
1680
  }
1099
1681
  }
@@ -1108,7 +1690,7 @@ export function checkLClassWrite(toolName, input, context) {
1108
1690
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
1109
1691
  const lClassRead = protectedReadToolMatchesLClass(toolName, input, pathOptions);
1110
1692
  if (lClassRead && context?.permissionMode === 'readonly') {
1111
- return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径' };
1693
+ return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', matchedPath: lClassRead };
1112
1694
  }
1113
1695
  return { behavior: 'allow' };
1114
1696
  }
@@ -1149,6 +1731,7 @@ export function checkLClassWrite(toolName, input, context) {
1149
1731
  message: context?.permissionMode === 'readonly'
1150
1732
  ? '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径'
1151
1733
  : '🔒 L-class 路径允许读取,但当前 Bash 复合命令无法证明为只读,已拒绝执行。请改用受控的 EvolCore 读取命令或白名单只读命令',
1734
+ matchedPath: protectedOperands.lClassPath,
1152
1735
  };
1153
1736
  }
1154
1737
  collectFilesystemWriteGrantPaths(input.additionalPermissions, writeGrantPaths, projectRootWriteSubpaths);
@@ -1167,6 +1750,7 @@ export function checkLClassWrite(toolName, input, context) {
1167
1750
  return {
1168
1751
  behavior: 'deny',
1169
1752
  message: '🔒 请求的文件系统写权限包含 L-class 只读路径,已拒绝授权',
1753
+ matchedPath: resolveProtectedCandidate(grantPath, pathOptions.cwd),
1170
1754
  };
1171
1755
  }
1172
1756
  }
@@ -1188,6 +1772,7 @@ export function checkLClassWrite(toolName, input, context) {
1188
1772
  return {
1189
1773
  behavior: 'deny',
1190
1774
  message: `🔒 L-class 路径允许读取但禁止 Agent 直接修改、删除或移动\n\n文件:${filePath}\n\n💡 请使用对应的 EvolCore 受控命令修改`,
1775
+ matchedPath: resolveProtectedCandidate(filePath, pathOptions.cwd),
1191
1776
  };
1192
1777
  }
1193
1778
  return { behavior: 'allow' };
@@ -1338,7 +1923,25 @@ function prepareBoundedOutputInput(input, command, kind, context) {
1338
1923
  export function evaluateToolPreflight(toolName, input, context) {
1339
1924
  if (toolName === 'Bash') {
1340
1925
  const command = typeof input.command === 'string' ? input.command : '';
1341
- const tempCheck = checkManagedTempShellCommand(command, managedTempOptions(context));
1926
+ const explicitCommandArgv = Array.isArray(input.commandArgv)
1927
+ && input.commandArgv.every(value => typeof value === 'string')
1928
+ ? input.commandArgv
1929
+ : undefined;
1930
+ if (Array.isArray(input.commandArgv) && !explicitCommandArgv) {
1931
+ return {
1932
+ behavior: 'deny',
1933
+ input,
1934
+ message: '🔒 Codex commandArgv 必须是完整的字符串 argv,无法安全解析时拒绝执行。',
1935
+ policyCode: 'ec_command_not_canonical',
1936
+ };
1937
+ }
1938
+ // When both forms are present, commandArgv is the app-server's structured
1939
+ // source of truth. Do not let a conflicting display string hide a path or
1940
+ // shell operator that is present in the argv payload.
1941
+ const commandForPolicy = explicitCommandArgv
1942
+ ? explicitCommandArgv.join(' ')
1943
+ : command;
1944
+ const tempCheck = checkManagedTempShellCommand(commandForPolicy, managedTempOptions(context));
1342
1945
  if (tempCheck.kind === 'deny') {
1343
1946
  return {
1344
1947
  behavior: 'deny',
@@ -1347,14 +1950,14 @@ export function evaluateToolPreflight(toolName, input, context) {
1347
1950
  policyCode: 'temporary_path_outside_tmpdir',
1348
1951
  };
1349
1952
  }
1350
- const explicitCommandArgv = Array.isArray(input.commandArgv)
1351
- && input.commandArgv.every(value => typeof value === 'string')
1352
- ? input.commandArgv
1353
- : undefined;
1354
1953
  const outerArgv = explicitCommandArgv ?? parseLiteralShellArgv(command) ?? undefined;
1355
- const shellCarrier = outerArgv ? resolveCodexShellCarrierArgv(outerArgv) : undefined;
1356
- const wrappedCommand = shellCarrier?.command;
1357
- const policyCommand = wrappedCommand ?? command;
1954
+ const shellCarrier = outerArgv
1955
+ ? resolveCodexShellCarrierArgv(outerArgv)
1956
+ : undefined;
1957
+ const stringShellCarrier = shellCarrier
1958
+ ?? (explicitCommandArgv ? undefined : resolveCodexShellCarrierString(command));
1959
+ const wrappedCommand = stringShellCarrier?.command;
1960
+ const policyCommand = wrappedCommand ?? commandForPolicy;
1358
1961
  if (context.sessionId && /^\s*(?:command\s+-v|which|type)\s+ec\s*$/i.test(policyCommand)) {
1359
1962
  return {
1360
1963
  behavior: 'deny',
@@ -1398,8 +2001,10 @@ export function evaluateToolPreflight(toolName, input, context) {
1398
2001
  policyCode: 'managed_ec_aid_scope_forbidden',
1399
2002
  };
1400
2003
  }
1401
- const ecCommand = classifyEvolcoreShellCommand(policyCommand, shellCarrier
1402
- ? { dialect: shellCarrier.dialect }
2004
+ const shellDialect = stringShellCarrier?.dialect
2005
+ ?? (hasCodexCmdCarrierEcIntent(input) ? 'cmd' : undefined);
2006
+ const ecCommand = classifyEvolcoreShellCommand(policyCommand, shellDialect
2007
+ ? { dialect: shellDialect }
1403
2008
  : {});
1404
2009
  if (ecCommand.kind === 'literal') {
1405
2010
  return { behavior: 'allow', input, reason: 'ec-command' };
@@ -1409,9 +2014,11 @@ export function evaluateToolPreflight(toolName, input, context) {
1409
2014
  }
1410
2015
  if (ecCommand.kind === 'composite') {
1411
2016
  if (ecCommand.issue === 'unsafe-expansion') {
1412
- const message = shellCarrier?.dialect === 'powershell'
2017
+ const message = shellDialect === 'powershell'
1413
2018
  ? '🔒 EC 双引号正文包含未转义的 PowerShell 展开;纯文字请优先使用单引号,或用 PowerShell 反引号转义 $ 和正文内的双引号。PowerShell 传给 ec 时会还原为原文字面量'
1414
- : '🔒 EC 双引号正文包含未转义的 Shell 展开;请将反引号写成 \\`,将 $()、${}、$变量中的 $ 写成 \\$。Shell 传给 ec 时会还原为原文字面量';
2019
+ : shellDialect === 'cmd'
2020
+ ? '🔒 EC 命令包含未转义的 cmd.exe 展开;请不要使用 %VAR%、!VAR! 或 ^ 转义。cmd.exe 传给 ec 时必须保持原文字面量'
2021
+ : '🔒 EC 双引号正文包含未转义的 Shell 展开;请将反引号写成 \\`,将 $()、${}、$变量中的 $ 写成 \\$。Shell 传给 ec 时会还原为原文字面量';
1415
2022
  return {
1416
2023
  behavior: 'deny',
1417
2024
  input,
@@ -1420,9 +2027,11 @@ export function evaluateToolPreflight(toolName, input, context) {
1420
2027
  };
1421
2028
  }
1422
2029
  if (ecCommand.issue === 'invalid-quote') {
1423
- const message = shellCarrier?.dialect === 'powershell'
2030
+ const message = shellDialect === 'powershell'
1424
2031
  ? '🔒 EC 命令引号未闭合或存在错误嵌套;PowerShell 正文内的双引号请用反引号转义,或改用单引号包裹纯文字'
1425
- : '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"';
2032
+ : shellDialect === 'cmd'
2033
+ ? '🔒 EC 命令引号未闭合或存在错误嵌套;cmd.exe carrier 的命令正文请保持一个完整参数,不要用未配对的双引号'
2034
+ : '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"';
1426
2035
  return {
1427
2036
  behavior: 'deny',
1428
2037
  input,
@@ -1430,9 +2039,11 @@ export function evaluateToolPreflight(toolName, input, context) {
1430
2039
  policyCode: 'ec_shell_invalid_quote',
1431
2040
  };
1432
2041
  }
1433
- const message = shellCarrier?.dialect === 'powershell'
2042
+ const message = shellDialect === 'powershell'
1434
2043
  ? '🔒 EC 命令调用被拒绝:一次 PowerShell/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要拼接探测命令,也不要使用 `&&`、`||`、`;`、`|`、重定向、变量展开或子表达式追加其它命令。'
1435
- : '🔒 EC 命令调用被拒绝:一次 Bash/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要先用 `command -v`/`ec aid` 探测,也不要使用 `&&`、`||`、`;`、`|`、重定向、变量展开、子 shell、`sh -c` 或 `bash -lc` 拼接其它命令。';
2044
+ : shellDialect === 'cmd'
2045
+ ? '🔒 EC 命令调用被拒绝:一次 cmd.exe/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要拼接其它命令,不要使用 &、|、重定向、变量展开或 ^ 转义。'
2046
+ : '🔒 EC 命令调用被拒绝:一次 Bash/exec_command 只能执行一条完整的 `ec` 命令。请直接调用 `ec ...`,不要先用 `command -v`/`ec aid` 探测,也不要使用 `&&`、`||`、`;`、`|`、重定向、变量展开、子 shell、`sh -c` 或 `bash -lc` 拼接其它命令。';
1436
2047
  return {
1437
2048
  behavior: 'deny',
1438
2049
  input,
@@ -1472,7 +2083,7 @@ export function evaluateToolPreflight(toolName, input, context) {
1472
2083
  allowProtectedMetadata: context.allowProtectedMetadata,
1473
2084
  });
1474
2085
  if (hClass.behavior === 'deny') {
1475
- return { behavior: 'deny', input: checkedInput, message: hClass.message, policyCode: 'h_class_protection' };
2086
+ return { behavior: 'deny', input: checkedInput, message: hClass.message, policyCode: 'h_class_protection', matchedPath: hClass.matchedPath };
1476
2087
  }
1477
2088
  const lClass = checkLClassWrite(toolName, checkedInput, {
1478
2089
  sessionId: context.sessionId,
@@ -1486,7 +2097,7 @@ export function evaluateToolPreflight(toolName, input, context) {
1486
2097
  managedTempDir: context.managedTempDir,
1487
2098
  });
1488
2099
  if (lClass.behavior === 'deny') {
1489
- return { behavior: 'deny', input: checkedInput, message: lClass.message, policyCode: 'l_class_protection' };
2100
+ return { behavior: 'deny', input: checkedInput, message: lClass.message, policyCode: 'l_class_protection', matchedPath: lClass.matchedPath };
1490
2101
  }
1491
2102
  const dangerous = checkDangerousCommand(toolName, checkedInput);
1492
2103
  if (dangerous.isDangerous