Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/bootstrap/licensing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Bootstrap copies an approved repository-local template into `LICENSE`, substitut
| Private repository without a license decision | omitted | Does not create or infer a license; conformance blocks | Legal/stewardship decision |
| Existing license matches the configured template | explicit matching mode | Adopts it without rewriting legal text | Review the manifest and template provenance |
| Existing license would change | explicit replacement mode | Hard-stops until transition evidence is present | Legal approval plus ownership, contributor, and distribution-history evidence |
| Managed third-party notices would change | `thirdPartyNoticesTransition` | Hard-stops until SHA-bound notice reconciliation evidence is present | Legal approval and component-obligation reconciliation |
| License policy is removed after Bootstrap managed it | omitted | Hard-stops; Bootstrap will not delete the license | Choose an explicit replacement mode and complete legal review |

## Manifest contract
Expand All @@ -36,7 +37,7 @@ license:

An SPDX policy uses `mode: spdx`, adds one `identifier`, such as `MIT`, and records the same value as `template.spdxIdentifier`. That binding prevents a template approved for one license from being relabeled as another, and any literal SPDX declaration in the rendered file must also match it. The approved template must contain `{{copyright_holder}}` and `{{copyright_years}}`; an SPDX template may also use `{{spdx_identifier}}`. Bootstrap substitutes supported tokens in one pass and preserves every other approved template byte exactly. Templates must be regular, singly linked UTF-8 files with no symlinked path components, must physically remain inside the target repository, cannot alias any selected managed or generated state output even on case-insensitive filesystems, and must match an exact byte-level SHA-256 pin. Existing `LICENSE` files must also be valid UTF-8, while transition decisions and hashes bind their exact bytes, including a leading BOM. Interpolated holder, approval, reference, and notice metadata rejects Unicode control, format, and separator characters; only notice bodies may span multiple LF-delimited lines. These constraints keep the reviewed source text versioned and prevent Bootstrap from inventing, downloading, silently changing, or spoofing terms.

Third-party entries are sorted deterministically. Their `kind` is one of `dependency`, `asset`, `font`, `media`, or `incorporated-source`. An optional `notice` preserves attribution or other required text. An existing unmanaged `THIRD_PARTY_NOTICES.md` is never overwritten; its obligations must first be reconciled into the manifest.
Third-party entries are sorted deterministically. Their `kind` is one of `dependency`, `asset`, `font`, `media`, or `incorporated-source`. An optional `notice` preserves attribution or other required text. An existing unmanaged `THIRD_PARTY_NOTICES.md` is never overwritten; its obligations must first be reconciled into the manifest. Changing an already managed notice inventory requires `thirdPartyNoticesTransition` evidence with `approvedBy`, `issue`, `reconciliation`, and SHA-256 hashes of the exact existing and rendered notice bytes.

## Existing-license hard stop

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/ADR-0002-explicit-license-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ When configured, the policy must:
3. Keep third-party dependency, asset, font, media, and incorporated-source notices separate from the first-party notice.
4. Bind each SPDX template approval and rendered SPDX declaration to the one selected identifier, reject proprietary templates that contain SPDX declarations, and substitute approved tokens without rescanning legal metadata.
5. Treat adoption of an unmanaged license, any change to existing license bytes, or any managed legal-classification change as a hard stop until approver, issue, ownership, contributor, and distribution-history evidence is bound to the exact before/after modes and content hashes.
6. Preserve managed-file ownership and fail when a managed license is edited directly or unmanaged third-party notices would be overwritten.
6. Preserve managed-file ownership and fail when a managed license or notice inventory is edited directly, unmanaged third-party notices would be overwritten, or a managed notice inventory changes without approved SHA-bound reconciliation evidence.
7. Show the exact before and after modes in plan output before apply.

The proprietary mode is a projection mechanism only. This decision does not approve a legal entity, copyright holder, or proprietary template for any downstream product.
Expand Down
50 changes: 45 additions & 5 deletions src/licensing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,17 +280,38 @@ function requireTransitionEvidence(
);
}
const transitionMatches =
policy.transition.fromMode === beforeMode &&
policy.transition.fromContentSha256.toLowerCase() === beforeContentSha256 &&
policy.transition.toMode === afterMode &&
policy.transition.toContentSha256.toLowerCase() === afterContentSha256;
policy.transition.from.mode === beforeMode &&
policy.transition.from.licenseSha256 === beforeContentSha256 &&
policy.transition.to.mode === afterMode &&
policy.transition.to.licenseSha256 === afterContentSha256;
if (!transitionMatches) {
throw new Error(
`PRS-LICENSE-TRANSITION-001: transition evidence does not match ${beforeMode}@${beforeContentSha256} -> ${afterMode}@${afterContentSha256}.`
);
}
}

