release-skill 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +25 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +25 -17
  10. package/README.zh-CN.md +16 -14
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +1 -1
  14. package/adapters/claude/bin/kernel-protocol.json +1 -1
  15. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  16. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  17. package/adapters/claude/bin/registry.json +64 -1
  18. package/adapters/claude/bin/release-skill.bundle.mjs +34860 -31252
  19. package/adapters/claude/bin/rules.json +1 -1
  20. package/adapters/claude/schemas/.render-manifest.json +6 -6
  21. package/adapters/claude/schemas/release-plan.schema.json +136 -4
  22. package/adapters/claude/schemas/release-project.schema.json +13 -4
  23. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  24. package/adapters/codex/bin/error-codes.json +1 -1
  25. package/adapters/codex/bin/kernel-protocol.json +1 -1
  26. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  27. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  28. package/adapters/codex/bin/registry.json +64 -1
  29. package/adapters/codex/bin/release-skill.bundle.mjs +34860 -31252
  30. package/adapters/codex/bin/rules.json +1 -1
  31. package/adapters/codex/schemas/.render-manifest.json +6 -6
  32. package/adapters/codex/schemas/release-plan.schema.json +136 -4
  33. package/adapters/codex/schemas/release-project.schema.json +13 -4
  34. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  35. package/adapters/kimi/bin/error-codes.json +1 -1
  36. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  37. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  38. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  39. package/adapters/kimi/bin/registry.json +64 -1
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +34860 -31252
  41. package/adapters/kimi/bin/rules.json +1 -1
  42. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  43. package/adapters/kimi/schemas/release-plan.schema.json +136 -4
  44. package/adapters/kimi/schemas/release-project.schema.json +13 -4
  45. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  46. package/adapters/workbuddy/bin/error-codes.json +1 -1
  47. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  48. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  49. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  50. package/adapters/workbuddy/bin/registry.json +64 -1
  51. package/adapters/workbuddy/bin/release-skill.bundle.mjs +34860 -31252
  52. package/adapters/workbuddy/bin/rules.json +1 -1
  53. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
  55. package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
  56. package/bin/error-codes.json +1 -1
  57. package/bin/kernel-protocol.json +1 -1
  58. package/bin/license-texts/Apache-2.0.txt +201 -0
  59. package/bin/license-texts/MIT.txt +21 -0
  60. package/bin/registry.json +64 -1
  61. package/bin/release-skill-cli.mjs +6 -6
  62. package/bin/release-skill.bundle.mjs +34860 -31252
  63. package/bin/rules.json +1 -1
  64. package/package.json +5 -4
  65. package/platform-manifest.json +2 -2
  66. package/references/02-project-config.md +1 -1
  67. package/references/06-adapter-contract.md +6 -3
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +136 -4
  70. package/schemas/release-project.schema.json +13 -4
  71. package/src/adapters/distribute-git.mjs +17 -11
  72. package/src/commands/approve.mjs +6 -10
  73. package/src/commands/distribute.mjs +251 -69
  74. package/src/commands/postverify.mjs +81 -7
  75. package/src/commands/prepare.mjs +176 -3
  76. package/src/core/docs-refresh-preset.mjs +8 -4
  77. package/src/core/evidence.mjs +8 -1
  78. package/src/core/git-url-policy.mjs +214 -0
  79. package/src/core/marketplace-registry-entry.mjs +4 -3
  80. package/src/core/postpublish-approval.mjs +132 -1
  81. package/src/core/postpublish-bundle.mjs +342 -0
  82. package/src/core/postpublish-projection.mjs +210 -0
  83. package/src/core/postpublish.mjs +26 -19
  84. package/src/core/preset-executor.mjs +25 -6
  85. package/src/core/preset-gitwrite.mjs +37 -16
  86. package/src/core/presets.mjs +67 -19
  87. package/src/core/proposal-inbox.mjs +36 -20
  88. package/src/core/redact.mjs +16 -3
  89. package/src/producers/build-adapters.mjs +28 -14
@@ -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
- ...structuredClone(unit.postPublish),
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.root - Release workspace root.
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, root, exec, hookRunner } = params ?? {};
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
- root,
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' ? { remoteUrl: target.remoteUrl } : {}),
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 ?? {}),
@@ -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 (obj.startsWith(prefix)) {
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.root - Release workspace root.
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, root, exec, hookRunner } = params ?? {};
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
- root,
170
+ releaseWorkspaceRoot,
170
171
  ...(exec !== undefined ? { exec } : {}),
171
172
  ...(hookRunner !== undefined ? { hookRunner } : {}),
172
173
  });