@deftai/directive-core 0.108.0 → 0.109.0

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 (138) hide show
  1. package/dist/cache/operations.js +1 -1
  2. package/dist/check/gate-lists.d.ts +14 -0
  3. package/dist/check/gate-lists.js +26 -3
  4. package/dist/check/session-completed-ac.d.ts +1 -1
  5. package/dist/check/session-completed-ac.js +1 -1
  6. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  7. package/dist/consumer-check-contract/evaluate.js +181 -15
  8. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  9. package/dist/delivery-attempt/evaluate.js +69 -0
  10. package/dist/delivery-attempt/index.d.ts +1 -1
  11. package/dist/delivery-attempt/index.js +1 -1
  12. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  13. package/dist/deposit/live-procedure-exclusions.js +110 -0
  14. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  15. package/dist/deposit/live-procedure-targets.js +274 -0
  16. package/dist/deposit/python-free.d.ts +6 -0
  17. package/dist/deposit/python-free.js +15 -0
  18. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  19. package/dist/deposit/rewrite-deposit-links.js +148 -0
  20. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  21. package/dist/deposit/run-stage-content-pack.js +3 -0
  22. package/dist/deposit/stage-content-pack.d.ts +17 -0
  23. package/dist/deposit/stage-content-pack.js +91 -0
  24. package/dist/design-critique/citation-grammar.d.ts +7 -0
  25. package/dist/design-critique/citation-grammar.js +1 -1
  26. package/dist/design-critique/completed-arc-record.d.ts +1 -1
  27. package/dist/design-critique/completed-arc-record.js +59 -14
  28. package/dist/hooks/classify/host-session-identity.d.ts +24 -11
  29. package/dist/hooks/classify/host-session-identity.js +46 -49
  30. package/dist/hooks/classify/index.d.ts +2 -2
  31. package/dist/hooks/classify/index.js +2 -2
  32. package/dist/hooks/classify/paths.d.ts +2 -0
  33. package/dist/hooks/classify/paths.js +8 -4
  34. package/dist/hooks/classify/stdin.js +69 -3
  35. package/dist/hooks/dest-form.d.ts +20 -1
  36. package/dist/hooks/dest-form.js +158 -21
  37. package/dist/hooks/dispatcher.d.ts +6 -2
  38. package/dist/hooks/dispatcher.js +258 -62
  39. package/dist/hooks/fixtures/cases.d.ts +6 -0
  40. package/dist/hooks/fixtures/cases.js +53 -0
  41. package/dist/hooks/git-destructive-log.d.ts +32 -0
  42. package/dist/hooks/git-destructive-log.js +46 -0
  43. package/dist/hooks/index.d.ts +2 -0
  44. package/dist/hooks/index.js +2 -0
  45. package/dist/hooks/shell-write-targets.d.ts +10 -0
  46. package/dist/hooks/shell-write-targets.js +274 -0
  47. package/dist/hooks/tools.d.ts +20 -2
  48. package/dist/hooks/tools.js +30 -1
  49. package/dist/init-deposit/gitignore.d.ts +7 -0
  50. package/dist/init-deposit/gitignore.js +24 -0
  51. package/dist/init-deposit/init-deposit.js +3 -0
  52. package/dist/init-deposit/refresh.js +7 -0
  53. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  54. package/dist/init-deposit/runtime-writers.js +33 -0
  55. package/dist/intake/issue-ingest.d.ts +6 -1
  56. package/dist/intake/issue-ingest.js +21 -2
  57. package/dist/intake/reconcile-issues.js +13 -13
  58. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  59. package/dist/lifecycle/brief-envelope.js +42 -0
  60. package/dist/lifecycle/index.d.ts +1 -0
  61. package/dist/lifecycle/index.js +1 -0
  62. package/dist/literal-acceptance/evaluate.js +14 -5
  63. package/dist/literal-acceptance/index.d.ts +1 -1
  64. package/dist/literal-acceptance/index.js +1 -1
  65. package/dist/literal-acceptance/run.d.ts +2 -0
  66. package/dist/literal-acceptance/run.js +19 -1
  67. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  68. package/dist/orphan-active/candidate-scope.js +157 -0
  69. package/dist/orphan-active/evaluate.d.ts +27 -0
  70. package/dist/orphan-active/evaluate.js +63 -6
  71. package/dist/orphan-active/index.d.ts +1 -0
  72. package/dist/orphan-active/index.js +1 -0
  73. package/dist/policy/merge-approval-head.js +7 -6
  74. package/dist/pr-closing-keywords/gh.js +32 -9
  75. package/dist/pr-closing-keywords/main.d.ts +1 -0
  76. package/dist/pr-closing-keywords/main.js +68 -3
  77. package/dist/pr-closing-keywords/types.d.ts +2 -0
  78. package/dist/pr-merge-readiness/gh.js +32 -9
  79. package/dist/pr-protected-issues/gh.js +36 -9
  80. package/dist/pr-wait-mergeable/wrappers.js +3 -3
  81. package/dist/product-first-done-gate/evaluate.js +6 -2
  82. package/dist/product-first-done-gate/types.js +2 -0
  83. package/dist/release/consumer-hard-stops.d.ts +48 -0
  84. package/dist/release/consumer-hard-stops.js +140 -0
  85. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  86. package/dist/release/consumer-readiness-disclosure.js +51 -0
  87. package/dist/release/index.d.ts +2 -0
  88. package/dist/release/index.js +2 -0
  89. package/dist/release/pipeline.js +55 -0
  90. package/dist/release/run-consumer-readiness.d.ts +15 -0
  91. package/dist/release/run-consumer-readiness.js +24 -0
  92. package/dist/release/types.d.ts +6 -0
  93. package/dist/review-monitor/github-lease.js +4 -3
  94. package/dist/run-summary/types.d.ts +2 -2
  95. package/dist/scm/build-command.d.ts +2 -2
  96. package/dist/scm/build-command.js +2 -2
  97. package/dist/scm/call-shape.d.ts +25 -0
  98. package/dist/scm/call-shape.js +59 -0
  99. package/dist/scm/call.d.ts +7 -4
  100. package/dist/scm/call.js +41 -11
  101. package/dist/scm/gh-rest.d.ts +25 -12
  102. package/dist/scm/gh-rest.js +65 -15
  103. package/dist/scm/index.d.ts +2 -0
  104. package/dist/scm/index.js +2 -0
  105. package/dist/scm/spawn-status.d.ts +33 -0
  106. package/dist/scm/spawn-status.js +53 -0
  107. package/dist/scope/acceptance-evidence.d.ts +1 -1
  108. package/dist/scope/transition.js +4 -14
  109. package/dist/session/ac-pass-banking.d.ts +2 -2
  110. package/dist/session/ac-pass-banking.js +2 -2
  111. package/dist/session/host-session-owner.d.ts +53 -0
  112. package/dist/session/host-session-owner.js +89 -0
  113. package/dist/session/index.d.ts +1 -0
  114. package/dist/session/index.js +1 -0
  115. package/dist/session/occupancy.d.ts +21 -1
  116. package/dist/session/occupancy.js +70 -12
  117. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  118. package/dist/session/verify-ac-session-cache.js +2 -2
  119. package/dist/triage/evaluate/worktrees.js +153 -6
  120. package/dist/umbrella-current-shape/index.d.ts +51 -3
  121. package/dist/umbrella-current-shape/index.js +106 -18
  122. package/dist/validate-content/deposit-required.d.ts +39 -0
  123. package/dist/validate-content/deposit-required.js +147 -0
  124. package/dist/validate-content/index.d.ts +1 -0
  125. package/dist/validate-content/index.js +1 -0
  126. package/dist/validate-content/validate-links.d.ts +2 -3
  127. package/dist/validate-content/validate-links.js +29 -2
  128. package/dist/vbrief-activate/activate.d.ts +7 -2
  129. package/dist/vbrief-activate/activate.js +26 -13
  130. package/dist/verify-ac/evaluate.d.ts +9 -0
  131. package/dist/verify-ac/evaluate.js +32 -9
  132. package/dist/verify-source/deposit-closure.d.ts +23 -0
  133. package/dist/verify-source/deposit-closure.js +162 -0
  134. package/dist/verify-source/index.d.ts +2 -0
  135. package/dist/verify-source/index.js +2 -0
  136. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  137. package/dist/verify-source/semantic-single-source.js +349 -0
  138. package/package.json +3 -3
