@deftai/directive-core 0.87.0 → 0.89.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 (193) hide show
  1. package/dist/authz/actions.d.ts +55 -0
  2. package/dist/authz/actions.js +125 -0
  3. package/dist/authz/classify.d.ts +23 -0
  4. package/dist/authz/classify.js +217 -0
  5. package/dist/authz/closed-verb.d.ts +42 -0
  6. package/dist/authz/closed-verb.js +279 -0
  7. package/dist/authz/evaluate.d.ts +41 -0
  8. package/dist/authz/evaluate.js +298 -0
  9. package/dist/authz/index.d.ts +15 -0
  10. package/dist/authz/index.js +15 -0
  11. package/dist/authz/origin.d.ts +28 -0
  12. package/dist/authz/origin.js +64 -0
  13. package/dist/authz/paths.d.ts +6 -0
  14. package/dist/authz/paths.js +19 -0
  15. package/dist/authz/store.d.ts +40 -0
  16. package/dist/authz/store.js +317 -0
  17. package/dist/authz/templates.d.ts +139 -0
  18. package/dist/authz/templates.js +241 -0
  19. package/dist/authz/types.d.ts +111 -0
  20. package/dist/authz/types.js +41 -0
  21. package/dist/authz/verb-classification.d.ts +44 -0
  22. package/dist/authz/verb-classification.js +237 -0
  23. package/dist/branch/evaluate.js +6 -1
  24. package/dist/cache/fetch.js +10 -5
  25. package/dist/cache/io.d.ts +9 -2
  26. package/dist/cache/io.js +30 -10
  27. package/dist/cache/scanner.d.ts +11 -1
  28. package/dist/cache/scanner.js +29 -4
  29. package/dist/cache/task-cache/store.js +11 -7
  30. package/dist/capacity/backfill.js +10 -4
  31. package/dist/category-b-namespace/index.js +10 -4
  32. package/dist/check/gate-lists.js +1 -0
  33. package/dist/codebase/default-extractor.js +3 -0
  34. package/dist/codebase/map.js +11 -4
  35. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  36. package/dist/content-contracts/skills/helpers.js +35 -0
  37. package/dist/deposit/copy-tree.d.ts +19 -1
  38. package/dist/deposit/copy-tree.js +134 -5
  39. package/dist/doctor/doctor-state.js +28 -4
  40. package/dist/doctor/main.d.ts +10 -0
  41. package/dist/doctor/main.js +208 -0
  42. package/dist/doctor/types.d.ts +11 -0
  43. package/dist/escalation/actions.d.ts +63 -0
  44. package/dist/escalation/actions.js +137 -0
  45. package/dist/escalation/index.d.ts +8 -0
  46. package/dist/escalation/index.js +8 -0
  47. package/dist/escalation/paths.d.ts +3 -0
  48. package/dist/escalation/paths.js +10 -0
  49. package/dist/escalation/store.d.ts +17 -0
  50. package/dist/escalation/store.js +172 -0
  51. package/dist/escalation/types.d.ts +73 -0
  52. package/dist/escalation/types.js +43 -0
  53. package/dist/eval/crud-telemetry.js +30 -10
  54. package/dist/eval/health.js +22 -7
  55. package/dist/eval/readback.js +11 -10
  56. package/dist/eval/run.js +32 -16
  57. package/dist/events/attribution-enrichment.js +10 -4
  58. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  59. package/dist/finish-loop/directive-finish-loop.js +239 -0
  60. package/dist/finish-loop/grant-gate.d.ts +31 -0
  61. package/dist/finish-loop/grant-gate.js +169 -0
  62. package/dist/finish-loop/index.d.ts +11 -0
  63. package/dist/finish-loop/index.js +11 -0
  64. package/dist/finish-loop/main.d.ts +35 -0
  65. package/dist/finish-loop/main.js +365 -0
  66. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  67. package/dist/finish-loop/pr-finish-loop.js +217 -0
  68. package/dist/finish-loop/progress.d.ts +17 -0
  69. package/dist/finish-loop/progress.js +45 -0
  70. package/dist/finish-loop/queue.d.ts +16 -0
  71. package/dist/finish-loop/queue.js +65 -0
  72. package/dist/finish-loop/types.d.ts +52 -0
  73. package/dist/finish-loop/types.js +10 -0
  74. package/dist/fs/contained-write.d.ts +101 -0
  75. package/dist/fs/contained-write.js +281 -0
  76. package/dist/fs/projection-containment.d.ts +18 -0
  77. package/dist/fs/projection-containment.js +40 -0
  78. package/dist/hooks/classify/classify.d.ts +10 -0
  79. package/dist/hooks/classify/classify.js +37 -0
  80. package/dist/hooks/classify/index.d.ts +14 -0
  81. package/dist/hooks/classify/index.js +13 -0
  82. package/dist/hooks/classify/paths.d.ts +22 -0
  83. package/dist/hooks/classify/paths.js +72 -0
  84. package/dist/hooks/classify/payload.d.ts +16 -0
  85. package/dist/hooks/classify/payload.js +45 -0
  86. package/dist/hooks/classify/stdin.d.ts +12 -0
  87. package/dist/hooks/classify/stdin.js +63 -0
  88. package/dist/hooks/classify/tool-name.d.ts +18 -0
  89. package/dist/hooks/classify/tool-name.js +85 -0
  90. package/dist/hooks/classify/types.d.ts +41 -0
  91. package/dist/hooks/classify/types.js +7 -0
  92. package/dist/hooks/dispatcher.d.ts +30 -15
  93. package/dist/hooks/dispatcher.js +326 -134
  94. package/dist/hooks/fixtures/cases.d.ts +44 -0
  95. package/dist/hooks/fixtures/cases.js +595 -0
  96. package/dist/hooks/fixtures/index.d.ts +2 -0
  97. package/dist/hooks/fixtures/index.js +2 -0
  98. package/dist/hooks/index.d.ts +2 -0
  99. package/dist/hooks/index.js +2 -0
  100. package/dist/hooks/tools.d.ts +31 -0
  101. package/dist/hooks/tools.js +74 -0
  102. package/dist/index.d.ts +4 -0
  103. package/dist/index.js +5 -0
  104. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  105. package/dist/init-deposit/agent-hooks.js +65 -10
  106. package/dist/init-deposit/gitignore.js +11 -3
  107. package/dist/init-deposit/headless-manifest.js +12 -4
  108. package/dist/init-deposit/hygiene.d.ts +16 -0
  109. package/dist/init-deposit/hygiene.js +26 -0
  110. package/dist/init-deposit/init-dispatch.js +28 -0
  111. package/dist/init-deposit/migrate.d.ts +1 -1
  112. package/dist/init-deposit/migrate.js +13 -3
  113. package/dist/init-deposit/prettierignore.js +12 -5
  114. package/dist/init-deposit/refresh.js +38 -7
  115. package/dist/init-deposit/scaffold.js +42 -29
  116. package/dist/init-deposit/xbrief-projections.js +17 -10
  117. package/dist/intake/candidates-log.js +46 -35
  118. package/dist/intake/github-body-cli.d.ts +6 -0
  119. package/dist/intake/github-body-cli.js +17 -0
  120. package/dist/intake/github-body.d.ts +46 -3
  121. package/dist/intake/github-body.js +191 -20
  122. package/dist/intake/issue-emit.d.ts +1 -0
  123. package/dist/intake/issue-emit.js +41 -23
  124. package/dist/intake/issue-ingest.js +11 -2
  125. package/dist/lifecycle/event-detect.js +12 -4
  126. package/dist/lifecycle/events.js +29 -20
  127. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  128. package/dist/packs/pack-render.d.ts +33 -0
  129. package/dist/packs/pack-render.js +172 -13
  130. package/dist/packs/quarantine-ext.d.ts +10 -0
  131. package/dist/packs/quarantine-ext.js +26 -2
  132. package/dist/plan-sequence/store.js +11 -4
  133. package/dist/policy/hotfix-criteria.d.ts +79 -0
  134. package/dist/policy/hotfix-criteria.js +197 -0
  135. package/dist/policy/index.d.ts +5 -0
  136. package/dist/policy/index.js +32 -1
  137. package/dist/policy/intent-ceiling.d.ts +79 -0
  138. package/dist/policy/intent-ceiling.js +181 -0
  139. package/dist/policy/no-deft-directive.d.ts +59 -0
  140. package/dist/policy/no-deft-directive.js +110 -0
  141. package/dist/policy/org-force-on-migration.d.ts +52 -0
  142. package/dist/policy/org-force-on-migration.js +269 -27
  143. package/dist/policy/require-human-merge.d.ts +75 -0
  144. package/dist/policy/require-human-merge.js +324 -0
  145. package/dist/policy/resolve.js +17 -6
  146. package/dist/policy/runtime-authority.d.ts +56 -2
  147. package/dist/policy/runtime-authority.js +297 -3
  148. package/dist/policy/write-fence.d.ts +78 -0
  149. package/dist/policy/write-fence.js +164 -0
  150. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  151. package/dist/pr-wait-mergeable/cascade.js +28 -0
  152. package/dist/preflight/evaluate.js +10 -0
  153. package/dist/product-signal/consent.js +21 -5
  154. package/dist/product-signal/submit.js +22 -12
  155. package/dist/release/build-dist-runner.js +5 -2
  156. package/dist/release/build-dist.d.ts +19 -1
  157. package/dist/release/build-dist.js +50 -2
  158. package/dist/release/native-steps.js +7 -2
  159. package/dist/release-publish/pipeline.d.ts +13 -0
  160. package/dist/release-publish/pipeline.js +46 -1
  161. package/dist/scope/audit-log.js +19 -24
  162. package/dist/scope/decompose.js +10 -5
  163. package/dist/scope/decomposed-refs.js +18 -3
  164. package/dist/scope/demote.js +9 -2
  165. package/dist/scope/undo.js +9 -2
  166. package/dist/session/release-availability.js +26 -6
  167. package/dist/session/ritual-sentinel.js +19 -12
  168. package/dist/session/session-start-hook.d.ts +3 -0
  169. package/dist/session/session-start-hook.js +15 -0
  170. package/dist/session/session-start.js +37 -0
  171. package/dist/staleness-tickler/state.js +26 -6
  172. package/dist/swarm/launch.js +13 -3
  173. package/dist/swarm/routing.js +9 -3
  174. package/dist/task-surface/index.js +24 -9
  175. package/dist/triage/bootstrap/gitignore.js +53 -10
  176. package/dist/triage/cache-path.js +10 -3
  177. package/dist/triage/welcome/summary.js +11 -4
  178. package/dist/triage/welcome/writers.js +45 -16
  179. package/dist/validate-content/validate-links.js +5 -0
  180. package/dist/value/readback.js +11 -6
  181. package/dist/vbrief-activate/activate.js +12 -4
  182. package/dist/vbrief-build/project-definition-io.js +14 -6
  183. package/dist/verify-source/contained-writes.d.ts +59 -0
  184. package/dist/verify-source/contained-writes.js +272 -0
  185. package/dist/verify-source/cursor-tier1.js +7 -2
  186. package/dist/verify-source/index.d.ts +1 -0
  187. package/dist/verify-source/index.js +1 -0
  188. package/dist/verify-source/openclaw-tier1.js +19 -1
  189. package/dist/verify-source/verify-stubs.js +3 -0
  190. package/dist/xbrief-migrate/migrate-project.js +26 -12
  191. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  192. package/dist/xbrief-migrate/transforms.js +37 -14
  193. package/package.json +16 -3
