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
4 changes: 4 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd "$(git rev-parse --show-toplevel)"
bun scripts/check-publication.ts --message-file "$1"
5 changes: 5 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd "$(git rev-parse --show-toplevel)"
bun scripts/check-publication.ts --staged
gitleaks git --pre-commit --staged --no-banner --redact --config .gitleaks.toml .
19 changes: 19 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/sh
set -eu
cd "$(git rev-parse --show-toplevel)"
bun scripts/check-publication.ts
while read -r local_ref local_sha remote_ref remote_sha; do
[ "$local_sha" != 0000000000000000000000000000000000000000 ] || continue
if [ "$remote_sha" = 0000000000000000000000000000000000000000 ]; then
if git rev-parse --verify --quiet origin/dev >/dev/null; then
base=$(git merge-base origin/dev "$local_sha")
range="$base..$local_sha"
else
range="$local_sha"
fi
else
range="$remote_sha..$local_sha"
fi
bun scripts/check-publication.ts --range "$range"
gitleaks git --no-banner --redact --config .gitleaks.toml --log-opts="$range" .
done
7 changes: 7 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Change

Describe the problem and resulting behavior.

## Validation

Commands run and relevant results. State any missing proof.
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.0
- name: Validate public contribution text
env:
PUBLIC_TITLE: ${{ github.event.pull_request.title }}
PUBLIC_BODY: ${{ github.event.pull_request.body }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: bun scripts/check-publication.ts --metadata-env --range "$BASE_SHA..HEAD"

- name: Install verified Gitleaks
env:
GITLEAKS_ARCHIVE: gitleaks_8.30.1_linux_x64.tar.gz
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
GITLEAKS_VERSION: 8.30.1
run: |
set -euo pipefail
curl --fail --silent --show-error --location \
--output "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" \
"https://github.com/gitleaks/gitleaks/releases/download/v$GITLEAKS_VERSION/$GITLEAKS_ARCHIVE"
printf '%s %s\n' "$GITLEAKS_SHA256" "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" | sha256sum --check --strict
tar --extract --gzip --file "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" --directory "$RUNNER_TEMP" gitleaks
printf '%s\n' "$RUNNER_TEMP" >> "$GITHUB_PATH"

- name: Prepare pinned Rust toolchain
run: rustup toolchain install 1.90 --profile minimal --component rustfmt --target wasm32-wasip2
- run: bun install --frozen-lockfile
Expand Down
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
title = "Rootform editor secret scanning"

[extend]
useDefault = true
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ Read `docs/editor-contract.md` and the affected editor package before editing.

Local development evidence stays outside the public repository. Do not commit
private implementation, project paths, inputs, credentials or operator notes.

Configure `git config core.hooksPath .githooks` before contributing. Validate
public messages before sending; diagnostics never reproduce sensitive values.
100 changes: 100 additions & 0 deletions scripts/check-publication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bun
import { execFileSync } from "node:child_process";
import { lstatSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { assertPublicMessage, publicationIssues } from "./publication-safety.ts";

export function trackedPublicationIssues(
directory: string,
revision?: string,
): Array<{ path: string; rule: string; line: number }> {
const git = (args: string[]) => {
try {
return execFileSync("git", args, {
cwd: directory,
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
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"],
)
.toString()
.split("\0")
.filter(Boolean);
return names.flatMap((path) => {
const full = join(directory, path);
let body: Buffer;
if (revision) body = git(["show", `${revision === ":" ? "" : revision}:${path}`]);
else {
try {
const stat = lstatSync(full);
if (stat.isSymbolicLink()) return [{ path, rule: "symlink", line: 1 }];
body = readFileSync(full);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
// Binary metadata is included; a tracked path is never skipped because it is ignored.
return publicationIssues(body.toString("utf8")).map((issue) => ({ path, ...issue }));
});
}

export function checkPublication(args: string[]): void {
const option = (name: string) => {
const index = args.indexOf(name);
return index < 0 ? undefined : args[index + 1];
};
const directory = resolve(option("--repo") ?? process.cwd());
const git = (args: string[]) => {
try {
return execFileSync("git", args, {
cwd: directory,
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}).toString();
} catch {
throw new Error("Publication refused: cannot inspect Git history");
}
};
const message = option("--message-file");
if (message) assertPublicMessage(readFileSync(message, "utf8"));
if (args.includes("--metadata-env"))
assertPublicMessage([process.env.PUBLIC_TITLE ?? "", process.env.PUBLIC_BODY ?? ""]);
const issues = message
? []
: trackedPublicationIssues(directory, args.includes("--staged") ? ":" : undefined);
const range = option("--range");
if (range) {
for (const commit of git(["rev-list", range]).trim().split("\n").filter(Boolean)) {
assertPublicMessage(git(["show", "-s", "--format=%B", commit]));
issues.push(...trackedPublicationIssues(directory, commit));
}
}
const issue = issues[0];
if (issue) {
// A malicious filename can itself contain sensitive text.
const location = publicationIssues(issue.path).length ? "tracked file" : issue.path;
throw new Error(`Publication refused: ${issue.rule} (${location}:${issue.line})`);
}
}

if (import.meta.main) {
try {
checkPublication(process.argv.slice(2));
} catch (error) {
const message = error instanceof Error ? error.message : "";
console.error(
/^(?:Publication|Public message) refused: /u.test(message)
? message
: "Publication refused: cannot inspect contribution",
);
process.exit(1);
}
}
46 changes: 46 additions & 0 deletions scripts/publication-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export type PublicationIssue = { rule: string; line: number };

const rules: Array<[string, RegExp]> = [
[
"personal-path",
/\/Users\/[^\s"'<>]+|\/home\/(?!rootform(?:\/|$)|runner(?:\/|$))[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\/gu,
],
[
"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,
],
[
"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,
],
[
"private-activity",
/(?:\d+\s+(?:merged\s+)?(?:private|engine|web)\s+(?:pull requests|PRs))|(?:private|engine|web)\s+(?:pull requests|PRs)\s+(?:included|merged|count)/giu,
],
];

/** Return only rule and line; never retain or display the matched value. */
export function publicationIssues(text: string): PublicationIssue[] {
return rules.flatMap(([rule, pattern]) =>
[...text.matchAll(pattern)].map((match) => ({
rule,
line: text.slice(0, match.index).split("\n").length,
})),
);
}

/** Validate all outbound strings before a bot makes its first write. */
export function assertPublicMessage(value: unknown): void {
if (typeof value === "string") {
const issue = publicationIssues(value)[0];
if (issue) throw new Error(`Public message refused: ${issue.rule}`);
} else if (Array.isArray(value)) {
for (const item of value) assertPublicMessage(item);
} else if (value !== null && typeof value === "object") {
for (const item of Object.values(value)) assertPublicMessage(item);
}
}
38 changes: 36 additions & 2 deletions scripts/verify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";

if (Bun.version !== "1.4.0") throw new Error("Bun 1.4.0 is required");

Expand All @@ -22,7 +24,39 @@ for (const [field, ...values] of mismatches) {
}
if (mismatches.length > 0) process.exit(1);

const scanRoot = mkdtempSync(join(tmpdir(), "rootform-editor-scan-"));
try {
const candidates = execFileSync("git", [
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.toString()
.split("\0")
.filter(Boolean);
for (const path of candidates) {
const stat = lstatSync(path);
if (!stat.isFile() || stat.isSymbolicLink())
throw new Error("Unexpected tracked entry in secret scan");
const target = join(scanRoot, path);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(path, target);
}
const scan = spawnSync(
"gitleaks",
["dir", "--no-banner", "--redact", "--config", join(process.cwd(), ".gitleaks.toml"), scanRoot],
{ stdio: "inherit" },
);
if (scan.status !== 0) process.exit(scan.status ?? 1);
} finally {
rmSync(scanRoot, { recursive: true, force: true });
}

for (const command of [
["bun", "scripts/check-publication.ts"],
["gitleaks", "git", "--no-banner", "--redact", "--config", ".gitleaks.toml", "."],
["bun", "run", "check:format"],
["bun", "run", "build:vscode"],
["bun", "run", "--cwd", "vscode", "test:unit"],
Expand Down
Loading