build: read leak-guard rules from an optional local file (#2137)

The leak guard's term lists now come from an optional local rules file instead of a file tracked in the repository. Set `OPENRIG_LEAK_GUARD_RULES` (or pass `--rules`) to an absolute path, and every reader uses it through one loader: the leak guard, `build-package.sh`, the context packs, the skill mirror and the control-plane generator. The publishing rules, the frontmatter keys and the internal-section fence stay in the repository unchanged, so mirrored skills are the same.

Without the file, a contributor's `npm run build:package` still works and prints that the term checks were skipped. The release substance gate refuses to run without a rules file, so a release can't pass with the checks silently off. The tracked rules file is removed, and the tests write their own fixture rules.

16 files, +227 -244.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-26 18:46:46 -07:00
1 parent 7d809d5456
commit c4de1b2c38
16 files changed
+227 -244

No files matched your search

+4 -3
View File
@@ -290,9 +290,10 @@ packages.
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
@@ -21,15 +21,6 @@ const REQUIRED_MEMBERSHIP_CATEGORIES = [
"ship_misses_add",
"sanitize_borderlines_ship",
];
const REQUIRED_DENYLIST_ARRAYS = [
"path_prefixes",
"seat_and_rig_patterns",
"host_patterns",
"charged_terms",
"frontmatter_drop_keys",
"internal_path_globs",
"allowed_context_substrings",
];
export async function generateControlPlaneJson({
repoRoot,
@@ -40,7 +31,6 @@ export async function generateControlPlaneJson({
}) {
const policyFiles = [
["product-public-skills.generated.json", membershipPath],
["internal-tokens.generated.json", denylistPath],
["skill-edge-layout.generated.json", layoutPath],
].map(([name, override]) => {
const source = resolve(repoRoot, "scripts", name);
@@ -49,16 +39,17 @@ export async function generateControlPlaneJson({
}
return { name, source };
});
[membershipPath, denylistPath, layoutPath] = policyFiles.map(({ source }) => source);
if (denylistPath !== undefined) {
throw new Error("The leak guard's term lists come from the local file OPENRIG_LEAK_GUARD_RULES names; --denylist is no longer supported.");
}
[membershipPath, layoutPath] = policyFiles.map(({ source }) => source);
const membership = readJson(membershipPath);
const denylist = readJson(denylistPath);
const layout = readJson(layoutPath);
validateMembership(membership, membershipPath);
validateDenylist(denylist, denylistPath);
const digests = buildEdgeDigests({ repoRoot, layout });
mkdirSync(outputDir, { recursive: true });
// These three files are authority, despite their historical .generated names.
// These two files are authority, despite their historical .generated names.
// Export exact bytes if requested; an in-place refresh never rewrites them.
for (const { name, source } of policyFiles) {
const destination = resolve(outputDir, name);
@@ -197,33 +188,6 @@ function validateMembership(value, sourcePath) {
}
}
function validateDenylist(value, sourcePath) {
if (!isObject(value)) invalid(sourcePath, "denylist must be an object");
for (const field of REQUIRED_DENYLIST_ARRAYS) {
if (Object.hasOwn(value, field) && !isStringArray(value[field])) {
invalid(sourcePath, `${field} must be an array`);
}
}
for (const field of REQUIRED_DENYLIST_ARRAYS) {
if (!isStringArray(value[field])) {
invalid(sourcePath, `${field} must be an array`);
}
}
if (Object.hasOwn(value, "allowed_context_lines") && (
!isStringArray(value.allowed_context_lines) ||
value.allowed_context_lines.some((line) => line === "" || /[\r\n]/.test(line))
)) {
invalid(sourcePath, "allowed_context_lines must be an array of nonempty single-line strings");
}
if (
!isObject(value.section_fence) ||
typeof value.section_fence.begin !== "string" ||
typeof value.section_fence.end !== "string"
) {
invalid(sourcePath, "section_fence.begin and section_fence.end are required");
}
}
function validateLayout(value, sourcePath) {
if (!isObject(value?.edges)) invalid(sourcePath, "edges must be an object");
const edgeNames = Object.keys(value.edges).sort();
@@ -44,7 +44,7 @@ describe("control-plane JSON generator", () => {
const root = tempRoot();
const input = seedGeneratorInput(root);
seedEdges(root);
const sourcePaths = [input.membershipPath, input.denylistPath, input.layoutPath];
const sourcePaths = [input.membershipPath, input.layoutPath];
const sourceBytes = sourcePaths.map((path) => readFileSync(path));
await generator.generateControlPlaneJson(input);
@@ -53,7 +53,6 @@ describe("control-plane JSON generator", () => {
expect(readGenerated(input.outputDir)).toEqual(first);
expect(sourcePaths.map((path) => readFileSync(path))).toEqual(sourceBytes);
expect(first.membership.product_public.clean).toEqual(["alpha"]);
expect(first.denylist.allowed_context_lines).toEqual([" Generic <path> placeholder"]);
expect(first.layout.skills.alpha).toEqual({ edges: ["canonical", "plugin", "spec"], category: "core" });
expect(first.digests.edges.plugin["alpha/SKILL.md"]).toBe(sha256("# Plugin alpha\n"));
});
@@ -177,12 +176,15 @@ describe("control-plane JSON generator", () => {
it("reports malformed product policy with its product path", async () => {
const generator = await loadGenerator();
const input = seedGeneratorInput(tempRoot());
const original = readFileSync(input.membershipPath);
write(input.membershipPath, JSON.stringify({ version: 0 }));
await expect(generator.generateControlPlaneJson(input)).rejects.toThrow(/product-public-skills\.generated\.json.*product_public/);
writeFileSync(input.membershipPath, original);
write(input.denylistPath, JSON.stringify({ charged_terms: "not-an-array" }));
await expect(generator.generateControlPlaneJson(input)).rejects.toThrow(/internal-tokens\.generated\.json.*charged_terms/);
});
it("refuses a denylist input: the leak guard's term lists come from a local file", async () => {
const generator = await loadGenerator();
const input = seedGeneratorInput(tempRoot());
await expect(generator.generateControlPlaneJson({ ...input, denylistPath: join(input.repoRoot, "denylist.json") }))
.rejects.toThrow(/OPENRIG_LEAK_GUARD_RULES/);
});
it("rejects edge file symlinks before digesting outside-root bytes", async () => {
@@ -207,18 +209,6 @@ describe("control-plane JSON generator", () => {
).toBe(false);
});
it("rejects malformed optional exact-line allowances", async () => {
const generator = await loadGenerator();
const root = tempRoot();
const input = seedGeneratorInput(root);
seedEdges(root);
const original = JSON.parse(readFileSync(input.denylistPath, "utf8"));
for (const value of [null, "text", [42], [""], ["two\nlines"], ["two\rlines"]]) {
write(input.denylistPath, JSON.stringify({ ...original, allowed_context_lines: value }));
await expect(generator.generateControlPlaneJson(input)).rejects.toThrow(/allowed_context_lines/);
}
});
it("rejects edge directory symlinks before traversal or digesting outside-root bytes", async () => {
const generator = await loadGenerator();
const root = tempRoot();
@@ -290,30 +280,22 @@ function seedEdges(root: string): void {
function seedGeneratorInput(root: string): {
repoRoot: string;
membershipPath: string;
denylistPath: string;
layoutPath: string;
outputDir: string;
} {
const scripts = join(root, "scripts");
const membershipPath = join(scripts, "product-public-skills.generated.json");
const denylistPath = join(scripts, "internal-tokens.generated.json");
const layoutPath = join(scripts, "skill-edge-layout.generated.json");
write(membershipPath, JSON.stringify({
version: 0,
product_public: { clean: ["alpha"], ship_after_fix: [], ship_misses_add: [], sanitize_borderlines_ship: [] },
vendored_ship_with_provenance: [], not_public: {}, pending_author_public: [],
}));
write(denylistPath, JSON.stringify({
version: 1, path_prefixes: [], seat_and_rig_patterns: [], host_patterns: [], charged_terms: [],
frontmatter_drop_keys: [], internal_path_globs: [],
section_fence: { begin: "<!-- internal:begin -->", end: "<!-- internal:end -->" },
allowed_context_substrings: [], allowed_context_lines: [" Generic <path> placeholder"],
}));
write(layoutPath, JSON.stringify({
version: 0, edges: edgeConfig(),
skills: { alpha: { edges: ["canonical", "plugin", "spec"], category: "core" } },
}));
return { repoRoot: root, membershipPath, denylistPath, layoutPath, outputDir: join(root, "export") };
return { repoRoot: root, membershipPath, layoutPath, outputDir: join(root, "export") };
}
function edgeConfig(): Record<string, unknown> {
@@ -336,19 +318,11 @@ function edgeConfig(): Record<string, unknown> {
function readGenerated(output: string): Record<string, any> {
return {
membership: readJson(join(output, "product-public-skills.generated.json")),
denylist: readJson(join(output, "internal-tokens.generated.json")),
layout: readJson(join(output, "skill-edge-layout.generated.json")),
digests: readJson(join(output, "skill-edge-digests.generated.json")),
};
}
it("the committed internal-token mirror covers the whole substrate shared-docs class", () => {
const generated = readJson(join(REPO_ROOT, "scripts/internal-tokens.generated.json"));
const legacyPrefix = ["code", "substrate", "shared-docs", ""].join("/");
expect(generated.path_prefixes).toContain("substrate/shared-docs/");
expect(generated.path_prefixes).not.toContain(legacyPrefix);
});
function readJson(path: string): any {
return JSON.parse(readFileSync(path, "utf8"));
}
+2 -2
View File
@@ -94,10 +94,10 @@ if [ -d "$DAEMON_DIR/specs" ]; then
# OPR.0.5.6.12 LP-7 — the specs tree is copied wholesale into the npm
# artifact, so scan the actual filesystem input (including untracked build
# inputs) before the copy. Path-safety and manifest validation solve other
# problems; this is the public-substance refusal floor.
# problems; this is the public-substance refusal floor. Its term lists come from
# the local file OPENRIG_LEAK_GUARD_RULES names, when set.
node "$REPO_ROOT/scripts/check-internal-leak-guard.mjs" \
--repo "$REPO_ROOT" \
--rules "$REPO_ROOT/scripts/internal-tokens.generated.json" \
--mode tree \
--tree "$DAEMON_DIR/specs"
cp -r "$DAEMON_DIR/specs" "$CLI_DIR/daemon/specs"
+8 -49
View File
@@ -7,10 +7,14 @@ import {
buildInternalLeakMessage,
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath, readTermRules } from "./leak-guard-rules.mjs";
export function main(argv = process.argv.slice(2)) {
const options = parseArguments(argv);
const rules = readRules(options.rules);
if (!options.rules) {
process.stderr.write(`No leak-guard rules file (--rules or ${LEAK_GUARD_RULES_ENV}): the term checks are skipped.\n`);
}
const rules = readTermRules(options.rules);
const files = selectFiles(options);
const findings = files.flatMap(({ path, bytes }) =>
scanInternalLeaks({ path, bytes, rules }),
@@ -42,8 +46,8 @@ function parseArguments(argv) {
}
values[key.slice(2)] = value;
}
if (!values.repo || !values.rules || !values.mode) {
throw new Error("--repo, --rules, and --mode are required");
if (!values.repo || !values.mode) {
throw new Error("--repo and --mode are required");
}
if (!["full", "staged", "range", "tree"].includes(values.mode)) {
throw new Error(`Unknown mode: ${values.mode}`);
@@ -60,25 +64,13 @@ function parseArguments(argv) {
return {
...values,
repo: resolve(values.repo),
rules: resolve(values.rules),
rules: values.rules ? resolve(values.rules) : leakGuardRulesPath(),
...(values.tree ? { tree: resolve(values.tree) } : {}),
...(values["files-manifest"] ? { filesManifest: resolve(values["files-manifest"]) } : {}),
...(values.report ? { report: resolve(values.report) } : {}),
};
}
function readRules(path) {
if (!existsSync(path)) throw new Error(`Rules file not found: ${path}`);
try {
const rules = JSON.parse(readFileSync(path, "utf8"));
validateRules(rules, path);
return rules;
} catch (error) {
if (error.message.startsWith(`Invalid rules file ${path}:`)) throw error;
throw new Error(`Invalid rules file ${path}: ${error.message}`);
}
}
function selectFiles(options) {
if (options.mode === "tree") {
if (!existsSync(options.tree)) {
@@ -196,39 +188,6 @@ function changedPathsAtCommit(repo, commit) {
].sort();
}
function validateRules(rules, path) {
if (rules === null || typeof rules !== "object" || Array.isArray(rules)) {
throw new Error(`Invalid rules file ${path}: rules must be an object`);
}
for (const field of [
"path_prefixes",
"seat_and_rig_patterns",
"host_patterns",
"charged_terms",
"internal_path_globs",
"allowed_context_substrings",
]) {
if (
!Array.isArray(rules[field]) ||
rules[field].length === 0 ||
rules[field].some((value) => typeof value !== "string" || value === "")
) {
throw new Error(
`Invalid rules file ${path}: ${field} must be a nonempty string array`,
);
}
}
if (Object.hasOwn(rules, "allowed_context_lines") && (
!Array.isArray(rules.allowed_context_lines) ||
rules.allowed_context_lines.some((value) =>
typeof value !== "string" || value === "" || /[\r\n]/.test(value))
)) {
throw new Error(
`Invalid rules file ${path}: allowed_context_lines must be an array of nonempty single-line strings`,
);
}
}
function git(repo, args) {
return execFileSync("git", args, { cwd: repo });
}
@@ -43,6 +43,33 @@ test("full mode passes a clean tracked tree and ignores untracked files", () =>
});
});
test("without a rules file the term checks are skipped with a note; the file the environment names turns them on", () => {
requireGuard();
withRepo((repo, rules) => {
writeFileSync(rules, JSON.stringify({ ...fixtureRules(), charged_terms: ["private-term"] }));
write(join(repo, "README.md"), "a private-term detail\n");
git(repo, "add", "README.md");
git(repo, "commit", "-m", "tracked term");
const env = { ...process.env };
delete env.OPENRIG_LEAK_GUARD_RULES;
const guard = (extra) => spawnSync(process.execPath, [GUARD, "--repo", repo, "--mode", "full"],
{ cwd: repo, encoding: "utf8", env: { ...env, ...extra } });
const skipped = guard({});
assert.equal(skipped.status, 0, skipped.stderr);
assert.match(skipped.stderr, /No leak-guard rules file .*the term checks are skipped/);
const named = guard({ OPENRIG_LEAK_GUARD_RULES: rules });
assert.notEqual(named.status, 0);
assert.match(named.stderr, /README\.md/);
assert.match(named.stderr, /private-term/);
const missing = guard({ OPENRIG_LEAK_GUARD_RULES: join(repo, "absent.json") });
assert.notEqual(missing.status, 0);
assert.match(missing.stderr, /Rules file not found/);
});
});
test("optional exact-line rules reach the full guard and malformed values are refused", () => {
withRepo((repo, rulesPath) => {
const line = "Generic operator-agent@<seat> example";
+7 -1
View File
@@ -18,6 +18,7 @@ import {
buildInternalLeakMessage,
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
@@ -136,7 +137,7 @@ function parseArguments(argv) {
return {
repo,
packageRoot,
rules: resolve(values.rules ?? join(repo, "scripts/internal-tokens.generated.json")),
rules: values.rules ? resolve(values.rules) : leakGuardRulesPath() ?? invalidRules(),
review: resolve(values.review),
receipt: resolve(values.receipt),
surfaceManifest: resolve(values["surface-manifest"] ?? join(packageRoot, "daemon/substance-surfaces.json")),
@@ -183,6 +184,11 @@ function partitionPackageArtifacts(artifactFiles) {
return { content, derived };
}
/** The release gate checks the leak guard's term lists, so it needs their file. */
function invalidRules() {
throw new Error(`--rules <file> or ${LEAK_GUARD_RULES_ENV} is required: the substance gate checks the leak guard's term lists`);
}
function selectDerivedRules(rules) {
return {
path_prefixes: (rules.path_prefixes ?? []).filter((entry) =>
+30 -4
View File
@@ -69,6 +69,33 @@ test("the content scan executes the named full-mode guard with the artifact boun
}
});
test("the substance gate needs the leak guard's term lists: --rules or the file the environment names", () => {
withFixture(({ repo, rules, review, receipt }) => {
const publicFile = join(repo, "public/guide.md");
write(publicFile, "# Public guide\nGeneric user-facing guidance.\n");
commitAll(repo, "public surface");
const cutSha = gitHead(repo);
writeFileSync(review, JSON.stringify({
surfaces: [{ path: "public/guide.md", sha256: sha256(publicFile), verdict: "ship",
reason: "Generic product guidance with no instance dependency.", candidateDispositions: [] }],
}));
const env = { ...process.env };
delete env.OPENRIG_LEAK_GUARD_RULES;
const gate = (extra) => spawnSync(process.execPath, [GATE, "--repo", repo, "--package-root", repo,
"--surface-manifest", join(repo, "substance-surfaces.json"), "--review", review, "--receipt", receipt,
"--judge", "dev-qa@fixture", "--cut-sha", cutSha], { encoding: "utf8", env: { ...env, ...extra } });
const refused = gate({});
assert.notEqual(refused.status, 0);
assert.match(refused.stderr, /--rules <file> or OPENRIG_LEAK_GUARD_RULES is required/);
assert.equal(existsSync(receipt), false);
const named = gate({ OPENRIG_LEAK_GUARD_RULES: rules });
assert.equal(named.status, 0, named.stderr);
assert.equal(JSON.parse(readFileSync(receipt, "utf8")).gate, "substance gate");
});
});
test("the named substance gate writes a cut-bound, per-surface receipt and records the full scan", () => {
withFixture(({ repo, rules, review, receipt }) => {
const publicFile = join(repo, "public/guide.md");
@@ -393,10 +420,9 @@ function writeGateHarness(root, source, invocationPath) {
mkdirSync(root, { recursive: true });
const gate = join(root, "check-substance-gate.mjs");
writeFileSync(gate, source);
writeFileSync(
join(root, "internal-leak-scanner.mjs"),
readFileSync(resolve("scripts/internal-leak-scanner.mjs")),
);
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
}
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
"import { readFileSync, writeFileSync } from \"node:fs\";",
`const invocationPath = ${JSON.stringify(invocationPath)};`,
+6 -9
View File
@@ -31,6 +31,7 @@ import { parse as parseYaml } from "yaml";
// two never drift (r1/r2 HIGH-1: a narrower rule dropped referenced helper assets).
import { EXCLUDES as MIRROR_EXCLUDES, shipSetFromMembership } from "./mirror-skills.mjs";
import { scanInternalLeaks, buildInternalLeakMessage } from "./internal-leak-scanner.mjs";
import { readTermRules } from "./leak-guard-rules.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "..");
@@ -51,15 +52,11 @@ const STATIC_SOURCE = process.env.OPENRIG_STATIC_PACKS_SOURCE
? path.resolve(process.env.OPENRIG_STATIC_PACKS_SOURCE)
: path.join(REPO, "packages/daemon/context-packs-src");
// Static pack content is scanned with the FULL committed leak authority —
// scripts/internal-tokens.generated.json, the same generated policy the public
// mirror flow consumes (charged terms, internal path prefixes, seat/rig and
// host identities, internal path globs, allow-context exceptions). One
// authority, never a local subset: a narrowed local list false-greens exactly
// the internal paths it omits (review50-r2 BLOCKING-2).
const STATIC_LEAK_RULES = JSON.parse(
fs.readFileSync(path.join(REPO, "scripts/internal-tokens.generated.json"), "utf8"),
);
// Static pack content is scanned with the leak guard's full term lists, the same
// lists the public mirror flow consumes: one authority, never a local subset, since a
// narrowed list false-greens exactly the paths it omits. They come from the local
// file OPENRIG_LEAK_GUARD_RULES names; without one the lists are empty.
const STATIC_LEAK_RULES = readTermRules();
// Reuse the DAEMON's manifest parser (the single authority; the entry-shape
// ruling forbids a second parser). It lives in compiled dist, which the package
+19 -9
View File
@@ -1,5 +1,5 @@
// OPR.0.5.3.7 R2 — tests for the package-time context-pack generator.
import test from "node:test";
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync, symlinkSync, lstatSync } from "node:fs";
@@ -18,6 +18,15 @@ const { EXCLUDES } = await import(pathToFileURL(join(HERE, "mirror-skills.mjs"))
const REAL_SKILLS = join(REPO, "packages/daemon/specs/agents/shared/skills");
const REAL_PLUGIN_SKILLS = join(REPO, "packages/daemon/assets/plugins/openrig-core/skills");
const REAL_STATIC_PACKS = join(REPO, "packages/daemon/context-packs-src");
// The leak guard's term lists live outside the repository: the static-pack runs name a fixture file holding the terms
// these tests plant.
const LEAK_RULES_DIR = mkdtempSync(join(tmpdir(), "packs-leak-rules-"));
const LEAK_RULES = join(LEAK_RULES_DIR, "rules.json");
writeFileSync(LEAK_RULES, JSON.stringify({
path_prefixes: ["private-root/"], seat_and_rig_patterns: ["@private-rig"], host_patterns: ["private-host"],
charged_terms: ["private-term"], internal_path_globs: ["**/internal/**"], allowed_context_substrings: ["do not ship"],
}));
after(() => rmSync(LEAK_RULES_DIR, { recursive: true, force: true }));
// Independent computation of the mirror's exclude-only ship set on a real tree —
// the discriminator that catches any narrowing of the projection (r2 HIGH-1).
@@ -281,6 +290,7 @@ function runWithStatic(source, staticSource, out, args = []) {
...process.env,
OPENRIG_SKILLS_SOURCE: source,
OPENRIG_STATIC_PACKS_SOURCE: staticSource,
OPENRIG_LEAK_GUARD_RULES: LEAK_RULES,
OPENRIG_PACKS_OUT: out,
OPENRIG_PACKAGE_VERSION: "0.5.3",
},
@@ -290,7 +300,7 @@ function runWithStatic(source, staticSource, out, args = []) {
function staticWorldPack(root, rel, { withCharged = false } = {}) {
const dir = join(root, rel);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "a.md"), `# A\n\nalpha body${withCharged ? " for the founder" : ""}\n`);
writeFileSync(join(dir, "a.md"), `# A\n\nalpha body${withCharged ? " for the private-term" : ""}\n`);
writeFileSync(join(dir, "b.md"), "# B\n\nbeta body\n");
writeFileSync(
join(dir, "manifest.yaml"),
@@ -358,11 +368,11 @@ function expectStaticLeakFailure(base, mutate, label) {
assert.ok(failed, `${label}: must fail the build`);
}
test("STATIC PACK LEAK GUARD runs the FULL committed authority: charged term, path prefix, AND seat/rig identity each FAIL THE BUILD", () => {
test("STATIC PACK LEAK GUARD runs the full term lists: charged term, path prefix, AND seat/rig identity each FAIL THE BUILD", () => {
for (const [label, content] of [
["charged term", "# A\n\nalpha body for the founder\n"],
["internal path prefix", "# A\n\nsee openrig-work/skills for the library\n"],
["instance seat/rig identity", "# A\n\nroute it to dev50@v-openrig-build when done\n"],
["charged term", "# A\n\nalpha body for the private-term\n"],
["internal path prefix", "# A\n\nsee private-root/skills for the library\n"],
["instance seat/rig identity", "# A\n\nroute it to dev50@private-rig when done\n"],
]) {
const base = mkdtempSync(join(tmpdir(), "s05-leak-"));
try {
@@ -378,7 +388,7 @@ test("STATIC PACK SIDECARS are part of the leak boundary regardless of suffix",
try {
expectStaticLeakFailure(
base,
(dir) => writeFileSync(join(dir, "PROVENANCE.yaml"), "source: openrig-work/rigs/private\n"),
(dir) => writeFileSync(join(dir, "PROVENANCE.yaml"), "source: private-root/rigs/private\n"),
"renamed provenance sidecar",
);
} finally {
@@ -396,7 +406,7 @@ test("STATIC SOURCE provenance sidecars cannot hide beside, rather than inside,
staticWorldPack(staticSrc, "world/install");
writeFileSync(
join(staticSrc, "PROVENANCE-world-install.yaml"),
"source: openrig-work/rigs/private\n",
"source: private-root/rigs/private\n",
);
let failure;
@@ -408,7 +418,7 @@ test("STATIC SOURCE provenance sidecars cannot hide beside, rather than inside,
assert.ok(failure, "a root-level provenance sidecar must be inside the scanned boundary");
assert.equal(failure.status, 1);
assert.match(String(failure.stderr), /PROVENANCE-world-install\.yaml/);
assert.match(String(failure.stderr), /openrig-work\//i);
assert.match(String(failure.stderr), /private-root\//i);
assert.ok(!existsSync(out), "no projection may be written after provenance refusal");
} finally {
rmSync(base, { recursive: true, force: true });
+13 -8
View File
@@ -113,16 +113,14 @@ test("exact-line allowances preserve modified lines, adjacent findings and inter
assert.equal(scan(line, "skills/internal/SKILL.md")[0].kind, "path");
});
test("generated rules allow the shipped compaction template and still detect concrete paths", async () => {
test("an allowed exact line lets the shipped compaction template through, and concrete paths are still found", async () => {
const { scanInternalLeaks } = await loadScanner();
const rules = JSON.parse(readFileSync(new URL("./internal-tokens.generated.json", import.meta.url), "utf8"));
const rules = allowedLineRules(COMPACTION_TEMPLATE);
const bytes = readFileSync(new URL(
"../packages/daemon/assets/plugins/openrig-core/skills/claude-compaction-restore/SKILL.md",
import.meta.url,
));
assert.ok(bytes.toString("utf8").split("\n").includes(COMPACTION_TEMPLATE));
assert.ok(rules.allowed_context_lines.includes(COMPACTION_TEMPLATE));
assert.ok(rules.path_prefixes.includes(".openrig/compaction/"));
for (const path of [
"daemon/assets/plugins/openrig-core/skills/claude-compaction-restore/SKILL.md",
"daemon/context-packs/skills/claude-compaction-restore/SKILL.md",
@@ -135,14 +133,12 @@ test("generated rules allow the shipped compaction template and still detect con
}
});
test("generated rules allow the startup guide's preparation-root sentence and nothing derived from it", async () => {
test("an allowed exact line lets the startup guide's preparation-root sentence through, and nothing derived from it", async () => {
const { scanInternalLeaks } = await loadScanner();
const rules = JSON.parse(readFileSync(new URL("./internal-tokens.generated.json", import.meta.url), "utf8"));
const rules = allowedLineRules(STARTUP_GUIDE_LINE);
const bytes = readFileSync(new URL("../docs/reference/agent-startup-guide.md", import.meta.url));
const text = bytes.toString("utf8");
assert.ok(text.split("\n").includes(STARTUP_GUIDE_LINE));
assert.ok(rules.allowed_context_lines.includes(STARTUP_GUIDE_LINE));
assert.ok(rules.path_prefixes.includes(".openrig/compaction/"));
for (const path of ["docs/reference/agent-startup-guide.md", "daemon/docs/reference/agent-startup-guide.md"]) {
assert.deepEqual(scanInternalLeaks({ path, bytes, rules }), []);
for (const modified of [
@@ -183,6 +179,15 @@ async function loadScanner() {
return scanner;
}
/** The term lists live outside the repository, so a test writes the one rule it needs: the compaction prefix, with one
* exact line allowed. */
function allowedLineRules(line) {
return {
path_prefixes: [".openrig/compaction/"], seat_and_rig_patterns: [], host_patterns: [], charged_terms: [],
internal_path_globs: [], allowed_context_substrings: [], allowed_context_lines: [line],
};
}
function fixtureRules() {
return {
path_prefixes: ["openrig-work/"],
-74
View File
@@ -1,74 +0,0 @@
{
"version": 1,
"path_prefixes": [
"openrig-work/",
"openrig-internal/",
"/Users/wrandom/",
"~/code/substrate/",
"substrate/shared-docs/",
".openrig/artifacts/",
".openrig/compaction/",
".openrig/backups/",
".openrig/agent-images/",
".openrig/secrets/"
],
"seat_and_rig_patterns": [
"@skill-library",
"@kernel",
"@openrig-pm",
"@openrig-studio",
"@openrig-comms",
"@v-openrig-build",
"operator-agent@",
"pm-lead@",
"skills-architect@",
"skills-analyst@",
"openrig-velocity",
"redo-driver",
"dev1-guard",
"dev-driver3"
],
"host_patterns": [
"mm2-",
"ovh1-",
"linkpix",
"vm-claude-test"
],
"charged_terms": [
"founder",
"founder-owned host",
"one open trust domain",
"default-open",
"bomb on this host",
"the unforgivable sin"
],
"frontmatter_drop_keys": [
"source_evidence",
"curation_note",
"content_curator",
"transfer_test",
"transfer_test_notes",
"naming_note",
"last_verified",
"structure"
],
"internal_path_globs": [
"*.internal.*",
"**/internal/**",
"*-internal/**"
],
"section_fence": {
"begin": "<!-- internal:begin -->",
"end": "<!-- internal:end -->"
},
"allowed_context_substrings": [
"deprecated",
"defunct",
"do not ship",
"internal-only (example)"
],
"allowed_context_lines": [
" `<launch cwd>/.openrig/compaction/preparation/<session>/<attempt>/RESTORE-MAP.md`; its parent",
"OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when"
]
}
+85
View File
@@ -0,0 +1,85 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
/** The leak guard's term lists come from an optional local JSON file named by this variable. Without one, every list is
* empty: the term checks find nothing, and every other check runs as before. */
export const LEAK_GUARD_RULES_ENV = "OPENRIG_LEAK_GUARD_RULES";
/** The term lists such a file holds. A file that holds any must hold them all, each a nonempty string array, except the
* optional allowed_context_lines. */
export const TERM_LISTS = [
"path_prefixes",
"seat_and_rig_patterns",
"host_patterns",
"charged_terms",
"internal_path_globs",
"allowed_context_substrings",
"allowed_context_lines",
];
/** How a skill is published: the frontmatter keys it drops and the fence around the sections it strips. */
export const PUBLISHING_RULES = {
frontmatter_drop_keys: [
"source_evidence",
"curation_note",
"content_curator",
"transfer_test",
"transfer_test_notes",
"naming_note",
"last_verified",
"structure",
],
section_fence: { begin: "<!-- internal:begin -->", end: "<!-- internal:end -->" },
};
/** The rules file the environment names, or null. */
export function leakGuardRulesPath(env = process.env) {
const path = env[LEAK_GUARD_RULES_ENV]?.trim();
return path ? resolve(path) : null;
}
/** The term lists from `path`, or empty lists when no file is named. A named file that is missing or invalid throws: it
* is never read as empty. */
export function readTermRules(path = leakGuardRulesPath()) {
const terms = Object.fromEntries(TERM_LISTS.map((key) => [key, []]));
if (!path) return terms;
if (!existsSync(path)) throw new Error(`Rules file not found: ${path}`);
let rules;
try {
rules = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
throw new Error(`Invalid rules file ${path}: ${error.message}`);
}
validateTermRules(rules, path);
for (const key of TERM_LISTS) if (Object.hasOwn(rules, key)) terms[key] = rules[key];
return terms;
}
/** What the skill mirror applies: the publishing rules and the term lists. */
export function readMirrorRules(path = leakGuardRulesPath()) {
return { ...PUBLISHING_RULES, ...readTermRules(path) };
}
export function validateTermRules(rules, path) {
if (rules === null || typeof rules !== "object" || Array.isArray(rules)) {
throw new Error(`Invalid rules file ${path}: rules must be an object`);
}
for (const field of TERM_LISTS.filter((key) => key !== "allowed_context_lines")) {
if (
!Array.isArray(rules[field]) ||
rules[field].length === 0 ||
rules[field].some((value) => typeof value !== "string" || value === "")
) {
throw new Error(`Invalid rules file ${path}: ${field} must be a nonempty string array`);
}
}
if (Object.hasOwn(rules, "allowed_context_lines") && (
!Array.isArray(rules.allowed_context_lines) ||
rules.allowed_context_lines.some((value) =>
typeof value !== "string" || value === "" || /[\r\n]/.test(value))
)) {
throw new Error(
`Invalid rules file ${path}: allowed_context_lines must be an array of nonempty single-line strings`,
);
}
}
+4 -3
View File
@@ -18,6 +18,7 @@ import {
buildInternalLeakMessage,
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { readMirrorRules } from "./leak-guard-rules.mjs";
// Each shipped skill has one editable home in this repo: its copy under
// packages/daemon/specs/agents/shared/skills/ when it has a spec edge, otherwise its copy under
@@ -571,7 +572,7 @@ function readGeneratedCheckInputs() {
layout: readJson(join(repoRoot, "scripts/skill-edge-layout.generated.json")),
digests: readJson(join(repoRoot, "scripts/skill-edge-digests.generated.json")),
membership: readJson(join(repoRoot, "scripts/product-public-skills.generated.json")),
rules: readJson(join(repoRoot, "scripts/internal-tokens.generated.json")),
rules: readMirrorRules(),
};
}
@@ -581,7 +582,7 @@ function readJson(path) {
function defaultGenerateControlPlaneJson() {
const repoRoot = process.cwd();
// Membership, rules and layout come from the product JSON; old YAML selectors are ignored.
// Membership and layout come from the product JSON; old YAML selectors are ignored.
execFileSync(
process.execPath,
[
@@ -607,7 +608,7 @@ function defaultReadAuthoringInputs() {
membership: readJson(
join(repoRoot, "scripts/product-public-skills.generated.json"),
),
rules: readJson(join(repoRoot, "scripts/internal-tokens.generated.json")),
rules: readMirrorRules(),
layout: readJson(
join(repoRoot, "scripts/skill-edge-layout.generated.json"),
),
+6 -4
View File
@@ -7,7 +7,7 @@ import { dirname, join, resolve } from "node:path";
const GENERATOR = resolve("packages/daemon/scripts/gen-control-plane-json.mjs");
const MIRROR = resolve("scripts/mirror-skills.mjs");
const NAMES = ["product-public-skills", "internal-tokens", "skill-edge-layout"];
const NAMES = ["product-public-skills", "skill-edge-layout"];
test("regeneration preserves product policy bytes in place and when exporting", () => {
withFixture(({ root, policyBytes }) => {
@@ -35,12 +35,14 @@ test("legacy YAML inputs refuse with product pointers before changing policy fil
for (const name of NAMES) {
assert.deepEqual(readFileSync(join(root, "scripts", `${name}.generated.json`)), policyBytes[name]);
}
for (const [index, flag] of ["membership", "denylist", "layout"].entries()) {
// The leak guard's term lists left the repository: a denylist override points at their local file instead.
for (const [flag, named] of [["membership", "product-public-skills.generated.json"], ["denylist", "OPENRIG_LEAK_GUARD_RULES"],
["layout", "skill-edge-layout.generated.json"]]) {
const single = run(GENERATOR, root, [
"--repo-root", root, "--output", join(root, "scripts"), `--${flag}`, legacy[flag],
]);
assert.notEqual(single.status, 0);
assert.ok(single.stderr.includes(`${NAMES[index]}.generated.json`), single.stderr);
assert.ok(single.stderr.includes(named), single.stderr);
for (const name of NAMES) {
assert.deepEqual(readFileSync(join(root, "scripts", `${name}.generated.json`)), policyBytes[name]);
}
@@ -105,7 +107,7 @@ function withFixture(check) {
};
const layout = { version: 0, edges, skills: { alpha: { edges: ["plugin"], category: null } } };
const policyBytes = {};
for (const [i, data] of [membership, rules, layout].entries()) {
for (const [i, data] of [membership, layout].entries()) {
// Deliberately nonstandard formatting must survive a refresh byte for byte.
policyBytes[NAMES[i]] = Buffer.from(JSON.stringify(data, null, 4) + "\n\n");
write(join(root, "scripts", `${NAMES[i]}.generated.json`), policyBytes[NAMES[i]]);
+2 -2
View File
@@ -68,8 +68,8 @@ generated copy; edit the home and re-run the script before commit.
A home must already be publishable: the script refuses to regenerate when a
home holds text or files that the public copies would drop, such as an
internal fence, a frontmatter key listed in
`scripts/internal-tokens.generated.json`, a `feedback.md`, or an `evals/`
internal fence, a frontmatter key the publishing rules drop
(`scripts/leak-guard-rules.mjs`), a `feedback.md`, or an `evals/`
folder. A skill change from anywhere else comes in as a normal pull request
that edits its home.