@@ -16,15 +16,17 @@ import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js"
16
16
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
17
17
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
18
18
  import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
19
- import { hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
20
- import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
19
+ import { hookApplyPatchBodyPaths, hookApplyPatchBodyText, hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, hostIdentityFallsBackToExplicitOwner, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
20
+ import { classifyGitDestructive, classifyProductDestForms, payloadWithInjectedWriteTarget, } from "./dest-form.js";
21
+ import { appendGitDestructiveRecord, GIT_DESTRUCTIVE_LOG_ENV } from "./git-destructive-log.js";
21
22
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
22
23
  import { inspectActiveScope } from "./scope.js";
24
+ import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
23
25
  import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
24
26
  // Pure parse/classify helpers are defined in ./classify/ and re-exported from
25
27
  // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
26
28
  export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
27
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
29
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
28
30
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
29
31
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
30
32
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -104,6 +106,66 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
104
106
  return false;
105
107
  return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
106
108
  }
109
+ /**
110
+ * ApplyPatch body present but no mutation header could be classified.
111
+ * Fail closed rather than authorizing the declared path alone (#3614).
112
+ */
113
+ function applyPatchBodyUnclassified(payload) {
114
+ const text = hookApplyPatchBodyText(payload);
115
+ return text !== null && hookApplyPatchBodyPaths(payload).length === 0;
116
+ }
117
+ /**
118
+ * Proposed-lifecycle exemption is universally quantified over every mutated
119
+ * path, not the declared write target alone (#3614).
120
+ */
121
+ function allMutationTargetsAreProposedLifecycleWrites(projectRoot, payload) {
122
+ if (applyPatchBodyUnclassified(payload))
123
+ return false;
124
+ const targets = hookMutationTargetPaths(payload);
125
+ if (targets.length === 0)
126
+ return false;
127
+ return targets.every((target) => isProposedLifecycleWrite(projectRoot, target));
128
+ }
129
+ function authzForMutationTargets(input, toolName, seams, options) {
130
+ const targets = hookMutationTargetPaths(input.payload);
131
+ const paths = targets.length > 0 ? targets : [null];
132
+ const pendingConsume = [];
133
+ for (const target of paths) {
134
+ const relPath = target !== null ? toProjectRelativePosix(options.projectRoot, target) : null;
135
+ const denied = authzForMutation(input, toolName, seams, {
136
+ isDirectWrite: options.isDirectWrite,
137
+ relPath,
138
+ scopePath: options.scopePath,
139
+ runGit: options.runGit,
140
+ consumeSingleUse: false,
141
+ pendingConsume,
142
+ });
143
+ if (denied !== null)
144
+ return denied;
145
+ }
146
+ const seen = new Set();
147
+ for (const ref of pendingConsume) {
148
+ if (seen.has(ref))
149
+ continue;
150
+ seen.add(ref);
151
+ try {
152
+ markGrantUsed(options.projectRoot, ref);
153
+ }
154
+ catch {
155
+ // Persistence failure must not flip allow after a successful check.
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ function allMutationTargetsAreAssistScratch(projectRoot, payload, environ) {
161
+ if (applyPatchBodyUnclassified(payload))
162
+ return false;
163
+ const targets = hookMutationTargetPaths(payload);
164
+ if (targets.length === 0) {
165
+ return isAssistScratchWrite(projectRoot, hookWriteTargetPath(payload), payload, environ);
166
+ }
167
+ return targets.every((target) => isAssistScratchWrite(projectRoot, target, payload, environ));
168
+ }
107
169
  /**
108
170
  * Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
109
171
  * Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
@@ -373,19 +435,20 @@ function deny(input, code, toolName, message, scopePath = null) {
373
435
  }
374
436
  /**
375
437
  * Resolve the cooperative actor presented to the occupancy gate.
376
- * Supported payload hosts are payload-authoritative; ambient identity may only
377
- * corroborate. Grok retains the pre-#3611 environment path until its payload
378
- * contract is verified.
438
+ * A resolved host identity is authoritative; ambient identity may only
439
+ * corroborate. Hosts whose identity source is the hook environment fall back to
440
+ * the explicit owner flow when that variable is absent (#3873).
379
441
  */
380
442
  function resolveMutationActor(input, environ) {
381
- const hostIdentity = resolveHookHostIdentity(input.host, input.payload);
443
+ const hostIdentity = resolveHookHostIdentity(input.host, input.payload, environ);
382
444
  const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
383
- if (hostIdentity.status === "unsupported") {
445
+ if (hostIdentity.status === "unsupported" ||
446
+ hostIdentityFallsBackToExplicitOwner(input.host, hostIdentity)) {
384
447
  return {
385
448
  sessionId: environmentId,
386
449
  issue: null,
387
450
  message: null,
388
- payloadAuthoritative: false,
451
+ hostAuthoritative: false,
389
452
  };
390
453
  }
391
454
  if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
@@ -393,23 +456,23 @@ function resolveMutationActor(input, environ) {
393
456
  return {
394
457
  sessionId: hostIdentity.sessionId,
395
458
  issue: "conflict",
396
- message: `Host payload owner ${hostIdentity.sessionId} conflicts with ` +
459
+ message: `Host owner ${hostIdentity.sessionId} conflicts with ` +
397
460
  `DEFT_SESSION_ID ${environmentId}.`,
398
- payloadAuthoritative: true,
461
+ hostAuthoritative: true,
399
462
  };
400
463
  }
401
464
  return {
402
465
  sessionId: hostIdentity.sessionId,
403
466
  issue: null,
404
467
  message: null,
405
- payloadAuthoritative: true,
468
+ hostAuthoritative: true,
406
469
  };
407
470
  }
408
471
  return {
409
472
  sessionId: undefined,
410
473
  issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
411
474
  message: hostIdentity.message,
412
- payloadAuthoritative: true,
475
+ hostAuthoritative: true,
413
476
  };
414
477
  }
415
478
  function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
@@ -448,20 +511,28 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effec
448
511
  // Neither layer active → allow (same as disabled runtimeAuthority historically).
449
512
  if (!fence.fenceActive)
450
513
  return null;
451
- const writeTarget = hookWriteTargetPath(input.payload);
452
- const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
453
- const verdict = evaluateRuntimeAuthorityDirectWrite({
454
- policy: fence.policy,
455
- relPathPosix: relPath,
456
- });
457
- if (verdict.allowed)
458
- return null;
459
514
  // #3794: relPath is now measured from the tree the write lands in. When that
460
515
  // differs from the payload root, a bare "outside file_scope" reason does not
461
516
  // say which tree the fence was relativised against.
462
517
  const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
463
- return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
464
- fenceRootNote, scopePath);
518
+ if (applyPatchBodyUnclassified(input.payload)) {
519
+ return deny(input, "runtime-policy-deny-path", toolName, "Directive denied this direct write: apply_patch body named no classifiable mutation target, so the write fence cannot authorize it." +
520
+ fenceRootNote, scopePath);
521
+ }
522
+ const targets = hookMutationTargetPaths(input.payload);
523
+ const toCheck = targets.length > 0 ? targets : [null];
524
+ for (const target of toCheck) {
525
+ const relPath = target !== null ? toProjectRelativePosix(fenceRoot, target) : null;
526
+ const verdict = evaluateRuntimeAuthorityDirectWrite({
527
+ policy: fence.policy,
528
+ relPathPosix: relPath,
529
+ });
530
+ if (!verdict.allowed) {
531
+ return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
532
+ fenceRootNote, scopePath);
533
+ }
534
+ }
535
+ return null;
465
536
  }
466
537
  function loadAuthzContext(projectRoot, seams) {
467
538
  if (seams.loadAuthzState !== undefined) {
@@ -582,12 +653,17 @@ function authzForMutation(input, toolName, seams, options) {
582
653
  }
583
654
  // Consume single-use grants after allow (persist usedAt).
584
655
  if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
585
- try {
586
- markGrantUsed(projectRoot, decision.humanApprovalRef);
656
+ if (options.consumeSingleUse === false) {
657
+ options.pendingConsume?.push(decision.humanApprovalRef);
587
658
  }
588
- catch {
589
- // Persistence failure must not flip allow → deny after a successful check;
590
- // next load will still see unused if write failed (prefer allow-once risk over deadlock).
659
+ else {
660
+ try {
661
+ markGrantUsed(projectRoot, decision.humanApprovalRef);
662
+ }
663
+ catch {
664
+ // Persistence failure must not flip allow → deny after a successful check;
665
+ // next load will still see unused if write failed (prefer allow-once risk over deadlock).
666
+ }
591
667
  }
592
668
  }
593
669
  }
@@ -727,17 +803,15 @@ function inspectMutationGates(input, toolName, seams, options) {
727
803
  // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
728
804
  // Tracked product paths never match the path fence. Read-only still denied upstream.
729
805
  if (!isSpawnTool(toolName)) {
730
- const scratchTarget = hookWriteTargetPath(input.payload);
731
806
  // #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
732
807
  // file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
733
808
  // assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
734
- if (isAssistScratchWrite(effectiveRoot, scratchTarget, input.payload, environ)) {
735
- const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
736
- const authzDeny = authzForMutation(input, toolName, seams, {
809
+ if (allMutationTargetsAreAssistScratch(effectiveRoot, input.payload, environ)) {
810
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
737
811
  isDirectWrite: true,
738
- relPath,
739
812
  scopePath: null,
740
813
  runGit: dispatchGit,
814
+ projectRoot,
741
815
  });
742
816
  if (authzDeny !== null)
743
817
  return authzDeny;
@@ -769,8 +843,8 @@ function inspectMutationGates(input, toolName, seams, options) {
769
843
  }
770
844
  : evaluateOccupancyWriteGate(effectiveRoot, {
771
845
  sessionId: actor?.sessionId,
772
- // Payload-supported hosts must not fall back to a stale ambient owner.
773
- env: actor?.payloadAuthoritative === true ? {} : environ,
846
+ // Hosts with a resolved owner must not fall back to a stale ambient one.
847
+ env: actor?.hostAuthoritative === true ? {} : environ,
774
848
  });
775
849
  // #3769: occupancy decides before any ritual persist. The gated verifier
776
850
  // executes agent_hooks and rebinds a forward HEAD, and both rewrite
@@ -907,7 +981,7 @@ function inspectMutationGates(input, toolName, seams, options) {
907
981
  return null;
908
982
  const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
909
983
  sessionId: actor.sessionId,
910
- env: actor.payloadAuthoritative ? {} : environ,
984
+ env: actor.hostAuthoritative ? {} : environ,
911
985
  refresh: true,
912
986
  });
913
987
  occupancyWarning = finalOccupancy.warning;
@@ -941,15 +1015,15 @@ function inspectMutationGates(input, toolName, seams, options) {
941
1015
  };
942
1016
  const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
943
1017
  if (options.proposedLifecycleExempt) {
944
- const writeTarget = hookWriteTargetPath(input.payload);
945
- if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
946
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1018
+ if (allMutationTargetsAreProposedLifecycleWrites(effectiveRoot, input.payload)) {
947
1019
  // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
948
- const authzDeny = authzForMutation(input, toolName, seams, {
1020
+ // Authz and the write fence run on every mutated path, not the declared
1021
+ // one (#3614).
1022
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
949
1023
  isDirectWrite: true,
950
- relPath,
951
1024
  scopePath: null,
952
1025
  runGit: dispatchGit,
1026
+ projectRoot,
953
1027
  });
954
1028
  if (authzDeny !== null)
955
1029
  return authzDeny;
@@ -993,7 +1067,9 @@ function inspectMutationGates(input, toolName, seams, options) {
993
1067
  // worktree that lives outside the payload root keeps today's skip. Worktrees
994
1068
  // under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
995
1069
  // are inside it and are now gated on their own active scope.
996
- const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
1070
+ const mutationTargets = hookMutationTargetPaths(input.payload);
1071
+ const outsideRoot = mutationTargets.length > 0 &&
1072
+ mutationTargets.every((target) => isOutsideProjectRootWrite(projectRoot, target));
997
1073
  if (!outsideRoot || isSpawnTool(toolName)) {
998
1074
  let proposedPathHint;
999
1075
  if (isSpawnTool(toolName)) {
@@ -1054,14 +1130,12 @@ function inspectMutationGates(input, toolName, seams, options) {
1054
1130
  }
1055
1131
  }
1056
1132
  if (!isSpawnTool(toolName)) {
1057
- const writeTarget = hookWriteTargetPath(input.payload);
1058
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1059
1133
  // Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
1060
- const authzDeny = authzForMutation(input, toolName, seams, {
1134
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
1061
1135
  isDirectWrite: true,
1062
- relPath,
1063
1136
  scopePath: scope.path,
1064
1137
  runGit: dispatchGit,
1138
+ projectRoot,
1065
1139
  });
1066
1140
  if (authzDeny !== null)
1067
1141
  return authzDeny;
@@ -1083,6 +1157,100 @@ function inspectMutationGates(input, toolName, seams, options) {
1083
1157
  scopePath: scope.path,
1084
1158
  };
1085
1159
  }
1160
+ function gitDestructiveIsFixture(form, projectRoot) {
1161
+ if (form.unprovable || form.relocators.length === 0)
1162
+ return false;
1163
+ return form.relocators.every((raw) => {
1164
+ const path = raw.trim();
1165
+ if (path.length === 0 || !isAbsolute(path))
1166
+ return false;
1167
+ return isOutsideProjectRootWrite(projectRoot, path);
1168
+ });
1169
+ }
1170
+ const GIT_DESTRUCTIVE_REMEDIATION = "Tree-wide destructive git (reset --hard, clean -f, checkout -f / switch -f, " +
1171
+ "stash drop/clear) is fail-closed when aimed at this checkout. Rewrite as a " +
1172
+ "non-destructive command, or run it against an absolute out-of-root fixture " +
1173
+ "(`git -C /abs/fixture reset --hard`). This is a guard, not a root-cause claim.";
1174
+ /**
1175
+ * Always-on recognition for tree-wide destructive git (#3917). Independent of
1176
+ * `shellDestForms`. Records a durable JSONL line outside the repository so the
1177
+ * next occurrence names its actor even if reflogs are gone.
1178
+ */
1179
+ function decideGitDestructive(input, toolName) {
1180
+ const command = hookShellCommand(input.payload);
1181
+ if (command === null)
1182
+ return null;
1183
+ const forms = classifyGitDestructive(command);
1184
+ if (forms.length === 0)
1185
+ return null;
1186
+ const projectRoot = resolve(input.projectRoot);
1187
+ const env = input.environ ?? process.env;
1188
+ const actor = env.DEFT_SESSION_ID?.trim() || env.GROK_SESSION_ID?.trim() || "";
1189
+ const logPath = env[GIT_DESTRUCTIVE_LOG_ENV]?.trim() || undefined;
1190
+ let anyProjectAimed = false;
1191
+ for (const form of forms) {
1192
+ const fixture = gitDestructiveIsFixture(form, projectRoot);
1193
+ if (!fixture)
1194
+ anyProjectAimed = true;
1195
+ appendGitDestructiveRecord({
1196
+ ts: new Date().toISOString(),
1197
+ kind: form.kind,
1198
+ disposition: fixture ? "allow-fixture" : "deny",
1199
+ command,
1200
+ projectRoot,
1201
+ host: input.host,
1202
+ toolName,
1203
+ actor,
1204
+ pid: process.pid,
1205
+ relocators: form.relocators,
1206
+ unprovable: form.unprovable,
1207
+ }, { env, logPath });
1208
+ }
1209
+ if (!anyProjectAimed) {
1210
+ return {
1211
+ verdict: "allow",
1212
+ code: "git-destructive-fixture",
1213
+ event: input.event,
1214
+ host: input.host,
1215
+ toolName,
1216
+ projectRoot,
1217
+ message: `Directive allowed ${toolName}: destructive git is aimed at an out-of-root fixture.`,
1218
+ scopePath: null,
1219
+ };
1220
+ }
1221
+ return deny(input, "git-destructive-deny", toolName, `Directive denied ${toolName}: ${GIT_DESTRUCTIVE_REMEDIATION}`);
1222
+ }
1223
+ /**
1224
+ * Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
1225
+ * Deny is returned; allow falls through so dest-forms and push/merge still run.
1226
+ */
1227
+ function decideShellWriteReissue(input, toolName, seams) {
1228
+ const command = hookShellCommand(input.payload);
1229
+ if (command === null)
1230
+ return null;
1231
+ const projectRoot = resolve(input.projectRoot);
1232
+ for (const dest of classifyShellWriteTargets(command)) {
1233
+ if (dest.unprovable === true) {
1234
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1235
+ continue;
1236
+ return deny(input, "scope-not-ready", toolName, "Directive denied " +
1237
+ toolName +
1238
+ ": Shell write dest is not reconstructable (compound command). Use one simple command or Edit/Write.");
1239
+ }
1240
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1241
+ continue;
1242
+ const destInput = {
1243
+ ...input,
1244
+ payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
1245
+ };
1246
+ const destDecision = inspectMutationGates(destInput, toolName, seams, {
1247
+ proposedLifecycleExempt: true,
1248
+ });
1249
+ if (destDecision.verdict === "deny")
1250
+ return destDecision;
1251
+ }
1252
+ return null;
1253
+ }
1086
1254
  /**
1087
1255
  * Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
1088
1256
  * Dest-form allow is kept when runtime authority has nothing classifiable.
@@ -1205,6 +1373,18 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1205
1373
  }
1206
1374
  return { aligned: true, message: "execution directory is project-root aligned" };
1207
1375
  }
1376
+ /**
1377
+ * Hosts whose PreToolUse wire format can carry a rewritten tool input.
1378
+ *
1379
+ * `renderHostDecision` reads the same list, so the bridge cannot offer -- or
1380
+ * deny on behalf of -- a delivery channel a host does not have (#3873). A host
1381
+ * outside it binds its claim in the CLI instead: `resolveOccupancySessionId`
1382
+ * resolves the same ambient host owner the write gate will present.
1383
+ */
1384
+ const UPDATED_INPUT_WIRE_HOSTS = ["codex", "claude", "cursor"];
1385
+ function hostAcceptsUpdatedInput(host) {
1386
+ return UPDATED_INPUT_WIRE_HOSTS.includes(host);
1387
+ }
1208
1388
  /**
1209
1389
  * Add the claim-time owner only after the existing shell decision allowed the
1210
1390
  * command. The host wire format must emit `allow` with updated input, so this
@@ -1213,6 +1393,8 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1213
1393
  function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1214
1394
  if (decision.verdict !== "allow")
1215
1395
  return decision;
1396
+ if (!hostAcceptsUpdatedInput(input.host))
1397
+ return decision;
1216
1398
  const lifecycle = inspectExactLifecycleCommand(input.payload);
1217
1399
  if (lifecycle === null)
1218
1400
  return decision;
@@ -1222,15 +1404,20 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1222
1404
  return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1223
1405
  "--session-id is empty, duplicated, or otherwise ambiguous.");
1224
1406
  }
1225
- const identity = resolveHookHostIdentity(input.host, input.payload);
1226
- if (identity.status === "unsupported")
1407
+ const identity = resolveHookHostIdentity(input.host, input.payload, input.environ ?? process.env);
1408
+ // #3873: an absent host-env variable is the pre-#3873 explicit-owner flow, not
1409
+ // a broken contract. Claim binding is skipped, the command keeps ordinary host
1410
+ // permission handling, and any owner it carries is still its own.
1411
+ if (identity.status === "unsupported" ||
1412
+ hostIdentityFallsBackToExplicitOwner(input.host, identity)) {
1227
1413
  return decision;
1414
+ }
1228
1415
  if (identity.status !== "ok" || identity.sessionId === null) {
1229
1416
  const code = identity.status === "conflict"
1230
1417
  ? "occupancy-identity-conflict"
1231
1418
  : "occupancy-identity-unavailable";
1232
1419
  return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1233
- `${identity.message ?? "host payload identity is unavailable"}. ` +
1420
+ `${identity.message ?? "host session identity is unavailable"}. ` +
1234
1421
  "Directive cannot bind the occupancy claim without a stable host owner; " +
1235
1422
  "manual callers must pass --session-id explicitly outside the host rewrite path.");
1236
1423
  }
@@ -1238,12 +1425,12 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1238
1425
  if (environmentId !== undefined &&
1239
1426
  environmentId.length > 0 &&
1240
1427
  environmentId !== identity.sessionId) {
1241
- return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host payload owner ${identity.sessionId} conflicts with ` +
1428
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host owner ${identity.sessionId} conflicts with ` +
1242
1429
  `DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
1243
1430
  }
1244
1431
  if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
1245
1432
  return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
1246
- `${lifecycle.sessionId ?? "<missing>"}, but the host payload owner is ` +
1433
+ `${lifecycle.sessionId ?? "<missing>"}, but the host owner is ` +
1247
1434
  `${identity.sessionId}.`);
1248
1435
  }
1249
1436
  const sourceTask = !lifecycle.task ||
@@ -1472,10 +1659,19 @@ export function decideHook(input, seams = {}) {
1472
1659
  }
1473
1660
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
1474
1661
  }
1475
- // Shell dest-forms (#3438): recognized product mutations share inspectMutationGates
1662
+ // Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
1663
+ // Dest-forms (#3438): recognized product mutations share inspectMutationGates
1476
1664
  // with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
1477
1665
  // on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
1478
1666
  if (isShellTool(toolName)) {
1667
+ const destructive = decideGitDestructive(input, toolName);
1668
+ if (destructive !== null) {
1669
+ return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
1670
+ }
1671
+ const writeReissue = decideShellWriteReissue(input, toolName, seams);
1672
+ if (writeReissue !== null) {
1673
+ return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
1674
+ }
1479
1675
  const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1480
1676
  return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1481
1677
  }
@@ -1532,7 +1728,9 @@ function softAgentsRebindWireText(decision) {
1532
1728
  */
1533
1729
  export function renderHostDecision(host, decision) {
1534
1730
  if (decision.verdict === "allow") {
1535
- if (decision.event === "tool.before" && decision.updatedInput !== undefined) {
1731
+ if (decision.event === "tool.before" &&
1732
+ decision.updatedInput !== undefined &&
1733
+ hostAcceptsUpdatedInput(host)) {
1536
1734
  if (host === "cursor") {
1537
1735
  return JSON.stringify({
1538
1736
  permission: "allow",
@@ -1540,15 +1738,13 @@ export function renderHostDecision(host, decision) {
1540
1738
  updated_input: decision.updatedInput,
1541
1739
  });
1542
1740
  }
1543
- if (host === "claude" || host === "codex") {
1544
- return JSON.stringify({
1545
- hookSpecificOutput: {
1546
- hookEventName: "PreToolUse",
1547
- permissionDecision: "allow",
1548
- updatedInput: decision.updatedInput,
1549
- },
1550
- });
1551
- }
1741
+ return JSON.stringify({
1742
+ hookSpecificOutput: {
1743
+ hookEventName: "PreToolUse",
1744
+ permissionDecision: "allow",
1745
+ updatedInput: decision.updatedInput,
1746
+ },
1747
+ });
1552
1748
  }
1553
1749
  const soft = softAgentsRebindWireText(decision);
1554
1750
  if (host === "cursor") {
@@ -19,6 +19,12 @@ export interface HookFixtureCase {
19
19
  readonly payload?: unknown;
20
20
  /** Raw stdin when testing BOM / free-form ApplyPatch parse. */
21
21
  readonly raw?: string;
22
+ /**
23
+ * Hook process environment for `host-env` identity providers (#3873).
24
+ * Identity assertions resolve against this instead of the ambient
25
+ * environment, so a corpus case cannot pass or fail by where it runs.
26
+ */
27
+ readonly environ?: Readonly<Record<string, string>>;
22
28
  readonly expected: {
23
29
  readonly toolName: string | null;
24
30
  readonly writeIntent: HookWriteIntent;
@@ -702,6 +702,59 @@ export const HOOK_FIXTURE_CASES = [
702
702
  hostIdentity: { status: "invalid", sessionId: null },
703
703
  },
704
704
  },
705
+ // --- Host-env session identity (#3873) ---
706
+ {
707
+ // The claim side of the loop: without this rewrite the lease is claimed
708
+ // under a minted UUID that the later write gate can never present.
709
+ id: "grok-posix-shell-host-env-lifecycle-rewrite",
710
+ host: "grok",
711
+ os: "posix",
712
+ tool: "Shell",
713
+ regression: ["#3873", "#3611"],
714
+ payload: {
715
+ tool_name: "Bash",
716
+ tool_input: { command: "deft session:start" },
717
+ },
718
+ environ: { GROK_SESSION_ID: "grok-session-a" },
719
+ expected: {
720
+ toolName: "Bash",
721
+ writeIntent: "shell",
722
+ writeTargetPath: null,
723
+ hostIdentity: {
724
+ status: "ok",
725
+ sessionId: "host:grok:v1:Z3Jvay1zZXNzaW9uLWE",
726
+ },
727
+ lifecycle: {
728
+ verb: "session:start",
729
+ requestedSessionId: "host:grok:v1:Z3Jvay1zZXNzaW9uLWE",
730
+ resultKind: "rewrite",
731
+ rewrittenCommand: "deft session:start --session-id=host:grok:v1:Z3Jvay1zZXNzaW9uLWE",
732
+ updatedInput: {
733
+ command: "deft session:start --session-id=host:grok:v1:Z3Jvay1zZXNzaW9uLWE",
734
+ },
735
+ },
736
+ },
737
+ },
738
+ {
739
+ id: "grok-posix-write-payload-session-id-untrusted",
740
+ host: "grok",
741
+ os: "posix",
742
+ tool: "Write",
743
+ regression: ["#3873"],
744
+ payload: {
745
+ tool_name: "Write",
746
+ session_id: "payload-owner",
747
+ tool_input: { content: "x", file_path: "src/app.ts" },
748
+ },
749
+ environ: {},
750
+ expected: {
751
+ toolName: "Write",
752
+ writeIntent: "direct-write",
753
+ writeTargetPath: "src/app.ts",
754
+ hostIdentity: { status: "missing", sessionId: null },
755
+ },
756
+ notes: "Grok's payload session_id contract is unverified; only the host variable is read",
757
+ },
705
758
  // --- Regression: empty / missing tool name ---
706
759
  {
707
760
  id: "cursor-posix-stdin-empty",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Durable attribution log for tree-wide destructive git at the write fence (#3917).
3
+ *
4
+ * Lives outside the repository so a `reset --hard` or a worktree prune cannot
5
+ * erase it. This is detection, not a root-cause claim.
6
+ */
7
+ import type { GitDestructiveKind } from "./dest-form.js";
8
+ export declare const GIT_DESTRUCTIVE_LOG_ENV = "DEFT_GIT_DESTRUCTIVE_LOG";
9
+ export type GitDestructiveDisposition = "deny" | "allow-fixture";
10
+ export interface GitDestructiveRecord {
11
+ readonly ts: string;
12
+ readonly kind: GitDestructiveKind;
13
+ readonly disposition: GitDestructiveDisposition;
14
+ readonly command: string;
15
+ readonly projectRoot: string;
16
+ readonly host: string;
17
+ readonly toolName: string | null;
18
+ readonly actor: string;
19
+ readonly pid: number;
20
+ readonly relocators: readonly string[];
21
+ readonly unprovable: boolean;
22
+ }
23
+ export declare function resolveGitDestructiveLogPath(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string): string;
24
+ /**
25
+ * Best-effort append. A log failure must not block the deny.
26
+ */
27
+ export declare function appendGitDestructiveRecord(record: GitDestructiveRecord, options?: {
28
+ readonly logPath?: string;
29
+ readonly env?: NodeJS.ProcessEnv;
30
+ readonly append?: (path: string, line: string) => void;
31
+ }): void;
32
+ //# sourceMappingURL=git-destructive-log.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Durable attribution log for tree-wide destructive git at the write fence (#3917).
3
+ *
4
+ * Lives outside the repository so a `reset --hard` or a worktree prune cannot
5
+ * erase it. This is detection, not a root-cause claim.
6
+ */
7
+ import { mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { containedWrite } from "../fs/contained-write.js";
11
+ import { platformUserConfigDir } from "../user-config/resolve-user-md.js";
12
+ export const GIT_DESTRUCTIVE_LOG_ENV = "DEFT_GIT_DESTRUCTIVE_LOG";
13
+ export function resolveGitDestructiveLogPath(env = process.env, platform = process.platform, homeDir = homedir()) {
14
+ const override = env[GIT_DESTRUCTIVE_LOG_ENV]?.trim();
15
+ if (override !== undefined && override.length > 0) {
16
+ return resolve(override);
17
+ }
18
+ return join(platformUserConfigDir(platform, env, homeDir), "logs", "git-destructive.jsonl");
19
+ }
20
+ function defaultAppend(path, line) {
21
+ const targetAbs = resolve(path);
22
+ const parent = dirname(targetAbs);
23
+ mkdirSync(parent, { recursive: true });
24
+ containedWrite({
25
+ root: parent,
26
+ target: targetAbs,
27
+ data: line,
28
+ mode: "append",
29
+ mkdir: false,
30
+ mutation: false,
31
+ });
32
+ }
33
+ /**
34
+ * Best-effort append. A log failure must not block the deny.
35
+ */
36
+ export function appendGitDestructiveRecord(record, options = {}) {
37
+ try {
38
+ const path = options.logPath ?? resolveGitDestructiveLogPath(options.env ?? process.env);
39
+ const line = `${JSON.stringify(record)}\n`;
40
+ (options.append ?? defaultAppend)(path, line);
41
+ }
42
+ catch {
43
+ // Attribution is best-effort; the fail-closed deny still stands.
44
+ }
45
+ }
46
+ //# sourceMappingURL=git-destructive-log.js.map