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.
Files changed (35) hide show
  1. package/.agents/skills/guard-git/SKILL.md +7 -2
  2. package/.cline/skills/guard-git/SKILL.md +7 -2
  3. package/.codebuddy/skills/guard-git/SKILL.md +7 -2
  4. package/.codex/skills/guard-git/SKILL.md +7 -2
  5. package/.copilot/skills/guard-git/SKILL.md +7 -2
  6. package/.cursor/rules/guard-git.mdc +7 -2
  7. package/.gemini/extensions/bigpowers/commands/prompts/guard-git.md +7 -2
  8. package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
  9. package/.gemini/extensions/bigpowers/skills/guard-git/SKILL.md +7 -2
  10. package/.hermes/skills/guard-git/SKILL.md +7 -2
  11. package/.kilocode/rules/guard-git.md +7 -2
  12. package/.mimocode/skills/guard-git/SKILL.md +7 -2
  13. package/.opencode/skills/guard-git/SKILL.md +7 -2
  14. package/.pi/package.json +1 -1
  15. package/.pi/prompts/guard-git.md +7 -2
  16. package/.pi/skills/guard-git/SKILL.md +7 -2
  17. package/.qwen/skills/guard-git/SKILL.md +7 -2
  18. package/.trae/skills/guard-git/SKILL.md +7 -2
  19. package/.windsurf/rules/guard-git.md +7 -2
  20. package/.zcode/skills/guard-git/SKILL.md +7 -2
  21. package/CHANGELOG.md +8 -0
  22. package/extensions/omp-hooks.ts +197 -58
  23. package/hooks/pre/bash.sh +54 -7
  24. package/hooks/pre-tool-use.sh +49 -11
  25. package/package.json +1 -1
  26. package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
  27. package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
  28. package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
  29. package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
  30. package/scripts/lib/git-argv.py +168 -0
  31. package/scripts/lib/golden-suite-gates.sh +2 -0
  32. package/scripts/omp-guard-probe.ts +46 -0
  33. package/scripts/test-guard-git-normalize.sh +189 -0
  34. package/skills/guard-git/SKILL.md +7 -2
  35. package/skills-lock.json +1 -1
@@ -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
- const DANGEROUS_PATTERNS = [
22
- /git\s+reset\s+--hard/,
23
- /git\s+clean\s+-f/,
24
- /git\s+branch\s+-D/,
25
- /git\s+checkout\s+\./,
26
- /git\s+restore\s+\./,
27
- /push\s+--force/,
28
- /push\s+-f(?:\s|$)/,
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
- if (!inGitRepo(sessionRoot)) return;
428
- const pushBranch = currentBranch(commandGitCwd(command, command.length, sessionRoot));
429
-
430
- if (/git\s+push/.test(command) && protectedPushTarget(command, pushBranch)) {
431
- return {
432
- block: true,
433
- reason:
434
- "BLOCKED: Direct push to protected branch. Use kickoff-branch + release-branch.",
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
- DANGEROUS_PATTERNS=(
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
- for p in "${DANGEROUS_PATTERNS[@]}"; do
25
- if echo "$COMMAND" | grep -qE "$p"; then
26
- printf '{"decision":"block","reason":"BLOCKED: dangerous pattern %q"}\n' "$p"
27
- exit 0
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
- done
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
- if [[ "$COMMAND" =~ -m[[:space:]]+"([^"]+)" ]]; then MSG="${BASH_REMATCH[1]}"; fi
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
@@ -11,16 +11,13 @@ set -euo pipefail
11
11
 
12
12
  # Configuration
13
13
  PROTECTED_BRANCHES=("main" "master")
14
- DANGEROUS_PATTERNS=(
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. Check for dangerous patterns (git push is NOT blanket-blocked)
76
- for p in "${DANGEROUS_PATTERNS[@]}"; do
77
- if echo "$COMMAND" | grep -qE "$p"; then
78
- deny "BLOCKED: '$COMMAND' matches dangerous pattern '$p'. Use a safer approach or ask the user."
79
- fi
80
- done
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.91.0",
3
+ "version": "2.91.1",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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"