Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ Use these NemoClaw precedents for durable evidence shape, not as inherited concl
- `docs/security/openclaw-2026.6.10-dependency-review.md` and
`test/openclaw-dependency-review.test.ts` for a tracked dependency review with contract tests;
- `docs/security/openshell-0.0.72-compatibility-review.mdx` for a runtime compatibility boundary;
- `scripts/checks/dependency-pins.ts` and `test/dependency-pins-check.test.ts` for selector
- `scripts/checks/dependency-pins.mts` and `test/dependency-pins-check.test.ts` for selector
coherence; and
- `scripts/check-installer-hash.sh` and `test/installer-hash-check.test.ts` for independently
trusted release manifests and consumed artifacts.
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/ci-static-checks/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ runs:
shell: bash
run: npm install --ignore-scripts

- name: Enforce base-trusted createRequire allowlist ratchet
shell: bash
run: npx tsx "$GITHUB_ACTION_PATH/create-require-ratchet.mts"

- name: Validate config schemas
shell: bash
run: npm run validate:configs
Expand Down
163 changes: 163 additions & 0 deletions .github/actions/ci-static-checks/create-require-ratchet.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Enforce the createRequire allowlist ratchet from the base-trusted CI action.
*
* Pull requests execute this file from the action checked out at the immutable
* base SHA. The comparison therefore cannot be weakened by changing the PR's
* scripts/checks implementation or returning early from its local check.
*/

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import ts from "typescript";

export type TrustedCreateRequireAllowlists = Readonly<{
cli: readonly string[];
testSupport: readonly string[];
}>;

const ALLOWLIST_PATHS = [
"scripts/checks/test-create-require-budget.mts",
"scripts/checks/test-create-require-budget.ts",
] as const;

const ALLOWLIST_EXPORTS = {
CLI_CREATE_REQUIRE_FILES: "cli",
TEST_SUPPORT_CREATE_REQUIRE_FILES: "testSupport",
} as const;

function arrayLiteral(initializer: ts.Expression | undefined): ts.ArrayLiteralExpression | null {
let expression = initializer;
while (
expression &&
(ts.isAsExpression(expression) ||
ts.isSatisfiesExpression(expression) ||
ts.isParenthesizedExpression(expression))
) {
expression = expression.expression;
}
return expression && ts.isArrayLiteralExpression(expression) ? expression : null;
}

export function extractTrustedCreateRequireAllowlists(
sourceText: string,
fileName: string = ALLOWLIST_PATHS[0],
): TrustedCreateRequireAllowlists {
const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
const values: Partial<Record<keyof TrustedCreateRequireAllowlists, string[]>> = {};

for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) continue;
const target = ALLOWLIST_EXPORTS[declaration.name.text as keyof typeof ALLOWLIST_EXPORTS];
if (!target) continue;
const array = arrayLiteral(declaration.initializer);
if (!array || array.elements.some((element) => !ts.isStringLiteralLike(element))) {
throw new Error(`${declaration.name.text} must be a literal string array`);
}
if (values[target]) throw new Error(`${declaration.name.text} must be declared exactly once`);
values[target] = array.elements.map((element) => (element as ts.StringLiteralLike).text);
}
}

if (!values.cli || !values.testSupport) {
throw new Error("createRequire allowlist source must declare both reviewed allowlists");
}
return { cli: values.cli, testSupport: values.testSupport };
}

export function trustedCreateRequireExpansionFailure(
current: TrustedCreateRequireAllowlists,
baseline: TrustedCreateRequireAllowlists,
): string | null {
const cliAdditions = current.cli.filter((file) => !baseline.cli.includes(file)).sort();
const supportAdditions = current.testSupport
.filter((file) => !baseline.testSupport.includes(file))
.sort();
if (cliAdditions.length === 0 && supportAdditions.length === 0) return null;

return [
"createRequire allowlists must not expand relative to the merge base.",
...cliAdditions.map((file) => `- CLI_CREATE_REQUIRE_FILES: ${file}`),
...supportAdditions.map((file) => `- TEST_SUPPORT_CREATE_REQUIRE_FILES: ${file}`),
].join("\n");
}

