mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+61
@@ -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
|
||||
Executable
+172
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user