@deftai/directive-core 0.107.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.
- package/dist/architecture/sor-preflight.js +2 -0
- package/dist/cache/operations.js +1 -1
- package/dist/check/cached-orchestrator.js +28 -3
- package/dist/check/gate-lists.d.ts +14 -0
- package/dist/check/gate-lists.js +26 -3
- package/dist/check/named-cause.js +18 -3
- package/dist/check/session-completed-ac.d.ts +1 -1
- package/dist/check/session-completed-ac.js +1 -1
- package/dist/codebase/provider.js +7 -1
- package/dist/consumer-check-contract/evaluate.d.ts +47 -0
- package/dist/consumer-check-contract/evaluate.js +181 -15
- package/dist/delivery-attempt/evaluate.d.ts +9 -1
- package/dist/delivery-attempt/evaluate.js +69 -0
- package/dist/delivery-attempt/handoff.js +1 -1
- package/dist/delivery-attempt/index.d.ts +1 -1
- package/dist/delivery-attempt/index.js +1 -1
- package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
- package/dist/deposit/live-procedure-exclusions.js +110 -0
- package/dist/deposit/live-procedure-targets.d.ts +45 -0
- package/dist/deposit/live-procedure-targets.js +274 -0
- package/dist/deposit/python-free.d.ts +6 -0
- package/dist/deposit/python-free.js +15 -0
- package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
- package/dist/deposit/rewrite-deposit-links.js +148 -0
- package/dist/deposit/run-stage-content-pack.d.ts +2 -0
- package/dist/deposit/run-stage-content-pack.js +3 -0
- package/dist/deposit/stage-content-pack.d.ts +17 -0
- package/dist/deposit/stage-content-pack.js +91 -0
- package/dist/design-critique/citation-grammar.d.ts +60 -0
- package/dist/design-critique/citation-grammar.js +303 -0
- package/dist/design-critique/completed-arc-record.d.ts +62 -0
- package/dist/design-critique/completed-arc-record.js +277 -0
- package/dist/freshness/bind.d.ts +32 -3
- package/dist/freshness/bind.js +58 -12
- package/dist/hooks/classify/host-session-identity.d.ts +89 -0
- package/dist/hooks/classify/host-session-identity.js +467 -0
- package/dist/hooks/classify/index.d.ts +3 -2
- package/dist/hooks/classify/index.js +3 -2
- package/dist/hooks/classify/paths.d.ts +9 -0
- package/dist/hooks/classify/paths.js +39 -0
- package/dist/hooks/classify/stdin.d.ts +5 -0
- package/dist/hooks/classify/stdin.js +94 -1
- package/dist/hooks/dest-form.d.ts +20 -1
- package/dist/hooks/dest-form.js +158 -21
- package/dist/hooks/dispatcher.d.ts +58 -3
- package/dist/hooks/dispatcher.js +762 -73
- package/dist/hooks/fixtures/cases.d.ts +22 -2
- package/dist/hooks/fixtures/cases.js +228 -0
- package/dist/hooks/git-destructive-log.d.ts +32 -0
- package/dist/hooks/git-destructive-log.js +46 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/scope.js +3 -1
- package/dist/hooks/shell-write-targets.d.ts +10 -0
- package/dist/hooks/shell-write-targets.js +274 -0
- package/dist/hooks/tools.d.ts +20 -2
- package/dist/hooks/tools.js +30 -1
- package/dist/init-deposit/agent-hooks.d.ts +2 -1
- package/dist/init-deposit/agent-hooks.js +8 -1
- package/dist/init-deposit/gitignore.d.ts +7 -0
- package/dist/init-deposit/gitignore.js +24 -0
- package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
- package/dist/init-deposit/hook-runtime-travel.js +269 -0
- package/dist/init-deposit/init-deposit.js +3 -0
- package/dist/init-deposit/refresh.js +7 -0
- package/dist/init-deposit/runtime-writers.d.ts +14 -0
- package/dist/init-deposit/runtime-writers.js +33 -0
- package/dist/intake/clause-derivation.js +9 -3
- package/dist/intake/issue-ingest.d.ts +6 -1
- package/dist/intake/issue-ingest.js +83 -4
- package/dist/intake/platform-capabilities.d.ts +9 -2
- package/dist/intake/platform-capabilities.js +38 -9
- package/dist/intake/reconcile-issues.js +13 -13
- package/dist/lifecycle/brief-envelope.d.ts +25 -0
- package/dist/lifecycle/brief-envelope.js +42 -0
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.js +1 -0
- package/dist/literal-acceptance/evaluate.js +14 -5
- package/dist/literal-acceptance/index.d.ts +1 -1
- package/dist/literal-acceptance/index.js +1 -1
- package/dist/literal-acceptance/run.d.ts +2 -0
- package/dist/literal-acceptance/run.js +19 -1
- package/dist/orphan-active/candidate-scope.d.ts +53 -0
- package/dist/orphan-active/candidate-scope.js +157 -0
- package/dist/orphan-active/evaluate.d.ts +52 -0
- package/dist/orphan-active/evaluate.js +250 -128
- package/dist/orphan-active/index.d.ts +2 -0
- package/dist/orphan-active/index.js +2 -0
- package/dist/orphan-active/issue-state.d.ts +121 -0
- package/dist/orphan-active/issue-state.js +278 -0
- package/dist/platform/cursor-managed-runtime.d.ts +95 -0
- package/dist/platform/cursor-managed-runtime.js +241 -0
- package/dist/platform/index.d.ts +1 -0
- package/dist/platform/index.js +1 -0
- package/dist/platform/platform-capabilities.d.ts +23 -1
- package/dist/platform/platform-capabilities.js +55 -15
- package/dist/policy/ceremony-dial.js +5 -9
- package/dist/policy/host-hooks.js +4 -9
- package/dist/policy/merge-approval-head.js +7 -6
- package/dist/policy/org-force-on-migration.js +5 -10
- package/dist/policy/plan-extensions.d.ts +14 -2
- package/dist/policy/plan-extensions.js +24 -3
- package/dist/policy/product-signal.js +5 -10
- package/dist/policy/require-human-merge.js +5 -9
- package/dist/policy/resolve.js +77 -16
- package/dist/policy/value-feedback.js +9 -18
- package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
- package/dist/pr-closeout-attestable/evaluate.js +306 -0
- package/dist/pr-closeout-attestable/index.d.ts +2 -0
- package/dist/pr-closeout-attestable/index.js +2 -0
- package/dist/pr-closing-keywords/gh.js +32 -9
- package/dist/pr-closing-keywords/main.d.ts +1 -0
- package/dist/pr-closing-keywords/main.js +68 -3
- package/dist/pr-closing-keywords/types.d.ts +2 -0
- package/dist/pr-merge-readiness/gh.js +32 -9
- package/dist/pr-protected-issues/gh.d.ts +6 -2
- package/dist/pr-protected-issues/gh.js +42 -11
- package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
- package/dist/pr-wait-mergeable/cascade.js +21 -1
- package/dist/pr-wait-mergeable/types.d.ts +2 -0
- package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
- package/dist/pr-wait-mergeable/wrappers.js +14 -3
- package/dist/preflight/evaluate.d.ts +15 -0
- package/dist/preflight/evaluate.js +29 -3
- package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
- package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
- package/dist/product-first-done-gate/evaluate.js +23 -9
- package/dist/product-first-done-gate/types.js +2 -0
- package/dist/release/cli-drift-report.d.ts +68 -0
- package/dist/release/cli-drift-report.js +189 -0
- package/dist/release/consumer-hard-stops.d.ts +48 -0
- package/dist/release/consumer-hard-stops.js +140 -0
- package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
- package/dist/release/consumer-readiness-disclosure.js +51 -0
- package/dist/release/index.d.ts +3 -0
- package/dist/release/index.js +3 -0
- package/dist/release/issue-state-fetch.d.ts +5 -3
- package/dist/release/issue-state-fetch.js +86 -21
- package/dist/release/native-steps.js +1 -0
- package/dist/release/pipeline.js +74 -0
- package/dist/release/run-consumer-readiness.d.ts +15 -0
- package/dist/release/run-consumer-readiness.js +24 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/render/constants.d.ts +4 -0
- package/dist/render/constants.js +11 -8
- package/dist/render/export-spec.js +31 -5
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/prd-render.d.ts +4 -1
- package/dist/render/prd-render.js +82 -23
- package/dist/render/project-render.js +14 -7
- package/dist/render/scope-outlook.d.ts +2 -0
- package/dist/render/scope-outlook.js +3 -0
- package/dist/render/spec-render.js +2 -2
- package/dist/resolution/package-manager.d.ts +33 -4
- package/dist/resolution/package-manager.js +210 -9
- package/dist/review-monitor/constants.d.ts +7 -0
- package/dist/review-monitor/constants.js +7 -0
- package/dist/review-monitor/github-lease.d.ts +98 -1
- package/dist/review-monitor/github-lease.js +186 -4
- package/dist/review-monitor/lease-comment.d.ts +36 -0
- package/dist/review-monitor/lease-comment.js +103 -2
- package/dist/run-summary/types.d.ts +2 -2
- package/dist/scm/build-command.d.ts +2 -2
- package/dist/scm/build-command.js +2 -2
- package/dist/scm/call-shape.d.ts +25 -0
- package/dist/scm/call-shape.js +59 -0
- package/dist/scm/call.d.ts +7 -4
- package/dist/scm/call.js +48 -9
- package/dist/scm/design-critique-chip.d.ts +1 -0
- package/dist/scm/design-critique-chip.js +22 -6
- package/dist/scm/gh-rest.d.ts +34 -13
- package/dist/scm/gh-rest.js +168 -15
- package/dist/scm/index.d.ts +2 -0
- package/dist/scm/index.js +2 -0
- package/dist/scm/readiness.d.ts +2 -0
- package/dist/scm/readiness.js +28 -4
- package/dist/scm/spawn-status.d.ts +33 -0
- package/dist/scm/spawn-status.js +53 -0
- package/dist/scope/acceptance-evidence.d.ts +1 -1
- package/dist/scope/project-definition-sync.js +14 -7
- package/dist/scope/transition.js +4 -14
- package/dist/session/ac-pass-banking.d.ts +2 -2
- package/dist/session/ac-pass-banking.js +2 -2
- package/dist/session/git.d.ts +24 -0
- package/dist/session/git.js +138 -2
- package/dist/session/host-session-owner.d.ts +53 -0
- package/dist/session/host-session-owner.js +89 -0
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/occupancy.d.ts +259 -13
- package/dist/session/occupancy.js +877 -33
- package/dist/session/ritual-sentinel.d.ts +5 -0
- package/dist/session/ritual-sentinel.js +5 -0
- package/dist/session/session-ready.d.ts +5 -1
- package/dist/session/session-ready.js +98 -8
- package/dist/session/session-start.d.ts +5 -1
- package/dist/session/session-start.js +32 -17
- package/dist/session/toolchain-preflight.d.ts +13 -3
- package/dist/session/toolchain-preflight.js +93 -18
- package/dist/session/verify-ac-session-cache.d.ts +2 -2
- package/dist/session/verify-ac-session-cache.js +2 -2
- package/dist/session/verify-session-ritual.d.ts +11 -0
- package/dist/session/verify-session-ritual.js +60 -15
- package/dist/slice/constants.d.ts +1 -1
- package/dist/spec-authority/constants.d.ts +10 -2
- package/dist/spec-authority/constants.js +53 -8
- package/dist/spec-authority/resolver.d.ts +3 -0
- package/dist/spec-authority/resolver.js +55 -7
- package/dist/subprocess/max-buffer.d.ts +14 -0
- package/dist/subprocess/max-buffer.js +15 -0
- package/dist/swarm/complete-cohort.d.ts +2 -0
- package/dist/swarm/complete-cohort.js +21 -13
- package/dist/swarm/index.d.ts +1 -0
- package/dist/swarm/index.js +1 -0
- package/dist/swarm/launch-cli.js +53 -33
- package/dist/swarm/launch.d.ts +8 -0
- package/dist/swarm/launch.js +63 -43
- package/dist/swarm/pre-dispatch-cli.js +2 -1
- package/dist/swarm/pre-dispatch.js +10 -1
- package/dist/swarm/subagent-status-dir.d.ts +27 -0
- package/dist/swarm/subagent-status-dir.js +42 -0
- package/dist/swarm/worktrees.d.ts +17 -3
- package/dist/swarm/worktrees.js +48 -4
- package/dist/triage/evaluate/worktrees.js +153 -6
- package/dist/triage/scope/mutations-core.d.ts +2 -6
- package/dist/triage/scope/mutations-core.js +23 -55
- package/dist/triage/scope-drift/add-ignore.js +5 -39
- package/dist/triage/subscribe/index.d.ts +2 -6
- package/dist/triage/subscribe/index.js +15 -65
- package/dist/triage/welcome/writers.js +15 -74
- package/dist/umbrella-current-shape/index.d.ts +51 -3
- package/dist/umbrella-current-shape/index.js +106 -18
- package/dist/validate-content/deposit-required.d.ts +39 -0
- package/dist/validate-content/deposit-required.js +147 -0
- package/dist/validate-content/index.d.ts +1 -0
- package/dist/validate-content/index.js +1 -0
- package/dist/validate-content/validate-links.d.ts +2 -3
- package/dist/validate-content/validate-links.js +29 -2
- package/dist/vbrief-activate/activate.d.ts +7 -2
- package/dist/vbrief-activate/activate.js +26 -13
- package/dist/vbrief-build/index.d.ts +2 -1
- package/dist/vbrief-build/index.js +2 -1
- package/dist/vbrief-build/parity-scenarios.js +5 -5
- package/dist/vbrief-build/project-definition-io.d.ts +63 -3
- package/dist/vbrief-build/project-definition-io.js +357 -31
- package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
- package/dist/vbrief-build/project-definition-mutation.js +43 -0
- package/dist/vbrief-validate/conformance.d.ts +1 -0
- package/dist/vbrief-validate/conformance.js +85 -8
- package/dist/vbrief-validate/main.js +2 -0
- package/dist/vbrief-validate/precutover.js +5 -10
- package/dist/verify-ac/clauses.d.ts +62 -2
- package/dist/verify-ac/clauses.js +183 -111
- package/dist/verify-ac/evaluate.d.ts +9 -0
- package/dist/verify-ac/evaluate.js +32 -9
- package/dist/verify-ac/index.d.ts +1 -1
- package/dist/verify-ac/index.js +1 -1
- package/dist/verify-env/node-runtime.d.ts +8 -4
- package/dist/verify-env/node-runtime.js +9 -6
- package/dist/verify-env/toolchain-check.d.ts +35 -3
- package/dist/verify-env/toolchain-check.js +155 -36
- package/dist/verify-source/deposit-closure.d.ts +23 -0
- package/dist/verify-source/deposit-closure.js +162 -0
- package/dist/verify-source/index.d.ts +2 -0
- package/dist/verify-source/index.js +2 -0
- package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
- package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
- package/dist/verify-source/semantic-single-source.d.ts +36 -0
- package/dist/verify-source/semantic-single-source.js +349 -0
- package/package.json +7 -3
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { appendAuthzAudit, classifyHookAuthzOps, evaluateAuthzMutation, evidenceSatisfiesImplementationApproval, listActiveHumanGrants, loadAuthzStateResult, markGrantUsed, shouldConsumeSingleUseGrant, utcIso, } from "../authz/index.js";
|
|
4
|
+
import { runningInsideDeftRepo } from "../doctor/paths.js";
|
|
4
5
|
import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
|
|
5
6
|
import { hasArtifactSuffix } from "../layout/resolve.js";
|
|
6
7
|
import { detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
|
|
@@ -9,21 +10,23 @@ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRE
|
|
|
9
10
|
import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
|
|
10
11
|
import { loadStoryWriteFenceFromPath, resolveWriteFence } from "../policy/write-fence.js";
|
|
11
12
|
import { appendSoftAgentsRebindToMessage, decisionCarriesSoftAgentsRebind, formatSoftAgentsRebindChecklist, } from "../session/compact-ritual.js";
|
|
12
|
-
import { detectBranch } from "../session/git.js";
|
|
13
|
+
import { defaultGitRunner, detectBranch, existingAncestorDir, gitCommonDir, memoizeGitRunner, worktreePathOrNull, } from "../session/git.js";
|
|
13
14
|
import { evaluateOccupancyWriteGate } from "../session/occupancy.js";
|
|
14
15
|
import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js";
|
|
15
16
|
import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
|
|
16
17
|
import { runSessionStartHookWrite } from "../session/session-start-hook.js";
|
|
17
|
-
import { formatRitualRecoveryInstruction, verifySessionRitual, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
|
|
18
|
-
import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
|
|
19
|
-
import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
|
|
18
|
+
import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.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";
|
|
20
22
|
import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
21
23
|
import { inspectActiveScope } from "./scope.js";
|
|
24
|
+
import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
|
|
22
25
|
import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
|
|
23
26
|
// Pure parse/classify helpers are defined in ./classify/ and re-exported from
|
|
24
27
|
// ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
|
|
25
28
|
export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
26
|
-
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";
|
|
27
30
|
export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
|
|
28
31
|
export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
|
|
29
32
|
/** Hosts that receive compact/resume hook deposits via init/update (#2113). */
|
|
@@ -103,6 +106,66 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
|
|
|
103
106
|
return false;
|
|
104
107
|
return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
|
|
105
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
|
+
}
|
|
106
169
|
/**
|
|
107
170
|
* Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
|
|
108
171
|
* Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
|
|
@@ -193,8 +256,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
|
|
|
193
256
|
return true;
|
|
194
257
|
return false;
|
|
195
258
|
}
|
|
196
|
-
function isWindowsDriveOnlyRoot(value) {
|
|
197
|
-
return /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
259
|
+
function isWindowsDriveOnlyRoot(value, platform = process.platform) {
|
|
260
|
+
return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
198
261
|
}
|
|
199
262
|
function hookPayloadRootCandidates(input) {
|
|
200
263
|
const candidates = [];
|
|
@@ -250,22 +313,113 @@ export function projectRootFromHookPayload(payload, fallback) {
|
|
|
250
313
|
}
|
|
251
314
|
return fallbackResolved;
|
|
252
315
|
}
|
|
316
|
+
/** Interpolate both roots so a deny names the tree that was judged (#3794). */
|
|
317
|
+
export function formatHookRootNote(payloadRoot, effectiveRoot) {
|
|
318
|
+
return `payloadRoot=${payloadRoot} effectiveRoot=${effectiveRoot}`;
|
|
319
|
+
}
|
|
320
|
+
function admitPayload(payload, candidate) {
|
|
321
|
+
return { root: payload, foreign: false, candidate, refusal: null };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Admit the Git toplevel of the write target only when it shares
|
|
325
|
+
* `--git-common-dir` with `payloadRoot`.
|
|
326
|
+
*
|
|
327
|
+
* Identity resolves to one of three outcomes, not two. A `payloadRoot` that is
|
|
328
|
+
* not a Git repository never posed a containment question, so it keeps today's
|
|
329
|
+
* payloadRoot gating. A `payloadRoot` that *is* a repository, paired with a
|
|
330
|
+
* target that resolved to some other toplevel whose identity cannot be read, is
|
|
331
|
+
* a question asked and left unanswered — that fails closed rather than
|
|
332
|
+
* inheriting payloadRoot's occupancy and ritual state (#3794).
|
|
333
|
+
*/
|
|
334
|
+
export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
|
|
335
|
+
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
336
|
+
if (writeTarget === null)
|
|
337
|
+
return admitPayload(payload, null);
|
|
338
|
+
const ancestor = existingAncestorDir(writeTarget);
|
|
339
|
+
if (ancestor === null)
|
|
340
|
+
return admitPayload(payload, null);
|
|
341
|
+
const candidateRaw = worktreePathOrNull(ancestor, runGit);
|
|
342
|
+
if (candidateRaw === null)
|
|
343
|
+
return admitPayload(payload, null);
|
|
344
|
+
const candidate = normalizeHookProjectRoot(candidateRaw);
|
|
345
|
+
if (candidate === payload)
|
|
346
|
+
return admitPayload(payload, candidate);
|
|
347
|
+
const payloadCommon = gitCommonDir(payload, runGit);
|
|
348
|
+
if (payloadCommon === null) {
|
|
349
|
+
// No containment question exists: payloadRoot is not a repository, so there
|
|
350
|
+
// is no boundary for the target to be inside or outside of. Denying here
|
|
351
|
+
// would invent a class this defect never had.
|
|
352
|
+
return admitPayload(payload, candidate);
|
|
353
|
+
}
|
|
354
|
+
const candidateCommon = gitCommonDir(candidate, runGit);
|
|
355
|
+
if (candidateCommon === null) {
|
|
356
|
+
return { root: payload, foreign: true, candidate, refusal: "unproven-identity" };
|
|
357
|
+
}
|
|
358
|
+
if (normalizeHookProjectRoot(payloadCommon) === normalizeHookProjectRoot(candidateCommon)) {
|
|
359
|
+
return { root: candidate, foreign: false, candidate, refusal: null };
|
|
360
|
+
}
|
|
361
|
+
return { root: payload, foreign: true, candidate, refusal: "foreign-repository" };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Admit every mutation target and require one effectiveRoot. A refused member
|
|
365
|
+
* short-circuits; a span of two admitted worktrees is also refused so
|
|
366
|
+
* occupancy/ritual cannot follow only the declared ApplyPatch path (#3794).
|
|
367
|
+
*/
|
|
368
|
+
export function admitMutationTargetSet(payloadRoot, targets, runGit) {
|
|
369
|
+
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
370
|
+
if (targets.length === 0)
|
|
371
|
+
return admitPayload(payload, null);
|
|
372
|
+
const admitted = [];
|
|
373
|
+
for (const target of targets) {
|
|
374
|
+
const next = admitEffectiveHookRoot(payload, target, runGit);
|
|
375
|
+
if (next.foreign)
|
|
376
|
+
return next;
|
|
377
|
+
admitted.push(next);
|
|
378
|
+
}
|
|
379
|
+
const roots = [...new Set(admitted.map((item) => item.root))];
|
|
380
|
+
if (roots.length > 1) {
|
|
381
|
+
return {
|
|
382
|
+
root: payload,
|
|
383
|
+
foreign: true,
|
|
384
|
+
candidate: roots.join(" | "),
|
|
385
|
+
refusal: "worktree-span",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return admitted[0] ?? admitPayload(payload, null);
|
|
389
|
+
}
|
|
253
390
|
export function isHookHost(value) {
|
|
254
391
|
return HOOK_HOSTS.includes(value);
|
|
255
392
|
}
|
|
256
393
|
export function isHookEvent(value) {
|
|
257
394
|
return HOOK_EVENTS.includes(value);
|
|
258
395
|
}
|
|
259
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Coverage sentence appended to a scope-not-ready deny.
|
|
398
|
+
*
|
|
399
|
+
* #3828: this used to claim Shell dest-form coverage unconditionally. Coverage
|
|
400
|
+
* is opt-in (`plan.policy.runtimeAuthority.shellDestForms: "enforce"`) and the
|
|
401
|
+
* default is `off`, so on a default-policy repository the sentence advertised a
|
|
402
|
+
* fence that was not there and, by naming `python -c` as the exception, implied
|
|
403
|
+
* the gap was a sliver rather than the whole Shell surface. State the coverage
|
|
404
|
+
* actually in force.
|
|
405
|
+
*/
|
|
406
|
+
function scopeNotReadyCoverageHint(toolName, shellDestFormsEnforced) {
|
|
260
407
|
if (isSpawnTool(toolName))
|
|
261
408
|
return "";
|
|
262
409
|
if (isShellTool(toolName)) {
|
|
263
410
|
return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
|
|
264
411
|
"(git checkout --, git restore, rm/rmdir of in-repo paths).");
|
|
265
412
|
}
|
|
266
|
-
|
|
267
|
-
"
|
|
268
|
-
|
|
413
|
+
if (shellDestFormsEnforced) {
|
|
414
|
+
return (" This gate covers direct-write tools (Edit/Write), implementation spawn, and " +
|
|
415
|
+
"recognized Shell dest-forms (git checkout --, git restore, rm/rmdir), because " +
|
|
416
|
+
"plan.policy.runtimeAuthority.shellDestForms is enforce. Shell whose target " +
|
|
417
|
+
"cannot be resolved to a product path stays outside this deny.");
|
|
418
|
+
}
|
|
419
|
+
return (" This gate covers direct-write tools (Edit/Write) and implementation spawn. " +
|
|
420
|
+
"plan.policy.runtimeAuthority.shellDestForms is off (the default), so no Shell " +
|
|
421
|
+
"command is covered by this deny -- reaching for one to get around it defeats " +
|
|
422
|
+
"the gate rather than satisfying it.");
|
|
269
423
|
}
|
|
270
424
|
function deny(input, code, toolName, message, scopePath = null) {
|
|
271
425
|
return {
|
|
@@ -279,8 +433,56 @@ function deny(input, code, toolName, message, scopePath = null) {
|
|
|
279
433
|
scopePath,
|
|
280
434
|
};
|
|
281
435
|
}
|
|
282
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Resolve the cooperative actor presented to the occupancy gate.
|
|
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).
|
|
441
|
+
*/
|
|
442
|
+
function resolveMutationActor(input, environ) {
|
|
443
|
+
const hostIdentity = resolveHookHostIdentity(input.host, input.payload, environ);
|
|
444
|
+
const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
|
|
445
|
+
if (hostIdentity.status === "unsupported" ||
|
|
446
|
+
hostIdentityFallsBackToExplicitOwner(input.host, hostIdentity)) {
|
|
447
|
+
return {
|
|
448
|
+
sessionId: environmentId,
|
|
449
|
+
issue: null,
|
|
450
|
+
message: null,
|
|
451
|
+
hostAuthoritative: false,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
|
|
455
|
+
if (environmentId !== undefined && environmentId !== hostIdentity.sessionId) {
|
|
456
|
+
return {
|
|
457
|
+
sessionId: hostIdentity.sessionId,
|
|
458
|
+
issue: "conflict",
|
|
459
|
+
message: `Host owner ${hostIdentity.sessionId} conflicts with ` +
|
|
460
|
+
`DEFT_SESSION_ID ${environmentId}.`,
|
|
461
|
+
hostAuthoritative: true,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
sessionId: hostIdentity.sessionId,
|
|
466
|
+
issue: null,
|
|
467
|
+
message: null,
|
|
468
|
+
hostAuthoritative: true,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
sessionId: undefined,
|
|
473
|
+
issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
|
|
474
|
+
message: hostIdentity.message,
|
|
475
|
+
hostAuthoritative: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
|
|
479
|
+
// #3794 commit 2: the project runtimeAuthority layer is project-level
|
|
480
|
+
// authorization config and stays on payloadRoot, alongside authz. Only the
|
|
481
|
+
// story fence and the path it is matched against follow the tree the write
|
|
482
|
+
// lands in, so a story `file_scope` of `packages/**` still matches a write
|
|
483
|
+
// inside a linked worktree rather than `.deft-scratch/worktrees/<x>/packages/`.
|
|
283
484
|
const projectRoot = resolve(input.projectRoot);
|
|
485
|
+
const fenceRoot = resolve(effectiveRoot);
|
|
284
486
|
// Project policy load failure: treat as disabled project layer, still apply
|
|
285
487
|
// independent story file_scope when present (#516 / #2443 Greptile P1).
|
|
286
488
|
let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
|
|
@@ -296,7 +498,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
296
498
|
let storyFence;
|
|
297
499
|
try {
|
|
298
500
|
storyFence = seams.loadStoryWriteFence
|
|
299
|
-
? seams.loadStoryWriteFence(
|
|
501
|
+
? seams.loadStoryWriteFence(fenceRoot, scopePath)
|
|
300
502
|
: loadStoryWriteFenceFromPath(scopePath);
|
|
301
503
|
}
|
|
302
504
|
catch {
|
|
@@ -309,15 +511,28 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
309
511
|
// Neither layer active → allow (same as disabled runtimeAuthority historically).
|
|
310
512
|
if (!fence.fenceActive)
|
|
311
513
|
return null;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
514
|
+
// #3794: relPath is now measured from the tree the write lands in. When that
|
|
515
|
+
// differs from the payload root, a bare "outside file_scope" reason does not
|
|
516
|
+
// say which tree the fence was relativised against.
|
|
517
|
+
const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
|
|
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;
|
|
321
536
|
}
|
|
322
537
|
function loadAuthzContext(projectRoot, seams) {
|
|
323
538
|
if (seams.loadAuthzState !== undefined) {
|
|
@@ -380,6 +595,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
|
|
|
380
595
|
* Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
|
|
381
596
|
*/
|
|
382
597
|
function authzForMutation(input, toolName, seams, options) {
|
|
598
|
+
// #3794: grant scoping and the authz audit trail stay on payloadRoot.
|
|
599
|
+
// Re-pointing would break exact-string UAT grants and shard the audit file.
|
|
383
600
|
const projectRoot = resolve(input.projectRoot);
|
|
384
601
|
let state;
|
|
385
602
|
let grants;
|
|
@@ -413,7 +630,7 @@ function authzForMutation(input, toolName, seams, options) {
|
|
|
413
630
|
// Structural context for bound grants (branch from git; repo optional env).
|
|
414
631
|
let branch = null;
|
|
415
632
|
try {
|
|
416
|
-
branch = detectBranch(projectRoot);
|
|
633
|
+
branch = detectBranch(projectRoot, options.runGit);
|
|
417
634
|
}
|
|
418
635
|
catch {
|
|
419
636
|
branch = null;
|
|
@@ -436,12 +653,17 @@ function authzForMutation(input, toolName, seams, options) {
|
|
|
436
653
|
}
|
|
437
654
|
// Consume single-use grants after allow (persist usedAt).
|
|
438
655
|
if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
|
|
439
|
-
|
|
440
|
-
|
|
656
|
+
if (options.consumeSingleUse === false) {
|
|
657
|
+
options.pendingConsume?.push(decision.humanApprovalRef);
|
|
441
658
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
}
|
|
445
667
|
}
|
|
446
668
|
}
|
|
447
669
|
}
|
|
@@ -552,23 +774,48 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
|
|
|
552
774
|
};
|
|
553
775
|
}
|
|
554
776
|
function inspectMutationGates(input, toolName, seams, options) {
|
|
555
|
-
|
|
777
|
+
// payloadRoot stays authoritative for authz grant scoping, the authz audit
|
|
778
|
+
// trail, the kill-switch, the #2885 outside-root carve-out, and
|
|
779
|
+
// deny().projectRoot. effectiveRoot governs occupancy and ritual (commit 1),
|
|
780
|
+
// and active scope, the story write fence and assist-scratch (commit 2).
|
|
781
|
+
const payloadRoot = resolve(input.projectRoot);
|
|
782
|
+
const projectRoot = payloadRoot;
|
|
556
783
|
const environ = input.environ ?? process.env;
|
|
784
|
+
const dispatchGit = memoizeGitRunner(seams.ritualRunGit ?? defaultGitRunner);
|
|
785
|
+
const mutationTargets = isSpawnTool(toolName) ? [] : hookMutationTargetPaths(input.payload);
|
|
786
|
+
const admission = isSpawnTool(toolName)
|
|
787
|
+
? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
|
|
788
|
+
: admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
|
|
789
|
+
const effectiveRoot = admission.root;
|
|
790
|
+
const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
|
|
791
|
+
if (admission.foreign) {
|
|
792
|
+
const candidate = admission.candidate ?? "<none>";
|
|
793
|
+
const reason = admission.refusal === "worktree-span"
|
|
794
|
+
? `write targets span more than one Git worktree (candidate=${candidate}).`
|
|
795
|
+
: admission.refusal === "unproven-identity"
|
|
796
|
+
? `write target resolved to a separate Git working tree whose repository ` +
|
|
797
|
+
`identity could not be read, so containment is unproven (candidate=${candidate}).`
|
|
798
|
+
: `write target is in a different Git repository ` +
|
|
799
|
+
`than the hook payload root (candidate=${candidate}).`;
|
|
800
|
+
return deny(input, "foreign-repository-deny", toolName, `Directive denied ${toolName}: ${reason}${rootsNote}`);
|
|
801
|
+
}
|
|
557
802
|
// Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
|
|
558
803
|
// assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
|
|
559
804
|
// Tracked product paths never match the path fence. Read-only still denied upstream.
|
|
560
805
|
if (!isSpawnTool(toolName)) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
806
|
+
// #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
|
|
807
|
+
// file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
|
|
808
|
+
// assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
|
|
809
|
+
if (allMutationTargetsAreAssistScratch(effectiveRoot, input.payload, environ)) {
|
|
810
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
565
811
|
isDirectWrite: true,
|
|
566
|
-
relPath,
|
|
567
812
|
scopePath: null,
|
|
813
|
+
runGit: dispatchGit,
|
|
814
|
+
projectRoot,
|
|
568
815
|
});
|
|
569
816
|
if (authzDeny !== null)
|
|
570
817
|
return authzDeny;
|
|
571
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
818
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
572
819
|
if (runtimeDeny !== null)
|
|
573
820
|
return runtimeDeny;
|
|
574
821
|
return {
|
|
@@ -586,6 +833,72 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
586
833
|
};
|
|
587
834
|
}
|
|
588
835
|
}
|
|
836
|
+
const actor = isSpawnTool(toolName) ? null : resolveMutationActor(input, environ);
|
|
837
|
+
const occupancyGate = isSpawnTool(toolName)
|
|
838
|
+
? {
|
|
839
|
+
allow: true,
|
|
840
|
+
message: null,
|
|
841
|
+
occupant: null,
|
|
842
|
+
admitted: null,
|
|
843
|
+
}
|
|
844
|
+
: evaluateOccupancyWriteGate(effectiveRoot, {
|
|
845
|
+
sessionId: actor?.sessionId,
|
|
846
|
+
// Hosts with a resolved owner must not fall back to a stale ambient one.
|
|
847
|
+
env: actor?.hostAuthoritative === true ? {} : environ,
|
|
848
|
+
});
|
|
849
|
+
// #3769: occupancy decides before any ritual persist. The gated verifier
|
|
850
|
+
// executes agent_hooks and rebinds a forward HEAD, and both rewrite
|
|
851
|
+
// .deft/ritual-state.json — so verifying first let a session this gate was
|
|
852
|
+
// about to deny rewrite the occupant's record. The deny paths below read the
|
|
853
|
+
// ritual through the no-write inspect, which runs no step and never rebinds;
|
|
854
|
+
// only a session occupancy would admit reaches the verifier.
|
|
855
|
+
//
|
|
856
|
+
// Ritual detail on an occupancy deny is telemetry and message decoration, so
|
|
857
|
+
// an inspect failure must not convert an occupancy verdict into a ritual one.
|
|
858
|
+
const ritualDetailForOccupancyDeny = () => {
|
|
859
|
+
try {
|
|
860
|
+
return (seams.inspectRitual ??
|
|
861
|
+
seams.verifyRitual ??
|
|
862
|
+
((root) => inspectSessionRitual(root, {
|
|
863
|
+
tier: "gated",
|
|
864
|
+
posture: "mutation",
|
|
865
|
+
runGit: dispatchGit,
|
|
866
|
+
requiredGatedSteps: WRITE_GATED_REQUIRED_STEPS,
|
|
867
|
+
})))(effectiveRoot);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
if (occupancyGate.occupant !== null && actor?.issue !== null && actor !== null) {
|
|
874
|
+
const inspected = ritualDetailForOccupancyDeny();
|
|
875
|
+
const code = actor.issue === "conflict" ? "occupancy-identity-conflict" : "occupancy-identity-unavailable";
|
|
876
|
+
const detail = actor.message ??
|
|
877
|
+
"The host did not supply a usable cooperative session/conversation identity.";
|
|
878
|
+
emitSessionRitualBlockedProcessCost({
|
|
879
|
+
toolName,
|
|
880
|
+
code,
|
|
881
|
+
recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
882
|
+
detail,
|
|
883
|
+
}, { projectRoot });
|
|
884
|
+
return deny(input, code, toolName, `Directive denied ${toolName}: ${detail} A live occupancy lease exists for ` +
|
|
885
|
+
`session ${occupancyGate.occupant.sessionId}; use an exact host-mediated lifecycle ` +
|
|
886
|
+
"command or pass the matching --session-id explicitly." +
|
|
887
|
+
rootsNote);
|
|
888
|
+
}
|
|
889
|
+
if (!occupancyGate.allow && occupancyGate.message !== null) {
|
|
890
|
+
const inspected = ritualDetailForOccupancyDeny();
|
|
891
|
+
const ritualNote = inspected !== null && inspected.code !== 0
|
|
892
|
+
? ` Also ritual-not-ready: ${inspected.message}`
|
|
893
|
+
: "";
|
|
894
|
+
emitSessionRitualBlockedProcessCost({
|
|
895
|
+
toolName,
|
|
896
|
+
code: "occupancy-occupied",
|
|
897
|
+
recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
898
|
+
detail: occupancyGate.message,
|
|
899
|
+
}, { projectRoot });
|
|
900
|
+
return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}${rootsNote}`);
|
|
901
|
+
}
|
|
589
902
|
let ritual;
|
|
590
903
|
try {
|
|
591
904
|
// Mutation dispatch is a live gated boundary: active verification reruns
|
|
@@ -600,8 +913,8 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
600
913
|
bypass: false,
|
|
601
914
|
runner: seams.ritualRunner,
|
|
602
915
|
detectWorkSelection: seams.detectWorkSelection,
|
|
603
|
-
runGit:
|
|
604
|
-
}))))(
|
|
916
|
+
runGit: dispatchGit,
|
|
917
|
+
}))))(effectiveRoot);
|
|
605
918
|
}
|
|
606
919
|
catch (cause) {
|
|
607
920
|
// #2994: best-effort local process-cost; never changes deny verdict.
|
|
@@ -612,20 +925,36 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
612
925
|
detail: `inspect threw: ${String(cause)}`,
|
|
613
926
|
}, { projectRoot });
|
|
614
927
|
return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
|
|
615
|
-
formatRitualRecoveryInstruction("cold")
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
928
|
+
formatRitualRecoveryInstruction("cold") +
|
|
929
|
+
rootsNote);
|
|
930
|
+
}
|
|
931
|
+
if (occupancyGate.occupant !== null && actor !== null) {
|
|
932
|
+
// A granted member writes under the owner's ceremony (#3755). Ritual state
|
|
933
|
+
// is single-owner and a child cannot bind its own while the owner's lease is
|
|
934
|
+
// live, so requiring the writer's own binding would make every admitted
|
|
935
|
+
// member's write unreachable — the grant would authorize nothing. What is
|
|
936
|
+
// checked instead is the identity that issued the grant: the tree's exact
|
|
937
|
+
// verified ritual owner must be the occupant whose lease admits this member.
|
|
938
|
+
const expectedOwner = occupancyGate.admitted === "member" ? occupancyGate.occupant.sessionId : actor.sessionId;
|
|
939
|
+
const verifiedOwner = ritual.boundSessionId;
|
|
940
|
+
const ritualOwnerMismatch = expectedOwner !== undefined &&
|
|
941
|
+
(verifiedOwner !== undefined ? verifiedOwner !== expectedOwner : ritual.code === 0);
|
|
942
|
+
if (ritualOwnerMismatch) {
|
|
943
|
+
const detail = verifiedOwner === undefined
|
|
944
|
+
? "gated ritual verification returned ready without an exact bound owner"
|
|
945
|
+
: `exact verified ritual owner ${verifiedOwner} differs from host/lease owner ${expectedOwner}`;
|
|
946
|
+
emitSessionRitualBlockedProcessCost({
|
|
947
|
+
toolName,
|
|
948
|
+
code: "occupancy-ritual-mismatch",
|
|
949
|
+
recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
950
|
+
detail,
|
|
951
|
+
}, { projectRoot });
|
|
952
|
+
return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: ${detail}. ` +
|
|
953
|
+
"Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
|
|
954
|
+
"otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony. " +
|
|
955
|
+
"Intermediate lease/ritual mismatches fail closed." +
|
|
956
|
+
rootsNote);
|
|
957
|
+
}
|
|
629
958
|
}
|
|
630
959
|
if (ritual.code !== 0) {
|
|
631
960
|
// #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
|
|
@@ -637,23 +966,73 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
637
966
|
recoveryTier,
|
|
638
967
|
detail: ritual.message,
|
|
639
968
|
}, { projectRoot });
|
|
640
|
-
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`
|
|
641
|
-
|
|
969
|
+
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
|
|
970
|
+
rootsNote);
|
|
971
|
+
}
|
|
972
|
+
// Narrow the cooperative file-gate race: authorization/scope checks can
|
|
973
|
+
// take long enough for another session to transition the lease. Re-read
|
|
974
|
+
// only occupancy immediately before each direct-write allow, while retaining
|
|
975
|
+
// the exact ritual owner above (never adopt a later ritual file here).
|
|
976
|
+
// #3599: the owner's lease is only kept alive here, on the last evaluation
|
|
977
|
+
// before an allowed write, so the stamp records a write that really happened.
|
|
978
|
+
let occupancyWarning = null;
|
|
979
|
+
const recheckOccupancyBeforeWriteAllow = () => {
|
|
980
|
+
if (actor === null)
|
|
981
|
+
return null;
|
|
982
|
+
const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
|
|
983
|
+
sessionId: actor.sessionId,
|
|
984
|
+
env: actor.hostAuthoritative ? {} : environ,
|
|
985
|
+
refresh: true,
|
|
986
|
+
});
|
|
987
|
+
occupancyWarning = finalOccupancy.warning;
|
|
988
|
+
if (finalOccupancy.occupant !== null && actor.issue !== null) {
|
|
989
|
+
return deny(input, actor.issue === "conflict"
|
|
990
|
+
? "occupancy-identity-conflict"
|
|
991
|
+
: "occupancy-identity-unavailable", toolName, `Directive denied ${toolName}: the live occupancy owner changed while mutation ` +
|
|
992
|
+
"gates were running, and this hook has no unambiguous matching actor identity." +
|
|
993
|
+
rootsNote);
|
|
994
|
+
}
|
|
995
|
+
if (!finalOccupancy.allow && finalOccupancy.message !== null) {
|
|
996
|
+
return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: occupancy changed while mutation gates were running. ` +
|
|
997
|
+
finalOccupancy.message +
|
|
998
|
+
rootsNote);
|
|
999
|
+
}
|
|
1000
|
+
// Same member rule as the pre-gate check above (#3755): an admitted member
|
|
1001
|
+
// is measured against the occupant that granted it, not against itself.
|
|
1002
|
+
const finalExpectedOwner = finalOccupancy.admitted === "member" && finalOccupancy.occupant !== null
|
|
1003
|
+
? finalOccupancy.occupant.sessionId
|
|
1004
|
+
: actor.sessionId;
|
|
1005
|
+
if (finalOccupancy.occupant !== null &&
|
|
1006
|
+
finalExpectedOwner !== undefined &&
|
|
1007
|
+
ritual.boundSessionId !== finalExpectedOwner) {
|
|
1008
|
+
return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: final lease owner ${finalExpectedOwner} does not match ` +
|
|
1009
|
+
`the exact verified ritual owner ${ritual.boundSessionId ?? "<unbound>"}. ` +
|
|
1010
|
+
"Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
|
|
1011
|
+
"otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony." +
|
|
1012
|
+
rootsNote);
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
};
|
|
1016
|
+
const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
|
|
642
1017
|
if (options.proposedLifecycleExempt) {
|
|
643
|
-
|
|
644
|
-
if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
|
|
645
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
1018
|
+
if (allMutationTargetsAreProposedLifecycleWrites(effectiveRoot, input.payload)) {
|
|
646
1019
|
// UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
|
|
647
|
-
|
|
1020
|
+
// Authz and the write fence run on every mutated path, not the declared
|
|
1021
|
+
// one (#3614).
|
|
1022
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
648
1023
|
isDirectWrite: true,
|
|
649
|
-
relPath,
|
|
650
1024
|
scopePath: null,
|
|
1025
|
+
runGit: dispatchGit,
|
|
1026
|
+
projectRoot,
|
|
651
1027
|
});
|
|
652
1028
|
if (authzDeny !== null)
|
|
653
1029
|
return authzDeny;
|
|
654
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
1030
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
655
1031
|
if (runtimeDeny !== null)
|
|
656
1032
|
return runtimeDeny;
|
|
1033
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
1034
|
+
if (occupancyDeny !== null)
|
|
1035
|
+
return occupancyDeny;
|
|
657
1036
|
return {
|
|
658
1037
|
verdict: "allow",
|
|
659
1038
|
code: "write-propose-ready",
|
|
@@ -661,27 +1040,36 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
661
1040
|
host: input.host,
|
|
662
1041
|
toolName,
|
|
663
1042
|
projectRoot,
|
|
664
|
-
message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
|
|
665
|
-
"(planning write; active scope not required).",
|
|
1043
|
+
message: withOccupancyWarning(`Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
|
|
1044
|
+
"(planning write; active scope not required)."),
|
|
666
1045
|
scopePath: null,
|
|
667
1046
|
};
|
|
668
1047
|
}
|
|
669
1048
|
}
|
|
670
1049
|
let scope;
|
|
671
1050
|
try {
|
|
672
|
-
scope
|
|
1051
|
+
// #3794 commit 2: a write is governed by the active scope of the worktree it
|
|
1052
|
+
// lands in, not the primary checkout's. Admission has already proved
|
|
1053
|
+
// effectiveRoot shares --git-common-dir with payloadRoot.
|
|
1054
|
+
scope = (seams.inspectScope ?? inspectActiveScope)(effectiveRoot);
|
|
673
1055
|
}
|
|
674
1056
|
catch (cause) {
|
|
675
1057
|
scope = { ready: false, path: null, message: String(cause) };
|
|
676
1058
|
}
|
|
677
1059
|
if (!scope.ready) {
|
|
678
1060
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
679
|
-
const relTarget = writeTarget !== null ? toProjectRelativePosix(
|
|
1061
|
+
const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
|
|
680
1062
|
// Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
|
|
681
1063
|
// (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
|
|
682
1064
|
// targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
|
|
683
1065
|
// Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
|
|
684
|
-
|
|
1066
|
+
// #3794: the #2885 carve-out is measured from payloadRoot, so a linked
|
|
1067
|
+
// worktree that lives outside the payload root keeps today's skip. Worktrees
|
|
1068
|
+
// under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
|
|
1069
|
+
// are inside it and are now gated on their own active scope.
|
|
1070
|
+
const mutationTargets = hookMutationTargetPaths(input.payload);
|
|
1071
|
+
const outsideRoot = mutationTargets.length > 0 &&
|
|
1072
|
+
mutationTargets.every((target) => isOutsideProjectRootWrite(projectRoot, target));
|
|
685
1073
|
if (!outsideRoot || isSpawnTool(toolName)) {
|
|
686
1074
|
let proposedPathHint;
|
|
687
1075
|
if (isSpawnTool(toolName)) {
|
|
@@ -720,8 +1108,17 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
720
1108
|
"assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
|
|
721
1109
|
}
|
|
722
1110
|
const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
|
|
1111
|
+
// #3794: the recovery has to run in the tree whose active scope was read.
|
|
1112
|
+
// Once the two roots differ, "the project root" names no single directory
|
|
1113
|
+
// the agent can act on, and activating in the payload root does not clear
|
|
1114
|
+
// a deny measured against the worktree.
|
|
1115
|
+
const recoveryRootNote = resolve(effectiveRoot) === payloadRoot
|
|
1116
|
+
? ""
|
|
1117
|
+
: ` Active scope was read from ${effectiveRoot}, so run that recovery there rather than in ${payloadRoot}.`;
|
|
723
1118
|
return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
|
|
724
|
-
scopeNotReadyCoverageHint(toolName))
|
|
1119
|
+
scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
|
|
1120
|
+
recoveryRootNote +
|
|
1121
|
+
rootsNote);
|
|
725
1122
|
}
|
|
726
1123
|
}
|
|
727
1124
|
const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
|
|
@@ -733,19 +1130,21 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
733
1130
|
}
|
|
734
1131
|
}
|
|
735
1132
|
if (!isSpawnTool(toolName)) {
|
|
736
|
-
const writeTarget = hookWriteTargetPath(input.payload);
|
|
737
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
738
1133
|
// Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
|
|
739
|
-
const authzDeny =
|
|
1134
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
740
1135
|
isDirectWrite: true,
|
|
741
|
-
relPath,
|
|
742
1136
|
scopePath: scope.path,
|
|
1137
|
+
runGit: dispatchGit,
|
|
1138
|
+
projectRoot,
|
|
743
1139
|
});
|
|
744
1140
|
if (authzDeny !== null)
|
|
745
1141
|
return authzDeny;
|
|
746
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
|
|
1142
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
|
|
747
1143
|
if (runtimeDeny !== null)
|
|
748
1144
|
return runtimeDeny;
|
|
1145
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
1146
|
+
if (occupancyDeny !== null)
|
|
1147
|
+
return occupancyDeny;
|
|
749
1148
|
}
|
|
750
1149
|
return {
|
|
751
1150
|
verdict: "allow",
|
|
@@ -754,10 +1153,104 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
754
1153
|
host: input.host,
|
|
755
1154
|
toolName,
|
|
756
1155
|
projectRoot,
|
|
757
|
-
message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}
|
|
1156
|
+
message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
|
|
758
1157
|
scopePath: scope.path,
|
|
759
1158
|
};
|
|
760
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
|
+
}
|
|
761
1254
|
/**
|
|
762
1255
|
* Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
|
|
763
1256
|
* Dest-form allow is kept when runtime authority has nothing classifiable.
|
|
@@ -810,6 +1303,166 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
|
810
1303
|
}
|
|
811
1304
|
return runtime;
|
|
812
1305
|
}
|
|
1306
|
+
const LIFECYCLE_EXECUTION_DIRECTORY_FIELDS = [
|
|
1307
|
+
"cwd",
|
|
1308
|
+
"workdir",
|
|
1309
|
+
"working_directory",
|
|
1310
|
+
"workingDirectory",
|
|
1311
|
+
];
|
|
1312
|
+
function sameExecutionDirectory(left, right) {
|
|
1313
|
+
if (process.platform === "win32")
|
|
1314
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
1315
|
+
return left === right;
|
|
1316
|
+
}
|
|
1317
|
+
function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
|
|
1318
|
+
const input = record(payload);
|
|
1319
|
+
if (input === null) {
|
|
1320
|
+
return { aligned: false, message: "the hook payload has no inspectable execution root" };
|
|
1321
|
+
}
|
|
1322
|
+
const expected = normalizeHookProjectRoot(projectRoot);
|
|
1323
|
+
const toolInput = toolInputRecord(input);
|
|
1324
|
+
const records = toolInput === null || toolInput === input ? [input] : [input, toolInput];
|
|
1325
|
+
for (const source of records) {
|
|
1326
|
+
for (const field of LIFECYCLE_EXECUTION_DIRECTORY_FIELDS) {
|
|
1327
|
+
if (!(field in source))
|
|
1328
|
+
continue;
|
|
1329
|
+
const raw = source[field];
|
|
1330
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
1331
|
+
return {
|
|
1332
|
+
aligned: false,
|
|
1333
|
+
message: `execution-directory field ${field} is missing or invalid`,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
const trimmed = raw.trim();
|
|
1337
|
+
// Cursor can report a drive-only cwd (for example `C:`) while the
|
|
1338
|
+
// explicit/workspace-derived project root remains usable (#2787). Keep
|
|
1339
|
+
// this fence aligned with projectRootFromHookPayload: a drive-only value
|
|
1340
|
+
// is not a concrete execution directory and must not override that root.
|
|
1341
|
+
if (isWindowsDriveOnlyRoot(trimmed, platform))
|
|
1342
|
+
continue;
|
|
1343
|
+
if (trimmed.replace(/\\/g, "/").split("/").includes("..")) {
|
|
1344
|
+
return {
|
|
1345
|
+
aligned: false,
|
|
1346
|
+
message: `execution-directory field ${field} contains parent traversal`,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
const candidate = isAbsolute(trimmed) ? trimmed : `${expected}${sep}${trimmed}`;
|
|
1350
|
+
let actual;
|
|
1351
|
+
let expectedReal;
|
|
1352
|
+
try {
|
|
1353
|
+
// Keep relative `..` segments intact until realpath resolution. Lexical
|
|
1354
|
+
// path.resolve would collapse `link/..` before observing that `link`
|
|
1355
|
+
// can be a symlink to a foreign tree.
|
|
1356
|
+
expectedReal = normalizeHookProjectRoot(realpath(expected));
|
|
1357
|
+
actual = normalizeHookProjectRoot(realpath(candidate));
|
|
1358
|
+
}
|
|
1359
|
+
catch (cause) {
|
|
1360
|
+
return {
|
|
1361
|
+
aligned: false,
|
|
1362
|
+
message: `execution-directory field ${field} could not be realpath-bound: ${String(cause)}`,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
if (!sameExecutionDirectory(actual, expectedReal)) {
|
|
1366
|
+
return {
|
|
1367
|
+
aligned: false,
|
|
1368
|
+
message: `execution-directory field ${field} resolves to ${actual}, not the hook project ` +
|
|
1369
|
+
`root ${expectedReal}`,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return { aligned: true, message: "execution directory is project-root aligned" };
|
|
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
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Add the claim-time owner only after the existing shell decision allowed the
|
|
1390
|
+
* command. The host wire format must emit `allow` with updated input, so this
|
|
1391
|
+
* path is intentionally restricted by rewriteExactLifecycleCommand.
|
|
1392
|
+
*/
|
|
1393
|
+
function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
1394
|
+
if (decision.verdict !== "allow")
|
|
1395
|
+
return decision;
|
|
1396
|
+
if (!hostAcceptsUpdatedInput(input.host))
|
|
1397
|
+
return decision;
|
|
1398
|
+
const lifecycle = inspectExactLifecycleCommand(input.payload);
|
|
1399
|
+
if (lifecycle === null)
|
|
1400
|
+
return decision;
|
|
1401
|
+
if (!lifecycle.requiresOwner)
|
|
1402
|
+
return decision;
|
|
1403
|
+
if (lifecycle.sessionIdStatus === "invalid") {
|
|
1404
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1405
|
+
"--session-id is empty, duplicated, or otherwise ambiguous.");
|
|
1406
|
+
}
|
|
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)) {
|
|
1413
|
+
return decision;
|
|
1414
|
+
}
|
|
1415
|
+
if (identity.status !== "ok" || identity.sessionId === null) {
|
|
1416
|
+
const code = identity.status === "conflict"
|
|
1417
|
+
? "occupancy-identity-conflict"
|
|
1418
|
+
: "occupancy-identity-unavailable";
|
|
1419
|
+
return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1420
|
+
`${identity.message ?? "host session identity is unavailable"}. ` +
|
|
1421
|
+
"Directive cannot bind the occupancy claim without a stable host owner; " +
|
|
1422
|
+
"manual callers must pass --session-id explicitly outside the host rewrite path.");
|
|
1423
|
+
}
|
|
1424
|
+
const environmentId = (input.environ ?? process.env).DEFT_SESSION_ID?.trim();
|
|
1425
|
+
if (environmentId !== undefined &&
|
|
1426
|
+
environmentId.length > 0 &&
|
|
1427
|
+
environmentId !== identity.sessionId) {
|
|
1428
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host owner ${identity.sessionId} conflicts with ` +
|
|
1429
|
+
`DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
|
|
1430
|
+
}
|
|
1431
|
+
if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
|
|
1432
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
|
|
1433
|
+
`${lifecycle.sessionId ?? "<missing>"}, but the host owner is ` +
|
|
1434
|
+
`${identity.sessionId}.`);
|
|
1435
|
+
}
|
|
1436
|
+
const sourceTask = !lifecycle.task ||
|
|
1437
|
+
(seams.runningInsideDeftRepo ?? runningInsideDeftRepo)(resolve(input.projectRoot));
|
|
1438
|
+
const executionRoot = lifecycleExecutionRootCheck(input.payload, input.projectRoot, seams.realpathLifecycleExecutionRoot ?? realpathSync, seams.lifecycleExecutionPlatform ?? process.platform);
|
|
1439
|
+
if (!executionRoot.aligned) {
|
|
1440
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${executionRoot.message}. ` +
|
|
1441
|
+
`Run the lifecycle command from ${normalizeHookProjectRoot(resolve(input.projectRoot))} ` +
|
|
1442
|
+
"so policy, lease, and ritual state target the same worktree.");
|
|
1443
|
+
}
|
|
1444
|
+
const rewriteAllowed = lifecycle.rewriteSafe && sourceTask;
|
|
1445
|
+
if (lifecycle.sessionIdStatus === "absent" && !rewriteAllowed) {
|
|
1446
|
+
let reason;
|
|
1447
|
+
if (lifecycle.task && !sourceTask) {
|
|
1448
|
+
reason = "Task lifecycle rewrites are only trusted in the Directive source repository";
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
reason = "this argument shape is outside the narrowly auto-approved rewrite surface";
|
|
1452
|
+
}
|
|
1453
|
+
return deny(input, "occupancy-identity-unavailable", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${reason}. ` +
|
|
1454
|
+
`Re-run the command with --session-id=${identity.sessionId}; Directive will verify ` +
|
|
1455
|
+
"that explicit owner without auto-approving or replacing the command.");
|
|
1456
|
+
}
|
|
1457
|
+
const rewrite = rewriteExactLifecycleCommand(input.payload, identity.sessionId);
|
|
1458
|
+
if (rewrite === null)
|
|
1459
|
+
return decision;
|
|
1460
|
+
if (rewrite.kind === "conflict") {
|
|
1461
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: ${rewrite.message} Host payload owner is ` +
|
|
1462
|
+
`${identity.sessionId}, but the command names ${rewrite.existingSessionId}.`);
|
|
1463
|
+
}
|
|
1464
|
+
return { ...decision, updatedInput: rewrite.updatedInput };
|
|
1465
|
+
}
|
|
813
1466
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
814
1467
|
export function decideHook(input, seams = {}) {
|
|
815
1468
|
const projectRoot = resolve(input.projectRoot);
|
|
@@ -817,6 +1470,8 @@ export function decideHook(input, seams = {}) {
|
|
|
817
1470
|
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
818
1471
|
// Tracked/committed flags do NOT bypass gates (repo-controlled content must
|
|
819
1472
|
// not disable enforcement for clones) — doctor warns instead.
|
|
1473
|
+
// #3794: the kill-switch is pinned to payloadRoot. The flag is untracked, so
|
|
1474
|
+
// linked worktrees do not carry it; re-pointing would silently re-arm.
|
|
820
1475
|
{
|
|
821
1476
|
const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
|
|
822
1477
|
const kill = detectKill(projectRoot);
|
|
@@ -843,6 +1498,12 @@ export function decideHook(input, seams = {}) {
|
|
|
843
1498
|
}
|
|
844
1499
|
}
|
|
845
1500
|
if (input.event === "session.compact") {
|
|
1501
|
+
// #3769 Path 2: compact stays always-allow and never routes through
|
|
1502
|
+
// inspectMutationGates — entering the mutation gates would re-run the
|
|
1503
|
+
// ritual persist Path 1 closes, or occupancy-deny an owner's own compact.
|
|
1504
|
+
// The stale-mark is fail-open: it writes even when no acting identity can
|
|
1505
|
+
// be bound, because compact has no issue context and no surface a human
|
|
1506
|
+
// reads. The accepted cost is an unidentified compact re-arming the owner.
|
|
846
1507
|
try {
|
|
847
1508
|
const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
|
|
848
1509
|
if (!result.changed) {
|
|
@@ -998,11 +1659,21 @@ export function decideHook(input, seams = {}) {
|
|
|
998
1659
|
}
|
|
999
1660
|
return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
|
|
1000
1661
|
}
|
|
1001
|
-
//
|
|
1662
|
+
// Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
|
|
1663
|
+
// Dest-forms (#3438): recognized product mutations share inspectMutationGates
|
|
1002
1664
|
// with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
|
|
1003
1665
|
// on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
|
|
1004
1666
|
if (isShellTool(toolName)) {
|
|
1005
|
-
|
|
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
|
+
}
|
|
1675
|
+
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
|
|
1676
|
+
return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
|
|
1006
1677
|
}
|
|
1007
1678
|
// Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
|
|
1008
1679
|
// Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
|
|
@@ -1057,6 +1728,24 @@ function softAgentsRebindWireText(decision) {
|
|
|
1057
1728
|
*/
|
|
1058
1729
|
export function renderHostDecision(host, decision) {
|
|
1059
1730
|
if (decision.verdict === "allow") {
|
|
1731
|
+
if (decision.event === "tool.before" &&
|
|
1732
|
+
decision.updatedInput !== undefined &&
|
|
1733
|
+
hostAcceptsUpdatedInput(host)) {
|
|
1734
|
+
if (host === "cursor") {
|
|
1735
|
+
return JSON.stringify({
|
|
1736
|
+
permission: "allow",
|
|
1737
|
+
code: decision.code,
|
|
1738
|
+
updated_input: decision.updatedInput,
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
return JSON.stringify({
|
|
1742
|
+
hookSpecificOutput: {
|
|
1743
|
+
hookEventName: "PreToolUse",
|
|
1744
|
+
permissionDecision: "allow",
|
|
1745
|
+
updatedInput: decision.updatedInput,
|
|
1746
|
+
},
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1060
1749
|
const soft = softAgentsRebindWireText(decision);
|
|
1061
1750
|
if (host === "cursor") {
|
|
1062
1751
|
if (decision.event === "session.start" && soft !== null) {
|