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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions docs/licence-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions eng/licence-boundary.mjs
Original file line number Diff line number Diff line change
@@ -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",
};
}
12 changes: 12 additions & 0 deletions eng/policy/licence-boundary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"repository": "AI",
"spdxLicense": "AGPL-3.0-only",
"licenceBoundary": "AGPL",
"projects": [
{
"path": "package.json",
"kind": "npm"
}
]
}
3 changes: 3 additions & 0 deletions eng/project.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions eng/tests/licence-boundary.test.mjs
Original file line number Diff line number Diff line change
@@ -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"), "<Project />");
assert.throws(() => auditLicences(root), /New build system/u);
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,8 @@
"typescript": "7.0.2",
"vitest": "4.1.11",
"wrangler": "4.131.2"
},
"arcforges": {
"licenceBoundary": "AGPL"
}
}
Loading