function resolveMergeBase(repoRoot: string): string | null {
const baseBranch = process.env.GITHUB_BASE_REF?.trim();
const baseRef = baseBranch ? `origin/${baseBranch}` : "origin/main";
const mergeBase = spawnSync("git", ["merge-base", "HEAD", baseRef], {
cwd: repoRoot,
encoding: "utf8",
timeout: 5_000,
});
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) {
if (baseBranch) {
throw new Error(`could not resolve the pull-request merge base against ${baseRef}`);
}
return null;
}
return mergeBase.stdout.trim();
}

function currentAllowlistSource(repoRoot: string): { path: string; source: string } {
for (const relativePath of ALLOWLIST_PATHS) {
const absolutePath = path.join(repoRoot, relativePath);
if (existsSync(absolutePath)) {
return { path: relativePath, source: readFileSync(absolutePath, "utf8") };
}
}
throw new Error("current checkout does not contain the createRequire budget check");
}

function baselineAllowlistSource(
repoRoot: string,
revision: string,
): { path: string; source: string } {
for (const relativePath of ALLOWLIST_PATHS) {
const result = spawnSync("git", ["show", `${revision}:${relativePath}`], {
cwd: repoRoot,
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) return { path: relativePath, source: result.stdout };
}
throw new Error(`merge base ${revision} does not contain the createRequire budget check`);
}

export function verifyTrustedCreateRequireRatchet(repoRoot: string): string | null {
const revision = resolveMergeBase(repoRoot);
if (!revision) return null;
const current = currentAllowlistSource(repoRoot);
const baseline = baselineAllowlistSource(repoRoot, revision);
return trustedCreateRequireExpansionFailure(
extractTrustedCreateRequireAllowlists(current.source, current.path),
extractTrustedCreateRequireAllowlists(baseline.source, baseline.path),
);
}

function main(): void {
const repoRoot = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd());
const failure = verifyTrustedCreateRequireRatchet(repoRoot);
if (failure) {
console.error(failure);
process.exitCode = 1;
return;
}
console.log("Base-trusted createRequire allowlist ratchet passed.");
}

if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
3 changes: 2 additions & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false