@@ -1,145 +1,29 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { appendAuthzAudit, classifyHookAuthzOps, evaluateAuthzMutation, evidenceSatisfiesImplementationApproval, listActiveHumanGrants, loadAuthzStateResult, markGrantUsed, shouldConsumeSingleUseGrant, utcIso, } from "../authz/index.js";
3
4
  import { hasArtifactSuffix } from "../layout/resolve.js";
4
- import { evaluateRuntimeAuthorityDirectWrite, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
5
+ import { evaluateIntentCeilingFromEnv } from "../policy/intent-ceiling.js";
6
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, } from "../policy/no-deft-directive.js";
7
+ import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
8
+ import { loadStoryWriteFenceFromPath, resolveWriteFence } from "../policy/write-fence.js";
9
+ import { detectBranch } from "../session/git.js";
5
10
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
6
11
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
7
12
  import { inspectSessionRitual } from "../session/verify-session-ritual.js";
13
+ import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
8
14
  import { isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
9
15
  import { inspectActiveScope } from "./scope.js";
10
- import { isDirectWriteTool, isSpawnTool } from "./tools.js";
16
+ import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
17
+ // Pure parse/classify helpers are defined in ./classify/ and re-exported from
18
+ // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
11
19
  export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
12
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isSpawnTool, READ_ONLY_HOOK_ENV, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
20
+ 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";
13
21
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
14
22
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
15
23
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
16
24
  export const COMPACT_HOOK_HOSTS = ["claude", "grok", "cursor"];
17
25
  /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
18
26
  export const COMPACT_HOOK_SKIP_HOSTS = ["codex"];
19
- function record(value) {
20
- return value !== null && typeof value === "object" && !Array.isArray(value)
21
- ? value
22
- : null;
23
- }
24
- function firstString(...values) {
25
- for (const value of values) {
26
- if (typeof value === "string" && value.trim().length > 0)
27
- return value.trim();
28
- }
29
- return null;
30
- }
31
- function fieldPresent(input, key) {
32
- return key in input;
33
- }
34
- function fieldString(input, key) {
35
- const value = input[key];
36
- if (typeof value === "string" && value.trim().length > 0)
37
- return value.trim();
38
- return null;
39
- }
40
- function toolInputRecord(payload) {
41
- const toolCall = record(payload.tool_call) ?? record(payload.toolCall);
42
- return (record(payload.tool_input) ??
43
- record(payload.toolInput) ??
44
- record(payload.input) ??
45
- record(payload.arguments) ??
46
- (toolCall !== null ? record(toolCall.arguments) : null));
47
- }
48
- /**
49
- * Cursor preToolUse payloads sometimes omit `tool_name` even when the hook matcher
50
- * fired for a direct-write tool (#2628). Infer from nested tool input when possible.
51
- */
52
- function inferCursorDirectWriteToolName(payload) {
53
- const toolInput = toolInputRecord(payload);
54
- if (toolInput !== null) {
55
- if (fieldPresent(toolInput, "new_string") ||
56
- fieldPresent(toolInput, "newString") ||
57
- fieldPresent(toolInput, "old_string") ||
58
- fieldPresent(toolInput, "oldString")) {
59
- return "StrReplace";
60
- }
61
- if (fieldString(toolInput, "contents") !== null ||
62
- fieldString(toolInput, "content") !== null ||
63
- fieldString(toolInput, "text") !== null) {
64
- return "Write";
65
- }
66
- if (fieldString(toolInput, "patch") !== null ||
67
- fieldString(toolInput, "unified_diff") !== null ||
68
- fieldString(toolInput, "diff") !== null) {
69
- return "ApplyPatch";
70
- }
71
- if (Array.isArray(toolInput.edits))
72
- return "MultiEdit";
73
- if (Array.isArray(toolInput.cells) || toolInput.cell_id != null) {
74
- return "NotebookEdit";
75
- }
76
- }
77
- // Cursor maps Claude Edit → Write; a write target without contents is still a direct write.
78
- if (hookWriteTargetPath(payload) !== null)
79
- return "Write";
80
- return null;
81
- }
82
- export function hookPayloadTopLevelKeys(payload) {
83
- const input = record(payload);
84
- if (input === null)
85
- return [];
86
- return Object.keys(input).sort();
87
- }
88
- export function hookToolName(payload, host) {
89
- const input = record(payload);
90
- if (input === null)
91
- return null;
92
- const toolObject = record(input.tool);
93
- const toolCall = record(input.tool_call) ?? record(input.toolCall);
94
- // OpenAI-style nestings are host-agnostic; checked before Cursor-only inference.
95
- const direct = fieldString(input, "tool_name") ??
96
- fieldString(input, "toolName") ??
97
- fieldString(input, "tool") ??
98
- (toolObject !== null ? fieldString(toolObject, "name") : null) ??
99
- (toolCall !== null ? fieldString(toolCall, "name") : null);
100
- if (direct !== null)
101
- return direct;
102
- if (host === "cursor")
103
- return inferCursorDirectWriteToolName(input);
104
- return null;
105
- }
106
- function missingToolNameMessage(input) {
107
- const { host, payload, context } = input;
108
- if (host === "cursor") {
109
- if (context?.stdinEmpty) {
110
- return ("Directive denied this Cursor preToolUse event because the host sent an empty payload " +
111
- "(stdin was empty — not a session ritual or scope failure). " +
112
- "If write tools should pass, update Directive or report the payload shape from Cursor.");
113
- }
114
- if (context?.parseFailed) {
115
- return ("Directive denied this Cursor preToolUse event because the host payload was not valid JSON " +
116
- "(host-integration mismatch — not a session ritual or scope failure). " +
117
- "If write tools should pass, update Directive or report the payload shape from Cursor.");
118
- }
119
- const keys = hookPayloadTopLevelKeys(payload);
120
- if (keys.length > 0) {
121
- return ("Directive denied this Cursor preToolUse event because the host payload omitted a " +
122
- "recognizable tool name (host-integration mismatch — not a session ritual or scope failure). " +
123
- `Top-level payload keys: ${keys.join(", ")}. ` +
124
- "If write tools should pass, update Directive or report the payload shape from Cursor.");
125
- }
126
- return ("Directive denied this Cursor preToolUse event because the host payload omitted a " +
127
- "recognizable tool name (host-integration mismatch — not a session ritual or scope failure). " +
128
- "If write tools should pass, update Directive or report the payload shape from Cursor.");
129
- }
130
- return "Directive denied this matched write event because the host payload omitted its tool name.";
131
- }
132
- /**
133
- * Best-effort write-target path from host PreToolUse payloads (#2625).
134
- * Hosts disagree on nesting (`tool_input.file_path` vs top-level `path`).
135
- */
136
- export function hookWriteTargetPath(payload) {
137
- const input = record(payload);
138
- if (input === null)
139
- return null;
140
- const toolInput = toolInputRecord(input);
141
- return firstString(toolInput?.file_path, toolInput?.filePath, toolInput?.path, input.file_path, input.filePath, input.path);
142
- }
143
27
  /** POSIX-ish project-relative path for lifecycle matching. */
144
28
  export function toProjectRelativePosix(projectRoot, targetPath) {
145
29
  const abs = resolve(projectRoot, targetPath.replace(/\\/g, "/"));
@@ -290,20 +174,275 @@ function deny(input, code, toolName, message, scopePath = null) {
290
174
  }
291
175
  function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
292
176
  const projectRoot = resolve(input.projectRoot);
293
- let policy;
177
+ // Project policy load failure: treat as disabled project layer, still apply
178
+ // independent story file_scope when present (#516 / #2443 Greptile P1).
179
+ let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
294
180
  try {
295
- policy = (seams.loadRuntimeAuthority ?? loadRuntimeAuthorityFromProject)(projectRoot);
181
+ basePolicy = (seams.loadRuntimeAuthority ?? loadRuntimeAuthorityFromProject)(projectRoot);
296
182
  }
297
183
  catch {
298
- // Fail-open on policy load crash — host behavior; ritual/scope gates already passed.
299
- return null;
184
+ basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
185
+ }
186
+ // Wave 3 unified write fence: intersect project runtimeAuthority with active
187
+ // story file_scope (#516 / #2443 / #2948). Single evaluation SoT via
188
+ // evaluateRuntimeAuthorityDirectWrite — no parallel writeScope engine.
189
+ let storyFence;
190
+ try {
191
+ storyFence = seams.loadStoryWriteFence
192
+ ? seams.loadStoryWriteFence(projectRoot, scopePath)
193
+ : loadStoryWriteFenceFromPath(scopePath);
300
194
  }
195
+ catch {
196
+ // Residual: host cannot load active story — project fence still applies.
197
+ storyFence = { fileScope: [], denyPaths: [] };
198
+ }
199
+ const fence = resolveWriteFence(basePolicy, storyFence.fileScope, {
200
+ storyDenyPaths: storyFence.denyPaths,
201
+ });
202
+ // Neither layer active → allow (same as disabled runtimeAuthority historically).
203
+ if (!fence.fenceActive)
204
+ return null;
301
205
  const writeTarget = hookWriteTargetPath(input.payload);
302
206
  const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
303
- const verdict = evaluateRuntimeAuthorityDirectWrite({ policy, relPathPosix: relPath });
207
+ const verdict = evaluateRuntimeAuthorityDirectWrite({
208
+ policy: fence.policy,
209
+ relPathPosix: relPath,
210
+ });
304
211
  if (verdict.allowed)
305
212
  return null;
306
- return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under runtime authority policy.", scopePath);
213
+ return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under write fence policy.", scopePath);
214
+ }
215
+ function loadAuthzContext(projectRoot, seams) {
216
+ if (seams.loadAuthzState !== undefined) {
217
+ const state = seams.loadAuthzState(projectRoot);
218
+ const raw = (seams.loadAuthzGrants ?? listActiveHumanGrants)(projectRoot, state);
219
+ // Production filter: self-authored grants never enter the implement gate (#2944).
220
+ const grants = raw.filter((g) => evidenceSatisfiesImplementationApproval({ grant: g }));
221
+ return { state, grants, corrupt: false, corruptReason: null };
222
+ }
223
+ const loaded = loadAuthzStateResult(projectRoot);
224
+ const raw = (seams.loadAuthzGrants ?? listActiveHumanGrants)(projectRoot, loaded.state);
225
+ const grants = raw.filter((g) => evidenceSatisfiesImplementationApproval({ grant: g }));
226
+ return {
227
+ state: loaded.state,
228
+ grants,
229
+ corrupt: loaded.corrupt,
230
+ corruptReason: loaded.ok ? null : loaded.reason,
231
+ };
232
+ }
233
+ function authzCodeToHook(code) {
234
+ // Map authz decision codes onto HookDecisionCode (same string values for denials).
235
+ if (code === "authz-uat-deny" ||
236
+ code === "authz-grant-missing" ||
237
+ code === "authz-grant-origin-reject" ||
238
+ code === "authz-grant-scope-deny" ||
239
+ code === "authz-grant-expired" ||
240
+ code === "authz-grant-revoked" ||
241
+ code === "authz-grant-single-use-spent") {
242
+ return code;
243
+ }
244
+ return "authz-uat-deny";
245
+ }
246
+ function recordAuthzAudit(projectRoot, decision, state, seams) {
247
+ if (seams.authzAudit === false)
248
+ return;
249
+ // Only audit when UAT is active or a deny occurred under an authz code.
250
+ const uatActive = state.uat?.active === true;
251
+ if (!uatActive && decision.allowed)
252
+ return;
253
+ try {
254
+ appendAuthzAudit(projectRoot, {
255
+ schemaVersion: 1,
256
+ ts: utcIso(),
257
+ humanApprovalRef: decision.humanApprovalRef,
258
+ approvedScope: decision.approvedScope,
259
+ attemptedOp: String(decision.attemptedOp),
260
+ path: decision.path,
261
+ result: decision.allowed ? "allow" : "deny",
262
+ code: decision.code,
263
+ message: decision.reason,
264
+ campaignId: state.uat?.campaignId ?? null,
265
+ });
266
+ }
267
+ catch {
268
+ // Audit write must not crash the host tool gate.
269
+ }
270
+ }
271
+ /**
272
+ * UAT lease + human-origin grant gate (#2944). Returns deny decision or null when allowed.
273
+ * Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
274
+ */
275
+ function authzForMutation(input, toolName, seams, options) {
276
+ const projectRoot = resolve(input.projectRoot);
277
+ let state;
278
+ let grants;
279
+ let corrupt;
280
+ let corruptReason;
281
+ try {
282
+ ({ state, grants, corrupt, corruptReason } = loadAuthzContext(projectRoot, seams));
283
+ }
284
+ catch (err) {
285
+ // Fail closed: unreadable authz store must not disable UAT enforcement (#2944).
286
+ return deny(input, "authz-uat-deny", toolName, `Directive denied this mutation: authz store unreadable (${String(err)}). ` +
287
+ "Human action required: repair `.deft/authz/state.json` or re-run `deft authz:uat-start`.", options.scopePath);
288
+ }
289
+ const shellCommand = isShellTool(toolName) ? hookShellCommand(input.payload) : null;
290
+ const ops = classifyHookAuthzOps({
291
+ toolName,
292
+ shellCommand,
293
+ isDirectWrite: options.isDirectWrite,
294
+ mcpArgsText: options.isDirectWrite ? null : hookMcpArgsText(input.payload),
295
+ });
296
+ // No classifiable authz ops (unrelated tools) — leave to other gates,
297
+ // except corrupt state still fails closed on direct writes / classifiable shell.
298
+ if (ops.length === 0) {
299
+ if (corrupt && (options.isDirectWrite || isShellTool(toolName))) {
300
+ return deny(input, "authz-uat-deny", toolName, `Directive denied this mutation: ${corruptReason ?? "authz state corrupt"}. ` +
301
+ "Fail closed while UAT authority cannot be verified. " +
302
+ "Human action required: repair `.deft/authz/state.json`.", options.scopePath);
303
+ }
304
+ return null;
305
+ }
306
+ // Structural context for bound grants (branch from git; repo optional env).
307
+ let branch = null;
308
+ try {
309
+ branch = detectBranch(projectRoot);
310
+ }
311
+ catch {
312
+ branch = null;
313
+ }
314
+ const repo = (process.env.DEFT_AUTHZ_REPO ?? process.env.GITHUB_REPOSITORY ?? "").trim() || null;
315
+ // Evaluate each op; first deny wins (compound shell must not short-circuit).
316
+ for (const op of ops) {
317
+ const decision = evaluateAuthzMutation({
318
+ state,
319
+ grants,
320
+ op,
321
+ path: options.relPath,
322
+ branch,
323
+ repo,
324
+ worktree: projectRoot,
325
+ });
326
+ recordAuthzAudit(projectRoot, decision, state, seams);
327
+ if (!decision.allowed) {
328
+ return deny(input, authzCodeToHook(decision.code), toolName, decision.reason, options.scopePath);
329
+ }
330
+ // Consume single-use grants after allow (persist usedAt).
331
+ if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
332
+ try {
333
+ markGrantUsed(projectRoot, decision.humanApprovalRef);
334
+ }
335
+ catch {
336
+ // Persistence failure must not flip allow → deny after a successful check;
337
+ // next load will still see unused if write failed (prefer allow-once risk over deadlock).
338
+ }
339
+ }
340
+ }
341
+ return null;
342
+ }
343
+ function loadRuntimeAuthorityPolicySafe(input, seams) {
344
+ const projectRoot = resolve(input.projectRoot);
345
+ try {
346
+ return (seams.loadRuntimeAuthority ?? loadRuntimeAuthorityFromProject)(projectRoot);
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
352
+ /**
353
+ * Evaluate scopes.push / scopes.merge for Shell/Bash or classifiable MCP tools (#2711).
354
+ * Returns a full HookDecision (allow or deny). Unclassifiable ops fail open.
355
+ */
356
+ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
357
+ const projectRoot = resolve(input.projectRoot);
358
+ // Wave 1 authz first: UAT lease denies push/PR/merge/settings/deploy without cohort (#2944).
359
+ // Composes with #2711 shell matchers; does not re-implement them.
360
+ const authzDeny = authzForMutation(input, toolName, seams, {
361
+ isDirectWrite: false,
362
+ relPath: null,
363
+ scopePath: null,
364
+ });
365
+ if (authzDeny !== null)
366
+ return authzDeny;
367
+ // #1193 intent ceiling: non-implement slash verbs cannot authorize push/merge (and deploy via merge).
368
+ const env = input.environ ?? process.env;
369
+ const shellOpsPreview = [];
370
+ if (isShellTool(toolName)) {
371
+ const command = hookShellCommand(input.payload);
372
+ if (command !== null)
373
+ shellOpsPreview.push(...listShellOps(command));
374
+ }
375
+ else {
376
+ const mcpOp = classifyMcpTool(toolName, hookMcpArgsText(input.payload));
377
+ if (mcpOp !== null)
378
+ shellOpsPreview.push(mcpOp);
379
+ }
380
+ for (const shellOp of shellOpsPreview) {
381
+ const intentOp = shellOp === "push" ? "push" : "merge";
382
+ const intent = evaluateIntentCeilingFromEnv(intentOp, env);
383
+ if (!intent.allowed) {
384
+ return deny(input, "intent-ceiling-deny", toolName, intent.reason);
385
+ }
386
+ }
387
+ const policy = loadRuntimeAuthorityPolicySafe(input, seams);
388
+ if (policy === null) {
389
+ return {
390
+ verdict: "allow",
391
+ code: "shell-op-unclassifiable",
392
+ event: input.event,
393
+ host: input.host,
394
+ toolName,
395
+ projectRoot,
396
+ message: `Directive allowed ${toolName}: runtimeAuthority policy load failed (fail open).`,
397
+ scopePath: null,
398
+ };
399
+ }
400
+ // Shell: evaluate every classifiable op in compound/multi-line commands (#2711 Greptile).
401
+ // MCP / bare push-merge names: single tool name maps to at most one op
402
+ // (includes bare `merge_pull_request` etc. that isMcpTool does not flag).
403
+ const ops = [];
404
+ if (isShellTool(toolName)) {
405
+ const command = hookShellCommand(input.payload);
406
+ if (command !== null)
407
+ ops.push(...listShellOps(command));
408
+ }
409
+ else {
410
+ const mcpOp = classifyMcpTool(toolName, hookMcpArgsText(input.payload));
411
+ if (mcpOp !== null)
412
+ ops.push(mcpOp);
413
+ }
414
+ if (ops.length === 0) {
415
+ return {
416
+ verdict: "allow",
417
+ code: "shell-op-unclassifiable",
418
+ event: input.event,
419
+ host: input.host,
420
+ toolName,
421
+ projectRoot,
422
+ message: `${toolName} is classifiable as Shell/MCP but the command/tool was not recognized as ` +
423
+ "push or merge — fail open (host gap; see runtime-authority.md).",
424
+ scopePath: null,
425
+ };
426
+ }
427
+ // Deny if any classifiable op is out of scope (compound commands must not short-circuit).
428
+ for (const op of ops) {
429
+ const verdict = evaluateRuntimeAuthorityShellOp({ policy, op });
430
+ if (!verdict.allowed) {
431
+ return deny(input, verdict.code ?? "runtime-policy-deny-scope", toolName, verdict.reason ??
432
+ "Directive denied this shell/MCP operation under runtime authority policy.");
433
+ }
434
+ }
435
+ const opsLabel = ops.join("+");
436
+ return {
437
+ verdict: "allow",
438
+ code: "shell-op-ready",
439
+ event: input.event,
440
+ host: input.host,
441
+ toolName,
442
+ projectRoot,
443
+ message: `Directive runtimeAuthority allowed classifiable ${opsLabel} via ${toolName}.`,
444
+ scopePath: null,
445
+ };
307
446
  }
308
447
  function inspectMutationGates(input, toolName, seams, options) {
309
448
  const projectRoot = resolve(input.projectRoot);
@@ -324,6 +463,15 @@ function inspectMutationGates(input, toolName, seams, options) {
324
463
  if (options.proposedLifecycleExempt) {
325
464
  const writeTarget = hookWriteTargetPath(input.payload);
326
465
  if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
466
+ const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
467
+ // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
468
+ const authzDeny = authzForMutation(input, toolName, seams, {
469
+ isDirectWrite: true,
470
+ relPath,
471
+ scopePath: null,
472
+ });
473
+ if (authzDeny !== null)
474
+ return authzDeny;
327
475
  const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
328
476
  if (runtimeDeny !== null)
329
477
  return runtimeDeny;
@@ -371,7 +519,24 @@ function inspectMutationGates(input, toolName, seams, options) {
371
519
  }
372
520
  }
373
521
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
522
+ // #1193: non-implement slash verbs must not authorize implement/spawn tooling.
523
+ {
524
+ const intent = evaluateIntentCeilingFromEnv("implement", input.environ ?? process.env);
525
+ if (!intent.allowed) {
526
+ return deny(input, "intent-ceiling-deny", toolName, intent.reason);
527
+ }
528
+ }
374
529
  if (!isSpawnTool(toolName)) {
530
+ const writeTarget = hookWriteTargetPath(input.payload);
531
+ const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
532
+ // Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
533
+ const authzDeny = authzForMutation(input, toolName, seams, {
534
+ isDirectWrite: true,
535
+ relPath,
536
+ scopePath: scope.path,
537
+ });
538
+ if (authzDeny !== null)
539
+ return authzDeny;
375
540
  const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
376
541
  if (runtimeDeny !== null)
377
542
  return runtimeDeny;
@@ -431,6 +596,24 @@ export function decideHook(input, seams = {}) {
431
596
  }
432
597
  }
433
598
  if (input.event === "session.start") {
599
+ // #2926: root `.no-deft-directive` wins — skip host SessionStart bookkeeping.
600
+ const detectOptOut = seams.detectNoDeftDirective ?? detectNoDeftDirective;
601
+ const optOut = detectOptOut(projectRoot);
602
+ if (optOut.present) {
603
+ const message = optOut.inconsistent
604
+ ? `${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}. ${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}`
605
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE;
606
+ return {
607
+ verdict: "allow",
608
+ code: "session-start-disabled",
609
+ event: input.event,
610
+ host: input.host,
611
+ toolName: null,
612
+ projectRoot,
613
+ message,
614
+ scopePath: null,
615
+ };
616
+ }
434
617
  try {
435
618
  const result = (seams.sessionStart ?? runSessionStartHookWrite)(projectRoot);
436
619
  if (result.code !== 0) {
@@ -510,6 +693,15 @@ export function decideHook(input, seams = {}) {
510
693
  }
511
694
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
512
695
  }
696
+ // Shell/Bash and classifiable MCP: enforce scopes.push / scopes.merge (#2711).
697
+ // Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
698
+ // isMcpTool is false — classifyMcpTool is the gate (dispatcher-side, no tools↔policy cycle).
699
+ // Does not require active-scope ritual — only runtimeAuthority when enabled.
700
+ if (isShellTool(toolName) ||
701
+ isMcpTool(toolName) ||
702
+ classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
703
+ return decideShellOrMcpRuntimeAuthority(input, toolName, seams);
704
+ }
513
705
  if (!isDirectWriteTool(toolName)) {
514
706
  return {
515
707
  verdict: "allow",
@@ -518,7 +710,7 @@ export function decideHook(input, seams = {}) {
518
710
  host: input.host,
519
711
  toolName,
520
712
  projectRoot,
521
- message: `${toolName} is outside the P0 direct-write/spawn enforcement slice.`,
713
+ message: `${toolName} is outside the P0 direct-write/spawn/shell enforcement slice.`,
522
714
  scopePath: null,
523
715
  };
524
716
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared host × OS × tool fixture corpus (#2950 Phase A skeleton, Phase B matrix expansion).
3
+ *
4
+ * Cases are pure data: raw stdin or structured payload → expected classification.
5
+ * Core tests and CLI hook-dispatch tests import this module (shared corpus collapse).
6
+ */
7
+ import type { ClassifyHookHost, HookWriteIntent } from "../classify/types.js";
8
+ export type FixtureOs = "win32" | "posix";
9
+ export type FixtureToolFamily = "Write" | "ApplyPatch" | "Task" | "StrReplace" | "other";
10
+ export interface HookFixtureCase {
11
+ readonly id: string;
12
+ readonly host: ClassifyHookHost;
13
+ readonly os: FixtureOs;
14
+ readonly tool: FixtureToolFamily;
15
+ /** Closed-issue tags for the regression class this case freezes. */
16
+ readonly regression: readonly string[];
17
+ /** Structured payload when the host sends JSON. */
18
+ readonly payload?: unknown;
19
+ /** Raw stdin when testing BOM / free-form ApplyPatch parse. */
20
+ readonly raw?: string;
21
+ readonly expected: {
22
+ readonly toolName: string | null;
23
+ readonly writeIntent: HookWriteIntent;
24
+ readonly writeTargetPath: string | null;
25
+ /** When raw is set: after parseHookStdin. */
26
+ readonly stdinEmpty?: boolean;
27
+ readonly parseFailed?: boolean;
28
+ };
29
+ readonly notes?: string;
30
+ }
31
+ /**
32
+ * Cursor × {win32, posix} × {Write, ApplyPatch, Task, StrReplace}
33
+ * plus recent regression classes (BOM, free-form, missing tool name, outside-root).
34
+ * Phase B expands Write/ApplyPatch depth and shares this corpus with CLI tests.
35
+ */
36
+ export declare const HOOK_FIXTURE_CASES: readonly HookFixtureCase[];
37
+ export declare function fixtureCasesFor(filter: {
38
+ host?: ClassifyHookHost;
39
+ os?: FixtureOs;
40
+ tool?: FixtureToolFamily;
41
+ }): HookFixtureCase[];
42
+ /** Look up a single case by stable id (CLI/core shared helpers). */
43
+ export declare function fixtureCaseById(id: string): HookFixtureCase | undefined;
44
+ //# sourceMappingURL=cases.d.ts.map