diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c4e87..3d2ec45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,14 @@ jobs: npm run types git diff --exit-code -- src/env.generated.d.ts - run: npm run check + - name: Retain evaluated licence declarations + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: licence-evidence-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/evidence/licence-boundary.json + if-no-files-found: error + retention-days: 30 quality: name: Repository security diff --git a/README.md b/README.md index 2d43ecb..80b7442 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Before the first merge, complete [Cloudflare setup and deployment](docs/deployin - [Bootstrap plan](docs/bootstrap-plan.md) - [Development and hooks](docs/development.md) +- [Project licence declarations and checks](docs/licence-boundary.md) - [Cloudflare setup, release and recovery](docs/deploying.md) - [Contributing](CONTRIBUTING.md), [conduct](CODE_OF_CONDUCT.md) and [security](SECURITY.md) - [AGPL-3.0-only license](LICENSE) and [third-party notices](THIRD_PARTY_NOTICES.md) diff --git a/docs/development.md b/docs/development.md index 39d8edb..58c62d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,7 @@ | Cloudflare Vitest plugin | 1.1.9 | Local Workerd/Workflow integration | | Vitest | 4.1.11 | Plugin requires Vitest 4.1; latest Vitest 5 is not a compatible upgrade | | Biome / Prettier | 2.5.13 / 3.9.6 | Lint/format without depending on the removed TypeScript JavaScript compiler API | -| ArcForges proto / protobuf runtime | 1.0.0-ci.25.1 / 2.14.1 | Published Contracts messages, not sibling source | +| ArcForges proto / protobuf runtime | 1.0.0-ci.25.1 / 2.15.0 | Published Contracts messages, not sibling source | `npm ci --ignore-scripts` restores the committed dependency graph on Windows and Linux without lifecycle scripts. The selected tools work with this installation mode. `package-lock.json` includes transitive/platform packages for reproducibility; do not shorten it by hand. Both platforms are verified in CI. diff --git a/docs/licence-boundary.md b/docs/licence-boundary.md new file mode 100644 index 0000000..db56e3e --- /dev/null +++ b/docs/licence-boundary.md @@ -0,0 +1,21 @@ +# Project licence boundary (WP00.02) + +The AI owner uses AGPL-3.0-only / AGPL for its original source and tooling, under +the accepted [Design profile](https://github.com/ArcForges/ArcForges-Design/blob/6ba885ad38dd71de532c74d7b69f439d01d19a0a/docs/architecture/01-solution-and-project-layout.md#41-project-declaration-and-verification-profile). +The root npm project declares both `license` and `arcforges.licenceBoundary`; +`eng/policy/licence-boundary.json` registers every current project manifest. + +Both `npm run check` and candidate construction check the actual Git inventory, +effective npm metadata and dependency graph. Missing/inconsistent declarations, +unregistered build scopes, unpublished source references and unknown first-party +packages (including aliases and transitive lock entries) fail. Negative tests run +against temporary Git repositories. This check does not relicense dependencies; +existing candidate license texts and notices remain required. + +`artifacts/evidence/licence-boundary.json` records the source commit, dirty state, +project declarations, dependency edges and findings. CI retains it separately from +the candidate. Existing source tests, bundle/Workflow tests and immutable delivery +gates continue to apply. Local model fixtures remain mocked inference; only the +post-merge Cloudflare Workflow smoke can establish the actual deployment and model +result. No product capability, account permission or commercial gate closes from a +licence declaration. diff --git a/eng/licence-boundary.mjs b/eng/licence-boundary.mjs new file mode 100644 index 0000000..e0d7a1b --- /dev/null +++ b/eng/licence-boundary.mjs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; + +const firstParty = new Set([ + "@arcforges/proto", + "@arcforges/api-client", + "@arcforges/ai-internal", + "@arcforges/contract-fixtures", + "@arcforges/ai", + "@arcforges/cloud-workspace", + "@arcforges/web-workspace", + "@arcforges/web-site", + "@arcforges/web-ui", +]); + +export function auditLicences(root) { + root = realpathSync(root); + const git = (...args) => + execFileSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true }).trim(); + const files = [ + ...new Set( + git("ls-files", "-z", "--cached", "--others", "--exclude-standard") + .split("\0") + .filter(Boolean), + ), + ]; + const read = (name) => { + const absolute = path.resolve(root, name); + assert(absolute.startsWith(root + path.sep), `Escaped licence input: ${name}`); + assert(realpathSync(absolute).startsWith(root + path.sep), `Linked licence input: ${name}`); + for (let current = absolute; current !== root; current = path.dirname(current)) + assert(!lstatSync(current).isSymbolicLink(), `Linked licence input: ${name}`); + return JSON.parse(readFileSync(absolute, "utf8")); + }; + const policy = read("eng/policy/licence-boundary.json"); + assert.deepEqual(Object.keys(policy).sort(), [ + "licenceBoundary", + "projects", + "repository", + "schemaVersion", + "spdxLicense", + ]); + assert.equal(policy.schemaVersion, 1); + assert.equal(policy.repository, "AI"); + assert.equal(policy.spdxLicense, "AGPL-3.0-only"); + assert.equal(policy.licenceBoundary, "AGPL"); + const manifests = files.filter((file) => path.basename(file) === "package.json").sort(); + assert( + !files.some((file) => + /\.(?:csproj|fsproj|vbproj|vcxproj|esproj)$|(?:^|\/)(?:build\.gradle(?:\.kts)?|CMakeLists\.txt)$/u.test( + file, + ), + ), + "New build system requires licence review and an evaluated verifier.", + ); + for (const row of policy.projects) { + assert.deepEqual(Object.keys(row).sort(), ["kind", "path"]); + assert.equal(row.kind, "npm"); + } + assert(manifests.length > 0); + assert.deepEqual( + policy.projects.map((row) => row.path).sort(), + manifests, + "Project licence inventory drift.", + ); + const names = new Map(manifests.map((file) => [read(file).name, file])); + assert.equal(names.size, manifests.length, "Duplicate npm project identity."); + const checkPackage = (name) => { + if (name.toLowerCase().startsWith("@arcforges/") && !names.has(name)) + assert(firstParty.has(name), `Unknown first-party package owner: ${name}`); + }; + const projects = manifests.map((file) => { + const manifest = read(file); + assert.equal(manifest.license, "AGPL-3.0-only", `Incorrect SPDX: ${file}`); + assert.deepEqual( + manifest.arcforges, + { licenceBoundary: "AGPL" }, + `Incorrect boundary: ${file}`, + ); + const dependencies = []; + for (const section of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) + for (const [name, version] of Object.entries(manifest[section] ?? {})) { + assert( + !/^(?:file:|link:|git\+|\.\.?\/)/u.test(version), + `Unpublished npm reference: ${file}: ${name}`, + ); + checkPackage(name); + if (version.startsWith("npm:")) { + const alias = version.slice(4); + const end = alias.indexOf("@", 1); + checkPackage(end < 0 ? alias : alias.slice(0, end)); + } + dependencies.push({ name, version, project: names.get(name) ?? null }); + } + return { + path: file, + kind: "npm", + spdxLicense: manifest.license, + licenceBoundary: manifest.arcforges.licenceBoundary, + dependencies, + }; + }); + for (const file of files.filter((file) => path.basename(file) === "package-lock.json")) + for (const [name, entry] of Object.entries(read(file).packages)) + checkPackage(entry.name ?? name.split("node_modules/").at(-1)); + return { + result: "passed", + repository: "AI", + commit: git("rev-parse", "HEAD"), + dirty: Boolean(git("status", "--porcelain")), + projects, + findings: [], + evidenceClass: "npm-project-inventory-and-locked-first-party-reference-audit", + }; +} diff --git a/eng/policy/licence-boundary.json b/eng/policy/licence-boundary.json new file mode 100644 index 0000000..92ca6a7 --- /dev/null +++ b/eng/policy/licence-boundary.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "repository": "AI", + "spdxLicense": "AGPL-3.0-only", + "licenceBoundary": "AGPL", + "projects": [ + { + "path": "package.json", + "kind": "npm" + } + ] +} diff --git a/eng/project.mjs b/eng/project.mjs index 27aaf52..a3b33b0 100644 --- a/eng/project.mjs +++ b/eng/project.mjs @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { auditLicences } from "./licence-boundary.mjs"; export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const CANDIDATE = path.resolve(ROOT, process.env.CANDIDATE_DIR ?? "artifacts/candidate"); @@ -124,6 +125,7 @@ export function verifyCandidate(directory = CANDIDATE, expectedCommit = process. } async function build() { + writeJson(path.join(ROOT, "artifacts/evidence/licence-boundary.json"), auditLicences(ROOT)); resetGeneratedCandidate(); const version = process.env.GITHUB_RUN_NUMBER ? versionFromRun(process.env.GITHUB_RUN_NUMBER, process.env.GITHUB_RUN_ATTEMPT ?? "1") @@ -291,6 +293,7 @@ async function testBundle() { } function check() { + writeJson(path.join(ROOT, "artifacts/evidence/licence-boundary.json"), auditLicences(ROOT)); const listed = run("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) .split("\0") .filter(Boolean); diff --git a/eng/tests/licence-boundary.test.mjs b/eng/tests/licence-boundary.test.mjs new file mode 100644 index 0000000..a129cfc --- /dev/null +++ b/eng/tests/licence-boundary.test.mjs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { auditLicences } from "../licence-boundary.mjs"; + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "ai-licence-test-")); + t.after(() => { + assert.equal(path.dirname(root), os.tmpdir()); + assert(path.basename(root).startsWith("ai-licence-test-")); + rmSync(root, { recursive: true }); + }); + const git = (...args) => execFileSync("git", args, { cwd: root, windowsHide: true }); + git("init", "-q"); + git( + "-c", + "user.name=Licence Test", + "-c", + "user.email=licence@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "fixture", + ); + const write = (file, value) => { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), JSON.stringify(value)); + }; + const policy = { + schemaVersion: 1, + repository: "AI", + spdxLicense: "AGPL-3.0-only", + licenceBoundary: "AGPL", + projects: [{ path: "package.json", kind: "npm" }], + }; + const manifest = { + name: "@arcforges/ai", + license: "AGPL-3.0-only", + arcforges: { licenceBoundary: "AGPL" }, + }; + write("eng/policy/licence-boundary.json", policy); + write("package.json", manifest); + return { root, write, policy, manifest }; +} + +test("actual Git inventory covers the complete npm project", (t) => { + const { root } = fixture(t); + assert.equal(auditLicences(root).projects.length, 1); +}); + +for (const value of [null, "Apache"]) + test(`missing/inconsistent boundary fails: ${value}`, (t) => { + const { root, write, manifest } = fixture(t); + manifest.arcforges.licenceBoundary = value; + write("package.json", manifest); + assert.throws(() => auditLicences(root), /Incorrect boundary/u); + }); + +test("edited repository assignment cannot authorize another boundary", (t) => { + const { root, write, policy } = fixture(t); + policy.licenceBoundary = "Apache"; + write("eng/policy/licence-boundary.json", policy); + assert.throws(() => auditLicences(root)); +}); + +test("new nonignored projects and duplicate registrations fail", (t) => { + const { root, write, manifest, policy } = fixture(t); + write("tools/package.json", { ...manifest, name: "@arcforges/tool" }); + assert.throws(() => auditLicences(root), /inventory drift/u); + policy.projects.push(policy.projects[0]); + write("eng/policy/licence-boundary.json", policy); + assert.throws(() => auditLicences(root), /inventory drift/u); +}); + +test("locked transitive first-party owner must be known", (t) => { + const { root, write } = fixture(t); + write("package-lock.json", { packages: { "node_modules/@arcforges/unknown": {} } }); + assert.throws(() => auditLicences(root), /Unknown first-party/u); +}); + +for (const version of ["file:../source", "npm:@arcforges/unknown", "npm:@arcforges/unknown@1.0.0"]) + test(`unpublished or hidden unknown dependency fails: ${version}`, (t) => { + const { root, write, manifest } = fixture(t); + write("package.json", { ...manifest, dependencies: { alias: version } }); + assert.throws(() => auditLicences(root), /Unpublished|Unknown first-party/u); + }); + +test("an introduced build system needs its owning verifier", (t) => { + const { root } = fixture(t); + writeFileSync(path.join(root, "unexpected.csproj"), ""); + assert.throws(() => auditLicences(root), /New build system/u); +}); diff --git a/package.json b/package.json index 43e1219..d2afaa0 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,8 @@ "typescript": "7.0.2", "vitest": "4.1.11", "wrangler": "4.131.2" + }, + "arcforges": { + "licenceBoundary": "AGPL" } }