- name: Checkout trusted CI actions
Expand Down Expand Up @@ -340,7 +341,7 @@ jobs:
# covered without executing a mutable replacement action.
- name: Validate changed live E2E mock parity (bootstrap)
if: ${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' && matrix.shard == 1 }}
run: npx tsx scripts/checks/e2e-mock-parity.ts --base HEAD^1 --head HEAD^2
run: npx tsx scripts/checks/e2e-mock-parity.mts --base HEAD^1 --head HEAD^2

- name: Run E2E support shard (bootstrap)
if: ${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' }}
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
"test:coverage:cli": "npm run clean:cli && npm run build:cli && tsx scripts/check-dist-sourcemaps.mts dist && vitest run --project cli --project integration --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/cli --coverage.include=\"bin/**/*.js\" --coverage.include=\"src/**/*.ts\" --coverage.exclude=\"test/**/*.js\" --coverage.exclude=\"test/**/*.ts\" && tsx scripts/check-coverage-ratchet.mts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json \"CLI coverage\"",
"test:coverage:plugin": "vitest run --project plugin --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/plugin --coverage.include=\"nemoclaw/src/**/*.ts\" --coverage.include=\"nemoclaw/src/**/*.cts\" --coverage.exclude=\"**/*.test.ts\" && tsx scripts/check-coverage-ratchet.mts coverage/plugin/coverage-summary.json ci/coverage-threshold-plugin.json \"Plugin coverage\"",
"test:live-e2e": "npm run clean:cli && npm run build:cli && NEMOCLAW_RUN_LIVE_E2E=1 vitest run --project e2e-live",
"test:imports:check": "tsx scripts/checks/no-test-dist-imports.ts",
"test:projects:check": "tsx scripts/checks/vitest-project-overlap.ts",
"test:titles:check": "tsx scripts/checks/test-title-style.ts",
"test:imports:check": "tsx scripts/checks/no-test-dist-imports.mts",
"test:projects:check": "tsx scripts/checks/vitest-project-overlap.mts",
"test:titles:check": "tsx scripts/checks/test-title-style.mts",
"bench": "tsx scripts/bench/run.mts",
"check": "npx prek run --all-files --stage pre-commit && npx prek run --all-files --stage manual",
"check:diff": "npx prek run --from-ref origin/main --to-ref HEAD --stage pre-commit && npx commitlint --from origin/main --to HEAD && npx prek run --from-ref origin/main --to-ref HEAD --stage pre-push",
"checks": "tsx scripts/checks/run.ts",
"checks": "tsx scripts/checks/run.mts",
"lint": "npx @biomejs/biome lint . && npm run checks",
"lint:fix": "npx @biomejs/biome lint --write . && npm run checks",
"lint:ts": "cd nemoclaw && npm run check",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ function deriveDependencyPins(rootDir: string = REPO_ROOT): {
},
};

if (pins.openshell.minVersion && !NUMERIC_VERSION_RE.test(pins.openshell.minVersion)) {
failures.push("nemoclaw-blueprint/blueprint.yaml min_openshell_version must match X.Y.Z");
}
if (pins.openshell.maxVersion && !NUMERIC_VERSION_RE.test(pins.openshell.maxVersion)) {
failures.push("nemoclaw-blueprint/blueprint.yaml max_openshell_version must match X.Y.Z");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
import { SUPPORTED_CREDENTIAL_ENV_NAMES } from "../../src/lib/security/credential-env";

const { SUPPORTED_CREDENTIAL_ENV_NAMES } = await import("../../src/lib/security/credential-env");
const CREDENTIAL_ENV_KEYS = SUPPORTED_CREDENTIAL_ENV_NAMES;

const MESSAGE =
Expand Down Expand Up @@ -251,7 +251,7 @@ function scriptKindForPath(filePath: string): ts.ScriptKind {
function main(): void {
const filePaths = process.argv.slice(2).filter((arg) => arg !== "--");
if (filePaths.length === 0) {
console.error("Usage: tsx scripts/checks/direct-credential-env.ts FILE...");
console.error("Usage: tsx scripts/checks/direct-credential-env.mts FILE...");
process.exitCode = 2;
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ function changedFiles(base: string, head: string): string[] {
function main(): void {
const base = argument("--base");
const head = argument("--head") ?? "HEAD";
if (!base) throw new Error("usage: e2e-mock-parity.ts --base <git-ref> [--head <git-ref>]");
if (!base) throw new Error("usage: e2e-mock-parity.mts --base <git-ref> [--head <git-ref>]");

const manifestPath = path.join(REPO_ROOT, DEFAULT_PARITY_MANIFEST);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MockParityManifest;
Expand Down
45 changes: 45 additions & 0 deletions scripts/checks/hermes-light-skin-boundary.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const { NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS } = await import(
"../../src/lib/domain/sandbox/connect-env"
);

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const HERMES_DOCKERFILE_BASE = "agents/hermes/Dockerfile.base";

export function checkHermesLightSkinBoundary(options: {
dockerfileText: string;
reviewedVersions: readonly string[];
}): string | null {
const { dockerfileText, reviewedVersions } = options;
const pinnedVersion = dockerfileText.match(/^ARG HERMES_VERSION=(\S+)$/m)?.[1];
if (!pinnedVersion) {
return `${HERMES_DOCKERFILE_BASE}: could not find ARG HERMES_VERSION`;
}
if (!reviewedVersions.includes(pinnedVersion)) {
return [
"Hermes light terminal compatibility skin needs re-review.",
`${HERMES_DOCKERFILE_BASE} pins ${pinnedVersion}, but connect-env.ts was reviewed for ${reviewedVersions.join(", ")}.`,
"Remove the NemoClaw-managed light skin if upstream Hermes is readable in light terminals, or update the reviewed version constant after validating it still needs the shim.",
].join(" ");
}
return null;
}

function main(): void {
const dockerfileText = fs.readFileSync(path.join(REPO_ROOT, HERMES_DOCKERFILE_BASE), "utf8");
const error = checkHermesLightSkinBoundary({
dockerfileText,
reviewedVersions: NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS,
});
if (error) throw new Error(error);
}

if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) {
main();
}
31 changes: 0 additions & 31 deletions scripts/checks/hermes-light-skin-boundary.ts

This file was deleted.

Loading
Loading