mirror of
https://github.com/Heretek-AI/openrig.git
synced 2026-11-07 02:56:00 -10:00
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:
1 parent
7d809d5456
commit
c4de1b2c38
16 files changed
+227
-244
No files matched your search
+4
-3
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)};`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/"],
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user