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