release-skill 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe conflict materialization and resolution submit with new plan derivation.
|
|
3
|
+
*
|
|
4
|
+
* @module artifacts/resolution
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdir, open, readFile, stat, lstat, chmod } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve, relative, isAbsolute, basename } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ReleaseError,
|
|
12
|
+
PLAN_STALE,
|
|
13
|
+
SENSITIVE_CONFLICT,
|
|
14
|
+
MISSING_PARAMETERS,
|
|
15
|
+
PATH_UNSAFE,
|
|
16
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
17
|
+
} from '../core/errors.mjs';
|
|
18
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
19
|
+
import { inspectArtifacts } from './inspect.mjs';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Constants
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const MAX_TEMPLATE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Buffer decode — handles both Buffer and JSON-roundtrip {type:'Buffer',data}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode a value that may be a native Buffer or a JSON-roundtrip
|
|
33
|
+
* {type:'Buffer',data:[...]} object into a proper Buffer.
|
|
34
|
+
* Fails closed on illegal shapes.
|
|
35
|
+
*
|
|
36
|
+
* @param {*} value
|
|
37
|
+
* @param {string} label - For error messages.
|
|
38
|
+
* @returns {Buffer|null} Decoded buffer, or null if value is nullish.
|
|
39
|
+
* @throws {ReleaseError} MISSING_PARAMETERS if shape is illegal.
|
|
40
|
+
*/
|
|
41
|
+
function decodeBuffer(value, label) {
|
|
42
|
+
if (value == null) return null;
|
|
43
|
+
if (Buffer.isBuffer(value)) return value;
|
|
44
|
+
if (typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) {
|
|
45
|
+
if (!value.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
|
46
|
+
throw new ReleaseError(
|
|
47
|
+
MISSING_PARAMETERS,
|
|
48
|
+
`illegal conflict ${label} byte array`,
|
|
49
|
+
{ label },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.from(value.data);
|
|
53
|
+
}
|
|
54
|
+
throw new ReleaseError(
|
|
55
|
+
MISSING_PARAMETERS,
|
|
56
|
+
`illegal conflict ${label} shape: expected Buffer or {type:'Buffer',data:[...]}`,
|
|
57
|
+
{ label, received: typeof value },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decode and validate conflict buffers: fatal UTF-8, no NUL/control chars.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} conflict
|
|
65
|
+
* @returns {{ base: Buffer|null, current: Buffer|null, generated: Buffer|null }}
|
|
66
|
+
*/
|
|
67
|
+
function decodeAndValidateConflictBuffers(conflict) {
|
|
68
|
+
const base = decodeBuffer(conflict.base, 'base');
|
|
69
|
+
const current = decodeBuffer(conflict.current, 'current');
|
|
70
|
+
const generated = decodeBuffer(conflict.generated, 'generated');
|
|
71
|
+
|
|
72
|
+
for (const [label, buf] of [['base', base], ['current', current], ['generated', generated]]) {
|
|
73
|
+
if (!buf) continue;
|
|
74
|
+
assertSafeContent(buf, label);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { base, current, generated };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Assert content is safe: valid UTF-8, no NUL, no control characters (except
|
|
82
|
+
* TAB/LF/CR), and within size limit.
|
|
83
|
+
*/
|
|
84
|
+
function assertSafeContent(bytes, label) {
|
|
85
|
+
// Size check
|
|
86
|
+
if (bytes.length > MAX_TEMPLATE_BYTES) {
|
|
87
|
+
throw new ReleaseError(
|
|
88
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
89
|
+
`conflict ${label} exceeds size limit (${bytes.length} > ${MAX_TEMPLATE_BYTES})`,
|
|
90
|
+
{ label, size: bytes.length, limit: MAX_TEMPLATE_BYTES },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fatal UTF-8 check
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new ReleaseError(
|
|
100
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
101
|
+
`conflict ${label} is not valid UTF-8`,
|
|
102
|
+
{ label },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// NUL and control character scan
|
|
107
|
+
for (let i = 0; i < text.length; i++) {
|
|
108
|
+
const code = text.charCodeAt(i);
|
|
109
|
+
if (code === 0) {
|
|
110
|
+
throw new ReleaseError(
|
|
111
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
112
|
+
`conflict ${label} contains NUL byte at offset ${i}`,
|
|
113
|
+
{ label, offset: i },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
// Allow TAB (0x09), LF (0x0A), CR (0x0D); reject other control chars
|
|
117
|
+
if (code < 0x20 && code !== 0x09 && code !== 0x0A && code !== 0x0D) {
|
|
118
|
+
throw new ReleaseError(
|
|
119
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
120
|
+
`conflict ${label} contains control character 0x${code.toString(16)} at offset ${i}`,
|
|
121
|
+
{ label, offset: i, charCode: code },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Sensitive content detection
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const SENSITIVE_PATTERNS = Object.freeze([
|
|
132
|
+
/api[_-]?key\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
|
|
133
|
+
/secret[_-]?key\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
|
|
134
|
+
/password\s*[=:]\s*["']?[^\s"']{8,}/i,
|
|
135
|
+
/token\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{16,}/i,
|
|
136
|
+
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
|
|
137
|
+
/AKIA[0-9A-Z]{16}/,
|
|
138
|
+
/ghp_[A-Za-z0-9]{36}/,
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
function containsSensitivePattern(bytes) {
|
|
142
|
+
if (!bytes || bytes.length === 0) return false;
|
|
143
|
+
let content;
|
|
144
|
+
try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { return false; }
|
|
145
|
+
return SENSITIVE_PATTERNS.some((p) => p.test(content));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkSensitiveConflict(artifact, decodedBuffers) {
|
|
149
|
+
const buffers = [decodedBuffers.base, decodedBuffers.current, decodedBuffers.generated].filter(Boolean);
|
|
150
|
+
for (const buf of buffers) {
|
|
151
|
+
if (containsSensitivePattern(buf)) {
|
|
152
|
+
return { sensitive: true, reason: 'conflict body contains sensitive data pattern' };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { sensitive: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isValidSensitiveAuthorization(auth) {
|
|
159
|
+
if (!auth || typeof auth !== 'object') return false;
|
|
160
|
+
return typeof auth.actor === 'string' && auth.actor.trim().length > 0
|
|
161
|
+
&& typeof auth.reason === 'string' && auth.reason.trim().length > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Path safety
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function assertSafeArtifactId(id) {
|
|
169
|
+
if (!id || typeof id !== 'string') {
|
|
170
|
+
throw new ReleaseError(PATH_UNSAFE, 'artifactId must be a non-empty string', { artifactId: id });
|
|
171
|
+
}
|
|
172
|
+
if (id.includes('/') || id.includes('\\') || id === '.' || id === '..'
|
|
173
|
+
|| id.includes('..') || id.includes('\0')) {
|
|
174
|
+
throw new ReleaseError(PATH_UNSAFE, `artifactId contains unsafe path characters: "${id}"`, { artifactId: id });
|
|
175
|
+
}
|
|
176
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
177
|
+
throw new ReleaseError(PATH_UNSAFE, `artifactId must be alphanumeric/dash/underscore/dot: "${id}"`, { artifactId: id });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Assert no symlinks at any directory level from root to resolvedPath.
|
|
183
|
+
* Checks: .release-skill, resolution, artifactId dir, and the file itself.
|
|
184
|
+
*/
|
|
185
|
+
async function assertNoSymlinksInPath(root, artifactId) {
|
|
186
|
+
const levels = [
|
|
187
|
+
join(root, '.release-skill'),
|
|
188
|
+
join(root, '.release-skill', 'resolution'),
|
|
189
|
+
join(root, '.release-skill', 'resolution', artifactId),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const dir of levels) {
|
|
193
|
+
try {
|
|
194
|
+
const st = await lstat(dir);
|
|
195
|
+
if (st.isSymbolicLink()) {
|
|
196
|
+
throw new ReleaseError(PATH_UNSAFE, `directory is a symlink: ${dir}`, { path: dir });
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err.code === 'ENOENT') continue; // doesn't exist yet, ok
|
|
200
|
+
if (err instanceof ReleaseError) throw err;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function assertSafeResolvedPath(root, artifactId, resolvedPath) {
|
|
207
|
+
const resolutionDir = resolve(root, '.release-skill', 'resolution', artifactId);
|
|
208
|
+
const resolved = resolve(resolvedPath);
|
|
209
|
+
|
|
210
|
+
await assertNoSymlinksInPath(root, artifactId);
|
|
211
|
+
|
|
212
|
+
const rel = relative(resolutionDir, resolved);
|
|
213
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
214
|
+
throw new ReleaseError(
|
|
215
|
+
PATH_UNSAFE,
|
|
216
|
+
`resolvedPath must be inside resolution directory ${resolutionDir}`,
|
|
217
|
+
{ resolvedPath, resolutionDir },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Exact filename check: must be <artifactId>.resolved
|
|
222
|
+
const filename = basename(resolvedPath);
|
|
223
|
+
if (filename !== `${artifactId}.resolved`) {
|
|
224
|
+
throw new ReleaseError(
|
|
225
|
+
PATH_UNSAFE,
|
|
226
|
+
`resolvedPath must be named "${artifactId}.resolved", got "${filename}"`,
|
|
227
|
+
{ resolvedPath, expected: `${artifactId}.resolved`, actual: filename },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (resolved !== resolve(resolutionDir, `${artifactId}.resolved`)) {
|
|
231
|
+
throw new ReleaseError(
|
|
232
|
+
PATH_UNSAFE,
|
|
233
|
+
'resolvedPath must be the exact materialized resolution file',
|
|
234
|
+
{ resolvedPath },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let st;
|
|
239
|
+
try {
|
|
240
|
+
st = await lstat(resolvedPath);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
throw new ReleaseError(
|
|
243
|
+
MISSING_PARAMETERS,
|
|
244
|
+
`cannot stat resolved file: ${err.message}`,
|
|
245
|
+
{ resolvedPath, cause: err.code },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (st.isSymbolicLink()) {
|
|
249
|
+
throw new ReleaseError(PATH_UNSAFE, 'resolvedPath must not be a symlink', { resolvedPath });
|
|
250
|
+
}
|
|
251
|
+
if (!st.isFile()) {
|
|
252
|
+
throw new ReleaseError(PATH_UNSAFE, 'resolvedPath must be a regular file', { resolvedPath });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Permission check: must be 0600 (owner read-write only)
|
|
256
|
+
const perms = st.mode & 0o777;
|
|
257
|
+
if (perms !== 0o600) {
|
|
258
|
+
throw new ReleaseError(
|
|
259
|
+
PATH_UNSAFE,
|
|
260
|
+
`resolvedPath must have 0600 permissions, got ${(perms).toString(8)}`,
|
|
261
|
+
{ resolvedPath, permissions: perms },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Conflict content formatting
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
function buildConflictTemplate(conflict, decodedBuffers) {
|
|
271
|
+
const base = decodedBuffers.base ? decodedBuffers.base.toString('utf8') : '';
|
|
272
|
+
const current = decodedBuffers.current ? decodedBuffers.current.toString('utf8') : '';
|
|
273
|
+
const generated = decodedBuffers.generated ? decodedBuffers.generated.toString('utf8') : '';
|
|
274
|
+
|
|
275
|
+
const lines = [
|
|
276
|
+
'<<<<<<< CURRENT (human)',
|
|
277
|
+
current,
|
|
278
|
+
'||||||| BASE',
|
|
279
|
+
base,
|
|
280
|
+
'=======',
|
|
281
|
+
generated,
|
|
282
|
+
'>>>>>>> GENERATED (producer)',
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const template = Buffer.from(lines.join('\n'), 'utf8');
|
|
286
|
+
|
|
287
|
+
// Size check on the assembled template
|
|
288
|
+
if (template.length > MAX_TEMPLATE_BYTES) {
|
|
289
|
+
throw new ReleaseError(
|
|
290
|
+
FORBIDDEN_CONTENT_DETECTED,
|
|
291
|
+
`assembled conflict template exceeds size limit (${template.length} > ${MAX_TEMPLATE_BYTES})`,
|
|
292
|
+
{ size: template.length, limit: MAX_TEMPLATE_BYTES },
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return template;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Optimistic plan capture (injectable)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
export async function withOptimisticPlanCapture(root, plan, expectedPlanDigest, fn, options = {}) {
|
|
304
|
+
const capture = options.captureBindings ?? defaultCaptureBindings;
|
|
305
|
+
|
|
306
|
+
const beforeCapture = await capture(root, plan);
|
|
307
|
+
const beforeBindings = canonicalJson(beforeCapture.bindings ?? {});
|
|
308
|
+
|
|
309
|
+
const planContentDigest = computePlanContentDigest(plan);
|
|
310
|
+
const expectedWithoutPrefix = expectedPlanDigest.startsWith('sha256:')
|
|
311
|
+
? expectedPlanDigest.slice(7)
|
|
312
|
+
: expectedPlanDigest;
|
|
313
|
+
if (planContentDigest !== expectedWithoutPrefix) {
|
|
314
|
+
throw new ReleaseError(
|
|
315
|
+
PLAN_STALE,
|
|
316
|
+
'plan content changed since this plan digest was issued',
|
|
317
|
+
{ expectedPlanDigest, recomputedDigest: `sha256:${planContentDigest}` },
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const planBindings = canonicalJson(plan.bindings ?? {});
|
|
322
|
+
if (beforeBindings !== planBindings) {
|
|
323
|
+
throw new ReleaseError(
|
|
324
|
+
PLAN_STALE,
|
|
325
|
+
'captured bindings differ from plan bindings — plan is stale',
|
|
326
|
+
{ expectedPlanDigest },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = await fn();
|
|
331
|
+
|
|
332
|
+
const afterCapture = await capture(root, plan);
|
|
333
|
+
const afterBindings = canonicalJson(afterCapture.bindings ?? {});
|
|
334
|
+
if (beforeBindings !== afterBindings) {
|
|
335
|
+
throw new ReleaseError(
|
|
336
|
+
PLAN_STALE,
|
|
337
|
+
'plan bindings changed during resolution operation',
|
|
338
|
+
{ expectedPlanDigest },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Default capture: calls inspectArtifacts({root, mode:'inspect'}) to re-read
|
|
347
|
+
* real bindings from the repository. Does NOT fall back to plan's own bindings.
|
|
348
|
+
* If inspect fails (no policy, no git, etc.), the error propagates — this is
|
|
349
|
+
* intentional: the caller must provide a real repository root or inject a
|
|
350
|
+
* stable capture function for unit tests.
|
|
351
|
+
*/
|
|
352
|
+
async function defaultCaptureBindings(root, _plan) {
|
|
353
|
+
const result = await inspectArtifacts({ root, mode: 'inspect' });
|
|
354
|
+
return { bindings: result.plan.bindings };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Plan digest computation (generic — strips only planDigest)
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function computePlanContentDigest(plan) {
|
|
362
|
+
const { planDigest: _stripped, ...rest } = plan;
|
|
363
|
+
const plain = {};
|
|
364
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
365
|
+
plain[k] = v;
|
|
366
|
+
}
|
|
367
|
+
return sha256Hex(canonicalJson(plain));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function computeResolutionDigest(plan) {
|
|
371
|
+
const { planDigest: _stripped, ...rest } = plan;
|
|
372
|
+
const plain = {};
|
|
373
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
374
|
+
plain[k] = v;
|
|
375
|
+
}
|
|
376
|
+
return `sha256:${sha256Hex(canonicalJson(plain))}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Public API
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
export async function materializeResolution({
|
|
384
|
+
root,
|
|
385
|
+
plan,
|
|
386
|
+
planDigest,
|
|
387
|
+
artifactId,
|
|
388
|
+
sensitiveAuthorization,
|
|
389
|
+
} = {}) {
|
|
390
|
+
// --- Validate plan digest ---
|
|
391
|
+
const recomputed = computePlanContentDigest(plan);
|
|
392
|
+
const expectedHex = planDigest.startsWith('sha256:') ? planDigest.slice(7) : planDigest;
|
|
393
|
+
if (recomputed !== expectedHex) {
|
|
394
|
+
throw new ReleaseError(
|
|
395
|
+
PLAN_STALE,
|
|
396
|
+
'plan digest does not match expected --plan-digest',
|
|
397
|
+
{ expected: planDigest, recomputed: `sha256:${recomputed}` },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Safe artifactId ---
|
|
402
|
+
assertSafeArtifactId(artifactId);
|
|
403
|
+
|
|
404
|
+
// --- Locate artifact ---
|
|
405
|
+
const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
|
|
406
|
+
if (!artifact) {
|
|
407
|
+
throw new ReleaseError(MISSING_PARAMETERS, `artifact "${artifactId}" not found in plan`, { artifactId });
|
|
408
|
+
}
|
|
409
|
+
if (artifact.status !== 'CONFLICT' && !artifact.conflict) {
|
|
410
|
+
throw new ReleaseError(MISSING_PARAMETERS, `artifact "${artifactId}" is not in CONFLICT status`, { artifactId, status: artifact.status });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Decode conflict buffers (handles Buffer + JSON roundtrip shape) ---
|
|
414
|
+
const decodedBuffers = decodeAndValidateConflictBuffers(artifact.conflict ?? {});
|
|
415
|
+
|
|
416
|
+
// --- Sensitive scan on decoded buffers ---
|
|
417
|
+
const { sensitive, reason } = checkSensitiveConflict(artifact, decodedBuffers);
|
|
418
|
+
if (sensitive && !isValidSensitiveAuthorization(sensitiveAuthorization)) {
|
|
419
|
+
throw new ReleaseError(
|
|
420
|
+
SENSITIVE_CONFLICT,
|
|
421
|
+
`conflict for artifact "${artifactId}" contains sensitive data: ${reason}. ` +
|
|
422
|
+
'Provide sensitiveAuthorization with non-empty actor and reason to override.',
|
|
423
|
+
{ artifactId, reason },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Build conflict template from decoded buffers ---
|
|
428
|
+
const template = buildConflictTemplate(artifact.conflict ?? {}, decodedBuffers);
|
|
429
|
+
const templateDigest = sha256Hex(template);
|
|
430
|
+
|
|
431
|
+
// --- Symlink check at all directory levels BEFORE creating anything ---
|
|
432
|
+
await assertNoSymlinksInPath(root, artifactId);
|
|
433
|
+
|
|
434
|
+
// --- Create resolution directory with restricted permissions ---
|
|
435
|
+
const resolutionDir = join(root, '.release-skill', 'resolution', artifactId);
|
|
436
|
+
await mkdir(resolutionDir, { recursive: true, mode: 0o700 });
|
|
437
|
+
|
|
438
|
+
// Re-assert permissions on existing directory
|
|
439
|
+
const dirStat = await stat(resolutionDir);
|
|
440
|
+
if ((dirStat.mode & 0o777) !== 0o700) {
|
|
441
|
+
await chmod(resolutionDir, 0o700);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// --- Write resolved file with exclusive open + restricted permissions ---
|
|
445
|
+
const resolvedPath = join(resolutionDir, `${artifactId}.resolved`);
|
|
446
|
+
const fh = await open(resolvedPath, 'wx', 0o600);
|
|
447
|
+
try {
|
|
448
|
+
await fh.write(template, 0, template.length);
|
|
449
|
+
await fh.sync();
|
|
450
|
+
} finally {
|
|
451
|
+
await fh.close();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return Object.freeze({
|
|
455
|
+
directory: resolutionDir,
|
|
456
|
+
resolvedPath,
|
|
457
|
+
metadata: Object.freeze({
|
|
458
|
+
artifactId,
|
|
459
|
+
templateDigest,
|
|
460
|
+
baseDigest: artifact.conflict?.baseDigest ?? null,
|
|
461
|
+
currentDigest: artifact.conflict?.currentDigest ?? null,
|
|
462
|
+
generatedDigest: artifact.conflict?.generatedDigest ?? null,
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function submitResolution({
|
|
468
|
+
root,
|
|
469
|
+
plan,
|
|
470
|
+
planDigest,
|
|
471
|
+
artifactId,
|
|
472
|
+
resolvedPath,
|
|
473
|
+
discardedHunkDigests = [],
|
|
474
|
+
captureOptions,
|
|
475
|
+
} = {}) {
|
|
476
|
+
assertSafeArtifactId(artifactId);
|
|
477
|
+
await assertSafeResolvedPath(root, artifactId, resolvedPath);
|
|
478
|
+
|
|
479
|
+
return withOptimisticPlanCapture(root, plan, planDigest, async () => {
|
|
480
|
+
const resolvedContent = await readAndValidateResolvedFile(resolvedPath);
|
|
481
|
+
assertConflictPointsResolved(plan, artifactId, resolvedContent);
|
|
482
|
+
validateDiscardedHunks(plan, artifactId, discardedHunkDigests);
|
|
483
|
+
verifyHumanHunksPreserved(plan, artifactId, resolvedContent, discardedHunkDigests);
|
|
484
|
+
return deriveResolvedPlan(plan, artifactId, planDigest, resolvedPath, resolvedContent, discardedHunkDigests);
|
|
485
|
+
}, captureOptions);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Internal
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
async function readAndValidateResolvedFile(resolvedPath) {
|
|
493
|
+
let content;
|
|
494
|
+
try {
|
|
495
|
+
content = await readFile(resolvedPath);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
throw new ReleaseError(MISSING_PARAMETERS, `cannot read resolved file: ${err.message}`, { resolvedPath, cause: err.code });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
assertSafeContent(content, 'resolved');
|
|
501
|
+
|
|
502
|
+
const text = content.toString('utf8');
|
|
503
|
+
if (text.includes('<<<<<<<') || text.includes('>>>>>>>') || text.includes('=======')) {
|
|
504
|
+
throw new ReleaseError(MISSING_PARAMETERS, 'resolved file still contains conflict markers — resolve before submitting', { resolvedPath });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return content;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function assertConflictPointsResolved(plan, artifactId, resolvedContent) {
|
|
511
|
+
const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
|
|
512
|
+
if (!artifact || !artifact.conflict) return;
|
|
513
|
+
if (resolvedContent.length === 0) {
|
|
514
|
+
throw new ReleaseError(MISSING_PARAMETERS, `resolved file for artifact "${artifactId}" is empty but conflict existed`, { artifactId });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Validate discardedHunkDigests: must exist in plan (even if protectedHunks is
|
|
520
|
+
* empty → always reject) and be unique.
|
|
521
|
+
*/
|
|
522
|
+
function validateDiscardedHunks(plan, artifactId, discardedHunkDigests) {
|
|
523
|
+
if (discardedHunkDigests.length === 0) return;
|
|
524
|
+
|
|
525
|
+
const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
|
|
526
|
+
const knownDigests = new Set(
|
|
527
|
+
(artifact?.protectedHunks ?? []).map((h) => h.hunkDigest),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const seen = new Set();
|
|
531
|
+
for (const digest of discardedHunkDigests) {
|
|
532
|
+
if (seen.has(digest)) {
|
|
533
|
+
throw new ReleaseError(MISSING_PARAMETERS, `duplicate discarded hunk digest: "${digest}"`, { artifactId, hunkDigest: digest });
|
|
534
|
+
}
|
|
535
|
+
seen.add(digest);
|
|
536
|
+
|
|
537
|
+
// Always reject if not found — even when knownDigests is empty
|
|
538
|
+
if (!knownDigests.has(digest)) {
|
|
539
|
+
throw new ReleaseError(
|
|
540
|
+
MISSING_PARAMETERS,
|
|
541
|
+
`discarded hunk digest not found in plan: "${digest}"`,
|
|
542
|
+
{ artifactId, hunkDigest: digest, knownDigests: [...knownDigests] },
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Verify undiscarded protected human hunks are present in the resolved body.
|
|
550
|
+
* Strict byte-level inclusion — no trim, no string approximation.
|
|
551
|
+
* Range validation and currentDigest verification — fail closed on mismatch.
|
|
552
|
+
*/
|
|
553
|
+
function verifyHumanHunksPreserved(plan, artifactId, resolvedContent, discardedHunkDigests) {
|
|
554
|
+
const artifact = (plan.artifacts ?? []).find((a) => a.id === artifactId);
|
|
555
|
+
if (!artifact?.protectedHunks || artifact.protectedHunks.length === 0) return;
|
|
556
|
+
|
|
557
|
+
const discarded = new Set(discardedHunkDigests);
|
|
558
|
+
|
|
559
|
+
// Decode current content from conflict (handles Buffer/JSON roundtrip)
|
|
560
|
+
const currentContent = decodeBuffer(artifact.conflict?.current, 'current');
|
|
561
|
+
if (!currentContent) {
|
|
562
|
+
throw new ReleaseError(
|
|
563
|
+
MISSING_PARAMETERS,
|
|
564
|
+
'protected human hunks require current conflict bytes',
|
|
565
|
+
{ artifactId },
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
for (const hunk of artifact.protectedHunks) {
|
|
570
|
+
if (discarded.has(hunk.hunkDigest)) continue;
|
|
571
|
+
if (!hunk.range) {
|
|
572
|
+
throw new ReleaseError(
|
|
573
|
+
MISSING_PARAMETERS,
|
|
574
|
+
'protected human hunk is missing its byte range',
|
|
575
|
+
{ artifactId, hunkDigest: hunk.hunkDigest },
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const { start, length } = hunk.range;
|
|
580
|
+
|
|
581
|
+
// Range boundary check — fail closed
|
|
582
|
+
if (start < 0 || length < 0 || start + length > currentContent.length) {
|
|
583
|
+
throw new ReleaseError(
|
|
584
|
+
MISSING_PARAMETERS,
|
|
585
|
+
`protected hunk range out of bounds: start=${start}, length=${length}, content length=${currentContent.length}`,
|
|
586
|
+
{ artifactId, hunkDigest: hunk.hunkDigest, range: hunk.range, contentLength: currentContent.length },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Extract exact hunk bytes from current content
|
|
591
|
+
const hunkBytes = currentContent.slice(start, start + length);
|
|
592
|
+
|
|
593
|
+
// Verify currentDigest if provided — fail closed on mismatch
|
|
594
|
+
if (hunk.currentDigest) {
|
|
595
|
+
const actualDigest = sha256Hex(hunkBytes);
|
|
596
|
+
if (actualDigest !== hunk.currentDigest) {
|
|
597
|
+
throw new ReleaseError(
|
|
598
|
+
MISSING_PARAMETERS,
|
|
599
|
+
`protected hunk currentDigest mismatch: expected ${hunk.currentDigest}, got ${actualDigest}`,
|
|
600
|
+
{ artifactId, hunkDigest: hunk.hunkDigest, expected: hunk.currentDigest, actual: actualDigest },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Strict byte-level inclusion in resolved content — no trim
|
|
606
|
+
if (hunkBytes.length > 0) {
|
|
607
|
+
if (resolvedContent.indexOf(hunkBytes) === -1) {
|
|
608
|
+
throw new ReleaseError(
|
|
609
|
+
MISSING_PARAMETERS,
|
|
610
|
+
`protected human hunk not found in resolved body: "${hunk.hunkDigest}"`,
|
|
611
|
+
{ artifactId, hunkDigest: hunk.hunkDigest },
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Plan derivation (phase 2)
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
function deriveResolvedPlan(plan, artifactId, oldPlanDigest, resolvedPath, resolvedContent, discardedHunkDigests) {
|
|
623
|
+
const resolvedDigest = sha256Hex(resolvedContent);
|
|
624
|
+
const resolutionRelPath = `.release-skill/resolution/${artifactId}/${artifactId}.resolved`;
|
|
625
|
+
|
|
626
|
+
const hunkDecisions = discardedHunkDigests.map((digest) => Object.freeze({
|
|
627
|
+
hunkDigest: digest,
|
|
628
|
+
action: 'discarded',
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
const updatedArtifacts = (plan.artifacts ?? []).map((a) => {
|
|
632
|
+
if (a.id !== artifactId) return Object.freeze({ ...a });
|
|
633
|
+
return Object.freeze({
|
|
634
|
+
...a,
|
|
635
|
+
status: 'RESOLVED',
|
|
636
|
+
safeToWrite: false,
|
|
637
|
+
resolvedDigest,
|
|
638
|
+
resolutionPath: resolutionRelPath,
|
|
639
|
+
hunkDecisions: Object.freeze(hunkDecisions),
|
|
640
|
+
allowedActions: ['inspect'],
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const newPlan = Object.freeze({
|
|
645
|
+
apiVersion: plan.apiVersion,
|
|
646
|
+
operation: 'resolve',
|
|
647
|
+
bindings: Object.freeze({ ...(plan.bindings ?? {}) }),
|
|
648
|
+
artifacts: Object.freeze(updatedArtifacts),
|
|
649
|
+
safeToWrite: false,
|
|
650
|
+
targetUnchanged: true,
|
|
651
|
+
nextAction: Object.freeze({ command: 'artifacts inspect --plan-digest <new-digest>' }),
|
|
652
|
+
supersedesPlanDigest: oldPlanDigest,
|
|
653
|
+
planDigest: undefined,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const digest = computeResolutionDigest(newPlan);
|
|
657
|
+
return Object.freeze({ ...newPlan, planDigest: digest });
|
|
658
|
+
}
|