@deftai/directive-core 0.108.0 → 0.109.1
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/cache/operations.js +1 -1
- package/dist/check/gate-lists.d.ts +14 -0
- package/dist/check/gate-lists.js +26 -3
- package/dist/check/session-completed-ac.d.ts +1 -1
- package/dist/check/session-completed-ac.js +1 -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/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 +7 -0
- package/dist/design-critique/citation-grammar.js +1 -1
- package/dist/design-critique/completed-arc-record.d.ts +8 -1
- package/dist/design-critique/completed-arc-record.js +98 -14
- package/dist/hooks/classify/host-session-identity.d.ts +25 -12
- package/dist/hooks/classify/host-session-identity.js +64 -49
- package/dist/hooks/classify/index.d.ts +2 -2
- package/dist/hooks/classify/index.js +2 -2
- package/dist/hooks/classify/paths.d.ts +2 -0
- package/dist/hooks/classify/paths.js +8 -4
- package/dist/hooks/classify/stdin.js +69 -3
- package/dist/hooks/dest-form.d.ts +20 -1
- package/dist/hooks/dest-form.js +158 -21
- package/dist/hooks/dispatcher.d.ts +22 -2
- package/dist/hooks/dispatcher.js +338 -66
- package/dist/hooks/fixtures/cases.d.ts +6 -0
- package/dist/hooks/fixtures/cases.js +53 -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 +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/owner-liveness.d.ts +92 -0
- package/dist/hooks/owner-liveness.js +103 -0
- 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 +49 -2
- package/dist/hooks/tools.js +96 -1
- package/dist/init-deposit/agent-hooks.d.ts +10 -0
- package/dist/init-deposit/agent-hooks.js +39 -0
- package/dist/init-deposit/gitignore.d.ts +7 -0
- package/dist/init-deposit/gitignore.js +24 -0
- package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
- package/dist/init-deposit/host-tool-coverage.js +150 -0
- package/dist/init-deposit/index.d.ts +1 -0
- package/dist/init-deposit/index.js +1 -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/issue-ingest.d.ts +6 -1
- package/dist/intake/issue-ingest.js +21 -2
- 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/orchestration/subagent-monitor.d.ts +6 -0
- package/dist/orchestration/subagent-monitor.js +23 -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 +27 -0
- package/dist/orphan-active/evaluate.js +63 -6
- package/dist/orphan-active/index.d.ts +1 -0
- package/dist/orphan-active/index.js +1 -0
- package/dist/policy/merge-approval-head.js +7 -6
- 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.js +36 -9
- package/dist/pr-wait-mergeable/wrappers.js +3 -3
- package/dist/product-first-done-gate/evaluate.js +6 -2
- package/dist/product-first-done-gate/types.js +2 -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 +2 -0
- package/dist/release/index.js +2 -0
- package/dist/release/pipeline.js +55 -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 +6 -0
- package/dist/review-monitor/github-lease.js +4 -3
- 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 +41 -11
- package/dist/scm/gh-rest.d.ts +25 -12
- package/dist/scm/gh-rest.js +65 -15
- package/dist/scm/index.d.ts +2 -0
- package/dist/scm/index.js +2 -0
- 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/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/child-occupancy.d.ts +72 -0
- package/dist/session/child-occupancy.js +209 -0
- package/dist/session/host-session-owner.d.ts +93 -0
- package/dist/session/host-session-owner.js +148 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/occupancy.d.ts +107 -2
- package/dist/session/occupancy.js +272 -34
- package/dist/session/verify-ac-session-cache.d.ts +2 -2
- package/dist/session/verify-ac-session-cache.js +2 -2
- package/dist/swarm/complete-cohort.js +2 -0
- package/dist/swarm/pre-dispatch.js +2 -0
- package/dist/swarm/subagent-status-dir.d.ts +2 -1
- package/dist/swarm/subagent-status-dir.js +11 -2
- package/dist/swarm/worktrees.js +2 -0
- package/dist/triage/evaluate/worktrees.js +153 -6
- 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/verify-ac/evaluate.d.ts +9 -0
- package/dist/verify-ac/evaluate.js +32 -9
- package/dist/verify-env/agent-hooks.d.ts +6 -1
- package/dist/verify-env/agent-hooks.js +28 -2
- 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/semantic-single-source.d.ts +36 -0
- package/dist/verify-source/semantic-single-source.js +349 -0
- package/package.json +3 -3
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -16,15 +16,18 @@ import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js"
|
|
|
16
16
|
import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
|
|
17
17
|
import { runSessionStartHookWrite } from "../session/session-start-hook.js";
|
|
18
18
|
import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
|
|
19
|
-
import { hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
|
|
20
|
-
import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
|
|
19
|
+
import { hookApplyPatchBodyPaths, hookApplyPatchBodyText, hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, hostIdentityFallsBackToExplicitOwner, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
|
|
20
|
+
import { classifyGitDestructive, classifyProductDestForms, payloadWithInjectedWriteTarget, } from "./dest-form.js";
|
|
21
|
+
import { appendGitDestructiveRecord, GIT_DESTRUCTIVE_LOG_ENV } from "./git-destructive-log.js";
|
|
22
|
+
import { restampOwnerLivenessOnHookEvent, } from "./owner-liveness.js";
|
|
21
23
|
import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
22
24
|
import { inspectActiveScope } from "./scope.js";
|
|
25
|
+
import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
|
|
23
26
|
import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
|
|
24
27
|
// Pure parse/classify helpers are defined in ./classify/ and re-exported from
|
|
25
28
|
// ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
|
|
26
29
|
export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
27
|
-
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
30
|
+
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, HOST_TOOL_SURFACE_AUDIT, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
28
31
|
export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
|
|
29
32
|
export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
|
|
30
33
|
/** Hosts that receive compact/resume hook deposits via init/update (#2113). */
|
|
@@ -104,6 +107,66 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
|
|
|
104
107
|
return false;
|
|
105
108
|
return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
|
|
106
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* ApplyPatch body present but no mutation header could be classified.
|
|
112
|
+
* Fail closed rather than authorizing the declared path alone (#3614).
|
|
113
|
+
*/
|
|
114
|
+
function applyPatchBodyUnclassified(payload) {
|
|
115
|
+
const text = hookApplyPatchBodyText(payload);
|
|
116
|
+
return text !== null && hookApplyPatchBodyPaths(payload).length === 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Proposed-lifecycle exemption is universally quantified over every mutated
|
|
120
|
+
* path, not the declared write target alone (#3614).
|
|
121
|
+
*/
|
|
122
|
+
function allMutationTargetsAreProposedLifecycleWrites(projectRoot, payload) {
|
|
123
|
+
if (applyPatchBodyUnclassified(payload))
|
|
124
|
+
return false;
|
|
125
|
+
const targets = hookMutationTargetPaths(payload);
|
|
126
|
+
if (targets.length === 0)
|
|
127
|
+
return false;
|
|
128
|
+
return targets.every((target) => isProposedLifecycleWrite(projectRoot, target));
|
|
129
|
+
}
|
|
130
|
+
function authzForMutationTargets(input, toolName, seams, options) {
|
|
131
|
+
const targets = hookMutationTargetPaths(input.payload);
|
|
132
|
+
const paths = targets.length > 0 ? targets : [null];
|
|
133
|
+
const pendingConsume = [];
|
|
134
|
+
for (const target of paths) {
|
|
135
|
+
const relPath = target !== null ? toProjectRelativePosix(options.projectRoot, target) : null;
|
|
136
|
+
const denied = authzForMutation(input, toolName, seams, {
|
|
137
|
+
isDirectWrite: options.isDirectWrite,
|
|
138
|
+
relPath,
|
|
139
|
+
scopePath: options.scopePath,
|
|
140
|
+
runGit: options.runGit,
|
|
141
|
+
consumeSingleUse: false,
|
|
142
|
+
pendingConsume,
|
|
143
|
+
});
|
|
144
|
+
if (denied !== null)
|
|
145
|
+
return denied;
|
|
146
|
+
}
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
for (const ref of pendingConsume) {
|
|
149
|
+
if (seen.has(ref))
|
|
150
|
+
continue;
|
|
151
|
+
seen.add(ref);
|
|
152
|
+
try {
|
|
153
|
+
markGrantUsed(options.projectRoot, ref);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Persistence failure must not flip allow after a successful check.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
function allMutationTargetsAreAssistScratch(projectRoot, payload, environ) {
|
|
162
|
+
if (applyPatchBodyUnclassified(payload))
|
|
163
|
+
return false;
|
|
164
|
+
const targets = hookMutationTargetPaths(payload);
|
|
165
|
+
if (targets.length === 0) {
|
|
166
|
+
return isAssistScratchWrite(projectRoot, hookWriteTargetPath(payload), payload, environ);
|
|
167
|
+
}
|
|
168
|
+
return targets.every((target) => isAssistScratchWrite(projectRoot, target, payload, environ));
|
|
169
|
+
}
|
|
107
170
|
/**
|
|
108
171
|
* Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
|
|
109
172
|
* Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
|
|
@@ -268,6 +331,19 @@ function admitPayload(payload, candidate) {
|
|
|
268
331
|
* target that resolved to some other toplevel whose identity cannot be read, is
|
|
269
332
|
* a question asked and left unanswered — that fails closed rather than
|
|
270
333
|
* inheriting payloadRoot's occupancy and ritual state (#3794).
|
|
334
|
+
*
|
|
335
|
+
* The `candidateRaw === null` fallback below — a target whose nearest existing
|
|
336
|
+
* ancestor has no Git toplevel at all (OS temp, a home file, anything outside
|
|
337
|
+
* every checkout) — is the third case and is deliberate, not a gap (#4013).
|
|
338
|
+
* `effectiveRoot` selects occupancy, ritual, active scope, the write fence and
|
|
339
|
+
* assist-scratch, so returning no root would drop all of them for an
|
|
340
|
+
* out-of-tree write, not just the lease; occupancy and ritual are cross-checked
|
|
341
|
+
* and the allow path re-stamps the lease, so exempting the lease alone is not a
|
|
342
|
+
* complete state transition; and `admitMutationTargetSet` demands one unique
|
|
343
|
+
* root, so a member contributing none has no defined combining rule and an
|
|
344
|
+
* untrusted patch path would become an authority-selection input. Gate-by-gate
|
|
345
|
+
* disposition, the three-surface matrix and the relative-target
|
|
346
|
+
* canonicalization limitation (#4023): content/docs/hook-root-admission.md.
|
|
271
347
|
*/
|
|
272
348
|
export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
|
|
273
349
|
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
@@ -373,19 +449,20 @@ function deny(input, code, toolName, message, scopePath = null) {
|
|
|
373
449
|
}
|
|
374
450
|
/**
|
|
375
451
|
* Resolve the cooperative actor presented to the occupancy gate.
|
|
376
|
-
*
|
|
377
|
-
* corroborate.
|
|
378
|
-
*
|
|
452
|
+
* A resolved host identity is authoritative; ambient identity may only
|
|
453
|
+
* corroborate. Hosts whose identity source is the hook environment fall back to
|
|
454
|
+
* the explicit owner flow when that variable is absent (#3873).
|
|
379
455
|
*/
|
|
380
456
|
function resolveMutationActor(input, environ) {
|
|
381
|
-
const hostIdentity = resolveHookHostIdentity(input.host, input.payload);
|
|
457
|
+
const hostIdentity = resolveHookHostIdentity(input.host, input.payload, environ);
|
|
382
458
|
const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
|
|
383
|
-
if (hostIdentity.status === "unsupported"
|
|
459
|
+
if (hostIdentity.status === "unsupported" ||
|
|
460
|
+
hostIdentityFallsBackToExplicitOwner(input.host, hostIdentity)) {
|
|
384
461
|
return {
|
|
385
462
|
sessionId: environmentId,
|
|
386
463
|
issue: null,
|
|
387
464
|
message: null,
|
|
388
|
-
|
|
465
|
+
hostAuthoritative: false,
|
|
389
466
|
};
|
|
390
467
|
}
|
|
391
468
|
if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
|
|
@@ -393,23 +470,23 @@ function resolveMutationActor(input, environ) {
|
|
|
393
470
|
return {
|
|
394
471
|
sessionId: hostIdentity.sessionId,
|
|
395
472
|
issue: "conflict",
|
|
396
|
-
message: `Host
|
|
473
|
+
message: `Host owner ${hostIdentity.sessionId} conflicts with ` +
|
|
397
474
|
`DEFT_SESSION_ID ${environmentId}.`,
|
|
398
|
-
|
|
475
|
+
hostAuthoritative: true,
|
|
399
476
|
};
|
|
400
477
|
}
|
|
401
478
|
return {
|
|
402
479
|
sessionId: hostIdentity.sessionId,
|
|
403
480
|
issue: null,
|
|
404
481
|
message: null,
|
|
405
|
-
|
|
482
|
+
hostAuthoritative: true,
|
|
406
483
|
};
|
|
407
484
|
}
|
|
408
485
|
return {
|
|
409
486
|
sessionId: undefined,
|
|
410
487
|
issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
|
|
411
488
|
message: hostIdentity.message,
|
|
412
|
-
|
|
489
|
+
hostAuthoritative: true,
|
|
413
490
|
};
|
|
414
491
|
}
|
|
415
492
|
function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
|
|
@@ -448,20 +525,28 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effec
|
|
|
448
525
|
// Neither layer active → allow (same as disabled runtimeAuthority historically).
|
|
449
526
|
if (!fence.fenceActive)
|
|
450
527
|
return null;
|
|
451
|
-
const writeTarget = hookWriteTargetPath(input.payload);
|
|
452
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
|
|
453
|
-
const verdict = evaluateRuntimeAuthorityDirectWrite({
|
|
454
|
-
policy: fence.policy,
|
|
455
|
-
relPathPosix: relPath,
|
|
456
|
-
});
|
|
457
|
-
if (verdict.allowed)
|
|
458
|
-
return null;
|
|
459
528
|
// #3794: relPath is now measured from the tree the write lands in. When that
|
|
460
529
|
// differs from the payload root, a bare "outside file_scope" reason does not
|
|
461
530
|
// say which tree the fence was relativised against.
|
|
462
531
|
const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
|
|
463
|
-
|
|
464
|
-
|
|
532
|
+
if (applyPatchBodyUnclassified(input.payload)) {
|
|
533
|
+
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." +
|
|
534
|
+
fenceRootNote, scopePath);
|
|
535
|
+
}
|
|
536
|
+
const targets = hookMutationTargetPaths(input.payload);
|
|
537
|
+
const toCheck = targets.length > 0 ? targets : [null];
|
|
538
|
+
for (const target of toCheck) {
|
|
539
|
+
const relPath = target !== null ? toProjectRelativePosix(fenceRoot, target) : null;
|
|
540
|
+
const verdict = evaluateRuntimeAuthorityDirectWrite({
|
|
541
|
+
policy: fence.policy,
|
|
542
|
+
relPathPosix: relPath,
|
|
543
|
+
});
|
|
544
|
+
if (!verdict.allowed) {
|
|
545
|
+
return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
|
|
546
|
+
fenceRootNote, scopePath);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
465
550
|
}
|
|
466
551
|
function loadAuthzContext(projectRoot, seams) {
|
|
467
552
|
if (seams.loadAuthzState !== undefined) {
|
|
@@ -582,12 +667,17 @@ function authzForMutation(input, toolName, seams, options) {
|
|
|
582
667
|
}
|
|
583
668
|
// Consume single-use grants after allow (persist usedAt).
|
|
584
669
|
if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
|
|
585
|
-
|
|
586
|
-
|
|
670
|
+
if (options.consumeSingleUse === false) {
|
|
671
|
+
options.pendingConsume?.push(decision.humanApprovalRef);
|
|
587
672
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
673
|
+
else {
|
|
674
|
+
try {
|
|
675
|
+
markGrantUsed(projectRoot, decision.humanApprovalRef);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// Persistence failure must not flip allow → deny after a successful check;
|
|
679
|
+
// next load will still see unused if write failed (prefer allow-once risk over deadlock).
|
|
680
|
+
}
|
|
591
681
|
}
|
|
592
682
|
}
|
|
593
683
|
}
|
|
@@ -711,6 +801,13 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
711
801
|
? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
|
|
712
802
|
: admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
|
|
713
803
|
const effectiveRoot = admission.root;
|
|
804
|
+
if (options.observation !== undefined) {
|
|
805
|
+
if (!options.observation.effectiveRoots.includes(effectiveRoot)) {
|
|
806
|
+
options.observation.effectiveRoots.push(effectiveRoot);
|
|
807
|
+
}
|
|
808
|
+
if (admission.foreign)
|
|
809
|
+
options.observation.foreignTarget = true;
|
|
810
|
+
}
|
|
714
811
|
const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
|
|
715
812
|
if (admission.foreign) {
|
|
716
813
|
const candidate = admission.candidate ?? "<none>";
|
|
@@ -727,17 +824,15 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
727
824
|
// assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
|
|
728
825
|
// Tracked product paths never match the path fence. Read-only still denied upstream.
|
|
729
826
|
if (!isSpawnTool(toolName)) {
|
|
730
|
-
const scratchTarget = hookWriteTargetPath(input.payload);
|
|
731
827
|
// #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
|
|
732
828
|
// file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
|
|
733
829
|
// assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
|
|
734
|
-
if (
|
|
735
|
-
const
|
|
736
|
-
const authzDeny = authzForMutation(input, toolName, seams, {
|
|
830
|
+
if (allMutationTargetsAreAssistScratch(effectiveRoot, input.payload, environ)) {
|
|
831
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
737
832
|
isDirectWrite: true,
|
|
738
|
-
relPath,
|
|
739
833
|
scopePath: null,
|
|
740
834
|
runGit: dispatchGit,
|
|
835
|
+
projectRoot,
|
|
741
836
|
});
|
|
742
837
|
if (authzDeny !== null)
|
|
743
838
|
return authzDeny;
|
|
@@ -769,8 +864,8 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
769
864
|
}
|
|
770
865
|
: evaluateOccupancyWriteGate(effectiveRoot, {
|
|
771
866
|
sessionId: actor?.sessionId,
|
|
772
|
-
//
|
|
773
|
-
env: actor?.
|
|
867
|
+
// Hosts with a resolved owner must not fall back to a stale ambient one.
|
|
868
|
+
env: actor?.hostAuthoritative === true ? {} : environ,
|
|
774
869
|
});
|
|
775
870
|
// #3769: occupancy decides before any ritual persist. The gated verifier
|
|
776
871
|
// executes agent_hooks and rebinds a forward HEAD, and both rewrite
|
|
@@ -907,7 +1002,7 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
907
1002
|
return null;
|
|
908
1003
|
const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
|
|
909
1004
|
sessionId: actor.sessionId,
|
|
910
|
-
env: actor.
|
|
1005
|
+
env: actor.hostAuthoritative ? {} : environ,
|
|
911
1006
|
refresh: true,
|
|
912
1007
|
});
|
|
913
1008
|
occupancyWarning = finalOccupancy.warning;
|
|
@@ -941,15 +1036,15 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
941
1036
|
};
|
|
942
1037
|
const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
|
|
943
1038
|
if (options.proposedLifecycleExempt) {
|
|
944
|
-
|
|
945
|
-
if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
|
|
946
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
1039
|
+
if (allMutationTargetsAreProposedLifecycleWrites(effectiveRoot, input.payload)) {
|
|
947
1040
|
// UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
|
|
948
|
-
|
|
1041
|
+
// Authz and the write fence run on every mutated path, not the declared
|
|
1042
|
+
// one (#3614).
|
|
1043
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
949
1044
|
isDirectWrite: true,
|
|
950
|
-
relPath,
|
|
951
1045
|
scopePath: null,
|
|
952
1046
|
runGit: dispatchGit,
|
|
1047
|
+
projectRoot,
|
|
953
1048
|
});
|
|
954
1049
|
if (authzDeny !== null)
|
|
955
1050
|
return authzDeny;
|
|
@@ -993,7 +1088,9 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
993
1088
|
// worktree that lives outside the payload root keeps today's skip. Worktrees
|
|
994
1089
|
// under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
|
|
995
1090
|
// are inside it and are now gated on their own active scope.
|
|
996
|
-
const
|
|
1091
|
+
const mutationTargets = hookMutationTargetPaths(input.payload);
|
|
1092
|
+
const outsideRoot = mutationTargets.length > 0 &&
|
|
1093
|
+
mutationTargets.every((target) => isOutsideProjectRootWrite(projectRoot, target));
|
|
997
1094
|
if (!outsideRoot || isSpawnTool(toolName)) {
|
|
998
1095
|
let proposedPathHint;
|
|
999
1096
|
if (isSpawnTool(toolName)) {
|
|
@@ -1054,14 +1151,12 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
1054
1151
|
}
|
|
1055
1152
|
}
|
|
1056
1153
|
if (!isSpawnTool(toolName)) {
|
|
1057
|
-
const writeTarget = hookWriteTargetPath(input.payload);
|
|
1058
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
1059
1154
|
// Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
|
|
1060
|
-
const authzDeny =
|
|
1155
|
+
const authzDeny = authzForMutationTargets(input, toolName, seams, {
|
|
1061
1156
|
isDirectWrite: true,
|
|
1062
|
-
relPath,
|
|
1063
1157
|
scopePath: scope.path,
|
|
1064
1158
|
runGit: dispatchGit,
|
|
1159
|
+
projectRoot,
|
|
1065
1160
|
});
|
|
1066
1161
|
if (authzDeny !== null)
|
|
1067
1162
|
return authzDeny;
|
|
@@ -1083,11 +1178,106 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
1083
1178
|
scopePath: scope.path,
|
|
1084
1179
|
};
|
|
1085
1180
|
}
|
|
1181
|
+
function gitDestructiveIsFixture(form, projectRoot) {
|
|
1182
|
+
if (form.unprovable || form.relocators.length === 0)
|
|
1183
|
+
return false;
|
|
1184
|
+
return form.relocators.every((raw) => {
|
|
1185
|
+
const path = raw.trim();
|
|
1186
|
+
if (path.length === 0 || !isAbsolute(path))
|
|
1187
|
+
return false;
|
|
1188
|
+
return isOutsideProjectRootWrite(projectRoot, path);
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
const GIT_DESTRUCTIVE_REMEDIATION = "Tree-wide destructive git (reset --hard, clean -f, checkout -f / switch -f, " +
|
|
1192
|
+
"stash drop/clear) is fail-closed when aimed at this checkout. Rewrite as a " +
|
|
1193
|
+
"non-destructive command, or run it against an absolute out-of-root fixture " +
|
|
1194
|
+
"(`git -C /abs/fixture reset --hard`). This is a guard, not a root-cause claim.";
|
|
1195
|
+
/**
|
|
1196
|
+
* Always-on recognition for tree-wide destructive git (#3917). Independent of
|
|
1197
|
+
* `shellDestForms`. Records a durable JSONL line outside the repository so the
|
|
1198
|
+
* next occurrence names its actor even if reflogs are gone.
|
|
1199
|
+
*/
|
|
1200
|
+
function decideGitDestructive(input, toolName) {
|
|
1201
|
+
const command = hookShellCommand(input.payload);
|
|
1202
|
+
if (command === null)
|
|
1203
|
+
return null;
|
|
1204
|
+
const forms = classifyGitDestructive(command);
|
|
1205
|
+
if (forms.length === 0)
|
|
1206
|
+
return null;
|
|
1207
|
+
const projectRoot = resolve(input.projectRoot);
|
|
1208
|
+
const env = input.environ ?? process.env;
|
|
1209
|
+
const actor = env.DEFT_SESSION_ID?.trim() || env.GROK_SESSION_ID?.trim() || "";
|
|
1210
|
+
const logPath = env[GIT_DESTRUCTIVE_LOG_ENV]?.trim() || undefined;
|
|
1211
|
+
let anyProjectAimed = false;
|
|
1212
|
+
for (const form of forms) {
|
|
1213
|
+
const fixture = gitDestructiveIsFixture(form, projectRoot);
|
|
1214
|
+
if (!fixture)
|
|
1215
|
+
anyProjectAimed = true;
|
|
1216
|
+
appendGitDestructiveRecord({
|
|
1217
|
+
ts: new Date().toISOString(),
|
|
1218
|
+
kind: form.kind,
|
|
1219
|
+
disposition: fixture ? "allow-fixture" : "deny",
|
|
1220
|
+
command,
|
|
1221
|
+
projectRoot,
|
|
1222
|
+
host: input.host,
|
|
1223
|
+
toolName,
|
|
1224
|
+
actor,
|
|
1225
|
+
pid: process.pid,
|
|
1226
|
+
relocators: form.relocators,
|
|
1227
|
+
unprovable: form.unprovable,
|
|
1228
|
+
}, { env, logPath });
|
|
1229
|
+
}
|
|
1230
|
+
if (!anyProjectAimed) {
|
|
1231
|
+
return {
|
|
1232
|
+
verdict: "allow",
|
|
1233
|
+
code: "git-destructive-fixture",
|
|
1234
|
+
event: input.event,
|
|
1235
|
+
host: input.host,
|
|
1236
|
+
toolName,
|
|
1237
|
+
projectRoot,
|
|
1238
|
+
message: `Directive allowed ${toolName}: destructive git is aimed at an out-of-root fixture.`,
|
|
1239
|
+
scopePath: null,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
return deny(input, "git-destructive-deny", toolName, `Directive denied ${toolName}: ${GIT_DESTRUCTIVE_REMEDIATION}`);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
|
|
1246
|
+
* Deny is returned; allow falls through so dest-forms and push/merge still run.
|
|
1247
|
+
*/
|
|
1248
|
+
function decideShellWriteReissue(input, toolName, seams, observation) {
|
|
1249
|
+
const command = hookShellCommand(input.payload);
|
|
1250
|
+
if (command === null)
|
|
1251
|
+
return null;
|
|
1252
|
+
const projectRoot = resolve(input.projectRoot);
|
|
1253
|
+
for (const dest of classifyShellWriteTargets(command)) {
|
|
1254
|
+
if (dest.unprovable === true) {
|
|
1255
|
+
if (!isInRepoShellWritePath(projectRoot, dest.path))
|
|
1256
|
+
continue;
|
|
1257
|
+
return deny(input, "scope-not-ready", toolName, "Directive denied " +
|
|
1258
|
+
toolName +
|
|
1259
|
+
": Shell write dest is not reconstructable (compound command). Use one simple command or Edit/Write.");
|
|
1260
|
+
}
|
|
1261
|
+
if (!isInRepoShellWritePath(projectRoot, dest.path))
|
|
1262
|
+
continue;
|
|
1263
|
+
const destInput = {
|
|
1264
|
+
...input,
|
|
1265
|
+
payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
|
|
1266
|
+
};
|
|
1267
|
+
const destDecision = inspectMutationGates(destInput, toolName, seams, {
|
|
1268
|
+
proposedLifecycleExempt: true,
|
|
1269
|
+
observation,
|
|
1270
|
+
});
|
|
1271
|
+
if (destDecision.verdict === "deny")
|
|
1272
|
+
return destDecision;
|
|
1273
|
+
}
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1086
1276
|
/**
|
|
1087
1277
|
* Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
|
|
1088
1278
|
* Dest-form allow is kept when runtime authority has nothing classifiable.
|
|
1089
1279
|
*/
|
|
1090
|
-
function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
1280
|
+
function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation) {
|
|
1091
1281
|
const command = hookShellCommand(input.payload);
|
|
1092
1282
|
let destAllow = null;
|
|
1093
1283
|
let expansionDeny = null;
|
|
@@ -1119,6 +1309,7 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
|
1119
1309
|
};
|
|
1120
1310
|
const destDecision = inspectMutationGates(destInput, toolName, seams, {
|
|
1121
1311
|
proposedLifecycleExempt: true,
|
|
1312
|
+
observation,
|
|
1122
1313
|
});
|
|
1123
1314
|
if (destDecision.verdict === "deny")
|
|
1124
1315
|
return destDecision;
|
|
@@ -1205,6 +1396,18 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
|
|
|
1205
1396
|
}
|
|
1206
1397
|
return { aligned: true, message: "execution directory is project-root aligned" };
|
|
1207
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Hosts whose PreToolUse wire format can carry a rewritten tool input.
|
|
1401
|
+
*
|
|
1402
|
+
* `renderHostDecision` reads the same list, so the bridge cannot offer -- or
|
|
1403
|
+
* deny on behalf of -- a delivery channel a host does not have (#3873). A host
|
|
1404
|
+
* outside it binds its claim in the CLI instead: `resolveOccupancySessionId`
|
|
1405
|
+
* resolves the same ambient host owner the write gate will present.
|
|
1406
|
+
*/
|
|
1407
|
+
const UPDATED_INPUT_WIRE_HOSTS = ["codex", "claude", "cursor"];
|
|
1408
|
+
function hostAcceptsUpdatedInput(host) {
|
|
1409
|
+
return UPDATED_INPUT_WIRE_HOSTS.includes(host);
|
|
1410
|
+
}
|
|
1208
1411
|
/**
|
|
1209
1412
|
* Add the claim-time owner only after the existing shell decision allowed the
|
|
1210
1413
|
* command. The host wire format must emit `allow` with updated input, so this
|
|
@@ -1213,6 +1416,8 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
|
|
|
1213
1416
|
function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
1214
1417
|
if (decision.verdict !== "allow")
|
|
1215
1418
|
return decision;
|
|
1419
|
+
if (!hostAcceptsUpdatedInput(input.host))
|
|
1420
|
+
return decision;
|
|
1216
1421
|
const lifecycle = inspectExactLifecycleCommand(input.payload);
|
|
1217
1422
|
if (lifecycle === null)
|
|
1218
1423
|
return decision;
|
|
@@ -1222,15 +1427,20 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
|
1222
1427
|
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1223
1428
|
"--session-id is empty, duplicated, or otherwise ambiguous.");
|
|
1224
1429
|
}
|
|
1225
|
-
const identity = resolveHookHostIdentity(input.host, input.payload);
|
|
1226
|
-
|
|
1430
|
+
const identity = resolveHookHostIdentity(input.host, input.payload, input.environ ?? process.env);
|
|
1431
|
+
// #3873: an absent host-env variable is the pre-#3873 explicit-owner flow, not
|
|
1432
|
+
// a broken contract. Claim binding is skipped, the command keeps ordinary host
|
|
1433
|
+
// permission handling, and any owner it carries is still its own.
|
|
1434
|
+
if (identity.status === "unsupported" ||
|
|
1435
|
+
hostIdentityFallsBackToExplicitOwner(input.host, identity)) {
|
|
1227
1436
|
return decision;
|
|
1437
|
+
}
|
|
1228
1438
|
if (identity.status !== "ok" || identity.sessionId === null) {
|
|
1229
1439
|
const code = identity.status === "conflict"
|
|
1230
1440
|
? "occupancy-identity-conflict"
|
|
1231
1441
|
: "occupancy-identity-unavailable";
|
|
1232
1442
|
return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
|
|
1233
|
-
`${identity.message ?? "host
|
|
1443
|
+
`${identity.message ?? "host session identity is unavailable"}. ` +
|
|
1234
1444
|
"Directive cannot bind the occupancy claim without a stable host owner; " +
|
|
1235
1445
|
"manual callers must pass --session-id explicitly outside the host rewrite path.");
|
|
1236
1446
|
}
|
|
@@ -1238,12 +1448,12 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
|
1238
1448
|
if (environmentId !== undefined &&
|
|
1239
1449
|
environmentId.length > 0 &&
|
|
1240
1450
|
environmentId !== identity.sessionId) {
|
|
1241
|
-
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host
|
|
1451
|
+
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host owner ${identity.sessionId} conflicts with ` +
|
|
1242
1452
|
`DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
|
|
1243
1453
|
}
|
|
1244
1454
|
if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
|
|
1245
1455
|
return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
|
|
1246
|
-
`${lifecycle.sessionId ?? "<missing>"}, but the host
|
|
1456
|
+
`${lifecycle.sessionId ?? "<missing>"}, but the host owner is ` +
|
|
1247
1457
|
`${identity.sessionId}.`);
|
|
1248
1458
|
}
|
|
1249
1459
|
const sourceTask = !lifecycle.task ||
|
|
@@ -1276,8 +1486,55 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
|
1276
1486
|
}
|
|
1277
1487
|
return { ...decision, updatedInput: rewrite.updatedInput };
|
|
1278
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Renew the owner's occupancy lease from a hook event that already proved the
|
|
1491
|
+
* owner is present (#3987).
|
|
1492
|
+
*
|
|
1493
|
+
* Deliberately runs AFTER the decision and never feeds it. Two reasons: a
|
|
1494
|
+
* liveness re-stamp must not be able to change a verdict, and re-stamping first
|
|
1495
|
+
* would reset the shared age floor and so suppress the mutation gate's own
|
|
1496
|
+
* `markWrite = true` refresh — leaving an actively-writing owner recorded as
|
|
1497
|
+
* having no recorded write. Failure is swallowed: liveness is bookkeeping, and
|
|
1498
|
+
* a lease that cannot be renewed simply ages out as it did before.
|
|
1499
|
+
*/
|
|
1500
|
+
function restampOwnerLiveness(input, seams, observation) {
|
|
1501
|
+
if (input.event !== "tool.before")
|
|
1502
|
+
return;
|
|
1503
|
+
// A refused admission proves no tree at all, so there is nothing to renew.
|
|
1504
|
+
if (observation.foreignTarget)
|
|
1505
|
+
return;
|
|
1506
|
+
try {
|
|
1507
|
+
const actor = resolveMutationActor(input, input.environ ?? process.env);
|
|
1508
|
+
const restamp = seams.restampOwnerLiveness ?? restampOwnerLivenessOnHookEvent;
|
|
1509
|
+
// The trees occupancy was authorized against, not the payload root. Each
|
|
1510
|
+
// was admitted on its own, so each is renewed on its own.
|
|
1511
|
+
const roots = observation.effectiveRoots.length > 0
|
|
1512
|
+
? observation.effectiveRoots
|
|
1513
|
+
: [resolve(input.projectRoot)];
|
|
1514
|
+
for (const projectRoot of roots) {
|
|
1515
|
+
restamp({
|
|
1516
|
+
projectRoot,
|
|
1517
|
+
ownerSessionId: actor.sessionId,
|
|
1518
|
+
hostAuthoritative: actor.hostAuthoritative && actor.issue === null,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
/* liveness is best-effort bookkeeping; never let it affect a verdict */
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1279
1526
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
1280
1527
|
export function decideHook(input, seams = {}) {
|
|
1528
|
+
const observation = { effectiveRoots: [], foreignTarget: false };
|
|
1529
|
+
const decision = routeHookDecision(input, seams, observation);
|
|
1530
|
+
// Skipped for the #3039 kill-switch and #2926 opt-out, which short-circuit
|
|
1531
|
+
// before any Directive enforcement runs — including this bookkeeping.
|
|
1532
|
+
if (decision.code !== "directive-disabled" && decision.code !== "session-start-disabled") {
|
|
1533
|
+
restampOwnerLiveness(input, seams, observation);
|
|
1534
|
+
}
|
|
1535
|
+
return decision;
|
|
1536
|
+
}
|
|
1537
|
+
function routeHookDecision(input, seams, observation) {
|
|
1281
1538
|
const projectRoot = resolve(input.projectRoot);
|
|
1282
1539
|
// #3039: local (untracked) `.deft-directive-disable` wins for enforcement
|
|
1283
1540
|
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
@@ -1470,13 +1727,25 @@ export function decideHook(input, seams = {}) {
|
|
|
1470
1727
|
scopePath: null,
|
|
1471
1728
|
};
|
|
1472
1729
|
}
|
|
1473
|
-
return inspectMutationGates(input, toolName, seams, {
|
|
1730
|
+
return inspectMutationGates(input, toolName, seams, {
|
|
1731
|
+
proposedLifecycleExempt: false,
|
|
1732
|
+
observation,
|
|
1733
|
+
});
|
|
1474
1734
|
}
|
|
1475
|
-
//
|
|
1735
|
+
// Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
|
|
1736
|
+
// Dest-forms (#3438): recognized product mutations share inspectMutationGates
|
|
1476
1737
|
// with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
|
|
1477
1738
|
// on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
|
|
1478
1739
|
if (isShellTool(toolName)) {
|
|
1479
|
-
const
|
|
1740
|
+
const destructive = decideGitDestructive(input, toolName);
|
|
1741
|
+
if (destructive !== null) {
|
|
1742
|
+
return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
|
|
1743
|
+
}
|
|
1744
|
+
const writeReissue = decideShellWriteReissue(input, toolName, seams, observation);
|
|
1745
|
+
if (writeReissue !== null) {
|
|
1746
|
+
return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
|
|
1747
|
+
}
|
|
1748
|
+
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation);
|
|
1480
1749
|
return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
|
|
1481
1750
|
}
|
|
1482
1751
|
// Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
|
|
@@ -1497,7 +1766,10 @@ export function decideHook(input, seams = {}) {
|
|
|
1497
1766
|
scopePath: null,
|
|
1498
1767
|
};
|
|
1499
1768
|
}
|
|
1500
|
-
return inspectMutationGates(input, toolName, seams, {
|
|
1769
|
+
return inspectMutationGates(input, toolName, seams, {
|
|
1770
|
+
proposedLifecycleExempt: true,
|
|
1771
|
+
observation,
|
|
1772
|
+
});
|
|
1501
1773
|
}
|
|
1502
1774
|
/**
|
|
1503
1775
|
* Soft AGENTS re-bind text for host injection when decision carries soft path (#3171).
|
|
@@ -1532,7 +1804,9 @@ function softAgentsRebindWireText(decision) {
|
|
|
1532
1804
|
*/
|
|
1533
1805
|
export function renderHostDecision(host, decision) {
|
|
1534
1806
|
if (decision.verdict === "allow") {
|
|
1535
|
-
if (decision.event === "tool.before" &&
|
|
1807
|
+
if (decision.event === "tool.before" &&
|
|
1808
|
+
decision.updatedInput !== undefined &&
|
|
1809
|
+
hostAcceptsUpdatedInput(host)) {
|
|
1536
1810
|
if (host === "cursor") {
|
|
1537
1811
|
return JSON.stringify({
|
|
1538
1812
|
permission: "allow",
|
|
@@ -1540,15 +1814,13 @@ export function renderHostDecision(host, decision) {
|
|
|
1540
1814
|
updated_input: decision.updatedInput,
|
|
1541
1815
|
});
|
|
1542
1816
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1817
|
+
return JSON.stringify({
|
|
1818
|
+
hookSpecificOutput: {
|
|
1819
|
+
hookEventName: "PreToolUse",
|
|
1820
|
+
permissionDecision: "allow",
|
|
1821
|
+
updatedInput: decision.updatedInput,
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1552
1824
|
}
|
|
1553
1825
|
const soft = softAgentsRebindWireText(decision);
|
|
1554
1826
|
if (host === "cursor") {
|
|
@@ -19,6 +19,12 @@ export interface HookFixtureCase {
|
|
|
19
19
|
readonly payload?: unknown;
|
|
20
20
|
/** Raw stdin when testing BOM / free-form ApplyPatch parse. */
|
|
21
21
|
readonly raw?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Hook process environment for `host-env` identity providers (#3873).
|
|
24
|
+
* Identity assertions resolve against this instead of the ambient
|
|
25
|
+
* environment, so a corpus case cannot pass or fail by where it runs.
|
|
26
|
+
*/
|
|
27
|
+
readonly environ?: Readonly<Record<string, string>>;
|
|
22
28
|
readonly expected: {
|
|
23
29
|
readonly toolName: string | null;
|
|
24
30
|
readonly writeIntent: HookWriteIntent;
|