function requireThirdPartyNoticesTransitionEvidence(
policy: LicensePolicy,
beforeContentSha256: string,
afterContentSha256: string
): void {
const transition = policy.thirdPartyNoticesTransition;
if (!transition) {
throw new Error(
"PRS-LICENSE-NOTICES-TRANSITION-001: changing managed third-party notices is a legal hard stop; record approvedBy, issue, reconciliation, and exact before/after SHA-256 evidence."
);
}
if (
transition.fromSha256 !== beforeContentSha256 ||
transition.toSha256 !== afterContentSha256
) {
throw new Error(
`PRS-LICENSE-NOTICES-TRANSITION-001: notices transition evidence does not match ${beforeContentSha256} -> ${afterContentSha256}.`
);
}
}

export async function projectLicensePolicy(
manifest: BootstrapManifest,
targetDir: string,
Expand Down Expand Up @@ -348,15 +369,34 @@ export async function projectLicensePolicy(
}
requireTransitionEvidence(policy, beforeMode, beforeContentSha256, afterMode, afterContentSha256);
}
const managedNoticesHash = state?.managedFiles[THIRD_PARTY_NOTICES_PATH];
if (existingNoticesFile === undefined && managedNoticesHash !== undefined) {
throw new Error("PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was deleted; restore it before planning a legal transition.");
}
const existingNoticesSha256 = existingNoticesFile === undefined ? undefined : sha256(existingNoticesFile.bytes);
if (
existingNoticesSha256 !== undefined &&
managedNoticesHash !== undefined &&
managedNoticesHash !== existingNoticesSha256
) {
throw new Error("PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was directly modified; restore it before planning a legal transition.");
}
if (
existingNotices !== undefined &&
existingNotices !== noticesContents &&
state?.managedFiles[THIRD_PARTY_NOTICES_PATH] === undefined
managedNoticesHash === undefined
) {
throw new Error(
"PRS-LICENSE-NOTICES-001: existing third-party notices are unmanaged and would be replaced; incorporate every obligation into license.thirdPartyNotices before applying."
);
}
if (
existingNoticesSha256 !== undefined &&
existingNotices !== noticesContents &&
managedNoticesHash !== undefined
) {
requireThirdPartyNoticesTransitionEvidence(policy, existingNoticesSha256, sha256(Buffer.from(noticesContents, "utf8")));
}

