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.
@@ -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 === 'codex' || consumer === 'kimi' ? ['.git'] : [],
178
+ excludeRootEntries: consumerTransportExclusions(consumer),
63
179
  });
64
180
  if (
65
- JSON.stringify(transportPayload(sourceSnapshot.entries))
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
- const sourcePath = consumer === 'claude'
1144
- ? entry.source
1145
- : entry.source?.source === 'local' ? entry.source?.path : null;
1146
- if (typeof sourcePath !== 'string' || sourcePath.length === 0) {
1147
- return createResult({
1148
- actionType,
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: `marketplace plugin entry source "${sourcePath}" is not a safe relative path`,
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 === 'codex' || consumer === 'kimi' ? ['.git'] : [],
2399
+ excludeRootEntries: consumerTransportExclusions(consumer),
2294
2400
  });
2295
2401
  manifestDigest = installedSnapshot.digest;
2296
2402
  } catch {
@@ -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,
@@ -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 (currently Codex's root `.git`
86
- * checkout metadata). Exclusions only apply to direct children of the root;
87
- * all payload paths retain the normal fail-closed file checks.
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 excluded = new Set(excludeRootEntries);
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 && excluded.has(child.name)) continue;
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}`);