@deftai/directive-core 0.106.0 → 0.108.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/check/cached-orchestrator.js +28 -3
- package/dist/check/gate-lists.js +2 -0
- package/dist/check/named-cause.js +18 -3
- package/dist/codebase/provider.js +7 -1
- package/dist/delivery-attempt/handoff.js +1 -1
- package/dist/design-critique/citation-grammar.d.ts +53 -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 +232 -0
- package/dist/design-critique/exclusive-chip.d.ts +33 -0
- package/dist/design-critique/exclusive-chip.js +68 -0
- package/dist/design-critique/parent-audit.d.ts +59 -0
- package/dist/design-critique/parent-audit.js +121 -0
- package/dist/doctor/checks.d.ts +6 -0
- package/dist/doctor/checks.js +35 -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 +76 -0
- package/dist/hooks/classify/host-session-identity.js +470 -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 +7 -0
- package/dist/hooks/classify/paths.js +35 -0
- package/dist/hooks/classify/stdin.d.ts +5 -0
- package/dist/hooks/classify/stdin.js +27 -0
- package/dist/hooks/dest-form.d.ts +46 -0
- package/dist/hooks/dest-form.js +575 -0
- package/dist/hooks/dispatcher.d.ts +69 -2
- package/dist/hooks/dispatcher.js +609 -45
- package/dist/hooks/fixtures/cases.d.ts +16 -2
- package/dist/hooks/fixtures/cases.js +175 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/scope.js +3 -1
- package/dist/init-deposit/agent-hooks.d.ts +2 -1
- package/dist/init-deposit/agent-hooks.js +8 -1
- package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
- package/dist/init-deposit/hook-runtime-travel.js +269 -0
- package/dist/intake/clause-derivation.js +9 -3
- package/dist/intake/github-auth-modes-cli.js +14 -2
- package/dist/intake/github-auth-modes.d.ts +54 -9
- package/dist/intake/github-auth-modes.js +326 -123
- package/dist/intake/issue-ingest.js +62 -2
- package/dist/intake/platform-capabilities.d.ts +9 -2
- package/dist/intake/platform-capabilities.js +38 -9
- package/dist/lifecycle/completed-tracked-on-delivery.d.ts +26 -0
- package/dist/lifecycle/completed-tracked-on-delivery.js +37 -14
- package/dist/lifecycle/completed-write-guard.d.ts +48 -0
- package/dist/lifecycle/completed-write-guard.js +373 -0
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.js +1 -0
- package/dist/literal-acceptance/capture.d.ts +16 -0
- package/dist/literal-acceptance/capture.js +75 -7
- package/dist/literal-acceptance/evaluate.js +25 -3
- package/dist/literal-acceptance/index.d.ts +2 -2
- package/dist/literal-acceptance/index.js +2 -2
- package/dist/literal-acceptance/run.js +9 -3
- package/dist/literal-acceptance/types.d.ts +6 -0
- package/dist/literal-acceptance/types.js +6 -0
- package/dist/orphan-active/evaluate.d.ts +25 -0
- package/dist/orphan-active/evaluate.js +193 -128
- package/dist/orphan-active/index.d.ts +1 -0
- package/dist/orphan-active/index.js +1 -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/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/runtime-authority.d.ts +18 -0
- package/dist/policy/runtime-authority.js +11 -0
- package/dist/policy/value-feedback.js +9 -18
- package/dist/policy/write-fence.js +3 -0
- 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-protected-issues/gh.d.ts +6 -2
- package/dist/pr-protected-issues/gh.js +7 -3
- 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 +11 -0
- package/dist/preflight/evaluate.d.ts +15 -0
- package/dist/preflight/evaluate.js +29 -3
- package/dist/preflight-cache/evaluate.js +9 -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 +53 -12
- package/dist/release/cli-drift-report.d.ts +68 -0
- package/dist/release/cli-drift-report.js +189 -0
- package/dist/release/index.d.ts +1 -0
- package/dist/release/index.js +1 -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 +19 -0
- package/dist/release/types.d.ts +14 -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 +182 -1
- package/dist/review-monitor/lease-comment.d.ts +36 -0
- package/dist/review-monitor/lease-comment.js +103 -2
- package/dist/scm/call.js +12 -3
- package/dist/scm/design-critique-chip.d.ts +38 -0
- package/dist/scm/design-critique-chip.js +187 -0
- package/dist/scm/gh-rest.d.ts +9 -1
- package/dist/scm/gh-rest.js +103 -0
- package/dist/scm/index.d.ts +1 -0
- package/dist/scm/index.js +1 -0
- package/dist/scm/main.d.ts +3 -0
- package/dist/scm/main.js +15 -1
- package/dist/scm/readiness-cli.d.ts +2 -0
- package/dist/scm/readiness-cli.js +42 -1
- package/dist/scm/readiness.d.ts +4 -1
- package/dist/scm/readiness.js +35 -5
- package/dist/scope/decompose.js +2 -1
- package/dist/scope/delivery-evidence.d.ts +27 -1
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/lifecycle-write.d.ts +24 -0
- package/dist/scope/lifecycle-write.js +64 -0
- package/dist/scope/main.js +3 -1
- package/dist/scope/project-definition-sync.js +14 -7
- package/dist/scope/transition.js +90 -11
- package/dist/scope-provenance/evaluate.js +5 -1
- package/dist/session/git.d.ts +42 -0
- package/dist/session/git.js +219 -3
- package/dist/session/occupancy.d.ts +239 -12
- package/dist/session/occupancy.js +820 -27
- 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 +17 -1
- package/dist/session/session-start.js +49 -17
- package/dist/session/toolchain-preflight.d.ts +13 -3
- package/dist/session/toolchain-preflight.js +93 -18
- package/dist/session/verify-session-ritual.d.ts +33 -3
- package/dist/session/verify-session-ritual.js +89 -19
- 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 +77 -0
- package/dist/swarm/launch.js +248 -38
- 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/actions/index.d.ts +4 -0
- package/dist/triage/actions/index.js +6 -0
- package/dist/triage/evaluate/evaluate.d.ts +11 -0
- package/dist/triage/evaluate/evaluate.js +169 -0
- package/dist/triage/evaluate/github.d.ts +16 -0
- package/dist/triage/evaluate/github.js +153 -0
- package/dist/triage/evaluate/index.d.ts +9 -0
- package/dist/triage/evaluate/index.js +8 -0
- package/dist/triage/evaluate/paths.d.ts +7 -0
- package/dist/triage/evaluate/paths.js +25 -0
- package/dist/triage/evaluate/sink.d.ts +5 -0
- package/dist/triage/evaluate/sink.js +52 -0
- package/dist/triage/evaluate/types.d.ts +93 -0
- package/dist/triage/evaluate/types.js +13 -0
- package/dist/triage/evaluate/validity.d.ts +8 -0
- package/dist/triage/evaluate/validity.js +87 -0
- package/dist/triage/evaluate/value.d.ts +12 -0
- package/dist/triage/evaluate/value.js +33 -0
- package/dist/triage/evaluate/wip-census.d.ts +5 -0
- package/dist/triage/evaluate/wip-census.js +39 -0
- package/dist/triage/evaluate/worktrees.d.ts +8 -0
- package/dist/triage/evaluate/worktrees.js +68 -0
- package/dist/triage/evaluate/xbrief-refs.d.ts +8 -0
- package/dist/triage/evaluate/xbrief-refs.js +60 -0
- package/dist/triage/help/registry-data.d.ts +21 -7
- package/dist/triage/help/registry-data.js +53 -5
- package/dist/triage/index.d.ts +1 -0
- package/dist/triage/index.js +1 -0
- 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/value/feedback-file.d.ts +17 -2
- package/dist/value/feedback-file.js +80 -6
- 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-reconcile/labels.d.ts +1 -0
- package/dist/vbrief-reconcile/labels.js +30 -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/vbrief-validation/story-quality.d.ts +7 -0
- package/dist/vbrief-validation/story-quality.js +8 -1
- package/dist/verify-ac/clauses.d.ts +62 -2
- package/dist/verify-ac/clauses.js +183 -111
- 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/project-definition-mutation-boundary.d.ts +104 -0
- package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
- package/dist/xbrief-migrate/agents-header.js +69 -7
- 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,13 +10,14 @@ 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, } from "../session/verify-session-ritual.js";
|
|
18
|
-
import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
|
|
18
|
+
import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
|
|
19
|
+
import { hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
|
|
20
|
+
import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
|
|
19
21
|
import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
20
22
|
import { inspectActiveScope } from "./scope.js";
|
|
21
23
|
import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
|
|
@@ -192,8 +194,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
|
|
|
192
194
|
return true;
|
|
193
195
|
return false;
|
|
194
196
|
}
|
|
195
|
-
function isWindowsDriveOnlyRoot(value) {
|
|
196
|
-
return /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
197
|
+
function isWindowsDriveOnlyRoot(value, platform = process.platform) {
|
|
198
|
+
return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
197
199
|
}
|
|
198
200
|
function hookPayloadRootCandidates(input) {
|
|
199
201
|
const candidates = [];
|
|
@@ -249,12 +251,114 @@ export function projectRootFromHookPayload(payload, fallback) {
|
|
|
249
251
|
}
|
|
250
252
|
return fallbackResolved;
|
|
251
253
|
}
|
|
254
|
+
/** Interpolate both roots so a deny names the tree that was judged (#3794). */
|
|
255
|
+
export function formatHookRootNote(payloadRoot, effectiveRoot) {
|
|
256
|
+
return `payloadRoot=${payloadRoot} effectiveRoot=${effectiveRoot}`;
|
|
257
|
+
}
|
|
258
|
+
function admitPayload(payload, candidate) {
|
|
259
|
+
return { root: payload, foreign: false, candidate, refusal: null };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Admit the Git toplevel of the write target only when it shares
|
|
263
|
+
* `--git-common-dir` with `payloadRoot`.
|
|
264
|
+
*
|
|
265
|
+
* Identity resolves to one of three outcomes, not two. A `payloadRoot` that is
|
|
266
|
+
* not a Git repository never posed a containment question, so it keeps today's
|
|
267
|
+
* payloadRoot gating. A `payloadRoot` that *is* a repository, paired with a
|
|
268
|
+
* target that resolved to some other toplevel whose identity cannot be read, is
|
|
269
|
+
* a question asked and left unanswered — that fails closed rather than
|
|
270
|
+
* inheriting payloadRoot's occupancy and ritual state (#3794).
|
|
271
|
+
*/
|
|
272
|
+
export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
|
|
273
|
+
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
274
|
+
if (writeTarget === null)
|
|
275
|
+
return admitPayload(payload, null);
|
|
276
|
+
const ancestor = existingAncestorDir(writeTarget);
|
|
277
|
+
if (ancestor === null)
|
|
278
|
+
return admitPayload(payload, null);
|
|
279
|
+
const candidateRaw = worktreePathOrNull(ancestor, runGit);
|
|
280
|
+
if (candidateRaw === null)
|
|
281
|
+
return admitPayload(payload, null);
|
|
282
|
+
const candidate = normalizeHookProjectRoot(candidateRaw);
|
|
283
|
+
if (candidate === payload)
|
|
284
|
+
return admitPayload(payload, candidate);
|
|
285
|
+
const payloadCommon = gitCommonDir(payload, runGit);
|
|
286
|
+
if (payloadCommon === null) {
|
|
287
|
+
// No containment question exists: payloadRoot is not a repository, so there
|
|
288
|
+
// is no boundary for the target to be inside or outside of. Denying here
|
|
289
|
+
// would invent a class this defect never had.
|
|
290
|
+
return admitPayload(payload, candidate);
|
|
291
|
+
}
|
|
292
|
+
const candidateCommon = gitCommonDir(candidate, runGit);
|
|
293
|
+
if (candidateCommon === null) {
|
|
294
|
+
return { root: payload, foreign: true, candidate, refusal: "unproven-identity" };
|
|
295
|
+
}
|
|
296
|
+
if (normalizeHookProjectRoot(payloadCommon) === normalizeHookProjectRoot(candidateCommon)) {
|
|
297
|
+
return { root: candidate, foreign: false, candidate, refusal: null };
|
|
298
|
+
}
|
|
299
|
+
return { root: payload, foreign: true, candidate, refusal: "foreign-repository" };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Admit every mutation target and require one effectiveRoot. A refused member
|
|
303
|
+
* short-circuits; a span of two admitted worktrees is also refused so
|
|
304
|
+
* occupancy/ritual cannot follow only the declared ApplyPatch path (#3794).
|
|
305
|
+
*/
|
|
306
|
+
export function admitMutationTargetSet(payloadRoot, targets, runGit) {
|
|
307
|
+
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
308
|
+
if (targets.length === 0)
|
|
309
|
+
return admitPayload(payload, null);
|
|
310
|
+
const admitted = [];
|
|
311
|
+
for (const target of targets) {
|
|
312
|
+
const next = admitEffectiveHookRoot(payload, target, runGit);
|
|
313
|
+
if (next.foreign)
|
|
314
|
+
return next;
|
|
315
|
+
admitted.push(next);
|
|
316
|
+
}
|
|
317
|
+
const roots = [...new Set(admitted.map((item) => item.root))];
|
|
318
|
+
if (roots.length > 1) {
|
|
319
|
+
return {
|
|
320
|
+
root: payload,
|
|
321
|
+
foreign: true,
|
|
322
|
+
candidate: roots.join(" | "),
|
|
323
|
+
refusal: "worktree-span",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return admitted[0] ?? admitPayload(payload, null);
|
|
327
|
+
}
|
|
252
328
|
export function isHookHost(value) {
|
|
253
329
|
return HOOK_HOSTS.includes(value);
|
|
254
330
|
}
|
|
255
331
|
export function isHookEvent(value) {
|
|
256
332
|
return HOOK_EVENTS.includes(value);
|
|
257
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Coverage sentence appended to a scope-not-ready deny.
|
|
336
|
+
*
|
|
337
|
+
* #3828: this used to claim Shell dest-form coverage unconditionally. Coverage
|
|
338
|
+
* is opt-in (`plan.policy.runtimeAuthority.shellDestForms: "enforce"`) and the
|
|
339
|
+
* default is `off`, so on a default-policy repository the sentence advertised a
|
|
340
|
+
* fence that was not there and, by naming `python -c` as the exception, implied
|
|
341
|
+
* the gap was a sliver rather than the whole Shell surface. State the coverage
|
|
342
|
+
* actually in force.
|
|
343
|
+
*/
|
|
344
|
+
function scopeNotReadyCoverageHint(toolName, shellDestFormsEnforced) {
|
|
345
|
+
if (isSpawnTool(toolName))
|
|
346
|
+
return "";
|
|
347
|
+
if (isShellTool(toolName)) {
|
|
348
|
+
return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
|
|
349
|
+
"(git checkout --, git restore, rm/rmdir of in-repo paths).");
|
|
350
|
+
}
|
|
351
|
+
if (shellDestFormsEnforced) {
|
|
352
|
+
return (" This gate covers direct-write tools (Edit/Write), implementation spawn, and " +
|
|
353
|
+
"recognized Shell dest-forms (git checkout --, git restore, rm/rmdir), because " +
|
|
354
|
+
"plan.policy.runtimeAuthority.shellDestForms is enforce. Shell whose target " +
|
|
355
|
+
"cannot be resolved to a product path stays outside this deny.");
|
|
356
|
+
}
|
|
357
|
+
return (" This gate covers direct-write tools (Edit/Write) and implementation spawn. " +
|
|
358
|
+
"plan.policy.runtimeAuthority.shellDestForms is off (the default), so no Shell " +
|
|
359
|
+
"command is covered by this deny -- reaching for one to get around it defeats " +
|
|
360
|
+
"the gate rather than satisfying it.");
|
|
361
|
+
}
|
|
258
362
|
function deny(input, code, toolName, message, scopePath = null) {
|
|
259
363
|
return {
|
|
260
364
|
verdict: "deny",
|
|
@@ -267,8 +371,55 @@ function deny(input, code, toolName, message, scopePath = null) {
|
|
|
267
371
|
scopePath,
|
|
268
372
|
};
|
|
269
373
|
}
|
|
270
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Resolve the cooperative actor presented to the occupancy gate.
|
|
376
|
+
* Supported payload hosts are payload-authoritative; ambient identity may only
|
|
377
|
+
* corroborate. Grok retains the pre-#3611 environment path until its payload
|
|
378
|
+
* contract is verified.
|
|
379
|
+
*/
|
|
380
|
+
function resolveMutationActor(input, environ) {
|
|
381
|
+
const hostIdentity = resolveHookHostIdentity(input.host, input.payload);
|
|
382
|
+
const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
|
|
383
|
+
if (hostIdentity.status === "unsupported") {
|
|
384
|
+
return {
|
|
385
|
+
sessionId: environmentId,
|
|
386
|
+
issue: null,
|
|
387
|
+
message: null,
|
|
388
|
+
payloadAuthoritative: false,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
|
|
392
|
+
if (environmentId !== undefined && environmentId !== hostIdentity.sessionId) {
|
|
393
|
+
return {
|
|
394
|
+
sessionId: hostIdentity.sessionId,
|
|
395
|
+
issue: "conflict",
|
|
396
|
+
message: `Host payload owner ${hostIdentity.sessionId} conflicts with ` +
|
|
397
|
+
`DEFT_SESSION_ID ${environmentId}.`,
|
|
398
|
+
payloadAuthoritative: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
sessionId: hostIdentity.sessionId,
|
|
403
|
+
issue: null,
|
|
404
|
+
message: null,
|
|
405
|
+
payloadAuthoritative: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
sessionId: undefined,
|
|
410
|
+
issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
|
|
411
|
+
message: hostIdentity.message,
|
|
412
|
+
payloadAuthoritative: true,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
|
|
416
|
+
// #3794 commit 2: the project runtimeAuthority layer is project-level
|
|
417
|
+
// authorization config and stays on payloadRoot, alongside authz. Only the
|
|
418
|
+
// story fence and the path it is matched against follow the tree the write
|
|
419
|
+
// lands in, so a story `file_scope` of `packages/**` still matches a write
|
|
420
|
+
// inside a linked worktree rather than `.deft-scratch/worktrees/<x>/packages/`.
|
|
271
421
|
const projectRoot = resolve(input.projectRoot);
|
|
422
|
+
const fenceRoot = resolve(effectiveRoot);
|
|
272
423
|
// Project policy load failure: treat as disabled project layer, still apply
|
|
273
424
|
// independent story file_scope when present (#516 / #2443 Greptile P1).
|
|
274
425
|
let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
|
|
@@ -284,7 +435,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
284
435
|
let storyFence;
|
|
285
436
|
try {
|
|
286
437
|
storyFence = seams.loadStoryWriteFence
|
|
287
|
-
? seams.loadStoryWriteFence(
|
|
438
|
+
? seams.loadStoryWriteFence(fenceRoot, scopePath)
|
|
288
439
|
: loadStoryWriteFenceFromPath(scopePath);
|
|
289
440
|
}
|
|
290
441
|
catch {
|
|
@@ -298,14 +449,19 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
298
449
|
if (!fence.fenceActive)
|
|
299
450
|
return null;
|
|
300
451
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
301
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(
|
|
452
|
+
const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
|
|
302
453
|
const verdict = evaluateRuntimeAuthorityDirectWrite({
|
|
303
454
|
policy: fence.policy,
|
|
304
455
|
relPathPosix: relPath,
|
|
305
456
|
});
|
|
306
457
|
if (verdict.allowed)
|
|
307
458
|
return null;
|
|
308
|
-
|
|
459
|
+
// #3794: relPath is now measured from the tree the write lands in. When that
|
|
460
|
+
// differs from the payload root, a bare "outside file_scope" reason does not
|
|
461
|
+
// say which tree the fence was relativised against.
|
|
462
|
+
const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
|
|
463
|
+
return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
|
|
464
|
+
fenceRootNote, scopePath);
|
|
309
465
|
}
|
|
310
466
|
function loadAuthzContext(projectRoot, seams) {
|
|
311
467
|
if (seams.loadAuthzState !== undefined) {
|
|
@@ -368,6 +524,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
|
|
|
368
524
|
* Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
|
|
369
525
|
*/
|
|
370
526
|
function authzForMutation(input, toolName, seams, options) {
|
|
527
|
+
// #3794: grant scoping and the authz audit trail stay on payloadRoot.
|
|
528
|
+
// Re-pointing would break exact-string UAT grants and shard the audit file.
|
|
371
529
|
const projectRoot = resolve(input.projectRoot);
|
|
372
530
|
let state;
|
|
373
531
|
let grants;
|
|
@@ -401,7 +559,7 @@ function authzForMutation(input, toolName, seams, options) {
|
|
|
401
559
|
// Structural context for bound grants (branch from git; repo optional env).
|
|
402
560
|
let branch = null;
|
|
403
561
|
try {
|
|
404
|
-
branch = detectBranch(projectRoot);
|
|
562
|
+
branch = detectBranch(projectRoot, options.runGit);
|
|
405
563
|
}
|
|
406
564
|
catch {
|
|
407
565
|
branch = null;
|
|
@@ -540,23 +698,50 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
|
|
|
540
698
|
};
|
|
541
699
|
}
|
|
542
700
|
function inspectMutationGates(input, toolName, seams, options) {
|
|
543
|
-
|
|
701
|
+
// payloadRoot stays authoritative for authz grant scoping, the authz audit
|
|
702
|
+
// trail, the kill-switch, the #2885 outside-root carve-out, and
|
|
703
|
+
// deny().projectRoot. effectiveRoot governs occupancy and ritual (commit 1),
|
|
704
|
+
// and active scope, the story write fence and assist-scratch (commit 2).
|
|
705
|
+
const payloadRoot = resolve(input.projectRoot);
|
|
706
|
+
const projectRoot = payloadRoot;
|
|
544
707
|
const environ = input.environ ?? process.env;
|
|
708
|
+
const dispatchGit = memoizeGitRunner(seams.ritualRunGit ?? defaultGitRunner);
|
|
709
|
+
const mutationTargets = isSpawnTool(toolName) ? [] : hookMutationTargetPaths(input.payload);
|
|
710
|
+
const admission = isSpawnTool(toolName)
|
|
711
|
+
? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
|
|
712
|
+
: admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
|
|
713
|
+
const effectiveRoot = admission.root;
|
|
714
|
+
const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
|
|
715
|
+
if (admission.foreign) {
|
|
716
|
+
const candidate = admission.candidate ?? "<none>";
|
|
717
|
+
const reason = admission.refusal === "worktree-span"
|
|
718
|
+
? `write targets span more than one Git worktree (candidate=${candidate}).`
|
|
719
|
+
: admission.refusal === "unproven-identity"
|
|
720
|
+
? `write target resolved to a separate Git working tree whose repository ` +
|
|
721
|
+
`identity could not be read, so containment is unproven (candidate=${candidate}).`
|
|
722
|
+
: `write target is in a different Git repository ` +
|
|
723
|
+
`than the hook payload root (candidate=${candidate}).`;
|
|
724
|
+
return deny(input, "foreign-repository-deny", toolName, `Directive denied ${toolName}: ${reason}${rootsNote}`);
|
|
725
|
+
}
|
|
545
726
|
// Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
|
|
546
727
|
// assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
|
|
547
728
|
// Tracked product paths never match the path fence. Read-only still denied upstream.
|
|
548
729
|
if (!isSpawnTool(toolName)) {
|
|
549
730
|
const scratchTarget = hookWriteTargetPath(input.payload);
|
|
550
|
-
|
|
731
|
+
// #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
|
|
732
|
+
// file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
|
|
733
|
+
// assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
|
|
734
|
+
if (isAssistScratchWrite(effectiveRoot, scratchTarget, input.payload, environ)) {
|
|
551
735
|
const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
|
|
552
736
|
const authzDeny = authzForMutation(input, toolName, seams, {
|
|
553
737
|
isDirectWrite: true,
|
|
554
738
|
relPath,
|
|
555
739
|
scopePath: null,
|
|
740
|
+
runGit: dispatchGit,
|
|
556
741
|
});
|
|
557
742
|
if (authzDeny !== null)
|
|
558
743
|
return authzDeny;
|
|
559
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
744
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
560
745
|
if (runtimeDeny !== null)
|
|
561
746
|
return runtimeDeny;
|
|
562
747
|
return {
|
|
@@ -574,19 +759,88 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
574
759
|
};
|
|
575
760
|
}
|
|
576
761
|
}
|
|
762
|
+
const actor = isSpawnTool(toolName) ? null : resolveMutationActor(input, environ);
|
|
763
|
+
const occupancyGate = isSpawnTool(toolName)
|
|
764
|
+
? {
|
|
765
|
+
allow: true,
|
|
766
|
+
message: null,
|
|
767
|
+
occupant: null,
|
|
768
|
+
admitted: null,
|
|
769
|
+
}
|
|
770
|
+
: evaluateOccupancyWriteGate(effectiveRoot, {
|
|
771
|
+
sessionId: actor?.sessionId,
|
|
772
|
+
// Payload-supported hosts must not fall back to a stale ambient owner.
|
|
773
|
+
env: actor?.payloadAuthoritative === true ? {} : environ,
|
|
774
|
+
});
|
|
775
|
+
// #3769: occupancy decides before any ritual persist. The gated verifier
|
|
776
|
+
// executes agent_hooks and rebinds a forward HEAD, and both rewrite
|
|
777
|
+
// .deft/ritual-state.json — so verifying first let a session this gate was
|
|
778
|
+
// about to deny rewrite the occupant's record. The deny paths below read the
|
|
779
|
+
// ritual through the no-write inspect, which runs no step and never rebinds;
|
|
780
|
+
// only a session occupancy would admit reaches the verifier.
|
|
781
|
+
//
|
|
782
|
+
// Ritual detail on an occupancy deny is telemetry and message decoration, so
|
|
783
|
+
// an inspect failure must not convert an occupancy verdict into a ritual one.
|
|
784
|
+
const ritualDetailForOccupancyDeny = () => {
|
|
785
|
+
try {
|
|
786
|
+
return (seams.inspectRitual ??
|
|
787
|
+
seams.verifyRitual ??
|
|
788
|
+
((root) => inspectSessionRitual(root, {
|
|
789
|
+
tier: "gated",
|
|
790
|
+
posture: "mutation",
|
|
791
|
+
runGit: dispatchGit,
|
|
792
|
+
requiredGatedSteps: WRITE_GATED_REQUIRED_STEPS,
|
|
793
|
+
})))(effectiveRoot);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
if (occupancyGate.occupant !== null && actor?.issue !== null && actor !== null) {
|
|
800
|
+
const inspected = ritualDetailForOccupancyDeny();
|
|
801
|
+
const code = actor.issue === "conflict" ? "occupancy-identity-conflict" : "occupancy-identity-unavailable";
|
|
802
|
+
const detail = actor.message ??
|
|
803
|
+
"The host did not supply a usable cooperative session/conversation identity.";
|
|
804
|
+
emitSessionRitualBlockedProcessCost({
|
|
805
|
+
toolName,
|
|
806
|
+
code,
|
|
807
|
+
recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
808
|
+
detail,
|
|
809
|
+
}, { projectRoot });
|
|
810
|
+
return deny(input, code, toolName, `Directive denied ${toolName}: ${detail} A live occupancy lease exists for ` +
|
|
811
|
+
`session ${occupancyGate.occupant.sessionId}; use an exact host-mediated lifecycle ` +
|
|
812
|
+
"command or pass the matching --session-id explicitly." +
|
|
813
|
+
rootsNote);
|
|
814
|
+
}
|
|
815
|
+
if (!occupancyGate.allow && occupancyGate.message !== null) {
|
|
816
|
+
const inspected = ritualDetailForOccupancyDeny();
|
|
817
|
+
const ritualNote = inspected !== null && inspected.code !== 0
|
|
818
|
+
? ` Also ritual-not-ready: ${inspected.message}`
|
|
819
|
+
: "";
|
|
820
|
+
emitSessionRitualBlockedProcessCost({
|
|
821
|
+
toolName,
|
|
822
|
+
code: "occupancy-occupied",
|
|
823
|
+
recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
824
|
+
detail: occupancyGate.message,
|
|
825
|
+
}, { projectRoot });
|
|
826
|
+
return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}${rootsNote}`);
|
|
827
|
+
}
|
|
577
828
|
let ritual;
|
|
578
829
|
try {
|
|
579
830
|
// Mutation dispatch is a live gated boundary: active verification reruns
|
|
580
831
|
// non-cacheable agent-hook readiness instead of trusting persisted success.
|
|
832
|
+
// Write-surface authority executes agent_hooks only (#3738). cache_fresh
|
|
833
|
+
// is not required and is never executed. doctor is read from the record.
|
|
581
834
|
// Its allow fixture is non-write and its deny fixture is read-only, so the
|
|
582
835
|
// installed-shim probe cannot recurse into this mutation path.
|
|
583
836
|
ritual = (seams.verifyRitual ??
|
|
584
837
|
seams.inspectRitual ??
|
|
585
|
-
((root) => verifySessionRitual(root, {
|
|
586
|
-
tier: "gated",
|
|
587
|
-
posture: "mutation",
|
|
838
|
+
((root) => verifySessionRitual(root, writeGateRitualOptions({
|
|
588
839
|
bypass: false,
|
|
589
|
-
|
|
840
|
+
runner: seams.ritualRunner,
|
|
841
|
+
detectWorkSelection: seams.detectWorkSelection,
|
|
842
|
+
runGit: dispatchGit,
|
|
843
|
+
}))))(effectiveRoot);
|
|
590
844
|
}
|
|
591
845
|
catch (cause) {
|
|
592
846
|
// #2994: best-effort local process-cost; never changes deny verdict.
|
|
@@ -597,20 +851,36 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
597
851
|
detail: `inspect threw: ${String(cause)}`,
|
|
598
852
|
}, { projectRoot });
|
|
599
853
|
return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
|
|
600
|
-
formatRitualRecoveryInstruction("cold")
|
|
854
|
+
formatRitualRecoveryInstruction("cold") +
|
|
855
|
+
rootsNote);
|
|
601
856
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
857
|
+
if (occupancyGate.occupant !== null && actor !== null) {
|
|
858
|
+
// A granted member writes under the owner's ceremony (#3755). Ritual state
|
|
859
|
+
// is single-owner and a child cannot bind its own while the owner's lease is
|
|
860
|
+
// live, so requiring the writer's own binding would make every admitted
|
|
861
|
+
// member's write unreachable — the grant would authorize nothing. What is
|
|
862
|
+
// checked instead is the identity that issued the grant: the tree's exact
|
|
863
|
+
// verified ritual owner must be the occupant whose lease admits this member.
|
|
864
|
+
const expectedOwner = occupancyGate.admitted === "member" ? occupancyGate.occupant.sessionId : actor.sessionId;
|
|
865
|
+
const verifiedOwner = ritual.boundSessionId;
|
|
866
|
+
const ritualOwnerMismatch = expectedOwner !== undefined &&
|
|
867
|
+
(verifiedOwner !== undefined ? verifiedOwner !== expectedOwner : ritual.code === 0);
|
|
868
|
+
if (ritualOwnerMismatch) {
|
|
869
|
+
const detail = verifiedOwner === undefined
|
|
870
|
+
? "gated ritual verification returned ready without an exact bound owner"
|
|
871
|
+
: `exact verified ritual owner ${verifiedOwner} differs from host/lease owner ${expectedOwner}`;
|
|
872
|
+
emitSessionRitualBlockedProcessCost({
|
|
873
|
+
toolName,
|
|
874
|
+
code: "occupancy-ritual-mismatch",
|
|
875
|
+
recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
|
|
876
|
+
detail,
|
|
877
|
+
}, { projectRoot });
|
|
878
|
+
return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: ${detail}. ` +
|
|
879
|
+
"Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
|
|
880
|
+
"otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony. " +
|
|
881
|
+
"Intermediate lease/ritual mismatches fail closed." +
|
|
882
|
+
rootsNote);
|
|
883
|
+
}
|
|
614
884
|
}
|
|
615
885
|
if (ritual.code !== 0) {
|
|
616
886
|
// #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
|
|
@@ -622,23 +892,73 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
622
892
|
recoveryTier,
|
|
623
893
|
detail: ritual.message,
|
|
624
894
|
}, { projectRoot });
|
|
625
|
-
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`
|
|
895
|
+
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
|
|
896
|
+
rootsNote);
|
|
626
897
|
}
|
|
898
|
+
// Narrow the cooperative file-gate race: authorization/scope checks can
|
|
899
|
+
// take long enough for another session to transition the lease. Re-read
|
|
900
|
+
// only occupancy immediately before each direct-write allow, while retaining
|
|
901
|
+
// the exact ritual owner above (never adopt a later ritual file here).
|
|
902
|
+
// #3599: the owner's lease is only kept alive here, on the last evaluation
|
|
903
|
+
// before an allowed write, so the stamp records a write that really happened.
|
|
904
|
+
let occupancyWarning = null;
|
|
905
|
+
const recheckOccupancyBeforeWriteAllow = () => {
|
|
906
|
+
if (actor === null)
|
|
907
|
+
return null;
|
|
908
|
+
const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
|
|
909
|
+
sessionId: actor.sessionId,
|
|
910
|
+
env: actor.payloadAuthoritative ? {} : environ,
|
|
911
|
+
refresh: true,
|
|
912
|
+
});
|
|
913
|
+
occupancyWarning = finalOccupancy.warning;
|
|
914
|
+
if (finalOccupancy.occupant !== null && actor.issue !== null) {
|
|
915
|
+
return deny(input, actor.issue === "conflict"
|
|
916
|
+
? "occupancy-identity-conflict"
|
|
917
|
+
: "occupancy-identity-unavailable", toolName, `Directive denied ${toolName}: the live occupancy owner changed while mutation ` +
|
|
918
|
+
"gates were running, and this hook has no unambiguous matching actor identity." +
|
|
919
|
+
rootsNote);
|
|
920
|
+
}
|
|
921
|
+
if (!finalOccupancy.allow && finalOccupancy.message !== null) {
|
|
922
|
+
return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: occupancy changed while mutation gates were running. ` +
|
|
923
|
+
finalOccupancy.message +
|
|
924
|
+
rootsNote);
|
|
925
|
+
}
|
|
926
|
+
// Same member rule as the pre-gate check above (#3755): an admitted member
|
|
927
|
+
// is measured against the occupant that granted it, not against itself.
|
|
928
|
+
const finalExpectedOwner = finalOccupancy.admitted === "member" && finalOccupancy.occupant !== null
|
|
929
|
+
? finalOccupancy.occupant.sessionId
|
|
930
|
+
: actor.sessionId;
|
|
931
|
+
if (finalOccupancy.occupant !== null &&
|
|
932
|
+
finalExpectedOwner !== undefined &&
|
|
933
|
+
ritual.boundSessionId !== finalExpectedOwner) {
|
|
934
|
+
return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: final lease owner ${finalExpectedOwner} does not match ` +
|
|
935
|
+
`the exact verified ritual owner ${ritual.boundSessionId ?? "<unbound>"}. ` +
|
|
936
|
+
"Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
|
|
937
|
+
"otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony." +
|
|
938
|
+
rootsNote);
|
|
939
|
+
}
|
|
940
|
+
return null;
|
|
941
|
+
};
|
|
942
|
+
const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
|
|
627
943
|
if (options.proposedLifecycleExempt) {
|
|
628
944
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
629
|
-
if (isProposedLifecycleWrite(
|
|
945
|
+
if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
|
|
630
946
|
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
631
947
|
// UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
|
|
632
948
|
const authzDeny = authzForMutation(input, toolName, seams, {
|
|
633
949
|
isDirectWrite: true,
|
|
634
950
|
relPath,
|
|
635
951
|
scopePath: null,
|
|
952
|
+
runGit: dispatchGit,
|
|
636
953
|
});
|
|
637
954
|
if (authzDeny !== null)
|
|
638
955
|
return authzDeny;
|
|
639
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
956
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
640
957
|
if (runtimeDeny !== null)
|
|
641
958
|
return runtimeDeny;
|
|
959
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
960
|
+
if (occupancyDeny !== null)
|
|
961
|
+
return occupancyDeny;
|
|
642
962
|
return {
|
|
643
963
|
verdict: "allow",
|
|
644
964
|
code: "write-propose-ready",
|
|
@@ -646,26 +966,33 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
646
966
|
host: input.host,
|
|
647
967
|
toolName,
|
|
648
968
|
projectRoot,
|
|
649
|
-
message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
|
|
650
|
-
"(planning write; active scope not required).",
|
|
969
|
+
message: withOccupancyWarning(`Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
|
|
970
|
+
"(planning write; active scope not required)."),
|
|
651
971
|
scopePath: null,
|
|
652
972
|
};
|
|
653
973
|
}
|
|
654
974
|
}
|
|
655
975
|
let scope;
|
|
656
976
|
try {
|
|
657
|
-
scope
|
|
977
|
+
// #3794 commit 2: a write is governed by the active scope of the worktree it
|
|
978
|
+
// lands in, not the primary checkout's. Admission has already proved
|
|
979
|
+
// effectiveRoot shares --git-common-dir with payloadRoot.
|
|
980
|
+
scope = (seams.inspectScope ?? inspectActiveScope)(effectiveRoot);
|
|
658
981
|
}
|
|
659
982
|
catch (cause) {
|
|
660
983
|
scope = { ready: false, path: null, message: String(cause) };
|
|
661
984
|
}
|
|
662
985
|
if (!scope.ready) {
|
|
663
986
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
664
|
-
const relTarget = writeTarget !== null ? toProjectRelativePosix(
|
|
987
|
+
const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
|
|
665
988
|
// Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
|
|
666
989
|
// (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
|
|
667
990
|
// targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
|
|
668
991
|
// Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
|
|
992
|
+
// #3794: the #2885 carve-out is measured from payloadRoot, so a linked
|
|
993
|
+
// worktree that lives outside the payload root keeps today's skip. Worktrees
|
|
994
|
+
// under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
|
|
995
|
+
// are inside it and are now gated on their own active scope.
|
|
669
996
|
const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
|
|
670
997
|
if (!outsideRoot || isSpawnTool(toolName)) {
|
|
671
998
|
let proposedPathHint;
|
|
@@ -705,7 +1032,17 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
705
1032
|
"assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
|
|
706
1033
|
}
|
|
707
1034
|
const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
|
|
708
|
-
|
|
1035
|
+
// #3794: the recovery has to run in the tree whose active scope was read.
|
|
1036
|
+
// Once the two roots differ, "the project root" names no single directory
|
|
1037
|
+
// the agent can act on, and activating in the payload root does not clear
|
|
1038
|
+
// a deny measured against the worktree.
|
|
1039
|
+
const recoveryRootNote = resolve(effectiveRoot) === payloadRoot
|
|
1040
|
+
? ""
|
|
1041
|
+
: ` Active scope was read from ${effectiveRoot}, so run that recovery there rather than in ${payloadRoot}.`;
|
|
1042
|
+
return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
|
|
1043
|
+
scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
|
|
1044
|
+
recoveryRootNote +
|
|
1045
|
+
rootsNote);
|
|
709
1046
|
}
|
|
710
1047
|
}
|
|
711
1048
|
const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
|
|
@@ -724,12 +1061,16 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
724
1061
|
isDirectWrite: true,
|
|
725
1062
|
relPath,
|
|
726
1063
|
scopePath: scope.path,
|
|
1064
|
+
runGit: dispatchGit,
|
|
727
1065
|
});
|
|
728
1066
|
if (authzDeny !== null)
|
|
729
1067
|
return authzDeny;
|
|
730
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
|
|
1068
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
|
|
731
1069
|
if (runtimeDeny !== null)
|
|
732
1070
|
return runtimeDeny;
|
|
1071
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
1072
|
+
if (occupancyDeny !== null)
|
|
1073
|
+
return occupancyDeny;
|
|
733
1074
|
}
|
|
734
1075
|
return {
|
|
735
1076
|
verdict: "allow",
|
|
@@ -738,10 +1079,203 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
738
1079
|
host: input.host,
|
|
739
1080
|
toolName,
|
|
740
1081
|
projectRoot,
|
|
741
|
-
message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}
|
|
1082
|
+
message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
|
|
742
1083
|
scopePath: scope.path,
|
|
743
1084
|
};
|
|
744
1085
|
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
|
|
1088
|
+
* Dest-form allow is kept when runtime authority has nothing classifiable.
|
|
1089
|
+
*/
|
|
1090
|
+
function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
1091
|
+
const command = hookShellCommand(input.payload);
|
|
1092
|
+
let destAllow = null;
|
|
1093
|
+
let expansionDeny = null;
|
|
1094
|
+
// #3438 / #3594: opt-in. `off` (the default) leaves Shell exactly as it was
|
|
1095
|
+
// before this gate existed — unrecognized and fail-open — so landing the
|
|
1096
|
+
// classifier cannot deny anything a consumer runs today. `enforce` turns on
|
|
1097
|
+
// BOTH halves together: resolved dests through inspectMutationGates and
|
|
1098
|
+
// fail-closed for targets that cannot be proved. ⊗ Do not split those two:
|
|
1099
|
+
// enforcing only resolved dests would allow `cd x && rm y` while denying
|
|
1100
|
+
// `rm x/y`, and enforcing only fail-closed would deny the compound while
|
|
1101
|
+
// letting the in-scope simple form through unchecked.
|
|
1102
|
+
// An unreadable policy resolves to `off`, which is the no-new-denials
|
|
1103
|
+
// direction and matches the opt-in default rather than failing closed.
|
|
1104
|
+
const destFormsEnforced = loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce";
|
|
1105
|
+
if (command !== null && destFormsEnforced) {
|
|
1106
|
+
for (const dest of classifyProductDestForms(command)) {
|
|
1107
|
+
if (dest.expansion === true) {
|
|
1108
|
+
expansionDeny = deny(input, "scope-not-ready", toolName, `Directive denied ${toolName}: Shell dest-form target is not reconstructable ` +
|
|
1109
|
+
"(compound command, glob/variable, leading ~, or a git context option). Name a " +
|
|
1110
|
+
"concrete path in ONE simple command — split `cd x && rm y` into a single " +
|
|
1111
|
+
"`rm x/y`, or one tool call per command — or use Edit/Write. Only a single simple " +
|
|
1112
|
+
"command with an absolute dest is checked soundly; a relative dest assumes the " +
|
|
1113
|
+
"shell is at the project root. Everything else stays fail-closed (#3438).");
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
const destInput = {
|
|
1117
|
+
...input,
|
|
1118
|
+
payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
|
|
1119
|
+
};
|
|
1120
|
+
const destDecision = inspectMutationGates(destInput, toolName, seams, {
|
|
1121
|
+
proposedLifecycleExempt: true,
|
|
1122
|
+
});
|
|
1123
|
+
if (destDecision.verdict === "deny")
|
|
1124
|
+
return destDecision;
|
|
1125
|
+
destAllow = destDecision;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const runtime = decideShellOrMcpRuntimeAuthority(input, toolName, seams);
|
|
1129
|
+
if (runtime.verdict === "deny")
|
|
1130
|
+
return runtime;
|
|
1131
|
+
if (expansionDeny !== null)
|
|
1132
|
+
return expansionDeny;
|
|
1133
|
+
if (destAllow !== null && runtime.code === "shell-op-unclassifiable") {
|
|
1134
|
+
return destAllow;
|
|
1135
|
+
}
|
|
1136
|
+
return runtime;
|
|
1137
|
+
}
|
|
1138
|
+
const LIFECYCLE_EXECUTION_DIRECTORY_FIELDS = [
|
|
1139
|
+
"cwd",
|
|
1140
|
+
"workdir",
|
|
1141
|
+
"working_directory",
|
|
1142
|
+
"workingDirectory",
|
|
1143
|
+
];
|
|
1144
|
+
function sameExecutionDirectory(left, right) {
|
|
1145
|
+
if (process.platform === "win32")
|
|
1146
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
1147
|
+
return left === right;
|
|
1148
|
+
}
|
|
1149
|
+
function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
|
|
1150
|
+
const input = record(payload);
|
|
1151
|
+
if (input === null) {
|
|
1152
|
+
return { aligned: false, message: "the hook payload has no inspectable execution root" };
|
|
1153
|
+
}
|
|
1154
|
+
const expected = normalizeHookProjectRoot(projectRoot);
|
|
1155
|
+
const toolInput = toolInputRecord(input);
|
|
1156
|
+
const records = toolInput === null || toolInput === input ? [input] : [input, toolInput];
|
|
1157
|
+
for (const source of records) {
|
|
1158
|
+
for (const field of LIFECYCLE_EXECUTION_DIRECTORY_FIELDS) {
|
|
1159
|
+
if (!(field in source))
|
|
1160
|
+
continue;
|
|
1161
|
+
const raw = source[field];
|
|
1162
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
1163
|
+
return {
|
|
1164
|
+
aligned: false,
|
|
1165
|
+
message: `execution-directory field ${field} is missing or invalid`,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const trimmed = raw.trim();
|
|
1169
|
+
// Cursor can report a drive-only cwd (for example `C:`) while the
|
|
1170
|
+
// explicit/workspace-derived project root remains usable (#2787). Keep
|
|
1171
|
+
// this fence aligned with projectRootFromHookPayload: a drive-only value
|
|
1172
|
+
// is not a concrete execution directory and must not override that root.
|
|
1173
|
+
if (isWindowsDriveOnlyRoot(trimmed, platform))
|
|
1174
|
+
continue;
|
|
1175
|
+
if (trimmed.replace(/\\/g, "/").split("/").includes("..")) {
|
|
1176
|
+
return {
|
|
1177
|
+
aligned: false,
|
|
1178
|
+
message: `execution-directory field ${field} contains parent traversal`,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const candidate = isAbsolute(trimmed) ? trimmed : `${expected}${sep}${trimmed}`;
|
|
1182
|
+
let actual;
|
|
1183
|
+
let expectedReal;
|
|
1184
|
+
try {
|
|
1185
|
+
// Keep relative `..` segments intact until realpath resolution. Lexical
|
|
1186
|
+
// path.resolve would collapse `link/..` before observing that `link`
|
|
1187
|
+
// can be a symlink to a foreign tree.
|
|
1188
|
+
expectedReal = normalizeHookProjectRoot(realpath(expected));
|
|
1189
|
+
actual = normalizeHookProjectRoot(realpath(candidate));
|
|
1190
|
+
}
|
|
1191
|
+
catch (cause) {
|
|
1192
|
+
return {
|
|
1193
|
+
aligned: false,
|
|
1194
|
+
message: `execution-directory field ${field} could not be realpath-bound: ${String(cause)}`,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
if (!sameExecutionDirectory(actual, expectedReal)) {
|
|
1198
|
+
return {
|
|
1199
|
+
aligned: false,
|
|
1200
|
+
message: `execution-directory field ${field} resolves to ${actual}, not the hook project ` +
|
|
1201
|
+
`root ${expectedReal}`,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return { aligned: true, message: "execution directory is project-root aligned" };
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Add the claim-time owner only after the existing shell decision allowed the
|
|
1210
|
+
* command. The host wire format must emit `allow` with updated input, so this
|
|
1211
|
+
* path is intentionally restricted by rewriteExactLifecycleCommand.
|
|
1212
|
+
*/
|
|
1213
|
+
function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
1214
|
+
if (decision.verdict !== "allow")
|
|
1215
|
+
return decision;
|
|
1216
|
+
const lifecycle = inspectExactLifecycleCommand(input.payload);
|
|
1217
|
+
if (lifecycle === null)
|
|
1218
|
+
return decision;
|
|
1219
|
+
if (!lifecycle.requiresOwner)
|
|
1220
|
+
return decision;
|
|
1221
|
+
if (lifecycle.sessionIdStatus === "invalid") {
|
|
1222
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1223
|
+
"--session-id is empty, duplicated, or otherwise ambiguous.");
|
|
1224
|
+
}
|
|
1225
|
+
const identity = resolveHookHostIdentity(input.host, input.payload);
|
|
1226
|
+
if (identity.status === "unsupported")
|
|
1227
|
+
return decision;
|
|
1228
|
+
if (identity.status !== "ok" || identity.sessionId === null) {
|
|
1229
|
+
const code = identity.status === "conflict"
|
|
1230
|
+
? "occupancy-identity-conflict"
|
|
1231
|
+
: "occupancy-identity-unavailable";
|
|
1232
|
+
return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1233
|
+
`${identity.message ?? "host payload identity is unavailable"}. ` +
|
|
1234
|
+
"Directive cannot bind the occupancy claim without a stable host owner; " +
|
|
1235
|
+
"manual callers must pass --session-id explicitly outside the host rewrite path.");
|
|
1236
|
+
}
|
|
1237
|
+
const environmentId = (input.environ ?? process.env).DEFT_SESSION_ID?.trim();
|
|
1238
|
+
if (environmentId !== undefined &&
|
|
1239
|
+
environmentId.length > 0 &&
|
|
1240
|
+
environmentId !== identity.sessionId) {
|
|
1241
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host payload owner ${identity.sessionId} conflicts with ` +
|
|
1242
|
+
`DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
|
|
1243
|
+
}
|
|
1244
|
+
if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
|
|
1245
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
|
|
1246
|
+
`${lifecycle.sessionId ?? "<missing>"}, but the host payload owner is ` +
|
|
1247
|
+
`${identity.sessionId}.`);
|
|
1248
|
+
}
|
|
1249
|
+
const sourceTask = !lifecycle.task ||
|
|
1250
|
+
(seams.runningInsideDeftRepo ?? runningInsideDeftRepo)(resolve(input.projectRoot));
|
|
1251
|
+
const executionRoot = lifecycleExecutionRootCheck(input.payload, input.projectRoot, seams.realpathLifecycleExecutionRoot ?? realpathSync, seams.lifecycleExecutionPlatform ?? process.platform);
|
|
1252
|
+
if (!executionRoot.aligned) {
|
|
1253
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${executionRoot.message}. ` +
|
|
1254
|
+
`Run the lifecycle command from ${normalizeHookProjectRoot(resolve(input.projectRoot))} ` +
|
|
1255
|
+
"so policy, lease, and ritual state target the same worktree.");
|
|
1256
|
+
}
|
|
1257
|
+
const rewriteAllowed = lifecycle.rewriteSafe && sourceTask;
|
|
1258
|
+
if (lifecycle.sessionIdStatus === "absent" && !rewriteAllowed) {
|
|
1259
|
+
let reason;
|
|
1260
|
+
if (lifecycle.task && !sourceTask) {
|
|
1261
|
+
reason = "Task lifecycle rewrites are only trusted in the Directive source repository";
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
reason = "this argument shape is outside the narrowly auto-approved rewrite surface";
|
|
1265
|
+
}
|
|
1266
|
+
return deny(input, "occupancy-identity-unavailable", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${reason}. ` +
|
|
1267
|
+
`Re-run the command with --session-id=${identity.sessionId}; Directive will verify ` +
|
|
1268
|
+
"that explicit owner without auto-approving or replacing the command.");
|
|
1269
|
+
}
|
|
1270
|
+
const rewrite = rewriteExactLifecycleCommand(input.payload, identity.sessionId);
|
|
1271
|
+
if (rewrite === null)
|
|
1272
|
+
return decision;
|
|
1273
|
+
if (rewrite.kind === "conflict") {
|
|
1274
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: ${rewrite.message} Host payload owner is ` +
|
|
1275
|
+
`${identity.sessionId}, but the command names ${rewrite.existingSessionId}.`);
|
|
1276
|
+
}
|
|
1277
|
+
return { ...decision, updatedInput: rewrite.updatedInput };
|
|
1278
|
+
}
|
|
745
1279
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
746
1280
|
export function decideHook(input, seams = {}) {
|
|
747
1281
|
const projectRoot = resolve(input.projectRoot);
|
|
@@ -749,6 +1283,8 @@ export function decideHook(input, seams = {}) {
|
|
|
749
1283
|
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
750
1284
|
// Tracked/committed flags do NOT bypass gates (repo-controlled content must
|
|
751
1285
|
// not disable enforcement for clones) — doctor warns instead.
|
|
1286
|
+
// #3794: the kill-switch is pinned to payloadRoot. The flag is untracked, so
|
|
1287
|
+
// linked worktrees do not carry it; re-pointing would silently re-arm.
|
|
752
1288
|
{
|
|
753
1289
|
const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
|
|
754
1290
|
const kill = detectKill(projectRoot);
|
|
@@ -775,6 +1311,12 @@ export function decideHook(input, seams = {}) {
|
|
|
775
1311
|
}
|
|
776
1312
|
}
|
|
777
1313
|
if (input.event === "session.compact") {
|
|
1314
|
+
// #3769 Path 2: compact stays always-allow and never routes through
|
|
1315
|
+
// inspectMutationGates — entering the mutation gates would re-run the
|
|
1316
|
+
// ritual persist Path 1 closes, or occupancy-deny an owner's own compact.
|
|
1317
|
+
// The stale-mark is fail-open: it writes even when no acting identity can
|
|
1318
|
+
// be bound, because compact has no issue context and no surface a human
|
|
1319
|
+
// reads. The accepted cost is an unidentified compact re-arming the owner.
|
|
778
1320
|
try {
|
|
779
1321
|
const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
|
|
780
1322
|
if (!result.changed) {
|
|
@@ -930,13 +1472,17 @@ export function decideHook(input, seams = {}) {
|
|
|
930
1472
|
}
|
|
931
1473
|
return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
|
|
932
1474
|
}
|
|
933
|
-
// Shell
|
|
1475
|
+
// Shell dest-forms (#3438): recognized product mutations share inspectMutationGates
|
|
1476
|
+
// with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
|
|
1477
|
+
// on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
|
|
1478
|
+
if (isShellTool(toolName)) {
|
|
1479
|
+
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
|
|
1480
|
+
return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
|
|
1481
|
+
}
|
|
1482
|
+
// Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
|
|
934
1483
|
// Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
|
|
935
1484
|
// isMcpTool is false — classifyMcpTool is the gate (dispatcher-side, no tools↔policy cycle).
|
|
936
|
-
|
|
937
|
-
if (isShellTool(toolName) ||
|
|
938
|
-
isMcpTool(toolName) ||
|
|
939
|
-
classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
|
|
1485
|
+
if (isMcpTool(toolName) || classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
|
|
940
1486
|
return decideShellOrMcpRuntimeAuthority(input, toolName, seams);
|
|
941
1487
|
}
|
|
942
1488
|
if (!isDirectWriteTool(toolName)) {
|
|
@@ -986,6 +1532,24 @@ function softAgentsRebindWireText(decision) {
|
|
|
986
1532
|
*/
|
|
987
1533
|
export function renderHostDecision(host, decision) {
|
|
988
1534
|
if (decision.verdict === "allow") {
|
|
1535
|
+
if (decision.event === "tool.before" && decision.updatedInput !== undefined) {
|
|
1536
|
+
if (host === "cursor") {
|
|
1537
|
+
return JSON.stringify({
|
|
1538
|
+
permission: "allow",
|
|
1539
|
+
code: decision.code,
|
|
1540
|
+
updated_input: decision.updatedInput,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
if (host === "claude" || host === "codex") {
|
|
1544
|
+
return JSON.stringify({
|
|
1545
|
+
hookSpecificOutput: {
|
|
1546
|
+
hookEventName: "PreToolUse",
|
|
1547
|
+
permissionDecision: "allow",
|
|
1548
|
+
updatedInput: decision.updatedInput,
|
|
1549
|
+
},
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
989
1553
|
const soft = softAgentsRebindWireText(decision);
|
|
990
1554
|
if (host === "cursor") {
|
|
991
1555
|
if (decision.event === "session.start" && soft !== null) {
|