From 087e3a931e41704825b8273ce9251404887b7c4f Mon Sep 17 00:00:00 2001 From: Thierno Bah <44367278+soulbah@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:59:24 +0200 Subject: [PATCH 1/2] fix: validate public contribution files and messages --- .githooks/commit-msg | 4 ++ .githooks/pre-commit | 5 +++ .githooks/pre-push | 19 +++++++++ .github/pull_request_template.md | 7 ++++ .github/workflows/ci.yml | 21 ++++++++++ .gitleaks.toml | 4 ++ AGENTS.md | 3 ++ scripts/check-publication.ts | 68 ++++++++++++++++++++++++++++++++ scripts/publication-safety.ts | 46 +++++++++++++++++++++ scripts/verify.ts | 38 +++++++++++++++++- 10 files changed, 213 insertions(+), 2 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 .github/pull_request_template.md create mode 100644 .gitleaks.toml create mode 100644 scripts/check-publication.ts create mode 100644 scripts/publication-safety.ts diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6cf69c8 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +bun scripts/check-publication.ts --message-file "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ca0b97d --- /dev/null +++ b/.githooks/pre-commit @@ -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 . diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2cc8436 --- /dev/null +++ b/.githooks/pre-push @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..66d8e47 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Change + +Describe the problem and resulting behavior. + +## Validation + +Commands run and relevant results. State any missing proof. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb2830..38ff753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..1c51ba7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "Rootform editor secret scanning" + +[extend] +useDefault = true diff --git a/AGENTS.md b/AGENTS.md index e072a8b..b60c336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/scripts/check-publication.ts b/scripts/check-publication.ts new file mode 100644 index 0000000..aee7f9a --- /dev/null +++ b/scripts/check-publication.ts @@ -0,0 +1,68 @@ +#!/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[]) => execFileSync("git", args, { cwd: directory }); + 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[]) => execFileSync("git", args, { cwd: directory }).toString(); + 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) checkPublication(process.argv.slice(2)); diff --git a/scripts/publication-safety.ts b/scripts/publication-safety.ts new file mode 100644 index 0000000..64cb90d --- /dev/null +++ b/scripts/publication-safety.ts @@ -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); + } +} diff --git a/scripts/verify.ts b/scripts/verify.ts index 48f39ec..56e2f9c 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -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"); @@ -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"], From c34801d8bfdeef6f7764c0a6de100455ec498d17 Mon Sep 17 00:00:00 2001 From: Thierno Bah <44367278+soulbah@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:01:13 +0200 Subject: [PATCH 2/2] fix: inspect tracked Git content without exposing command errors --- scripts/check-publication.ts | 38 +++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/scripts/check-publication.ts b/scripts/check-publication.ts index aee7f9a..4fd734f 100644 --- a/scripts/check-publication.ts +++ b/scripts/check-publication.ts @@ -8,7 +8,17 @@ export function trackedPublicationIssues( directory: string, revision?: string, ): Array<{ path: string; rule: string; line: number }> { - const git = (args: string[]) => execFileSync("git", args, { cwd: directory }); + 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] @@ -42,7 +52,17 @@ export function checkPublication(args: string[]): void { return index < 0 ? undefined : args[index + 1]; }; const directory = resolve(option("--repo") ?? process.cwd()); - const git = (args: string[]) => execFileSync("git", args, { cwd: directory }).toString(); + 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")) @@ -65,4 +85,16 @@ export function checkPublication(args: string[]): void { } } -if (import.meta.main) checkPublication(process.argv.slice(2)); +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); + } +}