@deftai/directive-core 0.107.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/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/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/dispatcher.d.ts +52 -1
- package/dist/hooks/dispatcher.js +538 -45
- package/dist/hooks/fixtures/cases.d.ts +16 -2
- package/dist/hooks/fixtures/cases.js +175 -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/issue-ingest.js +62 -2
- package/dist/intake/platform-capabilities.d.ts +9 -2
- package/dist/intake/platform-capabilities.js +38 -9
- 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/value-feedback.js +9 -18
- package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
- package/dist/pr-closeout-attestable/evaluate.js +306 -0
- package/dist/pr-closeout-attestable/index.d.ts +2 -0
- package/dist/pr-closeout-attestable/index.js +2 -0
- package/dist/pr-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/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 +17 -7
- 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 +1 -0
- package/dist/scm/design-critique-chip.js +22 -6
- package/dist/scm/gh-rest.d.ts +9 -1
- package/dist/scm/gh-rest.js +103 -0
- package/dist/scm/readiness.d.ts +2 -0
- package/dist/scm/readiness.js +28 -4
- package/dist/scope/project-definition-sync.js +14 -7
- package/dist/session/git.d.ts +24 -0
- package/dist/session/git.js +138 -2
- package/dist/session/occupancy.d.ts +238 -12
- package/dist/session/occupancy.js +815 -29
- package/dist/session/ritual-sentinel.d.ts +5 -0
- package/dist/session/ritual-sentinel.js +5 -0
- package/dist/session/session-ready.d.ts +5 -1
- package/dist/session/session-ready.js +98 -8
- package/dist/session/session-start.d.ts +5 -1
- package/dist/session/session-start.js +32 -17
- package/dist/session/toolchain-preflight.d.ts +13 -3
- package/dist/session/toolchain-preflight.js +93 -18
- package/dist/session/verify-session-ritual.d.ts +11 -0
- package/dist/session/verify-session-ritual.js +60 -15
- package/dist/slice/constants.d.ts +1 -1
- package/dist/spec-authority/constants.d.ts +10 -2
- package/dist/spec-authority/constants.js +53 -8
- package/dist/spec-authority/resolver.d.ts +3 -0
- package/dist/spec-authority/resolver.js +55 -7
- package/dist/subprocess/max-buffer.d.ts +14 -0
- package/dist/subprocess/max-buffer.js +15 -0
- package/dist/swarm/complete-cohort.d.ts +2 -0
- package/dist/swarm/complete-cohort.js +21 -13
- package/dist/swarm/index.d.ts +1 -0
- package/dist/swarm/index.js +1 -0
- package/dist/swarm/launch-cli.js +53 -33
- package/dist/swarm/launch.d.ts +8 -0
- package/dist/swarm/launch.js +63 -43
- package/dist/swarm/pre-dispatch-cli.js +2 -1
- package/dist/swarm/pre-dispatch.js +10 -1
- package/dist/swarm/subagent-status-dir.d.ts +27 -0
- package/dist/swarm/subagent-status-dir.js +42 -0
- package/dist/swarm/worktrees.d.ts +17 -3
- package/dist/swarm/worktrees.js +48 -4
- package/dist/triage/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/vbrief-build/index.d.ts +2 -1
- package/dist/vbrief-build/index.js +2 -1
- package/dist/vbrief-build/parity-scenarios.js +5 -5
- package/dist/vbrief-build/project-definition-io.d.ts +63 -3
- package/dist/vbrief-build/project-definition-io.js +357 -31
- package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
- package/dist/vbrief-build/project-definition-mutation.js +43 -0
- package/dist/vbrief-validate/conformance.d.ts +1 -0
- package/dist/vbrief-validate/conformance.js +85 -8
- package/dist/vbrief-validate/main.js +2 -0
- package/dist/vbrief-validate/precutover.js +5 -10
- package/dist/verify-ac/clauses.d.ts +62 -2
- package/dist/verify-ac/clauses.js +183 -111
- package/dist/verify-ac/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/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,13 @@ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRE
|
|
|
9
10
|
import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
|
|
10
11
|
import { loadStoryWriteFenceFromPath, resolveWriteFence } from "../policy/write-fence.js";
|
|
11
12
|
import { appendSoftAgentsRebindToMessage, decisionCarriesSoftAgentsRebind, formatSoftAgentsRebindChecklist, } from "../session/compact-ritual.js";
|
|
12
|
-
import { detectBranch } from "../session/git.js";
|
|
13
|
+
import { defaultGitRunner, detectBranch, existingAncestorDir, gitCommonDir, memoizeGitRunner, worktreePathOrNull, } from "../session/git.js";
|
|
13
14
|
import { evaluateOccupancyWriteGate } from "../session/occupancy.js";
|
|
14
15
|
import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js";
|
|
15
16
|
import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
|
|
16
17
|
import { runSessionStartHookWrite } from "../session/session-start-hook.js";
|
|
17
|
-
import { formatRitualRecoveryInstruction, verifySessionRitual, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
|
|
18
|
-
import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
|
|
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";
|
|
19
20
|
import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
|
|
20
21
|
import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
21
22
|
import { inspectActiveScope } from "./scope.js";
|
|
@@ -193,8 +194,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
|
|
|
193
194
|
return true;
|
|
194
195
|
return false;
|
|
195
196
|
}
|
|
196
|
-
function isWindowsDriveOnlyRoot(value) {
|
|
197
|
-
return /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
197
|
+
function isWindowsDriveOnlyRoot(value, platform = process.platform) {
|
|
198
|
+
return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
198
199
|
}
|
|
199
200
|
function hookPayloadRootCandidates(input) {
|
|
200
201
|
const candidates = [];
|
|
@@ -250,22 +251,113 @@ export function projectRootFromHookPayload(payload, fallback) {
|
|
|
250
251
|
}
|
|
251
252
|
return fallbackResolved;
|
|
252
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
|
+
}
|
|
253
328
|
export function isHookHost(value) {
|
|
254
329
|
return HOOK_HOSTS.includes(value);
|
|
255
330
|
}
|
|
256
331
|
export function isHookEvent(value) {
|
|
257
332
|
return HOOK_EVENTS.includes(value);
|
|
258
333
|
}
|
|
259
|
-
|
|
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) {
|
|
260
345
|
if (isSpawnTool(toolName))
|
|
261
346
|
return "";
|
|
262
347
|
if (isShellTool(toolName)) {
|
|
263
348
|
return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
|
|
264
349
|
"(git checkout --, git restore, rm/rmdir of in-repo paths).");
|
|
265
350
|
}
|
|
266
|
-
|
|
267
|
-
"
|
|
268
|
-
|
|
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.");
|
|
269
361
|
}
|
|
270
362
|
function deny(input, code, toolName, message, scopePath = null) {
|
|
271
363
|
return {
|
|
@@ -279,8 +371,55 @@ function deny(input, code, toolName, message, scopePath = null) {
|
|
|
279
371
|
scopePath,
|
|
280
372
|
};
|
|
281
373
|
}
|
|
282
|
-
|
|
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/`.
|
|
283
421
|
const projectRoot = resolve(input.projectRoot);
|
|
422
|
+
const fenceRoot = resolve(effectiveRoot);
|
|
284
423
|
// Project policy load failure: treat as disabled project layer, still apply
|
|
285
424
|
// independent story file_scope when present (#516 / #2443 Greptile P1).
|
|
286
425
|
let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
|
|
@@ -296,7 +435,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
296
435
|
let storyFence;
|
|
297
436
|
try {
|
|
298
437
|
storyFence = seams.loadStoryWriteFence
|
|
299
|
-
? seams.loadStoryWriteFence(
|
|
438
|
+
? seams.loadStoryWriteFence(fenceRoot, scopePath)
|
|
300
439
|
: loadStoryWriteFenceFromPath(scopePath);
|
|
301
440
|
}
|
|
302
441
|
catch {
|
|
@@ -310,14 +449,19 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
|
|
|
310
449
|
if (!fence.fenceActive)
|
|
311
450
|
return null;
|
|
312
451
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
313
|
-
const relPath = writeTarget !== null ? toProjectRelativePosix(
|
|
452
|
+
const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
|
|
314
453
|
const verdict = evaluateRuntimeAuthorityDirectWrite({
|
|
315
454
|
policy: fence.policy,
|
|
316
455
|
relPathPosix: relPath,
|
|
317
456
|
});
|
|
318
457
|
if (verdict.allowed)
|
|
319
458
|
return null;
|
|
320
|
-
|
|
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);
|
|
321
465
|
}
|
|
322
466
|
function loadAuthzContext(projectRoot, seams) {
|
|
323
467
|
if (seams.loadAuthzState !== undefined) {
|
|
@@ -380,6 +524,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
|
|
|
380
524
|
* Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
|
|
381
525
|
*/
|
|
382
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.
|
|
383
529
|
const projectRoot = resolve(input.projectRoot);
|
|
384
530
|
let state;
|
|
385
531
|
let grants;
|
|
@@ -413,7 +559,7 @@ function authzForMutation(input, toolName, seams, options) {
|
|
|
413
559
|
// Structural context for bound grants (branch from git; repo optional env).
|
|
414
560
|
let branch = null;
|
|
415
561
|
try {
|
|
416
|
-
branch = detectBranch(projectRoot);
|
|
562
|
+
branch = detectBranch(projectRoot, options.runGit);
|
|
417
563
|
}
|
|
418
564
|
catch {
|
|
419
565
|
branch = null;
|
|
@@ -552,23 +698,50 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
|
|
|
552
698
|
};
|
|
553
699
|
}
|
|
554
700
|
function inspectMutationGates(input, toolName, seams, options) {
|
|
555
|
-
|
|
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;
|
|
556
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
|
+
}
|
|
557
726
|
// Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
|
|
558
727
|
// assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
|
|
559
728
|
// Tracked product paths never match the path fence. Read-only still denied upstream.
|
|
560
729
|
if (!isSpawnTool(toolName)) {
|
|
561
730
|
const scratchTarget = hookWriteTargetPath(input.payload);
|
|
562
|
-
|
|
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)) {
|
|
563
735
|
const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
|
|
564
736
|
const authzDeny = authzForMutation(input, toolName, seams, {
|
|
565
737
|
isDirectWrite: true,
|
|
566
738
|
relPath,
|
|
567
739
|
scopePath: null,
|
|
740
|
+
runGit: dispatchGit,
|
|
568
741
|
});
|
|
569
742
|
if (authzDeny !== null)
|
|
570
743
|
return authzDeny;
|
|
571
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
744
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
572
745
|
if (runtimeDeny !== null)
|
|
573
746
|
return runtimeDeny;
|
|
574
747
|
return {
|
|
@@ -586,6 +759,72 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
586
759
|
};
|
|
587
760
|
}
|
|
588
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
|
+
}
|
|
589
828
|
let ritual;
|
|
590
829
|
try {
|
|
591
830
|
// Mutation dispatch is a live gated boundary: active verification reruns
|
|
@@ -600,8 +839,8 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
600
839
|
bypass: false,
|
|
601
840
|
runner: seams.ritualRunner,
|
|
602
841
|
detectWorkSelection: seams.detectWorkSelection,
|
|
603
|
-
runGit:
|
|
604
|
-
}))))(
|
|
842
|
+
runGit: dispatchGit,
|
|
843
|
+
}))))(effectiveRoot);
|
|
605
844
|
}
|
|
606
845
|
catch (cause) {
|
|
607
846
|
// #2994: best-effort local process-cost; never changes deny verdict.
|
|
@@ -612,20 +851,36 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
612
851
|
detail: `inspect threw: ${String(cause)}`,
|
|
613
852
|
}, { projectRoot });
|
|
614
853
|
return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
|
|
615
|
-
formatRitualRecoveryInstruction("cold")
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
854
|
+
formatRitualRecoveryInstruction("cold") +
|
|
855
|
+
rootsNote);
|
|
856
|
+
}
|
|
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
|
+
}
|
|
629
884
|
}
|
|
630
885
|
if (ritual.code !== 0) {
|
|
631
886
|
// #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
|
|
@@ -637,23 +892,73 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
637
892
|
recoveryTier,
|
|
638
893
|
detail: ritual.message,
|
|
639
894
|
}, { projectRoot });
|
|
640
|
-
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`
|
|
641
|
-
|
|
895
|
+
return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
|
|
896
|
+
rootsNote);
|
|
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}`;
|
|
642
943
|
if (options.proposedLifecycleExempt) {
|
|
643
944
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
644
|
-
if (isProposedLifecycleWrite(
|
|
945
|
+
if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
|
|
645
946
|
const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
|
|
646
947
|
// UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
|
|
647
948
|
const authzDeny = authzForMutation(input, toolName, seams, {
|
|
648
949
|
isDirectWrite: true,
|
|
649
950
|
relPath,
|
|
650
951
|
scopePath: null,
|
|
952
|
+
runGit: dispatchGit,
|
|
651
953
|
});
|
|
652
954
|
if (authzDeny !== null)
|
|
653
955
|
return authzDeny;
|
|
654
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
|
|
956
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
|
|
655
957
|
if (runtimeDeny !== null)
|
|
656
958
|
return runtimeDeny;
|
|
959
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
960
|
+
if (occupancyDeny !== null)
|
|
961
|
+
return occupancyDeny;
|
|
657
962
|
return {
|
|
658
963
|
verdict: "allow",
|
|
659
964
|
code: "write-propose-ready",
|
|
@@ -661,26 +966,33 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
661
966
|
host: input.host,
|
|
662
967
|
toolName,
|
|
663
968
|
projectRoot,
|
|
664
|
-
message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
|
|
665
|
-
"(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)."),
|
|
666
971
|
scopePath: null,
|
|
667
972
|
};
|
|
668
973
|
}
|
|
669
974
|
}
|
|
670
975
|
let scope;
|
|
671
976
|
try {
|
|
672
|
-
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);
|
|
673
981
|
}
|
|
674
982
|
catch (cause) {
|
|
675
983
|
scope = { ready: false, path: null, message: String(cause) };
|
|
676
984
|
}
|
|
677
985
|
if (!scope.ready) {
|
|
678
986
|
const writeTarget = hookWriteTargetPath(input.payload);
|
|
679
|
-
const relTarget = writeTarget !== null ? toProjectRelativePosix(
|
|
987
|
+
const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
|
|
680
988
|
// Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
|
|
681
989
|
// (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
|
|
682
990
|
// targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
|
|
683
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.
|
|
684
996
|
const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
|
|
685
997
|
if (!outsideRoot || isSpawnTool(toolName)) {
|
|
686
998
|
let proposedPathHint;
|
|
@@ -720,8 +1032,17 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
720
1032
|
"assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
|
|
721
1033
|
}
|
|
722
1034
|
const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
|
|
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}.`;
|
|
723
1042
|
return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
|
|
724
|
-
scopeNotReadyCoverageHint(toolName))
|
|
1043
|
+
scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
|
|
1044
|
+
recoveryRootNote +
|
|
1045
|
+
rootsNote);
|
|
725
1046
|
}
|
|
726
1047
|
}
|
|
727
1048
|
const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
|
|
@@ -740,12 +1061,16 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
740
1061
|
isDirectWrite: true,
|
|
741
1062
|
relPath,
|
|
742
1063
|
scopePath: scope.path,
|
|
1064
|
+
runGit: dispatchGit,
|
|
743
1065
|
});
|
|
744
1066
|
if (authzDeny !== null)
|
|
745
1067
|
return authzDeny;
|
|
746
|
-
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
|
|
1068
|
+
const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
|
|
747
1069
|
if (runtimeDeny !== null)
|
|
748
1070
|
return runtimeDeny;
|
|
1071
|
+
const occupancyDeny = recheckOccupancyBeforeWriteAllow();
|
|
1072
|
+
if (occupancyDeny !== null)
|
|
1073
|
+
return occupancyDeny;
|
|
749
1074
|
}
|
|
750
1075
|
return {
|
|
751
1076
|
verdict: "allow",
|
|
@@ -754,7 +1079,7 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
754
1079
|
host: input.host,
|
|
755
1080
|
toolName,
|
|
756
1081
|
projectRoot,
|
|
757
|
-
message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}
|
|
1082
|
+
message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
|
|
758
1083
|
scopePath: scope.path,
|
|
759
1084
|
};
|
|
760
1085
|
}
|
|
@@ -810,6 +1135,147 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
|
810
1135
|
}
|
|
811
1136
|
return runtime;
|
|
812
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
|
+
}
|
|
813
1279
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
814
1280
|
export function decideHook(input, seams = {}) {
|
|
815
1281
|
const projectRoot = resolve(input.projectRoot);
|
|
@@ -817,6 +1283,8 @@ export function decideHook(input, seams = {}) {
|
|
|
817
1283
|
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
818
1284
|
// Tracked/committed flags do NOT bypass gates (repo-controlled content must
|
|
819
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.
|
|
820
1288
|
{
|
|
821
1289
|
const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
|
|
822
1290
|
const kill = detectKill(projectRoot);
|
|
@@ -843,6 +1311,12 @@ export function decideHook(input, seams = {}) {
|
|
|
843
1311
|
}
|
|
844
1312
|
}
|
|
845
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.
|
|
846
1320
|
try {
|
|
847
1321
|
const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
|
|
848
1322
|
if (!result.changed) {
|
|
@@ -1002,7 +1476,8 @@ export function decideHook(input, seams = {}) {
|
|
|
1002
1476
|
// with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
|
|
1003
1477
|
// on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
|
|
1004
1478
|
if (isShellTool(toolName)) {
|
|
1005
|
-
|
|
1479
|
+
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
|
|
1480
|
+
return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
|
|
1006
1481
|
}
|
|
1007
1482
|
// Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
|
|
1008
1483
|
// Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
|
|
@@ -1057,6 +1532,24 @@ function softAgentsRebindWireText(decision) {
|
|
|
1057
1532
|
*/
|
|
1058
1533
|
export function renderHostDecision(host, decision) {
|
|
1059
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
|
+
}
|
|
1060
1553
|
const soft = softAgentsRebindWireText(decision);
|
|
1061
1554
|
if (host === "cursor") {
|
|
1062
1555
|
if (decision.event === "session.start" && soft !== null) {
|