chore: block restricted literals in local commits (#132223)

* chore: block restricted literals in local commits

* fix: fully redact overlapping private literals
This commit is contained in:
Peter Steinberger
2026-08-28 20:00:36 -07:00
committed by GitHub
parent 3b3372bd76
commit 1879e4ea6b
7 changed files with 651 additions and 113 deletions
+28
View File
@@ -79,6 +79,34 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
- Use American English spelling and grammar in code, comments, docs, and UI strings
- Do not edit files covered by `CODEOWNERS` security ownership unless a listed owner authored or explicitly requested the change, or is already reviewing it with you. For governance changes to ownership/review policy itself, explicit direction from an organization owner is also sufficient only when live GitHub organization membership shows `state: active` and `role: admin`; repository `ADMIN`, `viewerCanAdminister`, or bypass permission alone never qualifies. Neither route waives a GitHub-enforced approval rule. Treat those paths as restricted review surfaces, not opportunistic cleanup targets.
## Local commit hook
The normal `pnpm install` setup automatically enables the repository's pre-commit
formatting hook. Its optional content guard reads a private UTF-8 file selected by
the native Git setting `hooks.blockedLiteralsFile`. Keep one literal per nonempty
line in a file outside the checkout, such as
`~/.config/openclaw/blocked-literals.txt`, then configure this checkout:
```bash
git config --local hooks.blockedLiteralsFile "$HOME/.config/openclaw/blocked-literals.txt"
```
Git metadata is another safe untracked location for the private file. Never put
private rule contents in tracked files or PRs. With no setting, the content guard
is disabled and formatting runs normally; a configured empty path or missing,
unreadable, empty, or invalid file blocks the commit.
When configured, the guard checks case-sensitive literal substrings before
formatting and again after formatting restages files. Each scan checks the full
staged contents of added, modified, and type-changed files, including rename
destinations and unchanged lines within modified files. Docs, tests, generated
files, and binary files are included; no tracked file is exempt.
If the hook blocks a commit, remove the matching content and restage the reported
files. Unchanged historical files and deletions are not scanned. Submodule contents
and symlink targets are not searched. This is a local safeguard, not CI or server
enforcement: bypassing or disabling hooks also bypasses this check.
## Review Conversations Are Author-Owned
After your PR receives Barnacle, ClawSweeper, or maintainer feedback, read the [pull request review flow](https://docs.openclaw.ai/reference/pull-request-review-flow) for how to interpret rank-up moves, proof guidance, re-review requests, and review conversation follow-up.
+1
View File
@@ -89,6 +89,7 @@ const repositoryScriptEntries = [
// Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json.
"scripts/oxlint-boundary-guards.mjs!",
"scripts/plugin-prerelease-liveish-matrix.mts!",
"scripts/pre-commit/guard-staged-content.mjs!",
// Generates the checked-in native protocol models from core descriptor metadata.
"scripts/protocol-gen.ts!",
"scripts/pr-gates-lock.mts!",
+4 -2
View File
@@ -668,8 +668,10 @@ Native dependency policy:
<Accordion title="Fast local iteration">
- `pnpm changed:lanes` shows which architectural lanes a diff triggers.
- The pre-commit hook is formatting-only. It restages formatted files
and does not run lint, typecheck, or tests.
- The pre-commit hook formats and restages files. When private rules are
configured, it also scans staged content before and after formatting.
See [Local commit hook setup](https://github.com/openclaw/openclaw/blob/main/CONTRIBUTING.md#local-commit-hook).
It does not run lint, typecheck, or tests.
- Run `pnpm check:changed` explicitly before handoff or push when you
need the smart local check gate.
- `pnpm test:changed` routes through cheap scoped lanes by default. Use
+5 -57
View File
@@ -1,61 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
RUN_NODE_TOOL="$ROOT_DIR/scripts/pre-commit/run-node-tool.sh"
FILTER_FILES="$ROOT_DIR/scripts/pre-commit/filter-staged-files.mjs"
if [[ ! -x "$RUN_NODE_TOOL" ]]; then
echo "Missing helper: $RUN_NODE_TOOL" >&2
ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo '[pre-commit] Cannot locate the checkout root. Check the repository and retry.' >&2
exit 1
fi
if [[ ! -f "$FILTER_FILES" ]]; then
echo "Missing helper: $FILTER_FILES" >&2
exit 1
fi
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$GIT_DIR" ]] && \
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
[[ -d "$GIT_DIR/rebase-merge" ]] || \
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
# Sequencer commits stage the operation result, not just the user's local edits.
exit 0
fi
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
# Robustness: NUL-delimited file list handles spaces/newlines safely.
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.
files=()
while IFS= read -r -d '' file; do
files+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR -z)
if [ "${#files[@]}" -eq 0 ]; then
exit 0
fi
restage_files=()
for file in "${files[@]}"; do
if ! git check-ignore --no-index -q -- "$file"; then
restage_files+=("$file")
fi
done
format_files=()
while IFS= read -r -d '' file; do
format_files+=("$file")
done < <(node "$FILTER_FILES" format -- "${restage_files[@]}")
if [ "${#format_files[@]}" -gt 0 ]; then
"$RUN_NODE_TOOL" oxfmt --write --no-error-on-unmatched-pattern "${format_files[@]}"
fi
if [ "${#restage_files[@]}" -gt 0 ]; then
git add -- "${restage_files[@]}"
fi
}
cd "$ROOT_DIR"
exec node scripts/pre-commit/guard-staged-content.mjs
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
RUN_NODE_TOOL="$ROOT_DIR/scripts/pre-commit/run-node-tool.sh"
FILTER_FILES="$ROOT_DIR/scripts/pre-commit/filter-staged-files.mjs"
if [[ ! -x "$RUN_NODE_TOOL" ]]; then
echo "Missing helper: $RUN_NODE_TOOL" >&2
exit 1
fi
if [[ ! -f "$FILTER_FILES" ]]; then
echo "Missing helper: $FILTER_FILES" >&2
exit 1
fi
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$GIT_DIR" ]] && \
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
[[ -d "$GIT_DIR/rebase-merge" ]] || \
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
# Sequencer commits stage the operation result, not just the user's local edits.
exit 0
fi
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
# Robustness: NUL-delimited file list handles spaces/newlines safely.
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.
files=()
while IFS= read -r -d '' file; do
files+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR -z)
if [ "${#files[@]}" -eq 0 ]; then
exit 0
fi
restage_files=()
for file in "${files[@]}"; do
if ! git check-ignore --no-index -q -- "$file"; then
restage_files+=("$file")
fi
done
format_files=()
while IFS= read -r -d '' file; do
format_files+=("$file")
done < <(node "$FILTER_FILES" format -- "${restage_files[@]}")
if [ "${#format_files[@]}" -gt 0 ]; then
"$RUN_NODE_TOOL" oxfmt --write --no-error-on-unmatched-pattern "${format_files[@]}"
fi
if [ "${#restage_files[@]}" -gt 0 ]; then
git add -- "${restage_files[@]}"
fi
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
const ruleSetting = "hooks.blockedLiteralsFile";
const decoder = new TextDecoder("utf-8", { fatal: true });
const maxBuffer = 16 * 1024 * 1024;
let rules = [];
function redact(text) {
// Match the original text so replacements cannot hide overlaps or rescan inserted markers.
const spans = [];
for (const rule of rules) {
for (let start = text.indexOf(rule); start !== -1; start = text.indexOf(rule, start + 1)) {
spans.push([start, start + rule.length]);
}
}
spans.sort((a, b) => a[0] - b[0]);
let redacted = "";
let cursor = 0;
for (const [start, end] of spans) {
if (start >= cursor) {
redacted += text.slice(cursor, start) + "[REDACTED]";
}
cursor = Math.max(cursor, end);
}
return redacted + text.slice(cursor);
}
function fail(message, code = 1) {
process.stderr.write(redact(`[pre-commit] ${message}\n[pre-commit] FAILED (exit ${code})\n`));
process.exit(code);
}
function nulPaths(bytes) {
if (bytes.length === 0) {
return [];
}
if (bytes.at(-1) !== 0) {
throw new Error("Invalid path list");
}
return decoder.decode(bytes.subarray(0, -1)).split("\0");
}
function git(args, input) {
const result = spawnSync("git", ["--no-pager", "--literal-pathspecs", ...args], {
input,
maxBuffer,
stdio: ["pipe", "pipe", "pipe"],
});
// Git grep can return its no-match status even when a staged blob cannot be read.
if (result.error || result.signal || result.status === null || result.stderr.length) {
fail(
`Git could not complete the content guard. Check ${ruleSetting}, the index and repository, then retry.`,
);
}
return result;
}
function scan() {
if (!rules.length) {
return;
}
// Rename destinations must be scanned even when their bytes are unchanged.
const changed = git([
"diff",
"--cached",
"--no-renames",
"--no-relative",
"--name-only",
"--diff-filter=AMT",
"-z",
"--",
]);
if (changed.status !== 0) {
fail("Git could not list staged files. Check the index and retry.");
}
const paths = nulPaths(changed.stdout);
const matches = [];
for (let offset = 0; offset < paths.length;) {
const batch = [];
let bytes = 0;
// Bound both argv count and bytes, leaving room for Git flags and the environment.
while (offset < paths.length && batch.length < 64) {
const size = Buffer.byteLength(paths[offset]) + 1;
if (batch.length && bytes + size > 16 * 1024) {
break;
}
batch.push(paths[offset++]);
bytes += size;
}
const found = git(
[
"grep",
"--cached",
"--fixed-strings",
"--no-ignore-case",
"--text",
"--files-with-matches",
"--null",
"--full-name",
"--no-color",
"--no-textconv",
"--no-recurse-submodules",
"-f",
"-",
"--",
...batch,
],
`${rules.join("\n")}\n`,
);
if (found.status !== 0 && found.status !== 1) {
fail("Git could not search staged content. Check the index and retry.");
}
if (found.status === 0) {
matches.push(...nulPaths(found.stdout));
}
}
if (matches.length) {
process.stderr.write("[pre-commit] Blocked staged content in:\n");
for (const name of matches) {
process.stderr.write(` ${JSON.stringify(redact(name))}\n`);
}
fail("Remove the blocked literals from these files and restage them, then retry.");
}
}
try {
const configured = git(["config", "--path", "--get", ruleSetting]);
if (configured.status !== 0 && configured.status !== 1) {
fail(`Git could not read ${ruleSetting}. Check the Git configuration and retry.`);
}
if (configured.status === 0) {
try {
const rulePath = decoder.decode(configured.stdout).replace(/\n$/, "");
rules = decoder
.decode(readFileSync(rulePath))
.split(/\r?\n/)
.filter((line) => line.length > 0);
} catch {
fail(
`Cannot read the private literal file selected by ${ruleSetting} as UTF-8. Set it to a readable file and retry.`,
);
}
if (!rules.length || rules.some((rule) => rule.includes("\0"))) {
fail(
`The private literal file selected by ${ruleSetting} is empty or invalid. Use nonempty literal lines without NUL bytes and retry.`,
);
}
}
// Check before the formatter's sequencer early return or any working-tree restaging.
scan();
const formatted = spawnSync("bash", ["scripts/pre-commit/format-staged.sh"], {
maxBuffer,
stdio: ["inherit", "pipe", "pipe"],
});
process.stdout.write(redact(formatted.stdout?.toString("utf8") ?? ""));
process.stderr.write(redact(formatted.stderr?.toString("utf8") ?? ""));
if (formatted.error || formatted.signal || formatted.status === null) {
fail("Formatter could not complete. Check the formatting helpers and retry.");
}
if (formatted.status !== 0) {
fail("Formatter failed. Fix the reported error and retry.", formatted.status);
}
// Formatting may change bytes or stage entirely new paths; enumerate the index again.
scan();
} catch {
fail(
`The staged scan could not complete. Check ${ruleSetting} and repository state, then retry.`,
);
}
+380 -54
View File
@@ -1,21 +1,44 @@
// Git hook tests validate pre-commit hook behavior and scripts.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "./helpers/temp-dir.js";
const baseGitEnv = {
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "3",
GIT_CONFIG_KEY_0: "user.name",
GIT_CONFIG_VALUE_0: "Hook Test",
GIT_CONFIG_KEY_1: "user.email",
GIT_CONFIG_VALUE_1: "hook@example.invalid",
GIT_CONFIG_KEY_2: "commit.gpgSign",
GIT_CONFIG_VALUE_2: "false",
};
const baseRunEnv: NodeJS.ProcessEnv = { ...process.env, ...baseGitEnv };
const baseRunEnv: NodeJS.ProcessEnv = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
...baseGitEnv,
};
const rulePath = ".git/private rules.txt";
const ruleSetting = "hooks.blockedLiteralsFile";
const literals = ["GUARD_SYNTHETIC_ALPHA", "GUARD_SYNTHETIC_BETA_[x].*42"] as const;
const tempDirs: string[] = [];
const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv) => {
return execFileSync(cmd, args, {
cwd,
encoding: "utf8",
stdio: "pipe",
env: env ? { ...baseRunEnv, ...env } : baseRunEnv,
}).trim();
};
@@ -63,50 +86,37 @@ function installPreCommitFixture(dir: string): string {
path.join(process.cwd(), "git-hooks", "pre-commit"),
path.join(dir, "git-hooks", "pre-commit"),
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
"#!/usr/bin/env bash\nexit 0\n",
{
encoding: "utf8",
mode: 0o755,
},
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
"process.exit(0);\n",
"utf8",
);
for (const name of [
"run-node-tool.sh",
"filter-staged-files.mjs",
"guard-staged-content.mjs",
"format-staged.sh",
]) {
copyFileSync(
path.join(process.cwd(), "scripts/pre-commit", name),
path.join(dir, "scripts/pre-commit", name),
);
}
writeFileSync(path.join(dir, rulePath), `${literals.join("\n")}\n`, { mode: 0o600 });
run(dir, "git", ["config", "--local", ruleSetting, path.join(dir, rulePath)]);
mkdirSync(path.join(dir, "node_modules/.bin"), { recursive: true });
writeExecutable(path.join(dir, "node_modules/.bin"), "oxfmt", "#!/bin/sh\nexit 0\n");
const fakeBinDir = path.join(dir, "bin");
mkdirSync(fakeBinDir, { recursive: true });
writeExecutable(fakeBinDir, "node", "#!/usr/bin/env bash\nexit 0\n");
return fakeBinDir;
}
function installFormattingRecorder(dir: string): string {
function installFormattingRecorder(dir: string, body = ""): string {
const logPath = path.join(dir, "hook-tool.log");
writeFileSync(
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
`const files = process.argv.slice(3).filter((arg) => arg !== "--");
for (const file of files) {
if (file.endsWith(".ts")) {
process.stdout.write(file);
process.stdout.write("\0");
}
}
`,
"utf8",
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
writeExecutable(
path.join(dir, "node_modules/.bin"),
"oxfmt",
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> ${JSON.stringify(logPath)}
printf 'oxfmt %s\n' "$*" >> hook-tool.log
${body}
`,
{
encoding: "utf8",
mode: 0o755,
},
);
return logPath;
}
@@ -212,6 +222,13 @@ describe("git-hooks/pre-commit (integration)", () => {
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(readFormatterLog(logPath)).toEqual([]);
writeFileSync(path.join(dir, "changed.ts"), literals[0]);
run(dir, "git", ["add", "--", "changed.ts"]);
expect(runFailure(dir, "bash", ["git-hooks/pre-commit"]).stderr).toContain(
"Blocked staged content",
);
expect(readFormatterLog(logPath)).toEqual([]);
});
it.each([
@@ -239,27 +256,58 @@ describe("git-hooks/pre-commit (integration)", () => {
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(readFormatterLog(logPath)).toEqual([]);
});
it("still formats staged files during a normal commit", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
const fakeBinDir = installPreCommitFixture(dir);
run(dir, "rm", ["-f", path.join(fakeBinDir, "node")]);
const logPath = installFormattingRecorder(dir);
writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8");
writeFileSync(path.join(dir, "changed.ts"), literals[1]);
run(dir, "git", ["add", "--", "changed.ts"]);
run(dir, "bash", ["git-hooks/pre-commit"], {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
expect(readFormatterLog(logPath)).toEqual([
"oxfmt --write --no-error-on-unmatched-pattern changed.ts",
]);
expect(runFailure(dir, "bash", ["git-hooks/pre-commit"]).stderr).toContain(
"Blocked staged content",
);
expect(readFormatterLog(logPath)).toEqual([]);
});
it.each(["configured", "unconfigured", "external"])(
"formats staged files with %s private rules",
(mode) => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
const fakeBinDir = installPreCommitFixture(dir);
const logPath = installFormattingRecorder(dir);
if (mode === "unconfigured") {
run(dir, "git", ["config", "--local", "--unset", ruleSetting]);
unlinkSync(path.join(dir, rulePath));
} else if (mode === "external") {
const privateDir = makeTempRepoRoot(tempDirs, "openclaw-private-rules-");
const privatePath = path.join(privateDir, "private rules.txt");
copyFileSync(path.join(dir, rulePath), privatePath);
unlinkSync(path.join(dir, rulePath));
run(dir, "git", ["config", "--local", ruleSetting, privatePath]);
expect(run(dir, "git", ["config", "--path", "--get", ruleSetting])).toBe(privatePath);
}
writeFileSync(
path.join(dir, "changed.ts"),
mode === "unconfigured" ? literals[0] : "export const value = 1;\n",
"utf8",
);
run(dir, "git", ["add", "--", "changed.ts"]);
run(dir, "bash", ["git-hooks/pre-commit"], {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
expect(readFormatterLog(logPath)).toEqual([
"oxfmt --write --no-error-on-unmatched-pattern changed.ts",
]);
if (mode === "external") {
writeFileSync(path.join(dir, "changed.ts"), literals[0]);
run(dir, "git", ["add", "--", "changed.ts"]);
expect(runFailure(dir, "bash", ["git-hooks/pre-commit"]).stderr).toContain(
"Blocked staged content",
);
}
},
);
it("does not run the changed-scope check for non-doc staged changes", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-no-check-changed-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
@@ -307,7 +355,7 @@ describe("git-hooks/pre-commit (integration)", () => {
expect(staged).toEqual([".agents/skills/discord-clawd/SKILL.md", ".gitignore"]);
});
it("ignores FAST_COMMIT because the hook is already formatting-only", () => {
it("does not invoke pnpm when FAST_COMMIT is set", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-fast-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
@@ -333,6 +381,284 @@ describe("git-hooks/pre-commit (integration)", () => {
});
});
describe("staged content guard", () => {
function fixture() {
const dir = makeTempRepoRoot(tempDirs, "openclaw-content-guard-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
installPreCommitFixture(dir);
return dir;
}
function stage(dir: string, name: string, content: string | Buffer) {
mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
writeFileSync(path.join(dir, name), content);
run(dir, "git", ["--literal-pathspecs", "add", "-f", "--", name]);
}
const commitArgs = ["-c", "core.hooksPath=git-hooks", "commit", "-qm", "guard proof"];
function blocked(dir: string, names: string[], commit = false) {
const result = commit
? runFailure(dir, "git", commitArgs)
: runFailure(dir, "bash", ["git-hooks/pre-commit"]);
const output = result.stdout + result.stderr;
expect(result.status).toBe(1);
expect(output).toContain("Blocked staged content");
expect(output).toContain("restage");
for (const name of names) {
expect(output).toContain(JSON.stringify(name));
}
for (const literal of literals) {
expect(output).not.toContain(literal);
}
expect(output).not.toContain("PRIVATE_SOURCE_CONTEXT");
return result;
}
it.each(literals)(
"blocks staged literal %s before formatting even with a clean working tree",
(literal) => {
const dir = fixture();
const log = installFormattingRecorder(dir);
stage(dir, "payload.ts", `PRIVATE_SOURCE_CONTEXT prefix${literal}suffix\n`);
writeFileSync(path.join(dir, "payload.ts"), "clean working tree\n");
blocked(dir, ["payload.ts"], true);
expect(readFormatterLog(log)).toEqual([]);
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
},
);
it.each(["payload.txt", "payload.ts"])(
"blocks working-tree bytes restaged by the real formatter path: %s",
(name) => {
const dir = fixture();
stage(dir, name, "clean staged version\n");
writeFileSync(path.join(dir, name), literals[0]);
blocked(dir, [name], true);
expect(run(dir, "git", ["show", `:${name}`])).toBe(literals[0]);
expect(runFailure(dir, "git", ["rev-parse", "--verify", "HEAD"]).status).not.toBe(0);
},
);
it("discovers a new path staged during formatting", () => {
const dir = fixture();
stage(dir, "payload.ts", "clean\n");
writeFileSync(path.join(dir, "introduced.txt"), literals[1]);
const log = installFormattingRecorder(dir, "git add -- introduced.txt");
blocked(dir, ["introduced.txt"], true);
expect(readFormatterLog(log)).toHaveLength(1);
});
it("uses fixed, case-sensitive matches despite Git grep defaults", () => {
const dir = fixture();
run(dir, "git", ["config", "grep.patternType", "extended"]);
run(dir, "git", ["config", "grep.ignoreCase", "true"]);
stage(dir, "payload.txt", `${literals[0].toLowerCase()}\nGUARD_SYNTHETIC_BETA_xanything42\n`);
run(dir, "git", commitArgs);
expect(run(dir, "git", ["show", "HEAD:payload.txt"])).toContain("xanything42");
});
it("scans unchanged lines in modified files but permits unchanged history and deletion-only commits", () => {
const dir = fixture();
stage(dir, "historical.txt", `${literals[0]}\nold line\n`);
run(dir, "git", ["commit", "-qm", "historical fixture"]);
stage(dir, "clean.txt", "clean\n");
run(dir, "git", commitArgs);
stage(dir, "historical.txt", `${literals[0]}\nnew line\n`);
blocked(dir, ["historical.txt"]);
run(dir, "git", ["rm", "-f", "--", "historical.txt"]);
run(dir, "git", commitArgs);
expect(run(dir, "git", ["ls-tree", "--name-only", "HEAD"])).toBe("clean.txt");
});
it.each(["rename", "typechange", "binary"])("scans the full staged blob for %s", (kind) => {
const dir = fixture();
if (kind === "rename") {
stage(dir, "old.txt", literals[0]);
run(dir, "git", ["commit", "-qm", "historical fixture"]);
run(dir, "git", ["mv", "--", "old.txt", "payload.txt"]);
} else if (kind === "typechange") {
symlinkSync("absent-target", path.join(dir, "payload.txt"));
run(dir, "git", ["add", "--", "payload.txt"]);
run(dir, "git", ["commit", "-qm", "symlink fixture"]);
unlinkSync(path.join(dir, "payload.txt"));
stage(dir, "payload.txt", literals[0]);
} else {
stage(
dir,
"payload.txt",
Buffer.concat([Buffer.from([0, 255]), Buffer.from(literals[1]), Buffer.from([0])]),
);
}
blocked(dir, ["payload.txt"]);
});
it("reports literal paths safely and includes ignored docs, tests and generated files", () => {
const dir = fixture();
writeFileSync(path.join(dir, ".gitignore"), "ignored/\n");
const names = [
"space name.txt",
"--all",
":(exclude)payload.txt",
"[literal]*?.txt",
"line\nbreak.txt",
"control\u001b.txt",
"ignored/file.txt",
"docs/example.md",
"test/example.ts",
"extensions/example/src/host/web/file.bundle.js",
];
for (const name of names) {
stage(dir, name, literals[0]);
}
stage(dir, `${literals[0]}.txt`, literals[1]);
const result = blocked(dir, [...names, "[REDACTED].txt"]);
expect(result.stderr).not.toContain("\u001b");
});
it("scans the former public rule filename and beyond both batch limits", () => {
const dir = fixture();
const formerRulePath = "scripts/pre-commit/blocked-literals.txt";
stage(dir, formerRulePath, literals[0]);
blocked(dir, [formerRulePath]);
// Long paths cross the byte budget before 64 entries; short paths cross the count budget.
const batchPaths = [];
for (let i = 0; i < 140; i++) {
const suffix = i < 70 ? `/${"x".repeat(180)}/${"y".repeat(180)}` : "";
const name = `batch-${String(i).padStart(3, "0")}${suffix}.txt`;
mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
writeFileSync(path.join(dir, name), "clean\n");
batchPaths.push(name);
}
run(dir, "git", ["add", "--", ...batchPaths]);
stage(dir, formerRulePath, "clean\n");
stage(dir, "zzz-last.txt", literals[1]);
blocked(dir, ["zzz-last.txt"]);
});
it("permits unborn and existing empty commits and ignores submodule contents", () => {
const dir = fixture();
run(dir, "git", [...commitArgs, "--allow-empty"]);
run(dir, "git", [...commitArgs, "--allow-empty"]);
const head = run(dir, "git", ["rev-parse", "HEAD"]);
run(dir, "git", ["update-index", "--add", "--cacheinfo", `160000,${head},submodule`]);
mkdirSync(path.join(dir, "submodule"));
writeFileSync(path.join(dir, "submodule", "payload.txt"), literals[0]);
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("submodule");
});
it.each([
["missing file", null],
["empty file", ""],
["blank lines", "\n\n"],
["invalid UTF-8", Buffer.from([255])],
["NUL literal", "\0"],
["empty setting", undefined],
])("fails closed with %s", (_label, content) => {
const dir = fixture();
const log = installFormattingRecorder(dir);
stage(dir, "payload.ts", "clean\n");
if (content === undefined) {
run(dir, "git", ["config", "--local", ruleSetting, ""]);
} else if (content === null) {
unlinkSync(path.join(dir, rulePath));
} else {
writeFileSync(path.join(dir, rulePath), content);
}
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
expect(result.status).toBe(1);
expect(result.stderr).toContain(ruleSetting);
expect(result.stderr).toContain("retry");
expect(result.stdout + result.stderr).not.toContain(path.join(dir, rulePath));
expect(readFormatterLog(log)).toEqual([]);
});
it.each([
["literal metacharacters", [...literals], literals.join(" "), "[REDACTED] [REDACTED]"],
[
"shorter prefix first",
["foo", "foobar"],
"foobar foo FOOBAR",
"[REDACTED] [REDACTED] FOOBAR",
],
["longer prefix first", ["foobar", "foo"], "foobar foo FOOBAR", "[REDACTED] [REDACTED] FOOBAR"],
["crossing overlaps", ["abc", "bcd"], "abcd", "[REDACTED]"],
["reversed crossing overlaps", ["bcd", "abc"], "abcd", "[REDACTED]"],
["self-overlap", ["aba"], "ababa", "[REDACTED]"],
["marker literal", ["foo", "REDACTED"], "foo REDACTED", "[REDACTED] [REDACTED]"],
])(
"redacts filenames and formatter streams with %s while preserving failure status",
(_label, rules, text, redacted) => {
const dir = fixture();
writeFileSync(path.join(dir, rulePath), `${rules.join("\n")}\n`);
const name = `report-${text}\n🦞.ts`;
stage(dir, name, text);
const finding = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
stage(dir, name, "clean\n");
const context = `🦞 café ${text}\nuntouched ${text} tail\n`;
const expected = `🦞 café ${redacted}\nuntouched ${redacted} tail\n`;
installFormattingRecorder(
dir,
`printf 'stdout %s' '${context}'\nprintf 'stderr %s' '${context}' >&2\nprintf broken > .git/index\nexit 23`,
);
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
expect(result).toEqual({
status: 23,
stdout: `stdout ${expected}`,
stderr: `stderr ${expected}[pre-commit] Formatter failed. Fix the reported error and retry.\n[pre-commit] FAILED (exit 23)\n`,
});
expect(finding.status).toBe(1);
expect(finding.stderr).toContain(` ${JSON.stringify(`report-${redacted}\n🦞.ts`)}\n`);
},
);
it.each(["config path", "index", "blob", "post-format blob"])(
"blocks Git %s read errors without raw diagnostics",
(kind) => {
const dir = fixture();
const name = `${literals[0]}.txt`;
stage(dir, name, "clean\n");
if (kind === "config path") {
run(dir, "git", ["config", "--local", ruleSetting, `~${literals[1]}/private rules.txt`]);
expect(runFailure(dir, "git", ["config", "--path", "--get", ruleSetting]).status).not.toBe(
1,
);
} else if (kind === "index") {
writeFileSync(path.join(dir, ".git/index"), literals[1]);
} else {
const oid = run(dir, "git", ["rev-parse", `:${name}`]);
const objectPath = `.git/objects/${oid.slice(0, 2)}/${oid.slice(2)}`;
if (kind === "post-format blob") {
// Keep git add from recreating the missing blob before the post-scan.
writeFileSync(path.join(dir, ".gitignore"), "*.txt\n");
stage(dir, "trigger.ts", "formatter trigger\n");
installFormattingRecorder(dir, `rm -- '${objectPath}'`);
} else {
unlinkSync(path.join(dir, objectPath));
const grep = runFailure(dir, "git", [
"grep",
"--cached",
"--fixed-strings",
"clean",
"--",
name,
]);
expect(grep.status).toBe(1);
expect(grep.stderr.length).toBeGreaterThan(0);
}
}
const result = runFailure(dir, "bash", ["git-hooks/pre-commit"]);
expect(result.stderr).toContain("Git could not");
for (const literal of literals) {
expect(result.stdout + result.stderr).not.toContain(literal);
}
expect(result.stderr).not.toContain("error:");
},
);
});
describe("scripts/pre-commit/run-node-tool.sh", () => {
it("runs the installed local tool without invoking pnpm", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-run-node-tool-local-");