From 432410e6579b5d8c0a06c6a51746c2ab56db2285 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 18 Sep 2026 15:00:38 -0700 Subject: [PATCH 1/2] Enforce WP00.02 Web project licence boundaries --- .github/workflows/ci.yml | 16 ++ ArcForges.Web.esproj | 4 + Directory.Build.targets | 23 +++ README.md | 7 +- apps/site/package.json | 3 + docs/cloud-hello.md | 12 +- docs/licence-boundary.md | 27 +++ eng/policy/licence-boundary.json | 24 +++ package.json | 3 + packages/ui/package.json | 3 + tests/unit/licence-boundary.test.ts | 136 ++++++++++++ tooling/licence-boundary.ts | 309 ++++++++++++++++++++++++++++ tooling/project.ts | 9 + 13 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 docs/licence-boundary.md create mode 100644 eng/policy/licence-boundary.json create mode 100644 tests/unit/licence-boundary.test.ts create mode 100644 tooling/licence-boundary.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3479a5..660c769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,22 @@ jobs: package-manager-cache: false - run: npm ci --ignore-scripts - run: npm run check + - name: Set up the IDE project evaluation toolchain + if: runner.os == 'Windows' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: "10.0.401" + - name: Verify effective IDE project declarations + if: runner.os == 'Windows' + run: node tooling/project.ts licence-evaluated + - name: Retain project 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-*.json + if-no-files-found: error + retention-days: 30 quality: name: Dependency audit and repository checks diff --git a/ArcForges.Web.esproj b/ArcForges.Web.esproj index 023965c..3349d5c 100644 --- a/ArcForges.Web.esproj +++ b/ArcForges.Web.esproj @@ -6,4 +6,8 @@ true $(MSBuildProjectDirectory)\artifacts\candidate + + AGPL + AGPL-3.0-only + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d5470e1 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,23 @@ + + + + + <_ArcForgesLicenceDeclaration Include="$(MSBuildProjectFullPath)"> + $(PackageLicenseExpression) + $(LicenceBoundary) + + + + + + + + + + + + diff --git a/README.md b/README.md index 24a9222..e91f532 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ React and TypeScript Web foundation for the ArcForges family. This first increment contains a public Hello World site, an interactive local greeting, shared UI, published Contracts consumption and an automated Cloudflare Workers Static Assets delivery pipeline. -It does not implement the planned Account/Chat application, authentication, payments or a C# backend. The `/hello/` greeting runs locally and sends no name to a server. A separate `/cloud-hello/` page prepares the published gRPC-Web Hello call for the future Cloud container; it currently reports unavailable until that service is deployed. Business authority remains in ArcForges Cloud. +It does not implement the planned Account/Chat application, authentication, payments or a C# backend. The `/hello/` greeting runs locally and sends no name to a server. A separate `/cloud-hello/` page uses the published gRPC-Web client to call the deployed Cloud container after the user clicks **Check connection**. Business authority remains in ArcForges Cloud. ## Start locally @@ -46,10 +46,13 @@ PRs run source checks on Linux/Windows, dependency auditing/review, secret scann The main-only GitHub `cloudflare` environment contains the account variable and deployment secret. The custom-domain binding is managed in Cloudflare; CI verifies that it belongs to this Worker before deploying. PR checks remain credential-free. See [deployment setup and recovery](docs/deploying.md) and [evidence](docs/validation.md). -Workers Static Assets supports this static React build directly. Frameworks that need request-time server code require a Workers-compatible adapter/runtime. This setup does not host C# or provide an API proxy. The future Cloud Worker will own the same-origin `/api/*` route and forward to its Native AOT container; see the [Hello integration boundary and remaining Cloud setup](docs/cloud-hello.md). See also the [official React guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react/) and [static assets guide](https://developers.cloudflare.com/workers/static-assets/get-started/). +Workers Static Assets supports this static React build directly. Frameworks that need request-time server code require a Workers-compatible adapter/runtime. This setup does not host C# or provide an API proxy. The Cloud Worker owns the same-origin `/api/*` route and forwards to its Native AOT container; see the [Hello integration boundary and Cloud ownership](docs/cloud-hello.md). See also the [official React guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react/) and [static assets guide](https://developers.cloudflare.com/workers/static-assets/get-started/). ## Contribute +The build and CI enforce the [project licence declarations](docs/licence-boundary.md) +across every npm workspace and the JavaScript IDE adapter. + Read [development](docs/development.md), [contributing](CONTRIBUTING.md), [security](SECURITY.md), [validation](docs/validation.md), and the [bootstrap plan](docs/bootstrap-plan.md). The existing repository license is **AGPL-3.0-only**; see [LICENSE](LICENSE). Upstream Contracts and other dependencies retain their own licenses. The built site exposes the license, source link and generated third-party notices. See [third-party notices](THIRD_PARTY_NOTICES.md). diff --git a/apps/site/package.json b/apps/site/package.json index 7bf89a6..c7dfdd8 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -18,5 +18,8 @@ "react-dom": "19.3.0", "react-router": "8.4.0", "isbot": "5.2.2" + }, + "arcforges": { + "licenceBoundary": "AGPL" } } diff --git a/docs/cloud-hello.md b/docs/cloud-hello.md index 90809f1..3aeb5c8 100644 --- a/docs/cloud-hello.md +++ b/docs/cloud-hello.md @@ -1,10 +1,10 @@ -# Connecting the future Cloud service +# Connecting the Cloud service ## Current Web behavior `/hello/` remains local. `/cloud-hello/` uses the published Contracts client to send one fixed diagnostic greeting after the user clicks **Check connection**. It sends no user-entered name, account cookie, authorization header or Cloudflare management token. Requests have a ten-second deadline, are cancelled on navigation, and are never automatically retried. Only the expected server response is displayed as success. -No Cloud service is currently deployed by this repository. Until its API route exists, the static Worker rejects the Hello POST with 405 Method Not Allowed; a GET at that missing path returns 404. The page shows an unavailable response. No mock is deployed and no successful response is generated locally. +The Cloud repository deploys the API Worker and Native AOT container independently of Web. A public browser check on 2026-09-18 verified that this page sends no API request on load, then sends exactly one POST after the click and displays `Hello, ArcForges!` from the real service. That observation used Web commit `86d6fded796d060eb064125baca7db690bc65419` and Cloud commit `6554400c04817491fe68d5e6319434034c5dc356`; it is a diagnostic integration result, not acceptance of later product APIs. If the Cloud API route is absent, the static Worker rejects the Hello POST with 405 Method Not Allowed; a GET at that missing path returns 404. The page shows an unavailable response. No mock is deployed and no successful response is generated locally. ## Fixed integration boundary @@ -21,20 +21,20 @@ No Cloud service is currently deployed by this repository. Until its API route e The Web origin stays `https://arcforges.com`; CSP keeps `connect-src 'self'` and no cross-origin exception is needed. A later account/authenticated API must define its own session behavior; this anonymous diagnostic must not silently start forwarding credentials. -## Work owned by the future Cloud repository +## Responsibilities owned by the Cloud repository 1. Build and test the actual C# Native AOT Linux container implementing this published wire contract. Cloudflare currently requires a `linux/amd64` image. Validate its AOT build, startup, gRPC-Web response/trailer framing, and failure statuses; the existing Contracts HelloHost example alone is not AOT evidence. 2. Deploy its Cloud Worker and Container binding in the same Cloudflare account. Forward to the container through that binding, removing only the leading `/api` from the public path. Preserve request/response protobuf bytes and gRPC-Web content type, statuses and framed trailers. Do not convert the payload to ad-hoc JSON or forward back to the public API URL. 3. Attach **Worker route** `arcforges.com/api/*` to the Cloud Worker. Keep the apex **Custom Domain** attached to `arcforges-web`. A route runs ahead of that custom-domain origin. The API route needs no second DNS hostname, browser token, Web service binding or Web rebuild. Unknown API methods must return an API error/404 rather than Web HTML. -4. Keep the example explicitly bounded: only the Hello diagnostic, with input/resource limits and no model call, paid user operation or database mutation. Authentication, quotas and commercial APIs remain a separate product increment. Worker/Container account permissions and plan availability are configured when Cloud is implemented. +4. Keep the example explicitly bounded: only the Hello diagnostic, with input/resource limits and no model call, paid user operation or database mutation. Authentication, quotas and commercial APIs remain a separate product increment. Cloud owns the Worker/Container account permissions and required plan availability. 5. Cloud's deployment gate must invoke the **public same-origin method using the published client**, verify the expected protobuf reply, and verify both success and failure behavior. Then check the button in the deployed Web page. A container health endpoint, mocked fixture or Web deployment alone does not establish this chain. -This repository does not provision the missing Cloud Worker, container image, API route, billing plan or credentials. +Cloud owns provisioning the Worker, container image, API route, billing plan and deployment credentials. ## Evidence and recovery `cloud-hello-fixture.spec.ts` intercepts the browser API request with explicitly labelled protobuf wire fixtures. It verifies the actual published client's request, unavailable response and recovery. It is excluded from live Web verification so mocked success cannot be reported as a real C# integration. Live Web tests check that the new page loads and sends nothing automatically. -Web delivery still verifies its assets and Web-owned public 404 behavior. Candidate-only tests verify that the static Worker cannot fake a successful API response; the live Web gate does not require Web HTML at `/api/*`, because Cloud will own those paths. Cloud owns the later API/container deployment and its independent real integration gate. Removing Cloud's API route restores the static Worker's rejection of that API request; the connection page reports failure instead of silently falling back to a local greeting. +Web delivery still verifies its assets and Web-owned public 404 behavior. Candidate-only tests verify that the static Worker cannot fake a successful API response; the live Web gate does not require Web HTML at `/api/*`, because Cloud owns those paths. Cloud owns API/container deployment and its independent real integration gate. Removing Cloud's API route restores the static Worker's rejection of that API request; the connection page reports failure instead of silently falling back to a local greeting. References: [routes before a Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/#interaction-with-routes), [Cloudflare Containers setup](https://developers.cloudflare.com/containers/get-started/). diff --git a/docs/licence-boundary.md b/docs/licence-boundary.md new file mode 100644 index 0000000..9ea8a14 --- /dev/null +++ b/docs/licence-boundary.md @@ -0,0 +1,27 @@ +# Project licence boundaries (WP00.02) + +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) +assigns Web's original code, tests and tooling to AGPL-3.0-only / AGPL. +`eng/policy/licence-boundary.json` enumerates every current project/build manifest. +The inventory check discovers tracked and nonignored files independently; changing +the policy cannot change the permitted repository assignment. + +The existing `npm run check` and candidate build check effective npm declarations, +source declarations for every build scope, imported MSBuild properties, project +reference containment and locked first-party package ownership. Tests exercise +new projects, missing/inconsistent properties, imports, escaped references, npm +aliases, unknown transitive first-party packages and invalid Gradle declarations. +No adjacent repository is imported or built. Third-party licences and existing +candidate notices remain separately owned and enforced. + +`node tooling/project.ts licence-evaluated` evaluates owned MSBuild projects in +Debug and Release and retains their actual properties and reference edges. Build +targets reject wrong effective values before build/pack. Source and evaluated +reports include the exact commit, dirty state, inventory and findings under +`artifacts/evidence/licence-*.json`; CI uploads the reports. + +The three npm scopes and the JavaScript IDE adapter are covered. Windows CI evaluates the IDE adapter using .NET SDK 10.0.401; normal npm development remains independent of that optional IDE toolchain. The existing final static candidate and three-browser tests remain required, followed by exact-byte Cloudflare delivery and live browser verification. + +These source/build-policy results do not establish product functionality or close +later commercial gates. Local candidate identity and fixture/runtime evidence are +recorded separately from the merged deployment and public release. diff --git a/eng/policy/licence-boundary.json b/eng/policy/licence-boundary.json new file mode 100644 index 0000000..fd07c1e --- /dev/null +++ b/eng/policy/licence-boundary.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "repository": "Web", + "spdxLicense": "AGPL-3.0-only", + "licenceBoundary": "AGPL", + "projects": [ + { + "path": "ArcForges.Web.esproj", + "kind": "msbuild" + }, + { + "path": "apps/site/package.json", + "kind": "npm" + }, + { + "path": "package.json", + "kind": "npm" + }, + { + "path": "packages/ui/package.json", + "kind": "npm" + } + ] +} diff --git a/package.json b/package.json index ac43e61..5b1f7d7 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,8 @@ "vite": "8.3.0", "vitest": "5.0.1", "wrangler": "4.132.0" + }, + "arcforges": { + "licenceBoundary": "AGPL" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 8b8234d..758009c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,5 +11,8 @@ "peerDependencies": { "react": "19.3.0", "react-dom": "19.3.0" + }, + "arcforges": { + "licenceBoundary": "AGPL" } } diff --git a/tests/unit/licence-boundary.test.ts b/tests/unit/licence-boundary.test.ts new file mode 100644 index 0000000..f3dd28f --- /dev/null +++ b/tests/unit/licence-boundary.test.ts @@ -0,0 +1,136 @@ +// 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 "vitest"; +import { auditLicences } from "../../tooling/licence-boundary.ts"; + +function fixture() { + const root = mkdtempSync(path.join(os.tmpdir(), "licence-inventory-")); + execFileSync("git", ["init", "-q"], { cwd: root, windowsHide: true }); + execFileSync( + "git", + [ + "-c", + "user.name=Licence Test", + "-c", + "user.email=licence@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "fixture", + ], + { cwd: root, windowsHide: true }, + ); + const write = (file: string, value: unknown) => { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), typeof value === "string" ? value : JSON.stringify(value)); + }; + const policy = { + schemaVersion: 1, + repository: "Web", + spdxLicense: "AGPL-3.0-only", + licenceBoundary: "AGPL", + projects: [{ path: "package.json", kind: "npm" }], + }; + const manifest = { + name: "@arcforges/test", + license: "AGPL-3.0-only", + arcforges: { licenceBoundary: "AGPL" }, + }; + write("eng/policy/licence-boundary.json", policy); + write("package.json", manifest); + const cleanup = () => { + assert.equal(path.dirname(root), os.tmpdir()); + assert(path.basename(root).startsWith("licence-inventory-")); + rmSync(root, { recursive: true }); + }; + return { root, write, policy, manifest, cleanup }; +} +test("Git inventory checks existing and newly added projects", () => { + const f = fixture(); + try { + assert.equal(auditLicences(f.root).projects.length, 1); + f.write("unregistered/package.json", f.manifest); + assert.throws(() => auditLicences(f.root), /inventory drift/u); + } finally { + f.cleanup(); + } +}); +test("a missing boundary and edited owner assignment both fail", () => { + const f = fixture(); + try { + f.write("package.json", { ...f.manifest, arcforges: {} }); + assert.throws(() => auditLicences(f.root), /Incorrect boundary/u); + f.write("package.json", f.manifest); + f.policy.licenceBoundary = "Apache"; + f.write("eng/policy/licence-boundary.json", f.policy); + assert.throws(() => auditLicences(f.root)); + } finally { + f.cleanup(); + } +}); +test("inconsistent managed SPDX, imported overrides and escaped references fail", () => { + const f = fixture(); + try { + f.policy.projects.push({ path: "app.csproj", kind: "msbuild" }); + f.write("eng/policy/licence-boundary.json", f.policy); + const project = + "AGPL-3.0-onlyAGPL"; + f.write("app.csproj", project); + assert.equal(auditLicences(f.root).projects.length, 2); + f.write("app.csproj", project.replace("AGPL-3.0-only", "Apache-2.0")); + assert.throws(() => auditLicences(f.root), /Incorrect SPDX/u); + f.write( + "app.csproj", + project.replace( + "", + '', + ), + ); + assert.throws(() => auditLicences(f.root), /Escaped\/unregistered/u); + f.write("app.csproj", project); + f.write( + "Directory.Build.props", + "Apache", + ); + assert.throws(() => auditLicences(f.root), /Imported licence override/u); + } finally { + f.cleanup(); + } +}); +test("unknown locked transitive packages and unversioned npm aliases fail", () => { + const f = fixture(); + try { + f.write("package-lock.json", { packages: { "node_modules/@arcforges/unknown": {} } }); + assert.throws(() => auditLicences(f.root), /Unknown first-party/u); + f.write("package-lock.json", { packages: {} }); + f.write("package.json", { ...f.manifest, dependencies: { alias: "npm:@arcforges/unknown" } }); + assert.throws(() => auditLicences(f.root), /Unknown first-party/u); + } finally { + f.cleanup(); + } +}); +test("Gradle declarations and unpublished source inputs are checked", () => { + const f = fixture(); + try { + f.policy.projects.push({ path: "build.gradle.kts", kind: "gradle" }); + f.write("eng/policy/licence-boundary.json", f.policy); + f.write( + "build.gradle.kts", + 'extra["spdxLicense"] = "AGPL-3.0-only"\nextra["licenceBoundary"] = "Apache"\n', + ); + assert.throws(() => auditLicences(f.root), /Incorrect Gradle/u); + f.write( + "build.gradle.kts", + 'extra["spdxLicense"] = "AGPL-3.0-only"\nextra["licenceBoundary"] = "AGPL"\nmavenLocal()\n', + ); + assert.throws(() => auditLicences(f.root), /Unpublished Gradle/u); + } finally { + f.cleanup(); + } +}); diff --git a/tooling/licence-boundary.ts b/tooling/licence-boundary.ts new file mode 100644 index 0000000..e01095e --- /dev/null +++ b/tooling/licence-boundary.ts @@ -0,0 +1,309 @@ +// 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 owner = "Web"; + +export function evaluatedManagedLicences(root: string) { + const source = auditLicences(root); + const evaluatedMSBuild = source.projects + .filter((row) => row.kind === "msbuild") + .flatMap((row) => + ["Debug", "Release"].map((configuration) => { + const evaluated = JSON.parse( + execFileSync( + "dotnet", + [ + "msbuild", + row.path, + "-t:ArcForgesVerifyLicenceBoundary", + `-p:Configuration=${configuration}`, + "-getProperty:PackageLicenseExpression,LicenceBoundary", + "-getItem:ProjectReference", + "-verbosity:quiet", + ], + { cwd: root, encoding: "utf8", windowsHide: true }, + ), + ) as { + Properties: { PackageLicenseExpression: string; LicenceBoundary: string }; + Items: { ProjectReference: { FullPath: string }[] }; + }; + assert.deepEqual(evaluated.Properties, { + PackageLicenseExpression: "AGPL-3.0-only", + LicenceBoundary: "AGPL", + }); + return { + path: row.path, + configuration, + properties: evaluated.Properties, + references: evaluated.Items.ProjectReference.map((item) => + path.relative(root, item.FullPath).split(path.sep).join("/"), + ), + }; + }), + ); + return { ...source, evaluatedMSBuild }; +} +const extensions = new Set([".csproj", ".fsproj", ".vbproj", ".vcxproj", ".esproj"]); +const npmOwners = new Set([ + "@arcforges/proto", + "@arcforges/api-client", + "@arcforges/contract-fixtures", + "@arcforges/ai-internal", + "@arcforges/ai", + "@arcforges/cloud-workspace", + "@arcforges/web-workspace", + "@arcforges/web-site", + "@arcforges/web-ui", +]); +interface Row { + path: string; + kind: string; +} +interface Manifest { + name: string; + license: string; + arcforges: { licenceBoundary: string }; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +} +function kind(file: string): string | undefined { + if (extensions.has(path.extname(file))) return "msbuild"; + return ( + { + "package.json": "npm", + "build.gradle.kts": "gradle", + "build.gradle": "gradle", + "CMakeLists.txt": "cmake", + } as Record + )[path.basename(file)]; +} +function checkPackage(name: string) { + if (name.toLowerCase().startsWith("@arcforges/")) + assert(npmOwners.has(name), `Unknown first-party package owner: ${name}`); + if (name.toLowerCase().startsWith("arcforges.")) + assert( + ["arcforges.contracts.publicapi", "arcforges.build.policy"].includes(name.toLowerCase()), + `Unknown first-party package owner: ${name}`, + ); + if (name.toLowerCase().startsWith("io.github.arcforges:")) + assert( + ["contracts-proto", "contracts-client", "contracts-connect-client", "contract-fixtures"].some( + (module) => name === `io.github.arcforges:${module}`, + ), + `Unknown first-party package owner: ${name}`, + ); +} +const xml = (text: string) => text.replace(//gu, ""); +const values = (text: string, key: string) => + [...xml(text).matchAll(new RegExp(`<${key}(?:\\s[^>]*)?>([^<]*)`, "gu"))].map( + (match) => match[1], + ); +export function auditLicences(root: string) { + root = realpathSync(root); + const git = (...args: string[]) => + 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), + ), + ].sort(); + const read = (file: string) => { + const full = path.resolve(root, file); + assert( + full.startsWith(root + path.sep) && realpathSync(full).startsWith(root + path.sep), + `Escaped input: ${file}`, + ); + for (let current = full; current !== root; current = path.dirname(current)) + assert(!lstatSync(current).isSymbolicLink(), `Linked input: ${file}`); + return readFileSync(full, "utf8"); + }; + const policy = JSON.parse(read("eng/policy/licence-boundary.json")) as { + schemaVersion: number; + repository: string; + spdxLicense: string; + licenceBoundary: string; + projects: Row[]; + }; + assert.deepEqual(Object.keys(policy).sort(), [ + "licenceBoundary", + "projects", + "repository", + "schemaVersion", + "spdxLicense", + ]); + assert.equal(policy.schemaVersion, 1); + assert.equal(policy.repository, owner); + assert.equal(policy.spdxLicense, "AGPL-3.0-only"); + assert.equal(policy.licenceBoundary, "AGPL"); + const actual = files + .filter((file) => kind(file)) + .map((file) => ({ path: file, kind: kind(file) })); + assert(actual.length > 0); + assert.deepEqual( + [...policy.projects].sort((a, b) => a.path.localeCompare(b.path, "en")), + [...actual].sort((a, b) => a.path.localeCompare(b.path, "en")), + "Project licence inventory drift.", + ); + const npmNames = new Map( + actual + .filter((row) => row.kind === "npm") + .map((row) => [(JSON.parse(read(row.path)) as Manifest).name, row.path]), + ); + assert.equal( + npmNames.size, + actual.filter((row) => row.kind === "npm").length, + "Duplicate npm identity.", + ); + const projectNames = new Set( + actual + .filter((row) => row.kind === "msbuild") + .map((row) => path.basename(row.path, path.extname(row.path)).toLowerCase()), + ); + const edges: { source: string; target: string; kind: string }[] = []; + for (const row of actual) { + const text = read(row.path); + if (row.kind === "npm") { + const manifest = JSON.parse(text) as Manifest; + assert.equal(manifest.license, "AGPL-3.0-only", `Incorrect SPDX: ${row.path}`); + assert.deepEqual( + manifest.arcforges, + { licenceBoundary: "AGPL" }, + `Incorrect boundary: ${row.path}`, + ); + for (const section of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] as const) + for (const [name, version] of Object.entries(manifest[section] ?? {})) { + assert( + !/^(?:file:|link:|git\+|\.\.?\/)/u.test(version), + `Unpublished source reference: ${name}`, + ); + if (!npmNames.has(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)); + } + edges.push({ + source: row.path, + target: npmNames.get(name) ?? name, + kind: npmNames.has(name) ? "project" : "package", + }); + } + } else if (row.kind === "msbuild") { + assert.deepEqual( + values(text, "PackageLicenseExpression"), + ["AGPL-3.0-only"], + `Incorrect SPDX: ${row.path}`, + ); + assert.deepEqual( + values(text, "LicenceBoundary"), + ["AGPL"], + `Incorrect boundary: ${row.path}`, + ); + for (const name of [...values(text, "AssemblyName"), ...values(text, "PackageId")]) + if (name) projectNames.add(name.toLowerCase()); + } else if (row.kind === "gradle") { + for (const [key, value] of [ + ["spdxLicense", "AGPL-3.0-only"], + ["licenceBoundary", "AGPL"], + ]) + assert.deepEqual( + [...text.matchAll(new RegExp(`extra\\["${key}"\\]\\s*=\\s*"([^"\\n]+)"`, "gu"))].map( + (match) => match[1], + ), + [value], + `Incorrect Gradle declaration: ${row.path}`, + ); + } else throw new Error(`A new build system needs a reviewed licence verifier: ${row.path}`); + } + for (const file of files) { + const name = path.basename(file); + if (extensions.has(path.extname(file)) || [".props", ".targets"].includes(path.extname(file))) { + const text = xml(read(file)); + for (const [key, expected] of [ + ["PackageLicenseExpression", "AGPL-3.0-only"], + ["LicenceBoundary", "AGPL"], + ] as const) + assert( + values(text, key).every((value) => value === expected), + `Imported licence override: ${file}`, + ); + for (const match of text.matchAll(/]*?Include=["']([^"']+)["']/gu)) { + const include = match[1]; + assert(include, "Missing project-reference path."); + assert(!/[$@*?;]/u.test(include), `Nonliteral reference: ${file}`); + const target = path + .relative(root, path.resolve(root, path.dirname(file), include.replaceAll("\\", "/"))) + .split(path.sep) + .join("/"); + assert( + actual.some((row) => row.path === target && row.kind === "msbuild"), + `Escaped/unregistered reference: ${file}`, + ); + edges.push({ source: file, target, kind: "project" }); + } + for (const match of text.matchAll( + /]*?(?:Include|Update)=["']([^"']+)["']/gu, + )) { + assert(match[1], "Missing package-reference identity."); + checkPackage(match[1]); + } + } else if (name === "package-lock.json") { + const locked = JSON.parse(read(file)) as { packages: Record }; + for (const [name, entry] of Object.entries(locked.packages)) { + const identity = entry.name ?? name.split("node_modules/").at(-1) ?? ""; + if (!npmNames.has(identity)) checkPackage(identity); + } + } else if (name === "packages.lock.json") { + const locked = JSON.parse(read(file)) as { + dependencies: Record>; + }; + for (const framework of Object.values(locked.dependencies)) + for (const [name, entry] of Object.entries(framework)) { + if (entry.type.toLowerCase() === "project") + assert(projectNames.has(name.toLowerCase()), `Unknown locked project: ${name}`); + else checkPackage(name); + } + } else if (name.endsWith(".lockfile")) { + for (const line of read(file).split("\n")) + if (line && !line.startsWith("#")) checkPackage(line.split(":").slice(0, 2).join(":")); + } else if (/^(?:build|settings)\.gradle(?:\.kts)?$/u.test(name)) { + const text = read(file); + assert( + !/includeBuild\s*\(|mavenLocal\s*\(/u.test(text), + `Unpublished Gradle source: ${file}`, + ); + assert( + !/\bproject\(\s*["']:/u.test(text), + `New Gradle project graph requires licence review: ${file}`, + ); + for (const match of text.matchAll(/io\.github\.arcforges:[A-Za-z0-9_.-]+/gu)) + checkPackage(match[0]); + } + } + return { + result: "passed", + repository: owner, + commit: git("rev-parse", "HEAD"), + dirty: Boolean(git("status", "--porcelain")), + projects: actual.map((row) => ({ + ...row, + spdxLicense: "AGPL-3.0-only", + licenceBoundary: "AGPL", + })), + references: edges, + findings: [], + evidenceClass: "source-and-npm-project-inventory-with-locked-first-party-references", + }; +} diff --git a/tooling/project.ts b/tooling/project.ts index fe53174..9a11daa 100644 --- a/tooling/project.ts +++ b/tooling/project.ts @@ -6,6 +6,7 @@ import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { type DefaultTreeAdapterTypes, parse } from "parse5"; +import { auditLicences, evaluatedManagedLicences } from "./licence-boundary.ts"; export const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const candidate = join(root, "artifacts/candidate"); @@ -183,6 +184,7 @@ async function notices() { return text; } async function build() { + await save(join(root, "artifacts/evidence/licence-boundary.json"), auditLicences(root)); const source = run("git", ["rev-parse", "HEAD"]).trim(); const dirty = run("git", ["status", "--porcelain", "--untracked-files=normal"]).trim().length > 0; const version = releaseVersion(process.env.GITHUB_RUN_NUMBER, process.env.GITHUB_RUN_ATTEMPT); @@ -280,6 +282,7 @@ export function verifyLockProvenance(packages: Record) { } async function policy() { + await save(join(root, "artifacts/evidence/licence-boundary.json"), auditLicences(root)); assert.equal( process.version, `v${(await readFile(join(root, ".node-version"), "utf8")).trim()}`, @@ -321,6 +324,12 @@ async function main() { case "policy": await policy(); break; + case "licence-evaluated": + await save( + join(root, "artifacts/evidence/licence-evaluated.json"), + evaluatedManagedLicences(root), + ); + break; case "hooks": run("git", ["config", "extensions.worktreeConfig", "true"]); run("git", ["config", "--worktree", "core.hooksPath", ".githooks"]); From 232a4d211ad99e38b3791a2d136347ee561501c3 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 18 Sep 2026 15:12:53 -0700 Subject: [PATCH 2/2] Preserve XML token boundaries when ignoring comments --- tests/unit/licence-boundary.test.ts | 15 +++++++++++++++ tooling/licence-boundary.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit/licence-boundary.test.ts b/tests/unit/licence-boundary.test.ts index f3dd28f..2cdce7b 100644 --- a/tests/unit/licence-boundary.test.ts +++ b/tests/unit/licence-boundary.test.ts @@ -134,3 +134,18 @@ test("Gradle declarations and unpublished source inputs are checked", () => { f.cleanup(); } }); + +test("comments cannot join fragments into a licence declaration", () => { + const f = fixture(); + try { + f.policy.projects.push({ path: "app.csproj", kind: "msbuild" }); + f.write("eng/policy/licence-boundary.json", f.policy); + f.write( + "app.csproj", + "AGPL-3.0-onlyoundary>AGPL", + ); + assert.throws(() => auditLicences(f.root), /Incorrect boundary/u); + } finally { + f.cleanup(); + } +}); diff --git a/tooling/licence-boundary.ts b/tooling/licence-boundary.ts index e01095e..e4b58ab 100644 --- a/tooling/licence-boundary.ts +++ b/tooling/licence-boundary.ts @@ -98,7 +98,7 @@ function checkPackage(name: string) { `Unknown first-party package owner: ${name}`, ); } -const xml = (text: string) => text.replace(//gu, ""); +const xml = (text: string) => text.replace(//gu, " "); const values = (text: string, key: string) => [...xml(text).matchAll(new RegExp(`<${key}(?:\\s[^>]*)?>([^<]*)`, "gu"))].map( (match) => match[1],