release-skill 0.6.3 → 0.7.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +25 -17
- package/README.zh-CN.md +16 -14
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +34860 -31252
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +136 -4
- package/adapters/claude/schemas/release-project.schema.json +13 -4
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +34860 -31252
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +136 -4
- package/adapters/codex/schemas/release-project.schema.json +13 -4
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +34860 -31252
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +136 -4
- package/adapters/kimi/schemas/release-project.schema.json +13 -4
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +34860 -31252
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +6 -6
- package/bin/release-skill.bundle.mjs +34860 -31252
- package/bin/rules.json +1 -1
- package/package.json +5 -4
- package/platform-manifest.json +2 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +136 -4
- package/schemas/release-project.schema.json +13 -4
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +6 -10
- package/src/commands/distribute.mjs +251 -69
- package/src/commands/postverify.mjs +81 -7
- package/src/commands/prepare.mjs +176 -3
- package/src/core/docs-refresh-preset.mjs +8 -4
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/marketplace-registry-entry.mjs +4 -3
- package/src/core/postpublish-approval.mjs +132 -1
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +26 -19
- package/src/core/preset-executor.mjs +25 -6
- package/src/core/preset-gitwrite.mjs +37 -16
- package/src/core/presets.mjs +67 -19
- package/src/core/proposal-inbox.mjs +36 -20
- package/src/core/redact.mjs +16 -3
- package/src/producers/build-adapters.mjs +28 -14
|
@@ -13,15 +13,39 @@
|
|
|
13
13
|
* plan digest, so approvals invalidate naturally with the plan; `runId` is
|
|
14
14
|
* audit-only and never participates in binding.
|
|
15
15
|
*
|
|
16
|
+
* F-02 (architecture gap remediation T2): consumption is bound to the
|
|
17
|
+
* immutable authority minted by commands/approve.mjs
|
|
18
|
+
* `approvePostPublishHook`:
|
|
19
|
+
*
|
|
20
|
+
* <plan owning .release-skill>/approvals/postpublish/<planDigest>/<approvalDigest>.json
|
|
21
|
+
*
|
|
22
|
+
* `assertPostPublishApprovalAuthority` recomputes planDigest from the
|
|
23
|
+
* current plan and approvalDigest from the consumed raw bytes, requires the
|
|
24
|
+
* consumption path to EQUAL that authority path byte-for-byte (identical
|
|
25
|
+
* content anywhere else is not an approval), and strictly re-reads the file
|
|
26
|
+
* through Foundation `readFileStrict` (existence, containment against every
|
|
27
|
+
* root-escape class, no-follow regular-file identity, digest receipt bound
|
|
28
|
+
* to the recomputed approvalDigest). Symbolic-link semantics are fully
|
|
29
|
+
* delegated to Foundation: anything escaping the root is rejected, a leaf
|
|
30
|
+
* symbolic link is never an authority, and a root-internal ancestor alias
|
|
31
|
+
* is not an independent security failure (2026-08-21 handoff ruling C). The
|
|
32
|
+
* check is strictly read-only: it never creates a directory. After the
|
|
33
|
+
* authority passes, the existing schema/hook/grading/time-window validation
|
|
34
|
+
* continues unchanged.
|
|
35
|
+
*
|
|
16
36
|
* @module core/postpublish-approval
|
|
17
37
|
*/
|
|
18
38
|
|
|
39
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
40
|
+
|
|
19
41
|
import Ajv from 'ajv';
|
|
20
42
|
import addFormats from 'ajv-formats';
|
|
21
43
|
|
|
44
|
+
import { readFileStrict } from 'skill-family-harness-node';
|
|
45
|
+
|
|
22
46
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
23
47
|
import { computePlanDigest } from './plan.mjs';
|
|
24
|
-
import { validateApprovalTimeWindow } from './approval.mjs';
|
|
48
|
+
import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
|
|
25
49
|
import { resolvePresetRequiresApproval } from './presets.mjs';
|
|
26
50
|
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
27
51
|
|
|
@@ -108,3 +132,110 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
|
|
|
108
132
|
|
|
109
133
|
return hook;
|
|
110
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Derive the immutable checkpoint-approval authority location for a plan.
|
|
138
|
+
*
|
|
139
|
+
* This is the SINGLE source of truth for the authority layout, shared by the
|
|
140
|
+
* minter (commands/approve.mjs `approvePostPublishHook`) and the consumers
|
|
141
|
+
* (`assertPostPublishApprovalAuthority` below): the record lives at
|
|
142
|
+
*
|
|
143
|
+
* <releaseDir>/approvals/postpublish/<planDigest>/<approvalDigest>.json
|
|
144
|
+
*
|
|
145
|
+
* where `releaseDir` is the directory owning the frozen plan — the
|
|
146
|
+
* `.release-skill` authority root when the plan uses the canonical
|
|
147
|
+
* `plans/<planDigest>.json` naming, otherwise the plan's own directory
|
|
148
|
+
* (non-production aliases), mirroring the minter exactly.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} planPath - Absolute path of the frozen plan.
|
|
151
|
+
* @param {string} planDigest - Plan digest recomputed from the current plan.
|
|
152
|
+
* @param {string} approvalDigest - Digest recomputed from the approval bytes.
|
|
153
|
+
* @returns {{releaseDir: string, authorityPath: string}}
|
|
154
|
+
*/
|
|
155
|
+
export function derivePostPublishApprovalAuthorityPath(planPath, planDigest, approvalDigest) {
|
|
156
|
+
const absolutePlanPath = resolve(planPath);
|
|
157
|
+
const planDir = dirname(absolutePlanPath);
|
|
158
|
+
const releaseDir = basename(planDir) === 'plans' && basename(absolutePlanPath) === `${planDigest}.json`
|
|
159
|
+
? dirname(planDir)
|
|
160
|
+
: planDir;
|
|
161
|
+
return {
|
|
162
|
+
releaseDir,
|
|
163
|
+
authorityPath: resolve(releaseDir, 'approvals', 'postpublish', planDigest, `${approvalDigest}.json`),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Assert that a checkpoint approval is consumed from its immutable authority.
|
|
169
|
+
*
|
|
170
|
+
* Fail-closed checks, in order (all GATE_FAILED):
|
|
171
|
+
* 1. `planDigest` is recomputed from the CURRENT plan;
|
|
172
|
+
* 2. `approvalDigest` is recomputed from the consumed raw approval bytes;
|
|
173
|
+
* 3. the consumption path must EQUAL the expected absolute authority path —
|
|
174
|
+
* the same bytes copied anywhere else are never an approval;
|
|
175
|
+
* 4. Foundation `readFileStrict` re-reads the record through the authority
|
|
176
|
+
* root: containment rejects every escape class (lexical traversal, a
|
|
177
|
+
* final-component symlink pointing out, and any ancestor chain whose
|
|
178
|
+
* canonical target leaves the root), the record must be one ordinary
|
|
179
|
+
* file, never a symbolic link (no-follow, O_NOFOLLOW open, dev/ino
|
|
180
|
+
* identity re-stat), and its bytes must digest to the recomputed
|
|
181
|
+
* approvalDigest (binding the consumed bytes to the authoritative bytes).
|
|
182
|
+
* A root-internal ancestor alias is a path alias, not an escape, and is
|
|
183
|
+
* not an independent security failure (2026-08-21 handoff ruling C).
|
|
184
|
+
*
|
|
185
|
+
* The check is strictly read-only: it never creates a directory or file.
|
|
186
|
+
* Callers continue with `validatePostPublishApproval` (schema, hook,
|
|
187
|
+
* grading, time window) only after this assertion passes.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} planPath - Absolute path of the frozen plan.
|
|
190
|
+
* @param {string} approvalPath - Path the consumer read the approval from.
|
|
191
|
+
* @param {object} plan - Parsed frozen plan (schema-valid).
|
|
192
|
+
* @param {string|Buffer} approvalRaw - Raw approval bytes the consumer read.
|
|
193
|
+
* @returns {Promise<{planDigest: string, approvalDigest: string, authorityPath: string}>}
|
|
194
|
+
* @throws {ReleaseError} GATE_FAILED on any violation.
|
|
195
|
+
*/
|
|
196
|
+
export async function assertPostPublishApprovalAuthority(planPath, approvalPath, plan, approvalRaw) {
|
|
197
|
+
if (!plan || typeof plan !== 'object') {
|
|
198
|
+
throw new ReleaseError(GATE_FAILED, 'postpublish approval authority check requires the parsed plan');
|
|
199
|
+
}
|
|
200
|
+
if (typeof approvalPath !== 'string' || approvalPath.length === 0) {
|
|
201
|
+
throw new ReleaseError(GATE_FAILED, 'postpublish approval authority check requires a consumption path');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const planDigest = computePlanDigest(plan);
|
|
205
|
+
const approvalDigest = computeApprovalDigest(approvalRaw);
|
|
206
|
+
const { releaseDir, authorityPath } = derivePostPublishApprovalAuthorityPath(
|
|
207
|
+
planPath,
|
|
208
|
+
planDigest,
|
|
209
|
+
approvalDigest,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Exact consumption-path equality: copying the bytes elsewhere never mints
|
|
213
|
+
// an approval. The comparison is lexical on resolve()d paths (no realpath
|
|
214
|
+
// normalization), so aliased spellings fail closed too.
|
|
215
|
+
const consumedPath = resolve(approvalPath);
|
|
216
|
+
if (consumedPath !== authorityPath) {
|
|
217
|
+
throw new ReleaseError(
|
|
218
|
+
GATE_FAILED,
|
|
219
|
+
`postpublish checkpoint approval must be consumed from its immutable authority path; expected ${authorityPath}, got ${consumedPath}`,
|
|
220
|
+
{ planDigest, approvalDigest, expectedAuthorityPath: authorityPath, consumedPath },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Strict authority read through Foundation (FG-1 semantics, never local):
|
|
225
|
+
// containment (every root-escape class), no-follow leaf, regular-file
|
|
226
|
+
// identity, digest receipt. The expected digest binds the authoritative
|
|
227
|
+
// bytes to the consumed bytes.
|
|
228
|
+
const relPath = relative(releaseDir, authorityPath).split(sep).join('/');
|
|
229
|
+
try {
|
|
230
|
+
await readFileStrict(releaseDir, relPath, { expectedSha256: approvalDigest });
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
if (cause instanceof ReleaseError) throw cause;
|
|
233
|
+
throw new ReleaseError(
|
|
234
|
+
GATE_FAILED,
|
|
235
|
+
`postpublish checkpoint approval authority read failed: ${cause?.message ?? cause}`,
|
|
236
|
+
{ authorityPath, kind: cause?.details?.kind },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return Object.freeze({ planDigest, approvalDigest, authorityPath });
|
|
241
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-publish private execution bundle (F-01 / T1).
|
|
3
|
+
*
|
|
4
|
+
* Release-domain orchestration ONLY. Path classification, containment,
|
|
5
|
+
* digesting, strict reads, exclusive publication, and temporary workspaces
|
|
6
|
+
* all come from `skill-family-harness-node` (0.6.0) by package import; this
|
|
7
|
+
* module never reimplements a hash, a containment check, an atomic writer,
|
|
8
|
+
* or a temporary-workspace algorithm. It supplies exactly the release
|
|
9
|
+
* semantics the Foundation mechanisms do not own:
|
|
10
|
+
*
|
|
11
|
+
* - WHERE the frozen bytes live: digest-addressed under the SAME
|
|
12
|
+
* `.release-skill` that owns the plan authority — mechanically derived,
|
|
13
|
+
* never a caller-supplied arbitrary root:
|
|
14
|
+
*
|
|
15
|
+
* <plan authority .release-skill>/postpublish-bundles/<closure.digest>/<resource.path>
|
|
16
|
+
*
|
|
17
|
+
* - WHAT gets frozen: the closed-world `postPublish.executionFiles` manifest
|
|
18
|
+
* (parent-workspace files that post-publish commands need but the frozen
|
|
19
|
+
* tag tree does not contain; helpers must be declared explicitly). The
|
|
20
|
+
* closure is the verbatim return of `computeResourceClosure()` — sorted,
|
|
21
|
+
* deduplicated, containment-read, digested by Foundation. The frozen plan
|
|
22
|
+
* (`postPublish.executionBundle`) is the ONLY source of truth for the
|
|
23
|
+
* bundle; there is no parallel manifest and no second bundle digest —
|
|
24
|
+
* `publicFiles` ride the plan digest with everything else.
|
|
25
|
+
*
|
|
26
|
+
* - HOW the bytes return at execution time: distribute/postVerify strictly
|
|
27
|
+
* re-read every bundle file (frozen sha256 content guard + publication
|
|
28
|
+
* mode guard), recompute the closure through Foundation in a disposable
|
|
29
|
+
* workspace, compare it with the plan's frozen closure, and only then
|
|
30
|
+
* install the verified bytes into the detached tag worktree — exclusively,
|
|
31
|
+
* so a bundle entry can never shadow a frozen tag file. Any mismatch
|
|
32
|
+
* fails closed BEFORE any hook or external write.
|
|
33
|
+
*
|
|
34
|
+
* Private files execute through the tag's interpreter and never depend on
|
|
35
|
+
* live executable modes; the bundle publishes and re-verifies one fixed
|
|
36
|
+
* regular-file mode.
|
|
37
|
+
*
|
|
38
|
+
* @module core/postpublish-bundle
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { mkdir } from 'node:fs/promises';
|
|
42
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
HARNESS_ERROR_KINDS,
|
|
46
|
+
classifyPathInput,
|
|
47
|
+
computeResourceClosure,
|
|
48
|
+
publishFileExclusive,
|
|
49
|
+
readFileStrict,
|
|
50
|
+
withTemporaryWorkspace,
|
|
51
|
+
} from 'skill-family-harness-node';
|
|
52
|
+
|
|
53
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
54
|
+
|
|
55
|
+
/** Foundation mechanism identity frozen into every executionBundle. */
|
|
56
|
+
export const EXECUTION_BUNDLE_MECHANISM = 'foundation.harness.resource-closure';
|
|
57
|
+
|
|
58
|
+
/** Bundle store directory name under the plan authority `.release-skill`. */
|
|
59
|
+
export const POSTPUBLISH_BUNDLES_DIRNAME = 'postpublish-bundles';
|
|
60
|
+
|
|
61
|
+
/** Authority directory that owns plans, approvals, runs — and bundles. */
|
|
62
|
+
export const RELEASE_SKILL_DIRNAME = '.release-skill';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The single regular-file mode every bundle byte is published with and must
|
|
66
|
+
* still carry at execution time. A mode deviation is tamper evidence: the
|
|
67
|
+
* frozen closure binds content, and publication mode is this fixed policy
|
|
68
|
+
* constant — no per-file mode manifest exists.
|
|
69
|
+
*/
|
|
70
|
+
export const EXECUTION_BUNDLE_FILE_MODE = 0o644;
|
|
71
|
+
|
|
72
|
+
function fail(message, details = {}) {
|
|
73
|
+
throw new ReleaseError(GATE_FAILED, `execution bundle: ${message}`, details);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mechanically derive the bundle store root from the `.release-skill`
|
|
78
|
+
* authority directory that owns the plan. The caller never supplies an
|
|
79
|
+
* arbitrary root: the store is a fixed child of the plan's own authority
|
|
80
|
+
* directory.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} releaseSkillDir - Absolute path of a `.release-skill` dir.
|
|
83
|
+
* @returns {string} Absolute bundle store root (may not exist yet).
|
|
84
|
+
*/
|
|
85
|
+
export function bundleRootForAuthorityDir(releaseSkillDir) {
|
|
86
|
+
const absolute = resolve(releaseSkillDir);
|
|
87
|
+
if (basename(absolute) !== RELEASE_SKILL_DIRNAME) {
|
|
88
|
+
fail('bundle root must be derived from a .release-skill authority directory', { releaseSkillDir: absolute });
|
|
89
|
+
}
|
|
90
|
+
return join(absolute, POSTPUBLISH_BUNDLES_DIRNAME);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mechanically derive the bundle store root from a plan authority path:
|
|
95
|
+
* anchor on the nearest `.release-skill` ancestor of the plan file.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} planPath - Absolute path of the frozen plan.
|
|
98
|
+
* @returns {string} Absolute bundle store root.
|
|
99
|
+
*/
|
|
100
|
+
export function deriveBundleRoot(planPath) {
|
|
101
|
+
const absolute = resolve(planPath);
|
|
102
|
+
const segments = absolute.split(sep).filter(Boolean);
|
|
103
|
+
const anchorIndex = segments.lastIndexOf(RELEASE_SKILL_DIRNAME);
|
|
104
|
+
if (anchorIndex < 0) {
|
|
105
|
+
fail('plan path has no .release-skill ancestor; cannot derive the bundle store root', { planPath: absolute });
|
|
106
|
+
}
|
|
107
|
+
const prefix = absolute.startsWith(sep) ? sep : '';
|
|
108
|
+
const releaseSkillDir = prefix + join(...segments.slice(0, anchorIndex + 1));
|
|
109
|
+
return bundleRootForAuthorityDir(releaseSkillDir);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Freeze the private execution bundle for one postPublish declaration.
|
|
114
|
+
*
|
|
115
|
+
* Order of operations (all before the plan is written):
|
|
116
|
+
* 1. Foundation path classification for every declared entry (no parallel
|
|
117
|
+
* path regex; ambiguous inputs never reach the filesystem);
|
|
118
|
+
* 2. `computeResourceClosure()` — Foundation sorts, deduplicates,
|
|
119
|
+
* containment-reads (every root-escape class rejected, root-internal
|
|
120
|
+
* ancestor aliases are path aliases, not escapes), and digests; the
|
|
121
|
+
* return value is frozen VERBATIM;
|
|
122
|
+
* 3. strict re-read of every entry bound to its frozen sha256 (any
|
|
123
|
+
* concurrent drift fails closed; a symbolic-link entry is never readable
|
|
124
|
+
* — Foundation no-follow), then exclusive publication of the exact bytes
|
|
125
|
+
* under `<bundleRoot>/<closure.digest>/<resource.path>`. Re-preparing
|
|
126
|
+
* identical bytes is idempotent; divergent bytes on an occupied
|
|
127
|
+
* digest-addressed slot fail closed.
|
|
128
|
+
*
|
|
129
|
+
* Symbolic-link policy is fully delegated to Foundation (2026-08-21 handoff
|
|
130
|
+
* ruling C): anything escaping the workspace root is rejected by closure
|
|
131
|
+
* containment, a leaf symbolic link is rejected by `readFileStrict`
|
|
132
|
+
* (no-follow), and ordinary-file identity and digests stay bound.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} params
|
|
135
|
+
* @param {string} params.workspaceRoot - Release workspace root.
|
|
136
|
+
* @param {string} params.releaseSkillDir - `.release-skill` authority dir
|
|
137
|
+
* that will own the frozen plan (mechanical bundle-root anchor).
|
|
138
|
+
* @param {string[]} [params.executionFiles] - Closed-world manifest.
|
|
139
|
+
* @param {object[]} [params.publicFiles] - Release-unit public projection
|
|
140
|
+
* frozen into the plan alongside the closure.
|
|
141
|
+
* @returns {Promise<{foundationMechanism: string, closure: object, publicFiles: object[]}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function freezeExecutionBundle({ workspaceRoot, releaseSkillDir, executionFiles = [], publicFiles = [] } = {}) {
|
|
144
|
+
if (!workspaceRoot || typeof workspaceRoot !== 'string') {
|
|
145
|
+
throw new TypeError('freezeExecutionBundle: workspaceRoot must be a directory path string');
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(executionFiles)) {
|
|
148
|
+
throw new TypeError('freezeExecutionBundle: executionFiles must be an array');
|
|
149
|
+
}
|
|
150
|
+
for (const relPath of executionFiles) {
|
|
151
|
+
const classification = classifyPathInput(relPath);
|
|
152
|
+
if (!classification.ok) {
|
|
153
|
+
fail(`executionFiles entry is not a safe workspace-relative path (kind: ${classification.kind})`, {
|
|
154
|
+
path: typeof relPath === 'string' ? relPath : typeof relPath,
|
|
155
|
+
kind: classification.kind,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Foundation closure: sort, dedupe, containment reads, sha256 — verbatim.
|
|
161
|
+
// Thin adapter mapping only: Foundation mechanism errors (lexical
|
|
162
|
+
// traversal, symlink/realpath escapes, missing inputs) surface as
|
|
163
|
+
// release-domain GATE_FAILED without reimplementing any mechanism.
|
|
164
|
+
let closure;
|
|
165
|
+
try {
|
|
166
|
+
closure = await computeResourceClosure({
|
|
167
|
+
root: workspaceRoot,
|
|
168
|
+
resources: executionFiles.map((path) => ({ path, role: 'input' })),
|
|
169
|
+
});
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
if (cause instanceof ReleaseError) throw cause;
|
|
172
|
+
fail(`cannot compute the executionFiles closure: ${cause?.message ?? cause}`, {
|
|
173
|
+
kind: cause?.details?.kind,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bundleRoot = bundleRootForAuthorityDir(releaseSkillDir);
|
|
178
|
+
await mkdir(bundleRoot, { recursive: true });
|
|
179
|
+
|
|
180
|
+
for (const resource of closure.resources) {
|
|
181
|
+
// Strict re-read bound to the frozen digest: any byte drift between the
|
|
182
|
+
// closure computation and the publication fails closed.
|
|
183
|
+
let receipt;
|
|
184
|
+
try {
|
|
185
|
+
receipt = await readFileStrict(workspaceRoot, resource.path, { expectedSha256: resource.sha256 });
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
fail(`cannot strictly read executionFiles entry for publication: ${cause?.message ?? cause}`, {
|
|
188
|
+
path: resource.path,
|
|
189
|
+
kind: cause?.details?.kind,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const relTarget = join(closure.digest, resource.path);
|
|
193
|
+
try {
|
|
194
|
+
await publishFileExclusive(bundleRoot, relTarget, receipt.content, {
|
|
195
|
+
mode: EXECUTION_BUNDLE_FILE_MODE,
|
|
196
|
+
createParents: true,
|
|
197
|
+
});
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
|
|
200
|
+
// Idempotent same-bytes re-prepare: the existing slot must carry the
|
|
201
|
+
// exact frozen bytes at the publication mode, verified strictly.
|
|
202
|
+
try {
|
|
203
|
+
const existing = await readFileStrict(bundleRoot, relTarget, { expectedSha256: resource.sha256 });
|
|
204
|
+
if (existing.mode !== EXECUTION_BUNDLE_FILE_MODE) {
|
|
205
|
+
fail('existing bundle file carries an unexpected mode', { path: resource.path, mode: existing.mode });
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
} catch (verifyCause) {
|
|
209
|
+
fail(`bundle slot already exists with divergent bytes: ${verifyCause?.message ?? verifyCause}`, {
|
|
210
|
+
path: resource.path,
|
|
211
|
+
kind: verifyCause?.details?.kind,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
fail(`cannot publish bundle bytes: ${cause?.message ?? cause}`, {
|
|
216
|
+
path: resource.path,
|
|
217
|
+
kind: cause?.details?.kind,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
foundationMechanism: EXECUTION_BUNDLE_MECHANISM,
|
|
224
|
+
closure,
|
|
225
|
+
publicFiles: structuredClone(publicFiles),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Field-by-field closure comparison (the recomputed Foundation closure must
|
|
231
|
+
* equal the frozen plan closure; the digest alone is compared first).
|
|
232
|
+
*/
|
|
233
|
+
function sameClosureResources(actual, expected) {
|
|
234
|
+
if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) return false;
|
|
235
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
236
|
+
const left = actual[index] ?? {};
|
|
237
|
+
const right = expected[index] ?? {};
|
|
238
|
+
if (left.path !== right.path || left.role !== right.role
|
|
239
|
+
|| left.exists !== right.exists || left.sha256 !== right.sha256) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Re-verify the frozen execution bundle and install ONLY the verified bytes
|
|
248
|
+
* into the detached tag worktree. Runs BEFORE any hook or external write.
|
|
249
|
+
*
|
|
250
|
+
* Steps:
|
|
251
|
+
* 1. Strictly read every frozen resource from the digest-addressed store
|
|
252
|
+
* (`readFileStrict` with the frozen sha256 content guard); the file mode
|
|
253
|
+
* must still equal the publication mode — any deviation is tamper;
|
|
254
|
+
* 2. recompute the closure through Foundation in a disposable workspace and
|
|
255
|
+
* compare it (digest + resources) with the plan's frozen closure;
|
|
256
|
+
* 3. exclusively publish the verified bytes into the worktree — an occupied
|
|
257
|
+
* target means the bundle would shadow a frozen tag file and fails closed.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} params
|
|
260
|
+
* @param {object} params.plan - Frozen plan (postPublish.executionBundle).
|
|
261
|
+
* @param {string} params.planPath - Absolute plan authority path (mechanical
|
|
262
|
+
* bundle-root anchor).
|
|
263
|
+
* @param {string} params.worktreePath - Detached tag worktree root.
|
|
264
|
+
* @returns {Promise<{installed: string[]}>} Installed relative paths.
|
|
265
|
+
*/
|
|
266
|
+
export async function verifyAndInstallExecutionBundle({ plan, planPath, worktreePath } = {}) {
|
|
267
|
+
const bundle = plan?.postPublish?.executionBundle;
|
|
268
|
+
if (!bundle) return { installed: [] };
|
|
269
|
+
const closure = bundle.closure;
|
|
270
|
+
if (!closure || !Array.isArray(closure.resources) || typeof closure.digest !== 'string') {
|
|
271
|
+
fail('plan carries a malformed executionBundle closure');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const bundleRoot = deriveBundleRoot(planPath);
|
|
275
|
+
const bytesByPath = new Map();
|
|
276
|
+
for (const resource of closure.resources) {
|
|
277
|
+
if (!resource || resource.role !== 'input' || resource.exists !== true || typeof resource.sha256 !== 'string') {
|
|
278
|
+
fail('executionBundle closure resources must be existing input resources with frozen digests', {
|
|
279
|
+
path: resource?.path,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
let receipt;
|
|
283
|
+
try {
|
|
284
|
+
receipt = await readFileStrict(bundleRoot, join(closure.digest, resource.path), {
|
|
285
|
+
expectedSha256: resource.sha256,
|
|
286
|
+
});
|
|
287
|
+
} catch (cause) {
|
|
288
|
+
fail(`bundle resource is missing or drifted from the frozen closure: ${cause?.message ?? cause}`, {
|
|
289
|
+
path: resource.path,
|
|
290
|
+
kind: cause?.details?.kind,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (receipt.mode !== EXECUTION_BUNDLE_FILE_MODE) {
|
|
294
|
+
fail(`bundle resource mode was tampered (expected ${EXECUTION_BUNDLE_FILE_MODE.toString(8)}, got ${receipt.mode.toString(8)})`, {
|
|
295
|
+
path: resource.path,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
bytesByPath.set(resource.path, receipt.content);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Foundation recomputation in a disposable workspace: any inconsistency
|
|
302
|
+
// fails before a hook or an external write.
|
|
303
|
+
try {
|
|
304
|
+
await withTemporaryWorkspace(async (workspace) => {
|
|
305
|
+
for (const resource of closure.resources) {
|
|
306
|
+
await workspace.writeFile(resource.path, bytesByPath.get(resource.path));
|
|
307
|
+
}
|
|
308
|
+
const recomputed = await computeResourceClosure({
|
|
309
|
+
root: workspace.root,
|
|
310
|
+
resources: closure.resources.map((resource) => ({ path: resource.path, role: resource.role })),
|
|
311
|
+
});
|
|
312
|
+
if (recomputed.digest !== closure.digest || !sameClosureResources(recomputed.resources, closure.resources)) {
|
|
313
|
+
fail('recomputed bundle closure differs from the frozen plan closure');
|
|
314
|
+
}
|
|
315
|
+
}, { prefix: 'rs-execution-bundle-verify-' });
|
|
316
|
+
} catch (cause) {
|
|
317
|
+
if (cause instanceof ReleaseError) throw cause;
|
|
318
|
+
fail(`bundle closure recomputation failed: ${cause?.message ?? cause}`, { kind: cause?.details?.kind });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Install verified bytes into the tag worktree — exclusively: the bundle
|
|
322
|
+
// must never overwrite (shadow) a file that already belongs to the tag.
|
|
323
|
+
const installed = [];
|
|
324
|
+
for (const resource of closure.resources) {
|
|
325
|
+
try {
|
|
326
|
+
await publishFileExclusive(worktreePath, resource.path, bytesByPath.get(resource.path), {
|
|
327
|
+
mode: EXECUTION_BUNDLE_FILE_MODE,
|
|
328
|
+
createParents: true,
|
|
329
|
+
});
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
|
|
332
|
+
fail(`execution bundle would overwrite a frozen tag file: ${resource.path}`, { path: resource.path });
|
|
333
|
+
}
|
|
334
|
+
fail(`cannot install bundle bytes into the tag worktree: ${cause?.message ?? cause}`, {
|
|
335
|
+
path: resource.path,
|
|
336
|
+
kind: cause?.details?.kind,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
installed.push(resource.path);
|
|
340
|
+
}
|
|
341
|
+
return { installed };
|
|
342
|
+
}
|