@deftai/directive-core 0.99.0 → 0.100.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/authz/actions.d.ts +6 -0
- package/dist/authz/actions.js +3 -0
- package/dist/authz/classify.js +157 -23
- package/dist/authz/decompose-apply.d.ts +67 -0
- package/dist/authz/decompose-apply.js +302 -0
- package/dist/authz/index.d.ts +3 -1
- package/dist/authz/index.js +3 -1
- package/dist/authz/store.d.ts +53 -0
- package/dist/authz/store.js +219 -1
- package/dist/authz/types.d.ts +19 -2
- package/dist/authz/types.js +5 -1
- package/dist/doctor/checks.d.ts +44 -0
- package/dist/doctor/checks.js +428 -2
- package/dist/doctor/main.d.ts +6 -7
- package/dist/doctor/main.js +72 -79
- package/dist/doctor/which.d.ts +20 -1
- package/dist/doctor/which.js +67 -1
- package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
- package/dist/finish-loop/pr-finish-loop.js +116 -5
- package/dist/finish-loop/types.d.ts +1 -1
- package/dist/hooks/dispatcher.d.ts +9 -5
- package/dist/hooks/dispatcher.js +23 -15
- package/dist/hooks/readonly.d.ts +6 -3
- package/dist/hooks/readonly.js +44 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init-deposit/agent-hooks.d.ts +20 -0
- package/dist/init-deposit/agent-hooks.js +51 -31
- package/dist/intake/issue-ingest.js +29 -0
- package/dist/lifecycle/completed-consistency.d.ts +60 -0
- package/dist/lifecycle/completed-consistency.js +443 -0
- package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
- package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/literal-acceptance/capture.d.ts +52 -0
- package/dist/literal-acceptance/capture.js +658 -0
- package/dist/literal-acceptance/evaluate.d.ts +47 -0
- package/dist/literal-acceptance/evaluate.js +193 -0
- package/dist/literal-acceptance/index.d.ts +12 -0
- package/dist/literal-acceptance/index.js +12 -0
- package/dist/literal-acceptance/run.d.ts +38 -0
- package/dist/literal-acceptance/run.js +216 -0
- package/dist/literal-acceptance/safety.d.ts +24 -0
- package/dist/literal-acceptance/safety.js +251 -0
- package/dist/literal-acceptance/types.d.ts +82 -0
- package/dist/literal-acceptance/types.js +26 -0
- package/dist/policy/ceremony-dial.d.ts +67 -11
- package/dist/policy/ceremony-dial.js +174 -23
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/merge-approval-head.d.ts +129 -0
- package/dist/policy/merge-approval-head.js +461 -0
- package/dist/policy/require-human-merge.d.ts +5 -0
- package/dist/policy/require-human-merge.js +5 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
- package/dist/pr-merge-readiness/ci-gate.js +65 -5
- package/dist/pr-merge-readiness/compute.d.ts +13 -0
- package/dist/pr-merge-readiness/compute.js +99 -14
- package/dist/pr-merge-readiness/gh.d.ts +49 -0
- package/dist/pr-merge-readiness/gh.js +268 -0
- package/dist/pr-merge-readiness/index.d.ts +2 -2
- package/dist/pr-merge-readiness/index.js +2 -2
- package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
- package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
- package/dist/pr-wait-mergeable/cascade.js +115 -1
- package/dist/pr-wait-mergeable/main.d.ts +4 -0
- package/dist/pr-wait-mergeable/main.js +4 -0
- package/dist/pr-wait-mergeable/types.d.ts +3 -1
- package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
- package/dist/pr-wait-mergeable/wrappers.js +7 -1
- package/dist/preflight/evaluate.d.ts +15 -1
- package/dist/preflight/evaluate.js +33 -2
- package/dist/render/framework-commands.js +4 -0
- package/dist/scope/acceptance-evidence.d.ts +76 -0
- package/dist/scope/acceptance-evidence.js +348 -0
- package/dist/scope/coverage-map.d.ts +98 -0
- package/dist/scope/coverage-map.js +558 -0
- package/dist/scope/decompose.js +125 -11
- package/dist/scope/index.d.ts +3 -0
- package/dist/scope/index.js +3 -0
- package/dist/scope/parent-lineage.d.ts +125 -0
- package/dist/scope/parent-lineage.js +626 -0
- package/dist/scope/transition.d.ts +8 -0
- package/dist/scope/transition.js +61 -2
- package/dist/session/active-cli.d.ts +79 -0
- package/dist/session/active-cli.js +382 -0
- package/dist/session/effort-budget.d.ts +130 -0
- package/dist/session/effort-budget.js +334 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/session-start.d.ts +6 -0
- package/dist/session/session-start.js +53 -2
- package/dist/session/verify-session-ritual.d.ts +14 -0
- package/dist/session/verify-session-ritual.js +33 -0
- package/dist/story-ready/evaluate.d.ts +11 -0
- package/dist/story-ready/evaluate.js +41 -3
- package/dist/triage/actions/candidates-log.d.ts +1 -2
- package/dist/triage/actions/candidates-log.js +37 -6
- package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
- package/dist/xbrief-migrate/migrate-project.js +92 -3
- package/package.json +7 -3
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-origin structural apply gate for scope:decompose (#3239 / epic #3237 Q2).
|
|
3
|
+
*
|
|
4
|
+
* Apply requires a human-origin grant bound to:
|
|
5
|
+
* project root identity (worktree and/or repo), parent path, draft/target path,
|
|
6
|
+
* SHA-256 of exact draft bytes, and op scope.decompose.apply.structural.
|
|
7
|
+
*
|
|
8
|
+
* --check stays ungated (caller responsibility). Missing / expired / mismatched /
|
|
9
|
+
* agent-origin grants fail closed before any child story writes.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
14
|
+
import { mintHumanOriginGrant } from "./actions.js";
|
|
15
|
+
import { evidenceSatisfiesImplementationApproval, isHumanOriginGrant, isRejectedOriginKind, } from "./origin.js";
|
|
16
|
+
import { listGrants } from "./store.js";
|
|
17
|
+
import { SCOPE_DECOMPOSE_APPLY_STRUCTURAL, } from "./types.js";
|
|
18
|
+
/** SHA-256 hex of a buffer or utf8 string. */
|
|
19
|
+
export function sha256Hex(data) {
|
|
20
|
+
return createHash("sha256").update(data).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
/** SHA-256 hex of file bytes at path (exact on-disk digest). */
|
|
23
|
+
export function sha256FileHex(path) {
|
|
24
|
+
return sha256Hex(readFileSync(path));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a path to project-relative POSIX form for grant binding compare.
|
|
28
|
+
* Returns null when the path escapes the project root.
|
|
29
|
+
*/
|
|
30
|
+
export function toProjectRelativePosix(projectRoot, path) {
|
|
31
|
+
const root = resolve(projectRoot);
|
|
32
|
+
const abs = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
33
|
+
const rel = relative(root, abs);
|
|
34
|
+
if (rel.startsWith("..") || rel === "..")
|
|
35
|
+
return null;
|
|
36
|
+
// Windows absolute on different drive yields absolute rel — treat as escape.
|
|
37
|
+
if (isAbsolute(rel))
|
|
38
|
+
return null;
|
|
39
|
+
return rel.split(sep).join("/");
|
|
40
|
+
}
|
|
41
|
+
function normalizeDigest(raw) {
|
|
42
|
+
if (raw === null || raw === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
const t = raw.trim().toLowerCase();
|
|
45
|
+
if (t.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
// Accept optional sha256: prefix.
|
|
48
|
+
return t.startsWith("sha256:") ? t.slice("sha256:".length) : t;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Exact path equality for grant binding (POSIX separators only).
|
|
52
|
+
* Case-sensitive: case-variant paths are distinct on case-sensitive filesystems
|
|
53
|
+
* (#3239 Greptile P1). Windows operators mint with the same relative form
|
|
54
|
+
* produce via toProjectRelativePosix.
|
|
55
|
+
*/
|
|
56
|
+
function pathsEqual(a, b) {
|
|
57
|
+
return a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
|
|
58
|
+
}
|
|
59
|
+
function grantValidity(grant, now) {
|
|
60
|
+
if (!isHumanOriginGrant(grant)) {
|
|
61
|
+
const kind = grant.origin.kind;
|
|
62
|
+
if (isRejectedOriginKind(kind)) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
code: "authz-grant-origin-reject",
|
|
66
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} origin.kind=${kind} is ` +
|
|
67
|
+
"agent/self-authored and cannot approve a decomposition draft. " +
|
|
68
|
+
"Human action required: mint via `deft authz:grant` (operator-cli) bound to the draft digest.",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: "authz-grant-origin-reject",
|
|
74
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} lacks human-origin provenance. ` +
|
|
75
|
+
"Human action required: `deft authz:grant` with operator-cli origin.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (grant.semantics.revokedAt !== null) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: "authz-grant-revoked",
|
|
82
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} was revoked at ${grant.semantics.revokedAt}.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (grant.semantics.singleUse && grant.semantics.usedAt !== null) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: "authz-grant-single-use-spent",
|
|
89
|
+
reason: `Directive denied scope:decompose apply: single-use grant ${grant.id} already spent at ${grant.semantics.usedAt}.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (grant.semantics.expiresAt !== null) {
|
|
93
|
+
const exp = Date.parse(grant.semantics.expiresAt);
|
|
94
|
+
if (!Number.isNaN(exp) && exp <= now.getTime()) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: "authz-grant-expired",
|
|
98
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} expired at ${grant.semantics.expiresAt}. ` +
|
|
99
|
+
"Human action required: re-mint a grant for the current draft digest.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
function grantHasStructuralOp(grant) {
|
|
106
|
+
return grant.scope.operations.includes(SCOPE_DECOMPOSE_APPLY_STRUCTURAL);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Evaluate whether a human-origin grant covers structural decompose apply for
|
|
110
|
+
* the exact draft digest + parent + target + project identity.
|
|
111
|
+
*/
|
|
112
|
+
export function evaluateDecomposeStructuralApply(input) {
|
|
113
|
+
const now = input.now ?? new Date();
|
|
114
|
+
const digest = normalizeDigest(input.draftDigest) ?? "";
|
|
115
|
+
const parentRel = toProjectRelativePosix(input.projectRoot, input.parentPath);
|
|
116
|
+
const targetRel = toProjectRelativePosix(input.projectRoot, input.draftPath);
|
|
117
|
+
const projectAbs = resolve(input.projectRoot);
|
|
118
|
+
const grants = input.grants ?? listGrants(input.projectRoot);
|
|
119
|
+
if (parentRel === null || targetRel === null) {
|
|
120
|
+
return {
|
|
121
|
+
allowed: false,
|
|
122
|
+
code: "authz-grant-scope-deny",
|
|
123
|
+
reason: "Directive denied scope:decompose apply: parent or draft path is outside the project root.",
|
|
124
|
+
humanApprovalRef: null,
|
|
125
|
+
draftDigest: digest,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
let lastReject = null;
|
|
129
|
+
for (const grant of grants) {
|
|
130
|
+
if (!evidenceSatisfiesImplementationApproval({ grant })) {
|
|
131
|
+
const validity = grantValidity(grant, now);
|
|
132
|
+
if (!validity.ok) {
|
|
133
|
+
lastReject = { code: validity.code, reason: validity.reason, grantId: grant.id };
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
lastReject = {
|
|
137
|
+
code: "authz-grant-origin-reject",
|
|
138
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} does not satisfy ` +
|
|
139
|
+
"human-origin implementation approval. Human action required: `deft authz:grant`.",
|
|
140
|
+
grantId: grant.id,
|
|
141
|
+
};
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const validity = grantValidity(grant, now);
|
|
145
|
+
if (!validity.ok) {
|
|
146
|
+
lastReject = { code: validity.code, reason: validity.reason, grantId: grant.id };
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!grantHasStructuralOp(grant)) {
|
|
150
|
+
lastReject = {
|
|
151
|
+
code: "authz-grant-scope-deny",
|
|
152
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} does not include operation ` +
|
|
153
|
+
`'${SCOPE_DECOMPOSE_APPLY_STRUCTURAL}'. Human action required: mint with that operation.`,
|
|
154
|
+
grantId: grant.id,
|
|
155
|
+
};
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const boundDigest = normalizeDigest(grant.scope.contentDigest ?? null);
|
|
159
|
+
const boundParent = grant.scope.parentPath?.trim() ?? "";
|
|
160
|
+
const boundTarget = grant.scope.targetPath?.trim() ?? "";
|
|
161
|
+
const boundRepo = grant.scope.repo?.trim() ?? "";
|
|
162
|
+
const boundWorktree = grant.scope.worktree?.trim() ?? "";
|
|
163
|
+
if (boundDigest === null || boundParent.length === 0 || boundTarget.length === 0) {
|
|
164
|
+
lastReject = {
|
|
165
|
+
code: "authz-grant-binding-incomplete",
|
|
166
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is missing required ` +
|
|
167
|
+
"structural bindings (contentDigest, parentPath, targetPath). " +
|
|
168
|
+
"Human action required: re-mint bound to the exact draft.",
|
|
169
|
+
grantId: grant.id,
|
|
170
|
+
};
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (boundRepo.length === 0 && boundWorktree.length === 0) {
|
|
174
|
+
lastReject = {
|
|
175
|
+
code: "authz-grant-binding-incomplete",
|
|
176
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} does not bind project ` +
|
|
177
|
+
"identity (repo and/or worktree). Human action required: re-mint with --repo or worktree.",
|
|
178
|
+
grantId: grant.id,
|
|
179
|
+
};
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (boundDigest !== digest) {
|
|
183
|
+
lastReject = {
|
|
184
|
+
code: "authz-grant-digest-mismatch",
|
|
185
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is bound to draft digest ` +
|
|
186
|
+
`${boundDigest.slice(0, 12)}… but the draft bytes hash to ${digest.slice(0, 12)}…. ` +
|
|
187
|
+
"Any content change after approval invalidates the grant. Re-mint for the current draft.",
|
|
188
|
+
grantId: grant.id,
|
|
189
|
+
};
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!pathsEqual(boundParent, parentRel)) {
|
|
193
|
+
lastReject = {
|
|
194
|
+
code: "authz-grant-parent-mismatch",
|
|
195
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is bound to parent ` +
|
|
196
|
+
`'${boundParent}' but apply targets '${parentRel}'.`,
|
|
197
|
+
grantId: grant.id,
|
|
198
|
+
};
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!pathsEqual(boundTarget, targetRel)) {
|
|
202
|
+
lastReject = {
|
|
203
|
+
code: "authz-grant-target-mismatch",
|
|
204
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is bound to draft path ` +
|
|
205
|
+
`'${boundTarget}' but apply uses '${targetRel}'.`,
|
|
206
|
+
grantId: grant.id,
|
|
207
|
+
};
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Project identity: when grant pins repo and/or worktree, attempt must match.
|
|
211
|
+
if (boundRepo.length > 0) {
|
|
212
|
+
const attemptRepo = (input.repo ?? "").trim();
|
|
213
|
+
if (attemptRepo.length === 0 || attemptRepo.toLowerCase() !== boundRepo.toLowerCase()) {
|
|
214
|
+
lastReject = {
|
|
215
|
+
code: "authz-grant-project-mismatch",
|
|
216
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is bound to repo ` +
|
|
217
|
+
`'${boundRepo}' which does not match the apply context.`,
|
|
218
|
+
grantId: grant.id,
|
|
219
|
+
};
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (boundWorktree.length > 0) {
|
|
224
|
+
const grantWt = resolve(boundWorktree);
|
|
225
|
+
// Worktree compare is path-normalize only (resolve); keep exact string match of
|
|
226
|
+
// resolved absolute paths without case-folding (same rule as parent/target paths).
|
|
227
|
+
if (grantWt !== projectAbs) {
|
|
228
|
+
lastReject = {
|
|
229
|
+
code: "authz-grant-project-mismatch",
|
|
230
|
+
reason: `Directive denied scope:decompose apply: grant ${grant.id} is bound to worktree ` +
|
|
231
|
+
`'${boundWorktree}' which does not match project root '${projectAbs}'.`,
|
|
232
|
+
grantId: grant.id,
|
|
233
|
+
};
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
allowed: true,
|
|
239
|
+
code: "authz-allow",
|
|
240
|
+
reason: `Directive allowed scope:decompose apply via human-origin grant ${grant.id} ` +
|
|
241
|
+
`bound to draft digest ${digest.slice(0, 12)}….`,
|
|
242
|
+
humanApprovalRef: grant.id,
|
|
243
|
+
draftDigest: digest,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (lastReject !== null) {
|
|
247
|
+
return {
|
|
248
|
+
allowed: false,
|
|
249
|
+
code: lastReject.code,
|
|
250
|
+
reason: lastReject.reason,
|
|
251
|
+
humanApprovalRef: lastReject.grantId,
|
|
252
|
+
draftDigest: digest,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
allowed: false,
|
|
257
|
+
code: "authz-grant-missing",
|
|
258
|
+
reason: "Directive denied scope:decompose apply: no human-origin grant covers " +
|
|
259
|
+
`'${SCOPE_DECOMPOSE_APPLY_STRUCTURAL}' for this draft digest. ` +
|
|
260
|
+
"Human action required: mint a grant bound to the exact draft SHA-256 " +
|
|
261
|
+
"(self-authored lifecycle/dispatch tokens do not count). --check remains ungated.",
|
|
262
|
+
humanApprovalRef: null,
|
|
263
|
+
draftDigest: digest,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Operator mint helper: human-origin grant bound to exact draft bytes + paths.
|
|
268
|
+
* Sole mint path remains mintHumanOriginGrant (operator-cli origin).
|
|
269
|
+
*/
|
|
270
|
+
export function mintDecomposeStructuralApplyGrant(input) {
|
|
271
|
+
const parentRel = toProjectRelativePosix(input.projectRoot, input.parentPath);
|
|
272
|
+
const targetRel = toProjectRelativePosix(input.projectRoot, input.draftPath);
|
|
273
|
+
if (parentRel === null || targetRel === null) {
|
|
274
|
+
throw new Error("mintDecomposeStructuralApplyGrant: parentPath and draftPath must resolve inside projectRoot");
|
|
275
|
+
}
|
|
276
|
+
const digest = input.contentDigest !== undefined
|
|
277
|
+
? (normalizeDigest(input.contentDigest) ?? "")
|
|
278
|
+
: sha256FileHex(isAbsolute(input.draftPath)
|
|
279
|
+
? input.draftPath
|
|
280
|
+
: resolve(input.projectRoot, input.draftPath));
|
|
281
|
+
if (digest.length === 0) {
|
|
282
|
+
throw new Error("mintDecomposeStructuralApplyGrant: contentDigest must be non-empty");
|
|
283
|
+
}
|
|
284
|
+
const mintInput = {
|
|
285
|
+
projectRoot: input.projectRoot,
|
|
286
|
+
actor: input.actor,
|
|
287
|
+
operations: [SCOPE_DECOMPOSE_APPLY_STRUCTURAL],
|
|
288
|
+
contentDigest: digest,
|
|
289
|
+
parentPath: parentRel,
|
|
290
|
+
targetPath: targetRel,
|
|
291
|
+
worktree: resolve(input.projectRoot),
|
|
292
|
+
repo: input.repo ?? null,
|
|
293
|
+
expiresAt: input.expiresAt ?? null,
|
|
294
|
+
singleUse: input.singleUse === true,
|
|
295
|
+
grantId: input.grantId,
|
|
296
|
+
now: input.now,
|
|
297
|
+
pinActive: input.pinActive,
|
|
298
|
+
eventRef: input.eventRef ?? null,
|
|
299
|
+
};
|
|
300
|
+
return mintHumanOriginGrant(mintInput);
|
|
301
|
+
}
|
|
302
|
+
//# sourceMappingURL=decompose-apply.js.map
|
package/dist/authz/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Human-origin approval grants + UAT mutation lease (#2944 Wave 1)
|
|
3
|
-
* + closed-verb release gates / AFK templates (#1095 Wave 4)
|
|
3
|
+
* + closed-verb release gates / AFK templates (#1095 Wave 4)
|
|
4
|
+
* + structural scope:decompose apply digest gate (#3239).
|
|
4
5
|
*/
|
|
5
6
|
export * from "./actions.js";
|
|
6
7
|
export * from "./classify.js";
|
|
7
8
|
export * from "./closed-verb.js";
|
|
9
|
+
export * from "./decompose-apply.js";
|
|
8
10
|
export * from "./evaluate.js";
|
|
9
11
|
export * from "./origin.js";
|
|
10
12
|
export * from "./paths.js";
|
package/dist/authz/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Human-origin approval grants + UAT mutation lease (#2944 Wave 1)
|
|
3
|
-
* + closed-verb release gates / AFK templates (#1095 Wave 4)
|
|
3
|
+
* + closed-verb release gates / AFK templates (#1095 Wave 4)
|
|
4
|
+
* + structural scope:decompose apply digest gate (#3239).
|
|
4
5
|
*/
|
|
5
6
|
export * from "./actions.js";
|
|
6
7
|
export * from "./classify.js";
|
|
7
8
|
export * from "./closed-verb.js";
|
|
9
|
+
export * from "./decompose-apply.js";
|
|
8
10
|
export * from "./evaluate.js";
|
|
9
11
|
export * from "./origin.js";
|
|
10
12
|
export * from "./paths.js";
|
package/dist/authz/store.d.ts
CHANGED
|
@@ -25,6 +25,59 @@ export declare function loadAuthzStateResult(projectRoot: string): AuthzStateLoa
|
|
|
25
25
|
export declare function loadAuthzState(projectRoot: string): AuthzState;
|
|
26
26
|
/** Persist single-use grant consumption after an allow decision (#2944). */
|
|
27
27
|
export declare function markGrantUsed(projectRoot: string, grantId: string, now?: Date): HumanOriginGrant | null;
|
|
28
|
+
/** Exclusive claim lock body under `.deft/authz/locks/<id>.lock` (#3239). */
|
|
29
|
+
export interface GrantClaimLockRecord {
|
|
30
|
+
readonly pid: number;
|
|
31
|
+
readonly startedAt: string;
|
|
32
|
+
readonly token: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a claim lock may be reclaimed after a crashed holder (#3239).
|
|
36
|
+
* Live owner PIDs are never reclaimed (no mtime-only steal of a live critical section).
|
|
37
|
+
* Corrupt / unreadable records are reclaimable; PID-reuse residual needs manual delete.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isGrantClaimLockReclaimable(rec: GrantClaimLockRecord | null): boolean;
|
|
40
|
+
export interface ClaimSingleUseGrantOptions {
|
|
41
|
+
readonly now?: Date;
|
|
42
|
+
/**
|
|
43
|
+
* Re-check grant after exclusive lock (revocation/expiry/origin/bindings).
|
|
44
|
+
* Called before protected apply / single-use mark so an invalidated grant cannot authorize writes.
|
|
45
|
+
*/
|
|
46
|
+
readonly revalidate?: (grant: HumanOriginGrant) => {
|
|
47
|
+
ok: true;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Protected work under the exclusive claim (#3239 residual).
|
|
54
|
+
* Order: lock → revalidate → mark single-use usedAt → apply → release lock.
|
|
55
|
+
* If apply throws, usedAt is rolled back (grant reusable) and the lock is released.
|
|
56
|
+
* Concurrent claimants fail closed while the lock is held.
|
|
57
|
+
*/
|
|
58
|
+
readonly apply?: (grant: HumanOriginGrant) => void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Claim a single-use grant for structural apply (#3239).
|
|
62
|
+
*
|
|
63
|
+
* Concurrent-safe + failure-safe order when `apply` is provided:
|
|
64
|
+
* exclusive lock → re-load → revalidate → mark single-use usedAt → apply (writes)
|
|
65
|
+
* → on apply throw: rollback usedAt + release lock (grant reusable for retry).
|
|
66
|
+
* Multi-use grants run apply (if any) without mutating usedAt.
|
|
67
|
+
* Without `apply`, single-use is marked under the lock (claim-only / test path).
|
|
68
|
+
*
|
|
69
|
+
* Crash after mark + before rollback: grant stays spent (no double-apply); operator remints.
|
|
70
|
+
* Dead-PID / corrupt locks reclaim via **rename-away** of the old lock path (only one
|
|
71
|
+
* renamer wins) then exclusive create — never blind rmSync of a live winner's lock.
|
|
72
|
+
* Live PIDs are never reclaimed. PID-reuse residual: operator deletes the lock file.
|
|
73
|
+
*/
|
|
74
|
+
export declare function claimSingleUseGrantForApply(projectRoot: string, grantId: string, options?: ClaimSingleUseGrantOptions | Date): {
|
|
75
|
+
ok: true;
|
|
76
|
+
grant: HumanOriginGrant;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
reason: string;
|
|
80
|
+
};
|
|
28
81
|
export declare function saveAuthzState(projectRoot: string, state: AuthzState): void;
|
|
29
82
|
export declare function loadGrant(projectRoot: string, grantId: string): HumanOriginGrant | null;
|
|
30
83
|
export declare function saveGrant(projectRoot: string, grant: HumanOriginGrant): void;
|
package/dist/authz/store.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
-
import { containedWrite } from "../fs/contained-write.js";
|
|
7
|
+
import { ContainedWriteError, containedWrite } from "../fs/contained-write.js";
|
|
8
8
|
import { assertWriteTargetSafe } from "../fs/projection-containment.js";
|
|
9
9
|
import { isHumanOrigin } from "./origin.js";
|
|
10
10
|
import { authzAuditPath, authzGrantPath, authzGrantsDir, authzStatePath } from "./paths.js";
|
|
@@ -105,6 +105,13 @@ function parseScope(raw) {
|
|
|
105
105
|
storyIds: readStringArray(rec.storyIds ?? rec.story_ids),
|
|
106
106
|
issueIds: readNumberArray(rec.issueIds ?? rec.issue_ids),
|
|
107
107
|
cohortId: readString(rec, "cohortId") ?? readString(rec, "cohort_id"),
|
|
108
|
+
// #3239 structural decompose apply bindings (snake_case accepted on load).
|
|
109
|
+
contentDigest: readString(rec, "contentDigest") ??
|
|
110
|
+
readString(rec, "content_digest") ??
|
|
111
|
+
readString(rec, "draftDigest") ??
|
|
112
|
+
readString(rec, "draft_digest"),
|
|
113
|
+
parentPath: readString(rec, "parentPath") ?? readString(rec, "parent_path"),
|
|
114
|
+
targetPath: readString(rec, "targetPath") ?? readString(rec, "target_path"),
|
|
108
115
|
};
|
|
109
116
|
}
|
|
110
117
|
function parseSemantics(raw) {
|
|
@@ -233,6 +240,217 @@ export function markGrantUsed(projectRoot, grantId, now = new Date()) {
|
|
|
233
240
|
saveGrant(projectRoot, used);
|
|
234
241
|
return used;
|
|
235
242
|
}
|
|
243
|
+
function isProcessAlive(pid) {
|
|
244
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
245
|
+
return false;
|
|
246
|
+
try {
|
|
247
|
+
process.kill(pid, 0);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const code = err.code;
|
|
252
|
+
// ESRCH → dead. EPERM → exists but unsignalable — treat as alive (never reclaim).
|
|
253
|
+
if (code === "EPERM")
|
|
254
|
+
return true;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether a claim lock may be reclaimed after a crashed holder (#3239).
|
|
260
|
+
* Live owner PIDs are never reclaimed (no mtime-only steal of a live critical section).
|
|
261
|
+
* Corrupt / unreadable records are reclaimable; PID-reuse residual needs manual delete.
|
|
262
|
+
*/
|
|
263
|
+
export function isGrantClaimLockReclaimable(rec) {
|
|
264
|
+
if (rec === null)
|
|
265
|
+
return true;
|
|
266
|
+
return !isProcessAlive(rec.pid);
|
|
267
|
+
}
|
|
268
|
+
function readGrantClaimLockRecord(lockPath) {
|
|
269
|
+
try {
|
|
270
|
+
const raw = readFileSync(lockPath, "utf8").trim();
|
|
271
|
+
// JSON form (current). Legacy: "pid\niso\n" lines from earlier #3239 revisions.
|
|
272
|
+
if (raw.startsWith("{")) {
|
|
273
|
+
const parsed = JSON.parse(raw);
|
|
274
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
275
|
+
return null;
|
|
276
|
+
const o = parsed;
|
|
277
|
+
const pid = typeof o.pid === "number" ? o.pid : Number(o.pid);
|
|
278
|
+
const startedAt = typeof o.startedAt === "string" ? o.startedAt : "";
|
|
279
|
+
const token = typeof o.token === "string" ? o.token : "";
|
|
280
|
+
if (!Number.isFinite(pid) || token.length === 0)
|
|
281
|
+
return null;
|
|
282
|
+
return { pid, startedAt, token };
|
|
283
|
+
}
|
|
284
|
+
const lines = raw.split(/\r?\n/);
|
|
285
|
+
const pid = Number(lines[0]);
|
|
286
|
+
if (!Number.isFinite(pid))
|
|
287
|
+
return null;
|
|
288
|
+
return {
|
|
289
|
+
pid,
|
|
290
|
+
startedAt: typeof lines[1] === "string" ? lines[1] : "",
|
|
291
|
+
token: "legacy",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Claim a single-use grant for structural apply (#3239).
|
|
300
|
+
*
|
|
301
|
+
* Concurrent-safe + failure-safe order when `apply` is provided:
|
|
302
|
+
* exclusive lock → re-load → revalidate → mark single-use usedAt → apply (writes)
|
|
303
|
+
* → on apply throw: rollback usedAt + release lock (grant reusable for retry).
|
|
304
|
+
* Multi-use grants run apply (if any) without mutating usedAt.
|
|
305
|
+
* Without `apply`, single-use is marked under the lock (claim-only / test path).
|
|
306
|
+
*
|
|
307
|
+
* Crash after mark + before rollback: grant stays spent (no double-apply); operator remints.
|
|
308
|
+
* Dead-PID / corrupt locks reclaim via **rename-away** of the old lock path (only one
|
|
309
|
+
* renamer wins) then exclusive create — never blind rmSync of a live winner's lock.
|
|
310
|
+
* Live PIDs are never reclaimed. PID-reuse residual: operator deletes the lock file.
|
|
311
|
+
*/
|
|
312
|
+
export function claimSingleUseGrantForApply(projectRoot, grantId, options = {}) {
|
|
313
|
+
// Back-compat: second arg was `now: Date` in the first #3239 revision.
|
|
314
|
+
const opts = options instanceof Date ? { now: options } : options;
|
|
315
|
+
const now = opts.now ?? new Date();
|
|
316
|
+
const usedAtIso = utcIso(now);
|
|
317
|
+
const safe = grantId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
318
|
+
const root = resolve(projectRoot);
|
|
319
|
+
const lockRel = join(".deft", "authz", "locks", `${safe}.lock`);
|
|
320
|
+
const lockPath = join(root, lockRel);
|
|
321
|
+
const lockToken = randomBytes(8).toString("hex");
|
|
322
|
+
const lockBody = `${JSON.stringify({
|
|
323
|
+
pid: process.pid,
|
|
324
|
+
startedAt: usedAtIso,
|
|
325
|
+
token: lockToken,
|
|
326
|
+
})}\n`;
|
|
327
|
+
const tryCreateLock = () => {
|
|
328
|
+
try {
|
|
329
|
+
containedWrite({
|
|
330
|
+
root,
|
|
331
|
+
target: lockPath,
|
|
332
|
+
data: lockBody,
|
|
333
|
+
mode: "create",
|
|
334
|
+
});
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
if (err instanceof ContainedWriteError && err.code === "CONTAINED_WRITE_EXISTS") {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
// Other containment/IO failures fail closed as reservation denial.
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
/** True when the on-disk lock still carries our token (not stolen mid-section). */
|
|
346
|
+
const stillOwnLock = () => {
|
|
347
|
+
const rec = readGrantClaimLockRecord(lockPath);
|
|
348
|
+
return rec !== null && rec.token === lockToken && rec.pid === process.pid;
|
|
349
|
+
};
|
|
350
|
+
let locked = tryCreateLock();
|
|
351
|
+
if (!locked) {
|
|
352
|
+
// Dead-PID / corrupt reclaim: rename the old lock aside (atomic contention) then create.
|
|
353
|
+
// Blind rmSync is forbidden — a second reclaimer must not delete a winner's new lock.
|
|
354
|
+
const existing = readGrantClaimLockRecord(lockPath);
|
|
355
|
+
if (isGrantClaimLockReclaimable(existing)) {
|
|
356
|
+
const side = `${lockPath}.reclaim.${randomBytes(6).toString("hex")}`;
|
|
357
|
+
try {
|
|
358
|
+
renameSync(lockPath, side);
|
|
359
|
+
try {
|
|
360
|
+
rmSync(side, { force: true });
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* best-effort side cleanup */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Lost rename race or lock already gone — fall through to exclusive create.
|
|
368
|
+
}
|
|
369
|
+
locked = tryCreateLock();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!locked) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
reason: `Directive denied scope:decompose apply: grant ${grantId} is already reserved ` +
|
|
376
|
+
"or spent by a concurrent apply. Human action required: remint if the prior apply failed " +
|
|
377
|
+
"(or remove a leftover `.deft/authz/locks/<id>.lock` after a dead-holder crash if reclaim fails).",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
let markedUsedAt = null;
|
|
381
|
+
try {
|
|
382
|
+
const grant = loadGrant(projectRoot, grantId);
|
|
383
|
+
if (grant === null) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
reason: `Directive denied scope:decompose apply: grant ${grantId} missing.`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (opts.revalidate !== undefined) {
|
|
390
|
+
const check = opts.revalidate(grant);
|
|
391
|
+
if (!check.ok) {
|
|
392
|
+
return { ok: false, reason: check.reason };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!grant.semantics.singleUse) {
|
|
396
|
+
if (opts.apply !== undefined) {
|
|
397
|
+
opts.apply(grant);
|
|
398
|
+
}
|
|
399
|
+
return { ok: true, grant };
|
|
400
|
+
}
|
|
401
|
+
if (grant.semantics.usedAt !== null) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: `Directive denied scope:decompose apply: single-use grant ${grantId} already spent at ` +
|
|
405
|
+
`${grant.semantics.usedAt}.`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// Spend under lock before protected writes so a post-write mark failure cannot
|
|
409
|
+
// leave completed mutations behind an unspent single-use grant (#3239 Greptile).
|
|
410
|
+
// Apply throw rolls usedAt back while the lock is still held (retry-safe).
|
|
411
|
+
const used = {
|
|
412
|
+
...grant,
|
|
413
|
+
semantics: {
|
|
414
|
+
...grant.semantics,
|
|
415
|
+
usedAt: usedAtIso,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
saveGrant(projectRoot, used);
|
|
419
|
+
markedUsedAt = usedAtIso;
|
|
420
|
+
if (opts.apply !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
opts.apply(used);
|
|
423
|
+
}
|
|
424
|
+
catch (applyErr) {
|
|
425
|
+
// Rollback spend so a failed multi-file apply does not strand the approval.
|
|
426
|
+
const current = loadGrant(projectRoot, grantId);
|
|
427
|
+
if (current?.semantics.singleUse && current.semantics.usedAt === markedUsedAt) {
|
|
428
|
+
const restored = {
|
|
429
|
+
...current,
|
|
430
|
+
semantics: { ...current.semantics, usedAt: null },
|
|
431
|
+
};
|
|
432
|
+
saveGrant(projectRoot, restored);
|
|
433
|
+
markedUsedAt = null;
|
|
434
|
+
}
|
|
435
|
+
throw applyErr;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!stillOwnLock()) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
reason: `Directive denied scope:decompose apply: grant ${grantId} lock ownership lost mid-claim. ` +
|
|
442
|
+
"Human action required: inspect partial outputs and remint if needed.",
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return { ok: true, grant: used };
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
// Only remove the lock when we still own it — never delete a successor's claim.
|
|
449
|
+
if (stillOwnLock()) {
|
|
450
|
+
rmSync(lockPath, { force: true });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
236
454
|
export function saveAuthzState(projectRoot, state) {
|
|
237
455
|
writeJsonContained(projectRoot, authzStatePath(projectRoot), state);
|
|
238
456
|
}
|
package/dist/authz/types.d.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* agent authors must not independently satisfy implementation-approval gates.
|
|
6
6
|
* Local-file forgery by a credential-compromised agent remains #983-class OOS.
|
|
7
7
|
*/
|
|
8
|
-
/**
|
|
9
|
-
export declare const
|
|
8
|
+
/** Structural apply op for scope:decompose (#3239 / epic #3237 Q2). Always gated. */
|
|
9
|
+
export declare const SCOPE_DECOMPOSE_APPLY_STRUCTURAL: "scope.decompose.apply.structural";
|
|
10
|
+
/** Operation classes bound on a grant (Wave 1 structural binding + Wave 4 closed verbs + #3239). */
|
|
11
|
+
export declare const AUTHZ_OPERATIONS: readonly ["edit", "push", "pr", "merge", "settings", "deployment", "issue_mutation", "release-cut", "release-publish", "release-rollback", "scope.decompose.apply.structural"];
|
|
10
12
|
export type AuthzOperation = (typeof AUTHZ_OPERATIONS)[number];
|
|
11
13
|
/**
|
|
12
14
|
* Human-origin kinds accepted as approval provenance.
|
|
@@ -41,6 +43,21 @@ export interface GrantScope {
|
|
|
41
43
|
* for product mutations; approving one cohort does not clear UAT.
|
|
42
44
|
*/
|
|
43
45
|
readonly cohortId: string | null;
|
|
46
|
+
/**
|
|
47
|
+
* SHA-256 hex of exact bound artifact bytes (decompose draft for #3239).
|
|
48
|
+
* Unset/null = unbound (cannot satisfy structural decompose apply).
|
|
49
|
+
*/
|
|
50
|
+
readonly contentDigest?: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Project-relative parent artifact path for structural decompose apply (#3239).
|
|
53
|
+
* Unset/null = unbound.
|
|
54
|
+
*/
|
|
55
|
+
readonly parentPath?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Project-relative draft/target path for structural decompose apply (#3239).
|
|
58
|
+
* Unset/null = unbound.
|
|
59
|
+
*/
|
|
60
|
+
readonly targetPath?: string | null;
|
|
44
61
|
}
|
|
45
62
|
export interface GrantSemantics {
|
|
46
63
|
readonly expiresAt: string | null;
|