Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 109 additions & 109 deletions dist/shared/index.js

Large diffs are not rendered by default.

31 changes: 14 additions & 17 deletions scripts/check-commit-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,24 @@ if (!/^[0-9a-f]{40}$/u.test(base) || !/^[0-9a-f]{40}$/u.test(head)) {
}

const root = repositoryRoot();
const result = git(["log", "-z", "--format=%s", `${base}..${head}`], root);
const result = git(["log", "-z", "--format=%H%n%B", `${base}..${head}`], root);
if (result.exitCode !== 0) {
console.error(result.stderr.trim());
console.error("Cannot inspect contribution commit messages");
process.exit(result.exitCode);
}

const errors: string[] = [];
const messages = git(["log", "-z", "--format=%B", `${base}..${head}`], root);
if (messages.exitCode !== 0) {
console.error("Cannot inspect contribution commit messages");
process.exit(1);
}
try {
for (const message of nullSeparated(messages.stdout)) assertPublicMessage(message);
} catch {
console.error("Publication refused: unsafe contribution commit message");
process.exit(1);
}
for (const subject of nullSeparated(result.stdout)) {
const validation = validateCommitSubject(subject);
if (!validation.valid) errors.push(validation.reason);
for (const entry of nullSeparated(result.stdout)) {
const separator = entry.indexOf("\n");
const commit = entry.slice(0, separator);
const message = entry.slice(separator + 1);
try {
assertPublicMessage(message);
} catch {
errors.push(`${commit.slice(0, 12)}: unsafe contribution commit message`);
continue;
}
const validation = validateCommitSubject(message);
if (!validation.valid) errors.push(`${commit.slice(0, 12)}: ${validation.reason}`);
}
if (errors.length > 0) {
console.error(errors.map((error) => `- ${error}`).join("\n"));
Expand Down
58 changes: 44 additions & 14 deletions scripts/check-publication.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { execFileSync } from "node:child_process";
import { lstatSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { assertPublicMessage, publicationIssues } from "../src/publication-safety.ts";

export function trackedPublicationIssues(
Expand All @@ -19,34 +19,64 @@ export function trackedPublicationIssues(
throw new Error("Publication refused: cannot read tracked Git content");
}
};
const names = git(
revision && revision !== ":"
? ["ls-tree", "-r", "--name-only", "-z", revision]
: ["ls-files", "-z"],
const records = git(
revision && revision !== ":" ? ["ls-tree", "-r", "-z", revision] : ["ls-files", "-s", "-z"],
)
.toString()
.split("\0")
.filter(Boolean);
return names.flatMap((path) => {
const entries = new Map(
records.map((entry) => {
const separator = entry.indexOf("\t");
return [entry.slice(separator + 1), entry.slice(0, separator).split(" ")[0]] as const;
}),
);
return [...entries].flatMap(([path, mode]) => {
const full = join(directory, path);
let body: Buffer;
if (revision) body = git(["show", `${revision === ":" ? "" : revision}:${path}`]);
else {
if (revision) {
body = git(["show", `${revision === ":" ? "" : revision}:${path}`]);
if (mode === "120000") {
try {
const target = realpathSync(resolve(directory, dirname(path), body.toString("utf8")));
if (
path.startsWith(".claude/skills/") &&
target.startsWith(`${resolve(directory, ".agents/skills")}/`)
)
return [];
} catch {
/* Dangling aliases are refused. */
}
return [{ path, rule: "symlink", line: 1 }];
}
if (mode !== "100644" && mode !== "100755")
return [{ path, rule: "irregular-entry", line: 1 }];
} else {
let stat: ReturnType<typeof lstatSync>;
try {
const stat = lstatSync(full);
if (stat.isSymbolicLink()) {
stat = lstatSync(full);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw new Error("Publication refused: cannot inspect tracked entry");
}
if (stat.isSymbolicLink()) {
try {
const target = realpathSync(full);
if (
path.startsWith(".claude/skills/") &&
target.startsWith(`${resolve(directory, ".agents/skills")}/`)
)
return [];
return [{ path, rule: "symlink", line: 1 }];
} catch {
/* Dangling aliases are refused. */
}
return [{ path, rule: "symlink", line: 1 }];
}
if (!stat.isFile()) return [{ path, rule: "irregular-entry", line: 1 }];
try {
body = readFileSync(full);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
} catch {
throw new Error("Publication refused: cannot read tracked file");
}
}
// Binary metadata is included; a tracked path is never skipped because it is ignored.
Expand Down
6 changes: 3 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { type Installation, installRootform } from "./install.ts";
import { containedInput } from "./paths.ts";
import { runPreparation } from "./preparation.ts";
import { assertPublicMessage } from "./publication-safety.ts";
import { assertReportMessage } from "./publication-safety.ts";
import {
type CommentResult,
type GitHubContext,
Expand Down Expand Up @@ -327,7 +327,7 @@ export async function main(
if (!context.pullRequest?.sameRepository || context.eventName !== "pull_request")
commentState = "skipped (not an eligible same-repository pull_request)";
else {
assertPublicMessage(report(60_000));
assertReportMessage(report(60_000));
const result = await dependencies.comment({
token,
identity: context.pullRequest,
Expand All @@ -345,7 +345,7 @@ export async function main(
if (summary) {
try {
const publicReport = report();
assertPublicMessage(publicReport);
assertReportMessage(publicReport);
await actionCore.summary.addRaw(publicReport).write();
} catch (error) {
failures.push(error);
Expand Down
20 changes: 20 additions & 0 deletions src/publication-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { assertPublicMessage, assertReportMessage } from "./publication-safety.ts";

test("consumer evidence keeps user paths and legitimate model terminology", () => {
const value = `AI agent follows a public spec. Local output: /${"Users/fictional/output"}`;
expect(() => assertReportMessage(value, "example/project")).not.toThrow();
expect(() => assertReportMessage(value, "rootform-dev/rootform")).toThrow("personal-path");
expect(() => assertPublicMessage(value)).toThrow("personal-path");
});

test("consumer reports and maintainer messages both refuse synthetic credentials", () => {
for (const value of [
"AKIA" + "Z".repeat(16),
"xoxb" + "-SYNTHETIC".repeat(6),
"Bearer " + "SYNTHETIC".repeat(8),
]) {
expect(() => assertReportMessage({ message: value })).toThrow("credential");
expect(() => assertPublicMessage(value)).toThrow("credential");
}
});
31 changes: 25 additions & 6 deletions src/publication-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ const rules: Array<[string, RegExp]> = [
],
[
"credential",
/BEGIN (?:RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|(?:github_pat_|ghp_)[A-Za-z0-9_]{12,}/gu,
],
[
"private-workspace",
/(?:rootform-dev\/)?notes\/(?:public-separation|candidate-|ci-budget|operator-preferences|agentic-delivery|editor-qualification|site-quality|language-learning)/gu,
/BEGIN (?:RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|(?:github_pat_|gh[pousr]_|npm_)[A-Za-z0-9_]{12,}|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|xox[abprs]-[A-Za-z0-9-]{12,}|AIza[A-Za-z0-9_-]{35}|\bBearer\s+[A-Za-z0-9._~+/-]{24,}=*|\b(?:CLOUDFLARE_API_TOKEN|AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|API_TOKEN|API_KEY)\s*[:=]\s*["\x27]?[A-Za-z0-9_+./~-]{24,}/gu,
],
["private-workspace", /\b(?:rootform-dev\/)?notes\/[A-Za-z0-9][A-Za-z0-9._/-]*/gu],
[
"session-instructions",
/^\s*(?:#+\s*)?(?:system prompt|agent handoff|session report|rapport de session|instructions? (?:to|aux|pour) (?:models?|agents?)|model routing)\b|(?:Fable\s*5\.1|DeepSeek[- ]V4|Claude[- ](?:Opus|Fable)).{0,50}(?:review|revue|Max|max|instructions?)/gimu,
/^\s*(?:#+\s*)?(?:system prompt|agent handoff|session report|rapport de session|instructions? (?:to|aux|pour) (?:models?|agents?)|model routing)\b|\b[A-Z][A-Za-z0-9_-]*[- ]V?\d+\.\d+\s+(?:Max|Ultra)\b.{0,40}\b(?:review|revue|instructions?)\b|\b(?:model routing|review with|revue avec)\s*[:=]/gimu,
],
[
"private-activity",
Expand Down Expand Up @@ -44,3 +41,25 @@ export function assertPublicMessage(value: unknown): void {
for (const item of Object.values(value)) assertPublicMessage(item);
}
}

/** Consumer reports may contain user-authored model data and local paths.
* Credential-shaped values are still refused before any public write. */
export function assertReportMessage(
value: unknown,
repository = process.env.GITHUB_REPOSITORY ?? "",
): void {
if (
["rootform-dev/rootform", "rootform-dev/action", "rootform-dev/editors"].includes(repository)
) {
assertPublicMessage(value);
return;
}
if (typeof value === "string") {
if (publicationIssues(value).some((issue) => issue.rule === "credential"))
throw new Error("Public report refused: credential");
} else if (Array.isArray(value)) {
for (const item of value) assertReportMessage(item, repository);
} else if (value !== null && typeof value === "object") {
for (const item of Object.values(value)) assertReportMessage(item, repository);
}
}
6 changes: 3 additions & 3 deletions src/pull-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ const identity = {
};
const stamp = "<!-- rootform-run:20:10:100:1:2026-10-02T10:00:00Z -->";

test("synthetic private report content is refused before any request", async () => {
test("synthetic credentials are refused before any report request", async () => {
let requests = 0;
await expect(
upsertPullRequestComment({
body: "/" + "Users/fictional/private-report",
body: "github_" + "pat_" + "SYNTHETIC".repeat(8),
identity,
token: "synthetic-test-token",
runId: "100",
Expand All @@ -28,7 +28,7 @@ test("synthetic private report content is refused before any request", async ()
return Response.json({});
},
}),
).rejects.toThrow("Public message refused: personal-path");
).rejects.toThrow("Public report refused: credential");
expect(requests).toBe(0);
});
function github(
Expand Down
4 changes: 2 additions & 2 deletions src/pull-request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import type { FetchLike } from "./github.ts";
import { assertPublicMessage } from "./publication-safety.ts";
import { assertReportMessage } from "./publication-safety.ts";
import { REPORT_MARKER } from "./report.ts";

const API_VERSION = "2026-03-10";
Expand Down Expand Up @@ -189,7 +189,7 @@ export async function upsertPullRequestComment(options: {
runId?: string;
runAttempt?: string;
}): Promise<CommentResult> {
assertPublicMessage(options.body);
assertReportMessage(options.body, options.identity.repository);
if (!options.token) throw new Error("github-token is required when comment is enabled");
if (!options.identity.sameRepository) {
throw new Error("pull-request comments are disabled for fork pull requests");
Expand Down
Loading