return {
files: [
Expand Down
61 changes: 39 additions & 22 deletions src/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import path from "node:path";
import spdxLicenseIdentifiers from "spdx-license-list/simple.js";
import YAML from "yaml";
import { z } from "zod";

import { readTextIfExists } from "./lib/fs.js";
import { isSupportedSpdxIdentifier } from "./spdx.js";
import type {
AdditionalWorkflowConfig,
DependabotConfig,
Expand Down Expand Up @@ -214,6 +214,7 @@ const notificationSchema = z.object({
webhookUrlEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/)
});

const sha256Schema = z.string().regex(/^[0-9a-fA-F]{64}$/).transform((value) => value.toLowerCase());
const licenseYearsSchema = z.string().regex(/^\d{4}(?:-\d{4})?$/, "Use a four-digit year or inclusive year range.").refine((value) => {
const start = Number(value.slice(0, 4));
const end = value.length === 4 ? start : Number(value.slice(5));
Expand All @@ -231,18 +232,18 @@ const singleLineLicenseString = z

const multilineLicenseText = z
.string()
.trim()
.min(1)
.refine(
(value) => !/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\p{Cf}\p{Zl}\p{Zp}]/u.test(value),
"Do not use control, format, or Unicode separator characters in legal text."
);
)
.transform((value) => value.trim())
.refine((value) => value.length > 0, "Must not be blank.");

const licenseTemplateSchema = z.object({
path: singleLineLicenseString,
sha256: z.string().regex(/^[0-9a-fA-F]{64}$/),
sha256: sha256Schema,
approval: singleLineLicenseString,
spdxIdentifier: z.string().optional()
spdxIdentifier: singleLineLicenseString.optional()
});

const thirdPartyNoticeSchema = z.object({
Expand All @@ -253,16 +254,41 @@ const thirdPartyNoticeSchema = z.object({
notice: multilineLicenseText.optional()
});

const licenseTransitionSchema = z.object({
const currentLicenseTransitionSchema = z.object({
from: z.object({ mode: singleLineLicenseString, licenseSha256: sha256Schema }),
to: z.object({ mode: singleLineLicenseString, licenseSha256: sha256Schema }),
approvedBy: singleLineLicenseString,
issue: singleLineLicenseString,
ownership: singleLineLicenseString,
contributors: singleLineLicenseString,
distributionHistory: singleLineLicenseString,
reconciles: z.array(z.object({ path: singleLineLicenseString, licenseSha256: sha256Schema })).optional()
});

const legacyLicenseTransitionSchema = z.object({
fromMode: singleLineLicenseString,
fromContentSha256: z.string().regex(/^[0-9a-fA-F]{64}$/),
fromContentSha256: sha256Schema,
toMode: singleLineLicenseString,
toContentSha256: z.string().regex(/^[0-9a-fA-F]{64}$/)
toContentSha256: sha256Schema,
approvedBy: singleLineLicenseString,
issue: singleLineLicenseString,
ownership: singleLineLicenseString,
contributors: singleLineLicenseString,
distributionHistory: singleLineLicenseString
}).transform(({ fromMode, fromContentSha256, toMode, toContentSha256, ...evidence }) => ({
from: { mode: fromMode, licenseSha256: fromContentSha256 },
to: { mode: toMode, licenseSha256: toContentSha256 },
...evidence
}));

const licenseTransitionSchema = z.union([currentLicenseTransitionSchema, legacyLicenseTransitionSchema]);

const thirdPartyNoticesTransitionSchema = z.object({
fromSha256: sha256Schema,
toSha256: sha256Schema,
approvedBy: singleLineLicenseString,
issue: singleLineLicenseString,
reconciliation: singleLineLicenseString
});

const licensePolicyBase = {
Expand All @@ -271,32 +297,23 @@ const licensePolicyBase = {
years: licenseYearsSchema,
template: licenseTemplateSchema,
thirdPartyNotices: z.array(thirdPartyNoticeSchema),
thirdPartyNoticesTransition: thirdPartyNoticesTransitionSchema.optional(),
transition: licenseTransitionSchema.optional()
};

const licensePolicySchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("spdx"),
identifier: z
.string()
.refine((value) => spdxLicenseIdentifiers.has(value), "Use a recognized SPDX license identifier."),
identifier: z.string().refine(isSupportedSpdxIdentifier, "Use a recognized SPDX license identifier from Bootstrap's supported list; LicenseRef values and expressions are not supported."),
...licensePolicyBase
}),
z.object({ mode: z.literal("proprietary"), ...licensePolicyBase })
]).superRefine((policy, context) => {
if (policy.mode === "spdx" && policy.template.spdxIdentifier !== policy.identifier) {
context.addIssue({
code: "custom",
path: ["template", "spdxIdentifier"],
message: "SPDX templates must be approved for the selected license identifier."
});
context.addIssue({ code: "custom", path: ["template", "spdxIdentifier"], message: "SPDX templates must be approved for the selected license identifier." });
}
if (policy.mode === "proprietary" && policy.template.spdxIdentifier !== undefined) {
context.addIssue({
code: "custom",
path: ["template", "spdxIdentifier"],
message: "Proprietary templates cannot declare an SPDX identifier approval."
});
context.addIssue({ code: "custom", path: ["template", "spdxIdentifier"], message: "Proprietary templates cannot declare an SPDX identifier approval." });
}
});

Expand Down
7 changes: 7 additions & 0 deletions src/spdx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import spdxLicenseIdentifiers from "spdx-license-list/simple.js";

export const SUPPORTED_SPDX_IDENTIFIERS = [...spdxLicenseIdentifiers].sort();

export function isSupportedSpdxIdentifier(value: string): boolean {
return spdxLicenseIdentifiers.has(value);
}
22 changes: 18 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,28 @@ export interface ThirdPartyNotice {
}

export interface LicenseTransitionEvidence {
from: {
mode: string;
licenseSha256: string;
};
to: {
mode: string;
licenseSha256: string;
};
approvedBy: string;
issue: string;
ownership: string;
contributors: string;
distributionHistory: string;
fromMode: string;
fromContentSha256: string;
toMode: string;
toContentSha256: string;
reconciles?: Array<{ path: string; licenseSha256: string }>;
}

export interface ThirdPartyNoticesTransitionEvidence {
fromSha256: string;
toSha256: string;
approvedBy: string;
issue: string;
reconciliation: string;
}

interface LicensePolicyBase {
Expand All @@ -50,6 +63,7 @@ interface LicensePolicyBase {
years: string;
template: LicenseTemplateReference;
thirdPartyNotices: ThirdPartyNotice[];
thirdPartyNoticesTransition?: ThirdPartyNoticesTransitionEvidence;
transition?: LicenseTransitionEvidence;
}

Expand Down
96 changes: 96 additions & 0 deletions tests/licensing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,4 +642,100 @@ describe("license policy projection", () => {
await writeFile(path.join(directory, "THIRD_PARTY_NOTICES.md"), "Existing obligations\n");
await expect(planRepo(manifest(proprietary()), directory)).rejects.toThrow("PRS-LICENSE-NOTICES-001");
});

it("requires SHA-bound evidence before changing managed third-party notices", async () => {
const directory = await fixture("managed-third-party-transition");
const originalPolicy = proprietary({
thirdPartyNotices: [{ name: "Existing SDK", kind: "dependency", license: "MIT", source: "https://example.invalid/sdk" }]
});
await applyRepo(manifest(originalPolicy), directory);
const noticesPath = path.join(directory, "THIRD_PARTY_NOTICES.md");
const originalNotices = await readFile(noticesPath, "utf8");

await writeFile(noticesPath, "Directly edited obligations\n");
await expect(planRepo(manifest(originalPolicy), directory)).rejects.toThrow(
"PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was directly modified"
);
await writeFile(noticesPath, originalNotices);

await unlink(noticesPath);
await expect(planRepo(manifest(originalPolicy), directory)).rejects.toThrow(
"PRS-OWNERSHIP-001: managed THIRD_PARTY_NOTICES.md was deleted"
);
await writeFile(noticesPath, originalNotices);

const updatedNotices = [
...originalPolicy.thirdPartyNotices,
{ name: "New Font", kind: "font" as const, license: "OFL-1.1", source: "assets/fonts/new-font" }
];
const withoutEvidence = manifest(proprietary({ thirdPartyNotices: updatedNotices }));
await expect(planRepo(withoutEvidence, directory)).rejects.toThrow("PRS-LICENSE-NOTICES-TRANSITION-001");

const mismatchedEvidence = manifest(proprietary({
thirdPartyNotices: updatedNotices,
thirdPartyNoticesTransition: {
fromSha256: sha256("incorrect before\n"),
toSha256: sha256("incorrect after\n"),
approvedBy: "legal-reviewer",
issue: "LEGAL-42",
reconciliation: "Every third-party obligation was reviewed"
}
}));
await expect(planRepo(mismatchedEvidence, directory)).rejects.toThrow("notices transition evidence does not match");

const updatedNoticesContents = [
"# Third-Party Notices",
"",
"This inventory is separate from the repository's first-party license. Each listed component remains subject to its own terms.",
"",
"## Existing SDK",
"",
"- Kind: dependency",
"- License: MIT",
"- Source: https://example.invalid/sdk",
"",
"## New Font",
"",
"- Kind: font",
"- License: OFL-1.1",
"- Source: assets/fonts/new-font",
""
].join("\n");
const withEvidence = manifest(proprietary({
thirdPartyNotices: updatedNotices,
thirdPartyNoticesTransition: {
fromSha256: sha256(originalNotices),
toSha256: sha256(updatedNoticesContents),
approvedBy: "legal-reviewer",
issue: "LEGAL-42",
reconciliation: "Every third-party obligation was reviewed"
}
}));
expect((await planRepo(withEvidence, directory)).changes).toContainEqual(
expect.objectContaining({ path: "THIRD_PARTY_NOTICES.md", type: "update" })
);

await applyRepo(withEvidence, directory);
const noNoticesContents = [
"# Third-Party Notices",
"",
"This inventory is separate from the repository's first-party license. Each listed component remains subject to its own terms.",
"",
"No third-party notices are declared in the Bootstrap manifest.",
""
].join("\n");
const emptyInventory = manifest(proprietary({
thirdPartyNotices: [],
thirdPartyNoticesTransition: {
fromSha256: sha256(await readFile(path.join(directory, "THIRD_PARTY_NOTICES.md"), "utf8")),
toSha256: sha256(noNoticesContents),
approvedBy: "legal-reviewer",
issue: "LEGAL-42",
reconciliation: "Every third-party obligation was reviewed"
}
}));
expect((await planRepo(emptyInventory, directory)).changes).toContainEqual(
expect.objectContaining({ path: "THIRD_PARTY_NOTICES.md", type: "update" })
);
});
});
Loading
Loading