release-skill 0.1.8 → 0.1.10
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/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +34 -22
- package/README.zh-CN.md +26 -18
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +87 -17
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +87 -17
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +87 -17
- package/bin/release-skill.bundle.mjs +87 -17
- package/package.json +1 -1
- package/src/adapters/plugin-marketplace.mjs +127 -21
- package/src/core/baseline.mjs +11 -0
- package/src/snapshot/frozen.mjs +30 -5
|
@@ -48,6 +48,99 @@ function transportPayload(entries) {
|
|
|
48
48
|
}));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Consumer-owned transport metadata written into the plugin install root
|
|
52
|
+
// that is not part of the published payload. Codex checks out the
|
|
53
|
+
// repository (root `.git` metadata) and materializes migrated command
|
|
54
|
+
// skills under `.codex-plugin/migrated-command-skills/` (the CLI converts
|
|
55
|
+
// plugin commands/ into skill format at install time); Claude marks in-use
|
|
56
|
+
// plugin checkouts with an empty root `.in_use` marker. Single-segment
|
|
57
|
+
// exclusions apply to root entries only; the multi-segment exclusion names
|
|
58
|
+
// the exact CLI-generated subtree. All other payload paths keep the
|
|
59
|
+
// fail-closed file checks — the exemption must never widen to
|
|
60
|
+
// ".codex-plugin/*" or arbitrary extra files.
|
|
61
|
+
function consumerTransportExclusions(consumer) {
|
|
62
|
+
if (consumer === 'claude') return ['.in_use'];
|
|
63
|
+
if (consumer === 'codex') return ['.git', '.codex-plugin/migrated-command-skills'];
|
|
64
|
+
if (consumer === 'kimi') return ['.git'];
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract the marketplace plugin entry's declared source as a validated,
|
|
70
|
+
* normalized snapshot-relative subpath ("." for root layouts).
|
|
71
|
+
*
|
|
72
|
+
* The rejection set is preserved verbatim from the preflight safety checks:
|
|
73
|
+
* non-empty string, no absolute paths, no ".." traversal (substring check,
|
|
74
|
+
* deliberately stricter than per-segment), no backslashes, no remote URLs.
|
|
75
|
+
* Normalization runs AFTER validation and collapses "./", ".", and trailing
|
|
76
|
+
* slashes. Throws with the preflight's exact error messages.
|
|
77
|
+
*/
|
|
78
|
+
function extractDeclaredPluginSource(consumer, entry) {
|
|
79
|
+
const rawSource = consumer === 'claude'
|
|
80
|
+
? entry.source
|
|
81
|
+
: entry.source?.source === 'local' ? entry.source?.path : null;
|
|
82
|
+
if (typeof rawSource !== 'string' || rawSource.length === 0) {
|
|
83
|
+
throw new Error(`marketplace plugin entry source must be a non-empty relative path${consumer === 'codex' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`);
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
rawSource.startsWith('/') ||
|
|
87
|
+
rawSource.includes('..') ||
|
|
88
|
+
rawSource.includes('\\') ||
|
|
89
|
+
/^https?:\/\//i.test(rawSource)
|
|
90
|
+
) {
|
|
91
|
+
throw new Error(`marketplace plugin entry source "${rawSource}" is not a safe relative path`);
|
|
92
|
+
}
|
|
93
|
+
const segments = rawSource.split('/').filter((segment) => segment !== '' && segment !== '.');
|
|
94
|
+
// Redundant post-normalization invariant: ".." was already rejected by the
|
|
95
|
+
// substring check above; fail closed if it ever survives normalization.
|
|
96
|
+
if (segments.some((segment) => segment === '..')) {
|
|
97
|
+
throw new Error(`marketplace plugin entry source "${rawSource}" is not a safe relative path`);
|
|
98
|
+
}
|
|
99
|
+
return segments.length === 0 ? '.' : segments.join('/');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the payload subpath the consumer CLI installs for this action,
|
|
104
|
+
* read from the marketplace manifest inside the digest-verified frozen
|
|
105
|
+
* snapshot. Returns "." when the whole snapshot is the installed payload
|
|
106
|
+
* (root layouts, and kimi which has no marketplace manifest).
|
|
107
|
+
*
|
|
108
|
+
* Throws (fail closed) if the manifest is absent from the verified entries,
|
|
109
|
+
* unreadable, names a different marketplace, or does not declare exactly
|
|
110
|
+
* one plugins[] entry for action.plugin. The manifest itself lives inside
|
|
111
|
+
* the digest-sealed snapshot, so the declared subpath is authority-bound:
|
|
112
|
+
* tampering with it fails the snapshot digest revalidation first.
|
|
113
|
+
*/
|
|
114
|
+
async function resolveInstalledPayloadSubpath(snapshotDir, sourceEntries, action, consumer) {
|
|
115
|
+
if (consumer === 'kimi') return '.';
|
|
116
|
+
const marketplaceRelative = consumer === 'claude'
|
|
117
|
+
? '.claude-plugin/marketplace.json'
|
|
118
|
+
: '.agents/plugins/marketplace.json';
|
|
119
|
+
// Anchor the manifest read to the digest-verified entry walk: the target
|
|
120
|
+
// must be one of the regular files that already passed the fail-closed
|
|
121
|
+
// read checks (O_NOFOLLOW, single link, before/after stat stability).
|
|
122
|
+
const anchored = sourceEntries.some((entry) => entry.type === 'file' && entry.path === marketplaceRelative);
|
|
123
|
+
if (!anchored) {
|
|
124
|
+
throw new Error(`frozen snapshot is missing the marketplace manifest ${marketplaceRelative}`);
|
|
125
|
+
}
|
|
126
|
+
const result = await validateManifestFile(resolve(snapshotDir, marketplaceRelative), ['name', 'plugins']);
|
|
127
|
+
if (!result.valid) {
|
|
128
|
+
throw new Error(`frozen snapshot ${marketplaceRelative} invalid: ${result.error}`);
|
|
129
|
+
}
|
|
130
|
+
if (result.manifest.name !== action.marketplace) {
|
|
131
|
+
throw new Error(`marketplace manifest name "${result.manifest.name}" does not match action marketplace "${action.marketplace}"`);
|
|
132
|
+
}
|
|
133
|
+
const plugins = result.manifest.plugins;
|
|
134
|
+
if (!Array.isArray(plugins)) {
|
|
135
|
+
throw new Error(`${marketplaceRelative} must have a plugins[] array`);
|
|
136
|
+
}
|
|
137
|
+
const matches = plugins.filter((entry) => entry.name === action.plugin);
|
|
138
|
+
if (matches.length !== 1) {
|
|
139
|
+
throw new Error(`expected exactly one plugins[] entry with name "${action.plugin}", found ${matches.length}`);
|
|
140
|
+
}
|
|
141
|
+
return extractDeclaredPluginSource(consumer, matches[0]);
|
|
142
|
+
}
|
|
143
|
+
|
|
51
144
|
async function verifyInstalledMarketplacePayload(action, context, installPath, consumer) {
|
|
52
145
|
const sourcePath = await resolveFrozenPath(
|
|
53
146
|
context.root,
|
|
@@ -58,11 +151,34 @@ async function verifyInstalledMarketplacePayload(action, context, installPath, c
|
|
|
58
151
|
if (sourceSnapshot.digest !== action.manifestDigest) {
|
|
59
152
|
throw new Error('frozen marketplace snapshot digest no longer matches the plan');
|
|
60
153
|
}
|
|
154
|
+
// Consumer marketplaces install only the plugin entry's declared source
|
|
155
|
+
// subtree (e.g. "./adapters/claude"), not the whole unit snapshot. The
|
|
156
|
+
// sealed whole-snapshot digest above remains the authority; bind the
|
|
157
|
+
// installed payload to that snapshot's declared subtree. Root layouts
|
|
158
|
+
// and kimi keep the whole-tree comparison ("." subpath, no filtering).
|
|
159
|
+
const payloadSubpath = await resolveInstalledPayloadSubpath(
|
|
160
|
+
sourcePath,
|
|
161
|
+
sourceSnapshot.entries,
|
|
162
|
+
action,
|
|
163
|
+
consumer,
|
|
164
|
+
);
|
|
165
|
+
const prefix = payloadSubpath === '.' ? null : `${payloadSubpath}/`;
|
|
166
|
+
const authorityEntries = prefix === null
|
|
167
|
+
? sourceSnapshot.entries
|
|
168
|
+
: sourceSnapshot.entries
|
|
169
|
+
// The trailing slash keeps sibling directories (e.g.
|
|
170
|
+
// "adapters/claude-x") out of the comparison set. Prefix removal on
|
|
171
|
+
// a path-sorted array is order-preserving, so no re-sort is needed.
|
|
172
|
+
.filter((entry) => entry.path.startsWith(prefix))
|
|
173
|
+
.map((entry) => ({ ...entry, path: entry.path.slice(prefix.length) }));
|
|
174
|
+
if (authorityEntries.length === 0) {
|
|
175
|
+
throw new Error('frozen snapshot contains no payload under the declared marketplace source');
|
|
176
|
+
}
|
|
61
177
|
const installedSnapshot = await computeFrozenSnapshot(installPath, {
|
|
62
|
-
excludeRootEntries: consumer
|
|
178
|
+
excludeRootEntries: consumerTransportExclusions(consumer),
|
|
63
179
|
});
|
|
64
180
|
if (
|
|
65
|
-
JSON.stringify(transportPayload(
|
|
181
|
+
JSON.stringify(transportPayload(authorityEntries))
|
|
66
182
|
!== JSON.stringify(transportPayload(installedSnapshot.entries))
|
|
67
183
|
) {
|
|
68
184
|
throw new Error('installed marketplace payload differs in path, bytes, size, or non-write mode bits');
|
|
@@ -1139,27 +1255,17 @@ export function createPluginMarketplaceAdapter(deps = {}) {
|
|
|
1139
1255
|
// Entry source must be a safe relative path within the snapshot.
|
|
1140
1256
|
// Accepts "./" (root-level), "./adapters/claude" (subdirectory),
|
|
1141
1257
|
// etc. Rejects absolute paths, ".." traversal, remote URLs, and
|
|
1142
|
-
// empty strings.
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
status: ActionStatus.PREFLIGHT_FAILED,
|
|
1150
|
-
error: `marketplace plugin entry source must be a non-empty relative path${consumer === 'codex' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`,
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
if (
|
|
1154
|
-
sourcePath.startsWith('/') ||
|
|
1155
|
-
sourcePath.includes('..') ||
|
|
1156
|
-
sourcePath.includes('\\') ||
|
|
1157
|
-
/^https?:\/\//i.test(sourcePath)
|
|
1158
|
-
) {
|
|
1258
|
+
// empty strings. Normalized to "." for root layouts; the same
|
|
1259
|
+
// helper backs verify-side payload subtree resolution, so both
|
|
1260
|
+
// paths can never drift apart.
|
|
1261
|
+
let sourcePath;
|
|
1262
|
+
try {
|
|
1263
|
+
sourcePath = extractDeclaredPluginSource(consumer, entry);
|
|
1264
|
+
} catch (sourceErr) {
|
|
1159
1265
|
return createResult({
|
|
1160
1266
|
actionType,
|
|
1161
1267
|
status: ActionStatus.PREFLIGHT_FAILED,
|
|
1162
|
-
error:
|
|
1268
|
+
error: sourceErr.message,
|
|
1163
1269
|
});
|
|
1164
1270
|
}
|
|
1165
1271
|
// Verify the declared source directory exists and contains the
|
|
@@ -2290,7 +2396,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
|
|
|
2290
2396
|
// is returned and verify therefore fails closed.
|
|
2291
2397
|
try {
|
|
2292
2398
|
const installedSnapshot = await computeFrozenSnapshot(installPath, {
|
|
2293
|
-
excludeRootEntries: consumer
|
|
2399
|
+
excludeRootEntries: consumerTransportExclusions(consumer),
|
|
2294
2400
|
});
|
|
2295
2401
|
manifestDigest = installedSnapshot.digest;
|
|
2296
2402
|
} catch {
|
package/src/core/baseline.mjs
CHANGED
|
@@ -17,6 +17,16 @@ const execFile = promisify(execFileCb);
|
|
|
17
17
|
* use reserved prefixes; immutable plans and approvals use exact digest-shaped
|
|
18
18
|
* paths so arbitrary files under similarly named directories remain visible.
|
|
19
19
|
*
|
|
20
|
+
* `kimi-attestations` holds Kimi's closure-protocol lifecycle artifacts:
|
|
21
|
+
* the manual installation requirement that publish/reconcile itself emits
|
|
22
|
+
* for a PARTIAL kimi checkpoint, and the human-written attestation that is
|
|
23
|
+
* the designed closure input (independently bound to planDigest,
|
|
24
|
+
* payloadDigest, exact version/tag, install path, responsible person, and
|
|
25
|
+
* expiry). Neither is publishable source or project configuration — they
|
|
26
|
+
* never enter the frozen snapshot — so excluding them keeps reconcile's own
|
|
27
|
+
* requirement output and the flow-required attestation from invalidating
|
|
28
|
+
* the baseline of every subsequent reconcile.
|
|
29
|
+
*
|
|
20
30
|
* `project.yaml` is intentionally **not** listed — changes to project
|
|
21
31
|
* configuration must always cause a baseline drift.
|
|
22
32
|
*/
|
|
@@ -27,6 +37,7 @@ const CONTROL_PLANE_PREFIXES = [
|
|
|
27
37
|
'.release-skill/lock-audit',
|
|
28
38
|
'.release-skill/runs',
|
|
29
39
|
'.release-skill/transactions',
|
|
40
|
+
'.release-skill/kimi-attestations',
|
|
30
41
|
];
|
|
31
42
|
const RESERVED_CONTROL_PREFIXES = [
|
|
32
43
|
...CONTROL_PLANE_PREFIXES,
|
package/src/snapshot/frozen.mjs
CHANGED
|
@@ -82,9 +82,15 @@ async function readStableRegularFile(filePath, displayPath) {
|
|
|
82
82
|
* Compute the canonical snapshot digest.
|
|
83
83
|
*
|
|
84
84
|
* `excludeRootEntries` is reserved for consumer-owned transport metadata
|
|
85
|
-
* that is not part of the published payload
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* that is not part of the published payload: Codex's root `.git` checkout
|
|
86
|
+
* metadata, Codex's CLI-generated `.codex-plugin/migrated-command-skills/`
|
|
87
|
+
* subtree, and Claude's root `.in_use` in-use plugin marker. Single-segment
|
|
88
|
+
* entries only match direct children of the root (historical behavior);
|
|
89
|
+
* multi-segment entries name an exact relative path whose subtree is
|
|
90
|
+
* skipped. Every other payload path retains the normal fail-closed file
|
|
91
|
+
* checks — the exemption must never widen to whole directories or
|
|
92
|
+
* arbitrary extra files. Malformed entries (absolute, "..", backslash)
|
|
93
|
+
* fail closed: a bad exclusion list is a caller bug, not transport metadata.
|
|
88
94
|
*/
|
|
89
95
|
export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries = [] } = {}) {
|
|
90
96
|
const root = await realpath(snapshotDir);
|
|
@@ -94,14 +100,33 @@ export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries =
|
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
const entries = [];
|
|
97
|
-
const
|
|
103
|
+
const excludedRootNames = new Set();
|
|
104
|
+
const excludedPathPrefixes = [];
|
|
105
|
+
for (const entry of excludeRootEntries) {
|
|
106
|
+
if (typeof entry !== 'string') {
|
|
107
|
+
throw frozenError('frozen snapshot exclusion entries must be strings');
|
|
108
|
+
}
|
|
109
|
+
const segments = entry.split('/').filter((segment) => segment !== '' && segment !== '.');
|
|
110
|
+
if (
|
|
111
|
+
segments.length === 0 || entry.startsWith('/') || entry.includes('\\') ||
|
|
112
|
+
segments.some((segment) => segment === '..')
|
|
113
|
+
) {
|
|
114
|
+
throw frozenError(`frozen snapshot exclusion entry is not a safe relative path: ${JSON.stringify(entry)}`);
|
|
115
|
+
}
|
|
116
|
+
if (segments.length === 1) {
|
|
117
|
+
excludedRootNames.add(segments[0]);
|
|
118
|
+
} else {
|
|
119
|
+
excludedPathPrefixes.push(segments.join('/'));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
98
122
|
async function walk(dir) {
|
|
99
123
|
const children = await readdir(dir, { withFileTypes: true });
|
|
100
124
|
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
101
125
|
for (const child of children) {
|
|
102
|
-
if (dir === root &&
|
|
126
|
+
if (dir === root && excludedRootNames.has(child.name)) continue;
|
|
103
127
|
const absolute = join(dir, child.name);
|
|
104
128
|
const rel = relative(root, absolute).split('\\').join('/');
|
|
129
|
+
if (excludedPathPrefixes.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`))) continue;
|
|
105
130
|
const st = await lstat(absolute);
|
|
106
131
|
if (st.isSymbolicLink()) {
|
|
107
132
|
throw frozenError(`frozen snapshot contains symlink: ${rel}`);
|