bigpowers 2.91.0 → 2.91.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/guard-git/SKILL.md +7 -2
- package/.cline/skills/guard-git/SKILL.md +7 -2
- package/.codebuddy/skills/guard-git/SKILL.md +7 -2
- package/.codex/skills/guard-git/SKILL.md +7 -2
- package/.copilot/skills/guard-git/SKILL.md +7 -2
- package/.cursor/rules/guard-git.mdc +7 -2
- package/.gemini/extensions/bigpowers/commands/prompts/guard-git.md +7 -2
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.gemini/extensions/bigpowers/skills/guard-git/SKILL.md +7 -2
- package/.hermes/skills/guard-git/SKILL.md +7 -2
- package/.kilocode/rules/guard-git.md +7 -2
- package/.mimocode/skills/guard-git/SKILL.md +7 -2
- package/.opencode/skills/guard-git/SKILL.md +7 -2
- package/.pi/package.json +1 -1
- package/.pi/prompts/guard-git.md +7 -2
- package/.pi/skills/guard-git/SKILL.md +7 -2
- package/.qwen/skills/guard-git/SKILL.md +7 -2
- package/.trae/skills/guard-git/SKILL.md +7 -2
- package/.windsurf/rules/guard-git.md +7 -2
- package/.zcode/skills/guard-git/SKILL.md +7 -2
- package/CHANGELOG.md +8 -0
- package/extensions/omp-hooks.ts +197 -58
- package/hooks/pre/bash.sh +54 -7
- package/hooks/pre-tool-use.sh +49 -11
- package/package.json +1 -1
- package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
- package/scripts/lib/git-argv.py +168 -0
- package/scripts/lib/golden-suite-gates.sh +2 -0
- package/scripts/omp-guard-probe.ts +46 -0
- package/scripts/test-guard-git-normalize.sh +189 -0
- package/skills/guard-git/SKILL.md +7 -2
- package/skills-lock.json +1 -1
package/extensions/omp-hooks.ts
CHANGED
|
@@ -18,15 +18,152 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
// only truly foreign entries (e.g. a stray node_modules symlink) need exclusion.
|
|
19
19
|
const EXCLUDED_DIRS = new Set([".git", "node_modules"]);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Git argv normalization (BUG-2026-11-03-guard-git-flag-normalize, #184).
|
|
23
|
+
//
|
|
24
|
+
// Literal substring patterns cannot see a global flag in front of the
|
|
25
|
+
// subcommand (`git -C repo reset --hard`), a flag between the subcommand and the
|
|
26
|
+
// destructive option (`git reset -q --hard`), or an alternative spelling of the
|
|
27
|
+
// same act (`git checkout -- .`, `git branch --delete --force`). Tokenize the
|
|
28
|
+
// command, drop the global options, then judge (subcommand, arguments).
|
|
29
|
+
//
|
|
30
|
+
// scripts/lib/git-argv.py is the same rule table in Python for the shell hooks;
|
|
31
|
+
// scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Quote-aware split; a quoting error degrades to whitespace splitting, never to eval. */
|
|
35
|
+
export function tokenizeArgs(command: string): string[] {
|
|
36
|
+
const tokens: string[] = [];
|
|
37
|
+
let current = "";
|
|
38
|
+
let quote: '"' | "'" | null = null;
|
|
39
|
+
let hasToken = false;
|
|
40
|
+
for (let i = 0; i < command.length; i++) {
|
|
41
|
+
const ch = command[i];
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (ch === quote) quote = null;
|
|
44
|
+
else current += ch;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"' || ch === "'") {
|
|
48
|
+
quote = ch as '"' | "'";
|
|
49
|
+
hasToken = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (/\s/.test(ch)) {
|
|
53
|
+
if (hasToken) {
|
|
54
|
+
tokens.push(current);
|
|
55
|
+
current = "";
|
|
56
|
+
hasToken = false;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
current += ch;
|
|
61
|
+
hasToken = true;
|
|
62
|
+
}
|
|
63
|
+
if (hasToken) tokens.push(current);
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Global options that consume the next token as their value.
|
|
68
|
+
const GIT_VALUE_FLAGS = new Set([
|
|
69
|
+
"-C", "-c", "--exec-path", "--git-dir", "--work-tree", "--namespace",
|
|
70
|
+
"--super-prefix", "--config-env",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Global options that stand alone.
|
|
74
|
+
const GIT_BARE_FLAGS = new Set([
|
|
75
|
+
"-p", "--paginate", "--no-pager", "--bare", "--no-replace-objects",
|
|
76
|
+
"--literal-pathspecs", "--glob-pathspecs", "--icase-pathspecs",
|
|
77
|
+
"--no-optional-locks", "--version", "--help",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const SEPARATORS = new Set([";", "&&", "||", "|", "&", "(", ")", "\n"]);
|
|
81
|
+
|
|
82
|
+
function shortFlagHas(args: string[], letter: string): boolean {
|
|
83
|
+
return args.some((a) => a.startsWith("-") && !a.startsWith("--") && a.slice(1).includes(letter));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Block reason for one normalized git invocation, or null. Mirror git-argv.py. */
|
|
87
|
+
export function classifyGitInvocation(sub: string, args: string[]): string | null {
|
|
88
|
+
if (sub === "reset" && args.includes("--hard")) return "git reset --hard discards uncommitted work";
|
|
89
|
+
if (sub === "clean" && (shortFlagHas(args, "f") || args.includes("--force"))) {
|
|
90
|
+
return "git clean -f deletes untracked files";
|
|
91
|
+
}
|
|
92
|
+
if (sub === "branch") {
|
|
93
|
+
const forced =
|
|
94
|
+
args.includes("-D") ||
|
|
95
|
+
(args.includes("--delete") && (args.includes("--force") || shortFlagHas(args, "f")));
|
|
96
|
+
if (forced) return "git branch -D deletes an unmerged branch";
|
|
97
|
+
}
|
|
98
|
+
if (sub === "checkout") {
|
|
99
|
+
if (args.includes("-f") || args.includes("--force")) return "git checkout -f discards local changes";
|
|
100
|
+
if (args.includes(".")) return "git checkout . discards local changes";
|
|
101
|
+
}
|
|
102
|
+
if (sub === "restore" && (args.includes("--worktree") || args.includes("."))) {
|
|
103
|
+
return "git restore discards local changes";
|
|
104
|
+
}
|
|
105
|
+
if (sub === "push") {
|
|
106
|
+
const leased = args.some((a) => a === "--force-with-lease" || a.startsWith("--force-with-lease="));
|
|
107
|
+
const forced =
|
|
108
|
+
args.includes("-f") ||
|
|
109
|
+
args.includes("--force") ||
|
|
110
|
+
args.some((a) => a.startsWith("+") && !a.startsWith("++"));
|
|
111
|
+
if (forced && !leased) return "force push rewrites remote history";
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every (subcommand, args) pair in a command, with global options stripped. */
|
|
117
|
+
export function gitInvocations(tokens: string[]): Array<{ sub: string; args: string[] }> {
|
|
118
|
+
const found: Array<{ sub: string; args: string[] }> = [];
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < tokens.length) {
|
|
121
|
+
const token = tokens[i];
|
|
122
|
+
if (token.split("/").pop() !== "git") {
|
|
123
|
+
i += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let j = i + 1;
|
|
127
|
+
while (j < tokens.length) {
|
|
128
|
+
const opt = tokens[j];
|
|
129
|
+
if (SEPARATORS.has(opt)) break;
|
|
130
|
+
const name = opt.split("=")[0];
|
|
131
|
+
if (GIT_VALUE_FLAGS.has(name)) {
|
|
132
|
+
j += opt.includes("=") ? 1 : 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (GIT_BARE_FLAGS.has(name) || (opt.startsWith("-") && !opt.startsWith("--") && opt.length > 1)) {
|
|
136
|
+
j += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (j >= tokens.length || SEPARATORS.has(tokens[j])) {
|
|
142
|
+
i = j + 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const sub = tokens[j];
|
|
146
|
+
const args: string[] = [];
|
|
147
|
+
let k = j + 1;
|
|
148
|
+
while (k < tokens.length && !SEPARATORS.has(tokens[k])) {
|
|
149
|
+
args.push(tokens[k]);
|
|
150
|
+
k += 1;
|
|
151
|
+
}
|
|
152
|
+
found.push({ sub, args });
|
|
153
|
+
i = k;
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The normalized verdict for a whole shell command: a reason to block, or null. */
|
|
159
|
+
export function dangerousGitCommand(command: string): string | null {
|
|
160
|
+
for (const { sub, args } of gitInvocations(tokenizeArgs(command))) {
|
|
161
|
+
const reason = classifyGitInvocation(sub, args);
|
|
162
|
+
if (reason) return reason;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
30
167
|
|
|
31
168
|
// Conventional Commits specifies `type(scope)!:`, but many repositories write
|
|
32
169
|
// `type!(scope):`. Accept both; the leading type is what matters.
|
|
@@ -224,6 +361,46 @@ function currentBranch(cwd: string): string {
|
|
|
224
361
|
}
|
|
225
362
|
}
|
|
226
363
|
|
|
364
|
+
/** Everything the tool_call handler decides, as a pure function (testable). */
|
|
365
|
+
export function evaluateBashCall(
|
|
366
|
+
command: string,
|
|
367
|
+
opts: {
|
|
368
|
+
landMode: boolean;
|
|
369
|
+
hasRepo: boolean;
|
|
370
|
+
branchFor: (upTo: number) => string | undefined;
|
|
371
|
+
},
|
|
372
|
+
): string | null {
|
|
373
|
+
const gitReason = dangerousGitCommand(command);
|
|
374
|
+
if (gitReason) return `BLOCKED: ${gitReason}. Use a safer approach or ask the user.`;
|
|
375
|
+
|
|
376
|
+
if (!opts.hasRepo) return null;
|
|
377
|
+
|
|
378
|
+
const pushBranch = opts.branchFor(command.length) ?? "unknown";
|
|
379
|
+
if (/git\s+push/.test(command) && !opts.landMode && protectedPushTarget(command, pushBranch)) {
|
|
380
|
+
return "BLOCKED: Direct push to protected branch. Use kickoff-branch + release-branch.";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (/git\s+commit/.test(command)) {
|
|
384
|
+
const commitBranches = [...command.matchAll(/\bgit\s+commit\b/g)].map(
|
|
385
|
+
(m) => opts.branchFor(m.index ?? 0) ?? "unknown",
|
|
386
|
+
);
|
|
387
|
+
const hit = commitBranches.find((b) => (PROTECTED_BRANCHES as readonly string[]).includes(b));
|
|
388
|
+
if (hit) {
|
|
389
|
+
return `BLOCKED: Direct commits to '${hit}' are forbidden. Use a feature branch.`;
|
|
390
|
+
}
|
|
391
|
+
const msg = extractCommitMessage(command);
|
|
392
|
+
if (msg) {
|
|
393
|
+
if (!CONVENTIONAL_RE.test(msg)) {
|
|
394
|
+
return `BLOCKED: Commit subject '${msg.split("\n")[0]}' must follow Conventional Commits: <type>(<scope>): <subject>.`;
|
|
395
|
+
}
|
|
396
|
+
if (msg.split("\n")[0].length > 72) {
|
|
397
|
+
return "BLOCKED: Commit subject line must be \u226472 characters.";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
227
404
|
function protectedPushTarget(command: string, branch: string): boolean {
|
|
228
405
|
if (PROTECTED_BRANCH_RE.test(command)) return true;
|
|
229
406
|
const trimmed = command.trim();
|
|
@@ -414,55 +591,17 @@ export default function bigpowers(pi: ExtensionAPI) {
|
|
|
414
591
|
const { command } = event.input as { command?: string };
|
|
415
592
|
if (!command) return;
|
|
416
593
|
|
|
417
|
-
for (const pat of DANGEROUS_PATTERNS) {
|
|
418
|
-
if (pat.test(command)) {
|
|
419
|
-
return {
|
|
420
|
-
block: true,
|
|
421
|
-
reason: `BLOCKED: '${command}' matches dangerous pattern '${pat}'.`,
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
594
|
const sessionRoot = process.cwd();
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (/git\s+commit/.test(command)) {
|
|
439
|
-
const commitBranches = [...command.matchAll(/\bgit\s+commit\b/g)].map((m) =>
|
|
440
|
-
currentBranch(commandGitCwd(command, m.index ?? 0, sessionRoot)),
|
|
441
|
-
);
|
|
442
|
-
const hit = commitBranches.find((b) =>
|
|
443
|
-
(PROTECTED_BRANCHES as readonly string[]).includes(b),
|
|
444
|
-
);
|
|
445
|
-
if (hit) {
|
|
446
|
-
return {
|
|
447
|
-
block: true,
|
|
448
|
-
reason: `BLOCKED: Direct commits to '${hit}' are forbidden. Use a feature branch.`,
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
const msg = extractCommitMessage(command);
|
|
452
|
-
if (msg) {
|
|
453
|
-
if (!CONVENTIONAL_RE.test(msg)) {
|
|
454
|
-
return {
|
|
455
|
-
block: true,
|
|
456
|
-
reason: `BLOCKED: Commit subject '${msg.split("\n")[0]}' must follow Conventional Commits: <type>(<scope>): <subject>.`,
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
if (msg.split("\n")[0].length > 72) {
|
|
460
|
-
return {
|
|
461
|
-
block: true,
|
|
462
|
-
reason: "BLOCKED: Commit subject line must be ≤72 characters.",
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
595
|
+
const verdict = evaluateBashCall(command, {
|
|
596
|
+
landMode: process.env.GIT_BIGPOWERS_LAND === "1",
|
|
597
|
+
// `undefined` means "there is no repo here"; the guard then only judges
|
|
598
|
+
// the argv rules, which need no branch.
|
|
599
|
+
branchFor: (upTo: number) =>
|
|
600
|
+
inGitRepo(sessionRoot)
|
|
601
|
+
? currentBranch(commandGitCwd(command, upTo, sessionRoot))
|
|
602
|
+
: undefined,
|
|
603
|
+
hasRepo: inGitRepo(sessionRoot),
|
|
604
|
+
});
|
|
605
|
+
if (verdict) return { block: true, reason: verdict };
|
|
467
606
|
});
|
|
468
607
|
}
|
package/hooks/pre/bash.sh
CHANGED
|
@@ -11,7 +11,8 @@ INPUT=$(cat)
|
|
|
11
11
|
COMMAND=$(echo "$INPUT" | jq -r '.command // .tool_input.command // empty' 2>/dev/null || true)
|
|
12
12
|
[ -z "$COMMAND" ] && exit 0
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Last-resort literals, used only when the normalizer is unreachable.
|
|
15
|
+
LEGACY_DANGEROUS=(
|
|
15
16
|
"git reset --hard"
|
|
16
17
|
"git clean -f"
|
|
17
18
|
"git branch -D"
|
|
@@ -19,18 +20,64 @@ DANGEROUS_PATTERNS=(
|
|
|
19
20
|
"push -f"
|
|
20
21
|
)
|
|
21
22
|
PROTECTED_BRANCHES=("main" "master")
|
|
23
|
+
COMMIT_MSG_RE='-m[[:space:]]+"([^"]+)"'
|
|
22
24
|
CONVENTIONAL_RE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:[[:space:]].+'
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# --- Normalized git argv (BUG-2026-11-03-guard-git-flag-normalize, #184) ------
|
|
27
|
+
# Literal substring matching cannot see a global flag in front of the subcommand
|
|
28
|
+
# or a flag between the subcommand and the destructive option: the destructive
|
|
29
|
+
# tokens are all still there, only their position changed. The rules live in
|
|
30
|
+
# scripts/lib/git-argv.py and are mirrored in extensions/omp-hooks.ts;
|
|
31
|
+
# scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
|
|
32
|
+
SELF_PATH="${BASH_SOURCE[0]}"
|
|
33
|
+
# Only pay for realpath when the hook actually is a symlink (installed copies).
|
|
34
|
+
# This hook runs on every tool call, so a spawn per invocation is real latency.
|
|
35
|
+
if [ -L "$SELF_PATH" ] && command -v python3 >/dev/null 2>&1; then
|
|
36
|
+
SELF_PATH="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$SELF_PATH" 2>/dev/null || echo "$SELF_PATH")"
|
|
37
|
+
fi
|
|
38
|
+
GUARD_ROOT="$(cd "$(dirname "$SELF_PATH")/.." 2>/dev/null && pwd -P || true)"
|
|
39
|
+
GIT_ARGV="$GUARD_ROOT/scripts/lib/git-argv.py"
|
|
40
|
+
|
|
41
|
+
# classify_git_argv <command>: echoes "block: <reason>" or nothing.
|
|
42
|
+
# Fast path: a command with no "git" token cannot contain a git invocation, so
|
|
43
|
+
# the helper (a python start) is skipped. Falls back to the legacy literal
|
|
44
|
+
# patterns when the helper is unreachable (hook symlinked outside the package).
|
|
45
|
+
classify_git_argv() {
|
|
46
|
+
local cmd="$1" out=""
|
|
47
|
+
case "$cmd" in
|
|
48
|
+
*git*) ;;
|
|
49
|
+
*) return 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
if command -v python3 >/dev/null 2>&1 && [ -f "$GIT_ARGV" ]; then
|
|
52
|
+
out="$(python3 "$GIT_ARGV" --command "$cmd" 2>/dev/null || true)"
|
|
53
|
+
case "$out" in
|
|
54
|
+
block:*) printf '%s' "$out"; return 0 ;;
|
|
55
|
+
*) return 0 ;;
|
|
56
|
+
esac
|
|
28
57
|
fi
|
|
29
|
-
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
GIT_VERDICT="$(classify_git_argv "$COMMAND")"
|
|
62
|
+
if [ -n "$GIT_VERDICT" ]; then
|
|
63
|
+
printf '{"decision":"block","reason":"BLOCKED: %s"}\n' "${GIT_VERDICT#block: }"
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
if [ ! -f "$GIT_ARGV" ]; then
|
|
67
|
+
for p in "${LEGACY_DANGEROUS[@]}"; do
|
|
68
|
+
if echo "$COMMAND" | grep -qE "$p"; then
|
|
69
|
+
printf '{"decision":"block","reason":"BLOCKED: dangerous pattern %q"}\n' "$p"
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
fi
|
|
30
74
|
|
|
31
75
|
if [[ "$COMMAND" =~ git[[:space:]]+commit ]]; then
|
|
32
76
|
MSG=""
|
|
33
|
-
|
|
77
|
+
# Parentheses inside [[ =~ ]] must come from a variable, not the literal
|
|
78
|
+
# pattern: bash parses `([^"]+)` as a syntax error (pre-existing since #118,
|
|
79
|
+
# so this fallback hook never ran; BUG-2026-11-03-guard-git-flag-normalize).
|
|
80
|
+
if [[ "$COMMAND" =~ $COMMIT_MSG_RE ]]; then MSG="${BASH_REMATCH[1]}"; fi
|
|
34
81
|
if [ -n "$MSG" ] && [[ ! "$MSG" =~ $CONVENTIONAL_RE ]]; then
|
|
35
82
|
echo '{"decision":"block","reason":"BLOCKED: Conventional Commits required"}'
|
|
36
83
|
exit 0
|
package/hooks/pre-tool-use.sh
CHANGED
|
@@ -11,16 +11,13 @@ set -euo pipefail
|
|
|
11
11
|
|
|
12
12
|
# Configuration
|
|
13
13
|
PROTECTED_BRANCHES=("main" "master")
|
|
14
|
-
|
|
14
|
+
# Last-resort literals, used only when the normalizer is unreachable.
|
|
15
|
+
LEGACY_DANGEROUS=(
|
|
15
16
|
"git reset --hard"
|
|
16
|
-
"git clean -fd"
|
|
17
17
|
"git clean -f"
|
|
18
18
|
"git branch -D"
|
|
19
|
-
"git checkout \\."
|
|
20
|
-
"git restore \\."
|
|
21
19
|
"push --force"
|
|
22
20
|
"push -f"
|
|
23
|
-
"reset --hard"
|
|
24
21
|
)
|
|
25
22
|
CONVENTIONAL_COMMITS_REGEX='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:[[:space:]].+'
|
|
26
23
|
LAND_MODE="${GIT_BIGPOWERS_LAND:-}"
|
|
@@ -29,6 +26,41 @@ LAND_MODE="${GIT_BIGPOWERS_LAND:-}"
|
|
|
29
26
|
# GIT_GUARDRAILS_MODE: claude (default) | cursor | gemini
|
|
30
27
|
MODE="${GIT_GUARDRAILS_MODE:-claude}"
|
|
31
28
|
|
|
29
|
+
# --- Normalized git argv (BUG-2026-11-03-guard-git-flag-normalize, #184) ------
|
|
30
|
+
# Literal substring matching cannot see a global flag in front of the subcommand
|
|
31
|
+
# or a flag between the subcommand and the destructive option: the destructive
|
|
32
|
+
# tokens are all still there, only their position changed. The rules live in
|
|
33
|
+
# scripts/lib/git-argv.py and are mirrored in extensions/omp-hooks.ts;
|
|
34
|
+
# scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
|
|
35
|
+
SELF_PATH="${BASH_SOURCE[0]}"
|
|
36
|
+
# Only pay for realpath when the hook actually is a symlink (installed copies).
|
|
37
|
+
# This hook runs on every tool call, so a spawn per invocation is real latency.
|
|
38
|
+
if [ -L "$SELF_PATH" ] && command -v python3 >/dev/null 2>&1; then
|
|
39
|
+
SELF_PATH="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$SELF_PATH" 2>/dev/null || echo "$SELF_PATH")"
|
|
40
|
+
fi
|
|
41
|
+
GUARD_ROOT="$(cd "$(dirname "$SELF_PATH")/.." 2>/dev/null && pwd -P || true)"
|
|
42
|
+
GIT_ARGV="$GUARD_ROOT/scripts/lib/git-argv.py"
|
|
43
|
+
|
|
44
|
+
# classify_git_argv <command>: echoes "block: <reason>" or nothing.
|
|
45
|
+
# Fast path: a command with no "git" token cannot contain a git invocation, so
|
|
46
|
+
# the helper (a python start) is skipped. Falls back to the legacy literal
|
|
47
|
+
# patterns when the helper is unreachable (hook symlinked outside the package).
|
|
48
|
+
classify_git_argv() {
|
|
49
|
+
local cmd="$1" out=""
|
|
50
|
+
case "$cmd" in
|
|
51
|
+
*git*) ;;
|
|
52
|
+
*) return 0 ;;
|
|
53
|
+
esac
|
|
54
|
+
if command -v python3 >/dev/null 2>&1 && [ -f "$GIT_ARGV" ]; then
|
|
55
|
+
out="$(python3 "$GIT_ARGV" --command "$cmd" 2>/dev/null || true)"
|
|
56
|
+
case "$out" in
|
|
57
|
+
block:*) printf '%s' "$out"; return 0 ;;
|
|
58
|
+
*) return 0 ;;
|
|
59
|
+
esac
|
|
60
|
+
fi
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
32
64
|
deny() {
|
|
33
65
|
local reason="$1"
|
|
34
66
|
if [ "$MODE" = "gemini" ]; then
|
|
@@ -72,12 +104,18 @@ if [ -z "$COMMAND" ]; then
|
|
|
72
104
|
exit 0
|
|
73
105
|
fi
|
|
74
106
|
|
|
75
|
-
# 1.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
# 1. Normalized git argv (git push is NOT blanket-blocked)
|
|
108
|
+
GIT_VERDICT="$(classify_git_argv "$COMMAND")"
|
|
109
|
+
if [ -n "$GIT_VERDICT" ]; then
|
|
110
|
+
deny "BLOCKED: ${GIT_VERDICT#block: }. Use a safer approach or ask the user."
|
|
111
|
+
fi
|
|
112
|
+
if [ ! -f "$GIT_ARGV" ]; then
|
|
113
|
+
for p in "${LEGACY_DANGEROUS[@]}"; do
|
|
114
|
+
if echo "$COMMAND" | grep -qE "$p"; then
|
|
115
|
+
deny "BLOCKED: '$COMMAND' matches dangerous pattern '$p'. Use a safer approach or ask the user."
|
|
116
|
+
fi
|
|
117
|
+
done
|
|
118
|
+
fi
|
|
81
119
|
|
|
82
120
|
# 2. Check for git commit / git push
|
|
83
121
|
if [[ "$COMMAND" =~ git[[:space:]]+commit ]] || [[ "$COMMAND" =~ git[[:space:]]+push ]]; then
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize git argv before applying the guard-git deny rules (#184).
|
|
3
|
+
|
|
4
|
+
The old guard matched literal substrings, so `git -C repo reset --hard`,
|
|
5
|
+
`git reset -q --hard` and `git checkout -- .` all slipped through: the
|
|
6
|
+
destructive tokens are still there, only their position or a global flag
|
|
7
|
+
changed. Matching on the raw command string cannot see that.
|
|
8
|
+
|
|
9
|
+
This module tokenizes the command (quote-aware), finds every `git` invocation,
|
|
10
|
+
strips the global options that precede the subcommand (`-C <path>`,
|
|
11
|
+
`--no-pager`, `-c k=v`, `--git-dir=…`, …), and then decides on the
|
|
12
|
+
(subcommand, arguments) pair. `extensions/omp-hooks.ts` implements the same
|
|
13
|
+
rules in TypeScript; `scripts/test-guard-git-normalize.sh` fails if the two
|
|
14
|
+
disagree on any fixture row.
|
|
15
|
+
|
|
16
|
+
Rule table (keep in sync with the TS port):
|
|
17
|
+
|
|
18
|
+
reset block when any argument is --hard
|
|
19
|
+
clean block when any short flag carries `f` (-f, -fd, -fdx) or --force
|
|
20
|
+
branch block on -D, or on --delete together with --force/-f
|
|
21
|
+
checkout block on -f/--force, or on a `.` pathspec (with or without --)
|
|
22
|
+
restore block on --worktree, or on a `.` pathspec
|
|
23
|
+
push block on -f/--force/+refspec, unless --force-with-lease is given
|
|
24
|
+
|
|
25
|
+
CLI: git-argv.py --command "<shell command>" → prints `block: <reason>` or `allow`
|
|
26
|
+
Exit: 0 allow, 10 block, 2 usage error.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import shlex
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
# Global options that consume the next token as their value.
|
|
34
|
+
VALUE_FLAGS = {
|
|
35
|
+
"-C",
|
|
36
|
+
"-c",
|
|
37
|
+
"--exec-path",
|
|
38
|
+
"--git-dir",
|
|
39
|
+
"--work-tree",
|
|
40
|
+
"--namespace",
|
|
41
|
+
"--super-prefix",
|
|
42
|
+
"--config-env",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Global options that stand alone.
|
|
46
|
+
BARE_FLAGS = {
|
|
47
|
+
"-p",
|
|
48
|
+
"--paginate",
|
|
49
|
+
"--no-pager",
|
|
50
|
+
"--bare",
|
|
51
|
+
"--no-replace-objects",
|
|
52
|
+
"--literal-pathspecs",
|
|
53
|
+
"--glob-pathspecs",
|
|
54
|
+
"--icase-pathspecs",
|
|
55
|
+
"--no-optional-locks",
|
|
56
|
+
"--version",
|
|
57
|
+
"--help",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Command separators: the guard judges each git invocation on its own.
|
|
61
|
+
SEPARATORS = {";", "&&", "||", "|", "&", "(", ")", "\n"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tokenize(command: str) -> list[str]:
|
|
65
|
+
"""Quote-aware split. Bad quoting degrades to whitespace splitting, never to eval."""
|
|
66
|
+
try:
|
|
67
|
+
return shlex.split(command, comments=False, posix=True)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return command.split()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def git_invocations(tokens: list[str]) -> list[tuple[str, list[str], int]]:
|
|
73
|
+
"""Yield (subcommand, args, index) for every git call in the token stream."""
|
|
74
|
+
found: list[tuple[str, list[str], int]] = []
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(tokens):
|
|
77
|
+
token = tokens[i]
|
|
78
|
+
if token.rsplit("/", 1)[-1] != "git":
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
start = i
|
|
82
|
+
j = i + 1
|
|
83
|
+
# Skip global options between `git` and the subcommand.
|
|
84
|
+
while j < len(tokens):
|
|
85
|
+
opt = tokens[j]
|
|
86
|
+
if opt in SEPARATORS:
|
|
87
|
+
break
|
|
88
|
+
name = opt.split("=", 1)[0]
|
|
89
|
+
if name in VALUE_FLAGS:
|
|
90
|
+
j += 2 if "=" not in opt else 1
|
|
91
|
+
continue
|
|
92
|
+
if name in BARE_FLAGS or (opt.startswith("-") and not opt.startswith("--") and len(opt) > 1):
|
|
93
|
+
j += 1
|
|
94
|
+
continue
|
|
95
|
+
break
|
|
96
|
+
if j >= len(tokens) or tokens[j] in SEPARATORS:
|
|
97
|
+
i = j + 1
|
|
98
|
+
continue
|
|
99
|
+
sub = tokens[j]
|
|
100
|
+
args: list[str] = []
|
|
101
|
+
k = j + 1
|
|
102
|
+
while k < len(tokens) and tokens[k] not in SEPARATORS:
|
|
103
|
+
args.append(tokens[k])
|
|
104
|
+
k += 1
|
|
105
|
+
found.append((sub, args, start))
|
|
106
|
+
i = k
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _short_flag_has(args: list[str], letter: str) -> bool:
|
|
111
|
+
for arg in args:
|
|
112
|
+
if arg.startswith("-") and not arg.startswith("--") and letter in arg[1:]:
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def classify_git(sub: str, args: list[str]) -> str | None:
|
|
118
|
+
"""Return a block reason, or None to allow."""
|
|
119
|
+
if sub == "reset" and "--hard" in args:
|
|
120
|
+
return "git reset --hard discards uncommitted work"
|
|
121
|
+
if sub == "clean" and (_short_flag_has(args, "f") or "--force" in args):
|
|
122
|
+
return "git clean -f deletes untracked files"
|
|
123
|
+
if sub == "branch":
|
|
124
|
+
forced = "-D" in args or ("--delete" in args and ("--force" in args or _short_flag_has(args, "f")))
|
|
125
|
+
if forced:
|
|
126
|
+
return "git branch -D deletes an unmerged branch"
|
|
127
|
+
if sub == "checkout":
|
|
128
|
+
if "-f" in args or "--force" in args:
|
|
129
|
+
return "git checkout -f discards local changes"
|
|
130
|
+
if "." in args:
|
|
131
|
+
return "git checkout . discards local changes"
|
|
132
|
+
if sub == "restore":
|
|
133
|
+
if "--worktree" in args or "." in args:
|
|
134
|
+
return "git restore discards local changes"
|
|
135
|
+
if sub == "push":
|
|
136
|
+
leased = any(a == "--force-with-lease" or a.startswith("--force-with-lease=") for a in args)
|
|
137
|
+
forced = (
|
|
138
|
+
"-f" in args
|
|
139
|
+
or "--force" in args
|
|
140
|
+
or any(a.startswith("+") and not a.startswith("++") for a in args)
|
|
141
|
+
)
|
|
142
|
+
if forced and not leased:
|
|
143
|
+
return "force push rewrites remote history"
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def classify(command: str) -> str | None:
|
|
148
|
+
for sub, args, _idx in git_invocations(tokenize(command)):
|
|
149
|
+
reason = classify_git(sub, args)
|
|
150
|
+
if reason:
|
|
151
|
+
return reason
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str]) -> int:
|
|
156
|
+
if len(argv) == 3 and argv[1] == "--command":
|
|
157
|
+
reason = classify(argv[2])
|
|
158
|
+
if reason:
|
|
159
|
+
print(f"block: {reason}")
|
|
160
|
+
return 10
|
|
161
|
+
print("allow")
|
|
162
|
+
return 0
|
|
163
|
+
print(__doc__, file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main(sys.argv))
|
|
@@ -76,6 +76,8 @@ GOLDEN_GATES=(
|
|
|
76
76
|
"import-boundaries:bash scripts/check-import-boundaries.sh:false"
|
|
77
77
|
"security-dashboard-render:node tests/security/dashboard-render.cjs:false"
|
|
78
78
|
"skill-catalog:bash scripts/validate-skill-catalog.sh:false"
|
|
79
|
+
# Guard-git judges git argv, not substrings; shell and TS must agree (#184).
|
|
80
|
+
"guard-git-normalize:bash scripts/test-guard-git-normalize.sh:false"
|
|
79
81
|
"specs-parse:bash scripts/validate-specs-yaml.sh:false"
|
|
80
82
|
# The runner itself: failing output is shown, skips are named and reasoned.
|
|
81
83
|
"golden-gate-runner:bash scripts/test-golden-gate-runner.sh:false"
|