release-skill 0.6.3 → 0.7.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/.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 +36 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +17 -23
- package/README.zh-CN.md +8 -20
- 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 +34866 -31254
- 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 +34866 -31254
- 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 +34866 -31254
- 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 +34866 -31254
- 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 +34866 -31254
- 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
- package/src/snapshot/scan.mjs +6 -2
package/src/commands/prepare.mjs
CHANGED
|
@@ -18,13 +18,14 @@
|
|
|
18
18
|
* @module commands/prepare
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
|
|
22
|
-
import { readFile, mkdir, readdir, realpath } from 'node:fs/promises';
|
|
21
|
+
import { resolve, relative, isAbsolute, normalize, dirname, basename, posix as pathPosix } from 'node:path';
|
|
22
|
+
import { readFile, mkdir, readdir, realpath, lstat } from 'node:fs/promises';
|
|
23
23
|
import { execFile as execFileCb } from 'node:child_process';
|
|
24
24
|
import { promisify } from 'node:util';
|
|
25
25
|
|
|
26
26
|
const execFile = promisify(execFileCb);
|
|
27
27
|
|
|
28
|
+
import { classifyPathInput } from 'skill-family-harness-node';
|
|
28
29
|
import { loadProjectConfig } from '../core/config.mjs';
|
|
29
30
|
import { captureBaseline } from '../core/baseline.mjs';
|
|
30
31
|
import { runHook } from '../core/hooks.mjs';
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
orderNormalizedHooks,
|
|
79
80
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
80
81
|
} from '../core/postpublish.mjs';
|
|
82
|
+
import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
|
|
81
83
|
|
|
82
84
|
// ---------------------------------------------------------------------------
|
|
83
85
|
// 安装契约常量
|
|
@@ -1122,6 +1124,128 @@ async function buildProductionAssets(
|
|
|
1122
1124
|
return assets;
|
|
1123
1125
|
}
|
|
1124
1126
|
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
// F-01 / T1: private execution inputs freeze gate
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Normalize one relative path for closure-style set comparison (the same
|
|
1133
|
+
* lexical normalization Foundation applies to closure resource paths).
|
|
1134
|
+
*
|
|
1135
|
+
* @param {string} value - Relative path candidate.
|
|
1136
|
+
* @returns {string} POSIX-normalized form.
|
|
1137
|
+
*/
|
|
1138
|
+
function normalizeRelativeClosurePath(value) {
|
|
1139
|
+
return pathPosix.normalize(String(value).replaceAll('\\', '/'));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Enumerate the paths present in the frozen tag tree: the production asset
|
|
1144
|
+
* commit is the commit the distribution tag will point at, and its tree
|
|
1145
|
+
* lives in the detached asset repository built by buildProductionAssets.
|
|
1146
|
+
* Read-only local git; no network.
|
|
1147
|
+
*
|
|
1148
|
+
* @param {string} root - Release workspace root.
|
|
1149
|
+
* @param {object} asset - productionAssets entry (gitObjectDir + commit).
|
|
1150
|
+
* @returns {Promise<Set<string>>} Normalized paths contained in the tag.
|
|
1151
|
+
*/
|
|
1152
|
+
async function enumerateFrozenTagPaths(root, asset) {
|
|
1153
|
+
const gitDir = resolve(root, asset.gitObjectDir);
|
|
1154
|
+
let stdout;
|
|
1155
|
+
try {
|
|
1156
|
+
({ stdout } = await execFile('git', [
|
|
1157
|
+
'--git-dir', gitDir,
|
|
1158
|
+
'ls-tree', '-r', '-z', '--name-only', asset.commit,
|
|
1159
|
+
]));
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
throw new ReleaseError(
|
|
1162
|
+
GATE_FAILED,
|
|
1163
|
+
`cannot enumerate the frozen tag tree for the executionFiles gate: ${err?.message ?? err}`,
|
|
1164
|
+
{ gitObjectDir: asset.gitObjectDir, commit: asset.commit },
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
return new Set(`${stdout}`.split('\0').filter(Boolean).map(normalizeRelativeClosurePath));
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Collect the relative-path candidates from every postPublish command array
|
|
1172
|
+
* (materialize, steps, custom command hooks). Flag-like elements are not
|
|
1173
|
+
* path candidates; lexical safety is decided by Foundation classification.
|
|
1174
|
+
*
|
|
1175
|
+
* @param {object} postPublish - The unit postPublish declaration.
|
|
1176
|
+
* @returns {Array<{where: string, element: string}>}
|
|
1177
|
+
*/
|
|
1178
|
+
function collectPostPublishCommandCandidates(postPublish) {
|
|
1179
|
+
const candidates = [];
|
|
1180
|
+
const visit = (where, command) => {
|
|
1181
|
+
if (!Array.isArray(command)) return;
|
|
1182
|
+
for (const element of command) {
|
|
1183
|
+
if (typeof element !== 'string' || element.length === 0 || element.startsWith('-')) continue;
|
|
1184
|
+
candidates.push({ where, element });
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
visit('materialize', postPublish.materialize?.command);
|
|
1188
|
+
for (const step of postPublish.steps ?? []) visit(`steps[${step.name}]`, step.command);
|
|
1189
|
+
for (const hook of postPublish.hooks ?? []) {
|
|
1190
|
+
if (Array.isArray(hook.command)) visit(`hooks[${hook.id}]`, hook.command);
|
|
1191
|
+
}
|
|
1192
|
+
return candidates;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* F-01 / T1 declaration gate (fail-closed, before any plan write):
|
|
1197
|
+
*
|
|
1198
|
+
* - a command-array element that is a safe relative path, EXISTS as a
|
|
1199
|
+
* regular file in the live workspace, is ABSENT from the frozen tag tree,
|
|
1200
|
+
* and is NOT declared in executionFiles is an undeclared private input —
|
|
1201
|
+
* report it immediately instead of letting distribute guess or copy;
|
|
1202
|
+
* - an executionFiles entry that already exists in the frozen tag is
|
|
1203
|
+
* rejected: tag files stay bound to tagCommit and the execution bundle
|
|
1204
|
+
* must never shadow them.
|
|
1205
|
+
*
|
|
1206
|
+
* When no frozen tag exists yet (non-production prepare) the tag-dependent
|
|
1207
|
+
* checks are skipped — distribute fails closed later without a tagCommit.
|
|
1208
|
+
*
|
|
1209
|
+
* @param {object} postPublish - Unit postPublish declaration.
|
|
1210
|
+
* @param {object} params
|
|
1211
|
+
* @param {string} params.workspaceRoot - Release workspace root (realpath).
|
|
1212
|
+
* @param {Set<string>|null} params.frozenTagPaths - Frozen tag tree paths.
|
|
1213
|
+
* @param {string[]} params.executionFiles - Declared closed-world manifest.
|
|
1214
|
+
*/
|
|
1215
|
+
async function assertPrivateExecutionDeclarations(postPublish, { workspaceRoot, frozenTagPaths, executionFiles }) {
|
|
1216
|
+
const declared = new Set(executionFiles.map(normalizeRelativeClosurePath));
|
|
1217
|
+
for (const { where, element } of collectPostPublishCommandCandidates(postPublish)) {
|
|
1218
|
+
const classification = classifyPathInput(element);
|
|
1219
|
+
if (!classification.ok) continue; // absolute/UNC/backslash inputs are not workspace-relative files
|
|
1220
|
+
let stats = null;
|
|
1221
|
+
try {
|
|
1222
|
+
stats = await lstat(resolve(workspaceRoot, element));
|
|
1223
|
+
} catch {
|
|
1224
|
+
continue; // not present in the live workspace: nothing to declare
|
|
1225
|
+
}
|
|
1226
|
+
if (!stats.isFile()) continue;
|
|
1227
|
+
const normalized = normalizeRelativeClosurePath(element);
|
|
1228
|
+
if (frozenTagPaths && frozenTagPaths.has(normalized)) continue; // bound by tagCommit
|
|
1229
|
+
if (declared.has(normalized)) continue;
|
|
1230
|
+
throw new ReleaseError(
|
|
1231
|
+
GATE_FAILED,
|
|
1232
|
+
`postPublish ${where} command references the workspace-private file "${element}" that exists in the workspace but is absent from the frozen tag; declare it in postPublish.executionFiles (closed world — helper files included)`,
|
|
1233
|
+
{ where, path: element },
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
if (frozenTagPaths) {
|
|
1237
|
+
for (const entry of executionFiles) {
|
|
1238
|
+
if (frozenTagPaths.has(normalizeRelativeClosurePath(entry))) {
|
|
1239
|
+
throw new ReleaseError(
|
|
1240
|
+
GATE_FAILED,
|
|
1241
|
+
`postPublish.executionFiles entry "${entry}" already exists in the frozen tag; tag files stay bound to tagCommit and the execution bundle must never shadow them`,
|
|
1242
|
+
{ path: entry },
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1125
1249
|
// ---------------------------------------------------------------------------
|
|
1126
1250
|
// External independent marketplace freeze (production + online only)
|
|
1127
1251
|
// ---------------------------------------------------------------------------
|
|
@@ -1836,6 +1960,15 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
|
|
|
1836
1960
|
* @param {boolean} [options.hookCache=true] - When false (CLI --no-hook-cache),
|
|
1837
1961
|
* every declared hook runs in full and the incremental hook cache is neither
|
|
1838
1962
|
* read nor written.
|
|
1963
|
+
* @param {Function} [options.adapterFreshnessFn] - Adapter derived-artifact
|
|
1964
|
+
* pre-gate (default assertAdapterFreshness). No environment variable
|
|
1965
|
+
* exempts the default gate (F-03): it runs in the CLI, plain function
|
|
1966
|
+
* calls, and the node:test harness alike. Tests that need a lightweight
|
|
1967
|
+
* fixture inject a double explicitly in-process; the CLI exposes no
|
|
1968
|
+
* parameter for it.
|
|
1969
|
+
* @param {Function} [options.selfBootstrapFactsFn] - Self-bootstrap fact-pin
|
|
1970
|
+
* pre-gate (default assertSelfBootstrapFacts); same injection contract as
|
|
1971
|
+
* adapterFreshnessFn.
|
|
1839
1972
|
*
|
|
1840
1973
|
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string, warnings: ReadonlyArray<object>, nextSteps: ReadonlyArray<{ code: string, message: string }> }>}
|
|
1841
1974
|
*
|
|
@@ -2108,6 +2241,11 @@ export async function prepareRelease(options) {
|
|
|
2108
2241
|
// stage so the same drift fails closed in seconds, before any hook. Like
|
|
2109
2242
|
// the bundle freshness gate this is artifact-integrity class: workflow
|
|
2110
2243
|
// trimming never exempts it. Installed layouts record not-applicable.
|
|
2244
|
+
// F-03 (2026-08-21 architecture review): the gates run identically in
|
|
2245
|
+
// the CLI, plain function calls, and the node:test harness — no
|
|
2246
|
+
// environment variable may exempt them (a test seam is never a
|
|
2247
|
+
// production switch). Tests that need lightweight fixtures inject the
|
|
2248
|
+
// adapterFreshnessFn/selfBootstrapFactsFn seams explicitly in-process.
|
|
2111
2249
|
await evidence.append({ phase: 'adapter-freshness', status: 'started' });
|
|
2112
2250
|
const adapterFreshnessFn = options.adapterFreshnessFn ?? assertAdapterFreshness;
|
|
2113
2251
|
let adapterFreshness;
|
|
@@ -3595,16 +3733,51 @@ export async function prepareRelease(options) {
|
|
|
3595
3733
|
// tagCommit, never from workspace state). planVersion 2 record-layer
|
|
3596
3734
|
// stripping does not strip this block, so every declaration detail is
|
|
3597
3735
|
// bound into the plan digest.
|
|
3736
|
+
//
|
|
3737
|
+
// F-01 / T1 private execution bundle: parent-workspace files that the
|
|
3738
|
+
// post-publish commands need but the frozen tag does not contain are
|
|
3739
|
+
// frozen here — Foundation closure (verbatim computeResourceClosure
|
|
3740
|
+
// return) + the release-unit publicFiles projection — and their bytes
|
|
3741
|
+
// are published digest-addressed under this plan's .release-skill. The
|
|
3742
|
+
// plan is the bundle's only source of truth (no parallel manifest, no
|
|
3743
|
+
// second bundle digest): the raw executionFiles list folds into the
|
|
3744
|
+
// closure and is NOT duplicated into the frozen block.
|
|
3598
3745
|
let frozenPostPublish = null;
|
|
3599
3746
|
if (postPublishDeclaration) {
|
|
3600
3747
|
const { unit, index } = postPublishDeclaration;
|
|
3601
3748
|
const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
|
|
3749
|
+
const declaredExecutionFiles = unit.postPublish.executionFiles ?? [];
|
|
3750
|
+
const frozenTagPaths = productionAssets
|
|
3751
|
+
? await enumerateFrozenTagPaths(realRoot, productionAssets[index])
|
|
3752
|
+
: null;
|
|
3753
|
+
await assertPrivateExecutionDeclarations(unit.postPublish, {
|
|
3754
|
+
workspaceRoot: realRoot,
|
|
3755
|
+
frozenTagPaths,
|
|
3756
|
+
executionFiles: declaredExecutionFiles,
|
|
3757
|
+
});
|
|
3758
|
+
const executionBundle = await freezeExecutionBundle({
|
|
3759
|
+
workspaceRoot: realRoot,
|
|
3760
|
+
releaseSkillDir: releaseDir,
|
|
3761
|
+
executionFiles: declaredExecutionFiles,
|
|
3762
|
+
publicFiles: unit.publicFiles ?? [],
|
|
3763
|
+
});
|
|
3764
|
+
await evidence.append({
|
|
3765
|
+
phase: 'postpublish-execution-bundle',
|
|
3766
|
+
status: 'frozen',
|
|
3767
|
+
unitId: unit.id,
|
|
3768
|
+
closureDigest: executionBundle.closure.digest,
|
|
3769
|
+
resourceCount: executionBundle.closure.resources.length,
|
|
3770
|
+
publicFileCount: executionBundle.publicFiles.length,
|
|
3771
|
+
bundleRoot: relative(realRoot, bundleRootForAuthorityDir(releaseDir)),
|
|
3772
|
+
});
|
|
3773
|
+
const { executionFiles: _executionFiles, ...declarationWithoutManifest } = structuredClone(unit.postPublish);
|
|
3602
3774
|
frozenPostPublish = {
|
|
3603
|
-
...
|
|
3775
|
+
...declarationWithoutManifest,
|
|
3604
3776
|
tag,
|
|
3605
3777
|
...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
|
|
3606
3778
|
unitId: unit.id,
|
|
3607
3779
|
payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
3780
|
+
executionBundle,
|
|
3608
3781
|
};
|
|
3609
3782
|
}
|
|
3610
3783
|
|
|
@@ -28,6 +28,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
28
28
|
|
|
29
29
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
30
30
|
import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
|
|
31
|
+
import { redactUrlCredentialsIfPresent } from './git-url-policy.mjs';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Assert `from` stays inside the materialized payload directory (declaration
|
|
@@ -60,14 +61,15 @@ function resolvePayloadSource(payloadDir, from) {
|
|
|
60
61
|
* @param {object} params.commitIdentity - Frozen commitIdentity.
|
|
61
62
|
* @param {string} params.payloadDir - Materialized payload directory
|
|
62
63
|
* (distribute phase; postVerify contexts never carry it).
|
|
63
|
-
* @param {string} params.
|
|
64
|
+
* @param {string} params.releaseWorkspaceRoot - Release workspace root (the
|
|
65
|
+
* real project root; workspace addressing resolution + write exclusion).
|
|
64
66
|
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
65
67
|
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
66
68
|
* @returns {Promise<{ status: string, observation: object,
|
|
67
69
|
* observations: object[], mode: string }>}
|
|
68
70
|
*/
|
|
69
71
|
export async function executeDocsRefreshHook(params) {
|
|
70
|
-
const { hook, contextProjection, commitIdentity, payloadDir,
|
|
72
|
+
const { hook, contextProjection, commitIdentity, payloadDir, releaseWorkspaceRoot, exec, hookRunner } = params ?? {};
|
|
71
73
|
const config = hook?.config;
|
|
72
74
|
const repositories = config?.repositories;
|
|
73
75
|
if (!Array.isArray(repositories) || repositories.length === 0) {
|
|
@@ -124,7 +126,7 @@ export async function executeDocsRefreshHook(params) {
|
|
|
124
126
|
mutate,
|
|
125
127
|
gates,
|
|
126
128
|
contextProjection,
|
|
127
|
-
|
|
129
|
+
releaseWorkspaceRoot,
|
|
128
130
|
...(exec !== undefined ? { exec } : {}),
|
|
129
131
|
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
130
132
|
});
|
|
@@ -137,7 +139,9 @@ export async function executeDocsRefreshHook(params) {
|
|
|
137
139
|
}
|
|
138
140
|
observations.push({
|
|
139
141
|
repositoryIndex: index,
|
|
140
|
-
...(typeof target.remoteUrl === 'string'
|
|
142
|
+
...(typeof target.remoteUrl === 'string'
|
|
143
|
+
? { remoteUrl: redactUrlCredentialsIfPresent(target.remoteUrl) }
|
|
144
|
+
: {}),
|
|
141
145
|
...(typeof target.workspace === 'string' ? { workspace: target.workspace } : {}),
|
|
142
146
|
branch: target.branch,
|
|
143
147
|
...(result.observation ?? {}),
|
package/src/core/evidence.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { open, mkdir, writeFile } from 'node:fs/promises';
|
|
11
11
|
import { basename } from 'node:path';
|
|
12
12
|
|
|
13
|
+
import { redactEmbeddedUrlCredentials } from './git-url-policy.mjs';
|
|
14
|
+
|
|
13
15
|
/** Schema version for evidence events. */
|
|
14
16
|
const SCHEMA_VERSION = 1;
|
|
15
17
|
|
|
@@ -66,11 +68,16 @@ export function redact(obj) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
if (typeof obj === 'string') {
|
|
71
|
+
// F-05: strip any embedded URL userinfo before the value reaches disk.
|
|
72
|
+
// Only credential-bearing URL spans are rewritten; ordinary strings and
|
|
73
|
+
// credential-free URLs pass through unchanged.
|
|
74
|
+
const urlRedacted = redactEmbeddedUrlCredentials(obj);
|
|
69
75
|
for (const { prefix, label } of CREDENTIAL_PREFIXES) {
|
|
70
|
-
if (
|
|
76
|
+
if (urlRedacted.startsWith(prefix)) {
|
|
71
77
|
return `[REDACTED:${label}]`;
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
return urlRedacted;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
return obj;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-domain Git remote URL policy (architecture gap F-05, T5).
|
|
3
|
+
*
|
|
4
|
+
* The SINGLE boundary for every postPublish remote-write URL: allowed
|
|
5
|
+
* protocols, the Git path shape, and the remote-write grading. Consumers:
|
|
6
|
+
* core/postpublish.mjs (declaration re-validation), core/presets.mjs (preset
|
|
7
|
+
* registry config), core/preset-gitwrite.mjs + core/proposal-inbox.mjs
|
|
8
|
+
* (write transports), adapters/distribute-git.mjs (legacy target mirror).
|
|
9
|
+
* The root JSON schemas are only the first gate; this runtime policy is the
|
|
10
|
+
* final authority, so plans frozen by older schema versions cannot smuggle
|
|
11
|
+
* unsafe URLs through.
|
|
12
|
+
*
|
|
13
|
+
* Boundary rules (what this module is NOT):
|
|
14
|
+
* - Parsing is delegated to the standard WHATWG URL — this module expresses
|
|
15
|
+
* release-domain POLICY over the parse result; it implements no generic
|
|
16
|
+
* URL parser.
|
|
17
|
+
* - Credential redaction is delegated to the Foundation
|
|
18
|
+
* `redactUrlCredentials` (skill-family-harness-node, FG-2) — this module
|
|
19
|
+
* implements no generic redactor; it only decides WHEN the Foundation
|
|
20
|
+
* redactor applies so non-URL strings are never collapsed into the opaque
|
|
21
|
+
* placeholder.
|
|
22
|
+
*
|
|
23
|
+
* Fail-closed vocabulary: every rejection reason is a stable token, and
|
|
24
|
+
* failure text NEVER carries the original URL — a rejected URL may hold
|
|
25
|
+
* userinfo credentials, and echoing it would defeat the boundary.
|
|
26
|
+
*
|
|
27
|
+
* @module core/git-url-policy
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
redactUrlCredentials,
|
|
32
|
+
REDACTED_URL_PLACEHOLDER,
|
|
33
|
+
} from 'skill-family-harness-node';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Protocols allowed for postPublish remote writes: http(s) for real remotes,
|
|
37
|
+
* file: strictly as the test transport.
|
|
38
|
+
*/
|
|
39
|
+
export const GIT_REMOTE_PROTOCOLS = Object.freeze(['http:', 'https:', 'file:']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remote-write grading (发布领域远端写分级): the protocol decides the grade —
|
|
43
|
+
* http(s) are real remote writes, file: is the test transport. Central here
|
|
44
|
+
* so no consumer re-derives grading from its own protocol regex.
|
|
45
|
+
*/
|
|
46
|
+
export const GIT_REMOTE_WRITE_GRADES = Object.freeze({
|
|
47
|
+
'http:': 'remote-write',
|
|
48
|
+
'https:': 'remote-write',
|
|
49
|
+
'file:': 'test-transport',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Rejection reason vocabulary; every entry is safe to surface (no URL). */
|
|
53
|
+
export const GIT_REMOTE_URL_REASONS = Object.freeze({
|
|
54
|
+
NOT_A_STRING: 'not-a-string',
|
|
55
|
+
CONTROL_CHARACTERS: 'control-characters',
|
|
56
|
+
UNPARSEABLE: 'unparseable',
|
|
57
|
+
PROTOCOL_NOT_ALLOWED: 'protocol-not-allowed',
|
|
58
|
+
MISSING_HOST: 'missing-host',
|
|
59
|
+
CREDENTIALS_PRESENT: 'credentials-present',
|
|
60
|
+
QUERY_OR_FRAGMENT_REJECTED: 'query-or-fragment-rejected',
|
|
61
|
+
NOT_A_GIT_PATH: 'not-a-git-path',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Human-readable failure text per rejection reason. Deliberately carries NO
|
|
66
|
+
* part of the offending URL (a credential-bearing URL must never be echoed
|
|
67
|
+
* back through an error surface).
|
|
68
|
+
*/
|
|
69
|
+
const FAILURE_TEXT = Object.freeze({
|
|
70
|
+
[GIT_REMOTE_URL_REASONS.NOT_A_STRING]: 'must be a non-empty string',
|
|
71
|
+
[GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS]: 'contains control characters',
|
|
72
|
+
[GIT_REMOTE_URL_REASONS.UNPARSEABLE]:
|
|
73
|
+
'must be an absolute URL parseable by the standard URL parser',
|
|
74
|
+
[GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED]:
|
|
75
|
+
'protocol must be http:, https:, or file: (file: is the test transport)',
|
|
76
|
+
[GIT_REMOTE_URL_REASONS.MISSING_HOST]: 'http(s) URLs must carry a host',
|
|
77
|
+
[GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT]:
|
|
78
|
+
'must never carry embedded credentials (username/password) — credentials belong to the host git credential helper, never to the URL',
|
|
79
|
+
[GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED]:
|
|
80
|
+
'must carry no query or fragment',
|
|
81
|
+
[GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH]: 'pathname must end in .git',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Describe one rejection reason for error messages (always URL-free).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} reason - One of GIT_REMOTE_URL_REASONS.
|
|
88
|
+
* @returns {string} Safe human-readable failure text.
|
|
89
|
+
*/
|
|
90
|
+
export function describeGitRemoteUrlFailure(reason) {
|
|
91
|
+
return FAILURE_TEXT[reason] ?? 'is not an allowed Git remote URL';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The runtime contract check for one postPublish remote-write URL:
|
|
96
|
+
* 1. non-empty string, no control characters (checked on the RAW value —
|
|
97
|
+
* WHATWG parsing silently folds \t\r\n, so the raw check fails closed);
|
|
98
|
+
* 2. `new URL()` parse (the standard parser is the only parser);
|
|
99
|
+
* 3. protocol allowlist: http:, https:, file: (test transport);
|
|
100
|
+
* 4. http(s) must carry a host;
|
|
101
|
+
* 5. ALL protocols reject a non-empty username or password;
|
|
102
|
+
* 6. no query or fragment (a git remote URL is a bare location);
|
|
103
|
+
* 7. the PATHNAME ends in .git.
|
|
104
|
+
*
|
|
105
|
+
* @param {*} remoteUrl
|
|
106
|
+
* @returns {{ ok: true, protocol: string, grade: string }
|
|
107
|
+
* | { ok: false, reason: string }} The verdict never carries the input.
|
|
108
|
+
*/
|
|
109
|
+
export function checkGitRemoteUrl(remoteUrl) {
|
|
110
|
+
if (typeof remoteUrl !== 'string' || remoteUrl.length === 0) {
|
|
111
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_STRING };
|
|
112
|
+
}
|
|
113
|
+
if (/[\x00-\x1f\x7f]/.test(remoteUrl)) {
|
|
114
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS };
|
|
115
|
+
}
|
|
116
|
+
let url;
|
|
117
|
+
try {
|
|
118
|
+
url = new URL(remoteUrl);
|
|
119
|
+
} catch {
|
|
120
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.UNPARSEABLE };
|
|
121
|
+
}
|
|
122
|
+
if (!GIT_REMOTE_PROTOCOLS.includes(url.protocol)) {
|
|
123
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED };
|
|
124
|
+
}
|
|
125
|
+
if (url.protocol !== 'file:' && url.hostname === '') {
|
|
126
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.MISSING_HOST };
|
|
127
|
+
}
|
|
128
|
+
if (url.username !== '' || url.password !== '') {
|
|
129
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT };
|
|
130
|
+
}
|
|
131
|
+
if (url.search !== '' || url.hash !== '') {
|
|
132
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED };
|
|
133
|
+
}
|
|
134
|
+
if (!url.pathname.endsWith('.git')) {
|
|
135
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, protocol: url.protocol, grade: GIT_REMOTE_WRITE_GRADES[url.protocol] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Boolean form of the policy check (cross-check skip decisions, probes).
|
|
142
|
+
*
|
|
143
|
+
* @param {*} remoteUrl
|
|
144
|
+
* @returns {boolean} True only for policy-allowed Git remote URLs.
|
|
145
|
+
*/
|
|
146
|
+
export function isAllowedGitRemoteUrl(remoteUrl) {
|
|
147
|
+
return checkGitRemoteUrl(remoteUrl).ok === true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Remote-write grade for one policy-allowed URL; null when the URL does not
|
|
152
|
+
* pass the policy (grading never applies to rejected URLs).
|
|
153
|
+
*
|
|
154
|
+
* @param {*} remoteUrl
|
|
155
|
+
* @returns {'remote-write'|'test-transport'|null}
|
|
156
|
+
*/
|
|
157
|
+
export function resolveGitRemoteWriteGrade(remoteUrl) {
|
|
158
|
+
const verdict = checkGitRemoteUrl(remoteUrl);
|
|
159
|
+
return verdict.ok ? verdict.grade : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Guarded credential redaction for long-lived outputs (evidence, error
|
|
164
|
+
* messages/details, preset observations). Delegates the actual redaction to
|
|
165
|
+
* the Foundation `redactUrlCredentials`:
|
|
166
|
+
* - values that parse as an absolute URL carrying userinfo come back as the
|
|
167
|
+
* Foundation's credential-free serialization;
|
|
168
|
+
* - unparseable `scheme://...@...` authority shapes cannot be proven
|
|
169
|
+
* credential-free and fail closed to the opaque placeholder (the same
|
|
170
|
+
* stance as the Foundation's degraded path);
|
|
171
|
+
* - every other value returns UNCHANGED (blindly applying the Foundation
|
|
172
|
+
* redactor would collapse ordinary non-URL strings into the placeholder).
|
|
173
|
+
*
|
|
174
|
+
* @param {*} value
|
|
175
|
+
* @returns {*} The credential-free value (or the input, unchanged).
|
|
176
|
+
*/
|
|
177
|
+
export function redactUrlCredentialsIfPresent(value) {
|
|
178
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
179
|
+
let url;
|
|
180
|
+
try {
|
|
181
|
+
url = new URL(value);
|
|
182
|
+
} catch {
|
|
183
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*@/.test(value)) {
|
|
184
|
+
return REDACTED_URL_PLACEHOLDER;
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
if (url.username === '' && url.password === '') return value;
|
|
189
|
+
return redactUrlCredentials(url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Candidate URL-span tokenizer for prose (log lines, error messages). It only
|
|
194
|
+
* LOCATES spans shaped like `scheme://...`; the credential decision and the
|
|
195
|
+
* redaction itself are delegated to the standard URL parser + the Foundation
|
|
196
|
+
* redactor via redactUrlCredentialsIfPresent. This is what makes the error
|
|
197
|
+
* and evidence chokepoints safe "even if a validation misses" (F-05): a
|
|
198
|
+
* credential-bearing URL embedded mid-sentence cannot survive to disk.
|
|
199
|
+
*/
|
|
200
|
+
const URL_SPAN_RE = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s'"<>`]+/g;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Redact credential-bearing URL spans anywhere inside a string (whole value
|
|
204
|
+
* or embedded in prose). Credential-free spans and all non-URL text return
|
|
205
|
+
* byte-for-byte unchanged.
|
|
206
|
+
*
|
|
207
|
+
* @param {*} text
|
|
208
|
+
* @returns {*} The credential-free string (or the input, unchanged).
|
|
209
|
+
*/
|
|
210
|
+
export function redactEmbeddedUrlCredentials(text) {
|
|
211
|
+
if (typeof text !== 'string' || text.length === 0) return text;
|
|
212
|
+
if (!text.includes('://')) return text;
|
|
213
|
+
return text.replace(URL_SPAN_RE, (span) => redactUrlCredentialsIfPresent(span));
|
|
214
|
+
}
|
|
@@ -99,13 +99,14 @@ export function serializeRegistry(registry) {
|
|
|
99
99
|
* @param {object} params.hook - Declared hook entry (config bound).
|
|
100
100
|
* @param {object} params.contextProjection - The §2.3 context projection.
|
|
101
101
|
* @param {object} params.commitIdentity - Frozen commitIdentity.
|
|
102
|
-
* @param {string} params.
|
|
102
|
+
* @param {string} params.releaseWorkspaceRoot - Release workspace root (the
|
|
103
|
+
* real project root; workspace addressing resolution + write exclusion).
|
|
103
104
|
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
104
105
|
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
105
106
|
* @returns {Promise<{ status: string, observation: object, registryPath: string }>}
|
|
106
107
|
*/
|
|
107
108
|
export async function executeMarketplaceRegistryEntryHook(params) {
|
|
108
|
-
const { hook, contextProjection, commitIdentity,
|
|
109
|
+
const { hook, contextProjection, commitIdentity, releaseWorkspaceRoot, exec, hookRunner } = params ?? {};
|
|
109
110
|
const config = hook?.config;
|
|
110
111
|
const target = config?.target;
|
|
111
112
|
if (!target || typeof target.branch !== 'string') {
|
|
@@ -166,7 +167,7 @@ export async function executeMarketplaceRegistryEntryHook(params) {
|
|
|
166
167
|
mutate,
|
|
167
168
|
gates: config?.gates ?? [],
|
|
168
169
|
contextProjection,
|
|
169
|
-
|
|
170
|
+
releaseWorkspaceRoot,
|
|
170
171
|
...(exec !== undefined ? { exec } : {}),
|
|
171
172
|
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
172
173
|
});
|