bigpowers 2.91.0 → 2.92.0
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 +15 -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/ab-sandbox.sh +3 -3
- package/scripts/adapters/agy.sh +1 -1
- package/scripts/adapters/cursor.sh +1 -1
- package/scripts/adapters/gemini.sh +1 -1
- package/scripts/adapters/mimo.sh +1 -1
- package/scripts/adapters/pi.sh +1 -1
- package/scripts/adapters/zcode.sh +1 -1
- package/scripts/generate-context-bundle.sh +2 -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/ab-eval.py +19 -5
- package/scripts/lib/ab_lean.py +116 -0
- package/scripts/lib/ab_metrics.py +8 -0
- package/scripts/lib/ab_progress.py +3 -1
- package/scripts/lib/ab_report.py +15 -4
- package/scripts/lib/ab_vsm.py +122 -0
- package/scripts/lib/ab_waste.py +173 -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-adapter-render.sh +1 -1
- package/scripts/test-guard-git-normalize.sh +189 -0
- package/scripts/test-test-lock-hook.sh +7 -7
- package/scripts/verify-adversarial.sh +8 -1
- package/skills/guard-git/SKILL.md +7 -2
- package/skills-lock.json +1 -1
- package/tests/test_ab_eval.py +0 -44
- package/tests/test_ab_lean.py +81 -0
- package/tests/test_ab_score.py +12 -2
- package/tests/test_ab_tasks.py +75 -0
- package/tests/test_ab_vsm.py +109 -0
- package/tests/test_ab_waste.py +106 -0
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
package/scripts/ab-sandbox.sh
CHANGED
|
@@ -15,7 +15,7 @@ INTERNAL_NET="bp-ab-internal"
|
|
|
15
15
|
EGRESS_NET="bp-ab-egress"
|
|
16
16
|
ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib/ab-sandbox" && pwd)"
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
sandbox_die() { echo "ERROR: $*" >&2; exit 1; }
|
|
19
19
|
|
|
20
20
|
have_image() { docker image inspect "$1" >/dev/null 2>&1; }
|
|
21
21
|
have_net() { docker network inspect "$1" >/dev/null 2>&1; }
|
|
@@ -43,7 +43,7 @@ wait_for_proxy() {
|
|
|
43
43
|
sleep 1
|
|
44
44
|
done
|
|
45
45
|
docker logs "$PROXY_NAME" >&2 || true
|
|
46
|
-
|
|
46
|
+
sandbox_die "proxy did not start listening"
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
cmd_up() {
|
|
@@ -81,5 +81,5 @@ case "${1:-status}" in
|
|
|
81
81
|
up) cmd_up ;;
|
|
82
82
|
down) cmd_down ;;
|
|
83
83
|
status) cmd_status ;;
|
|
84
|
-
*)
|
|
84
|
+
*) sandbox_die "usage: $(basename "$0") up|down|status" ;;
|
|
85
85
|
esac
|
package/scripts/adapters/agy.sh
CHANGED
|
@@ -38,7 +38,7 @@ wire_context() {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
41
|
-
if [[ ! -t 0 ]]; then
|
|
41
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
42
42
|
JSON_INPUT=$(cat)
|
|
43
43
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
44
44
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
|
@@ -29,7 +29,7 @@ wire_context() {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
32
|
-
if [[ ! -t 0 ]]; then
|
|
32
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
33
33
|
JSON_INPUT=$(cat)
|
|
34
34
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
35
35
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
|
@@ -101,7 +101,7 @@ if [[ "${1:-}" == "--list-hook-events" ]]; then
|
|
|
101
101
|
fi
|
|
102
102
|
|
|
103
103
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
104
|
-
if [[ ! -t 0 ]]; then
|
|
104
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
105
105
|
JSON_INPUT=$(cat)
|
|
106
106
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
107
107
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
package/scripts/adapters/mimo.sh
CHANGED
|
@@ -30,7 +30,7 @@ wire_context() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
33
|
-
if [[ ! -t 0 ]]; then
|
|
33
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
34
34
|
JSON_INPUT=$(cat)
|
|
35
35
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
36
36
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
package/scripts/adapters/pi.sh
CHANGED
|
@@ -41,7 +41,7 @@ wire_context() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
44
|
-
if [[ ! -t 0 ]]; then
|
|
44
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
45
45
|
JSON_INPUT=$(cat)
|
|
46
46
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
47
47
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
|
@@ -27,7 +27,7 @@ wire_context() {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
# If stdin is a pipe/redirect, read the SkillIR JSON and render
|
|
30
|
-
if [[ ! -t 0 ]]; then
|
|
30
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [[ ! -t 0 ]]; then
|
|
31
31
|
JSON_INPUT=$(cat)
|
|
32
32
|
if [[ -n "$JSON_INPUT" ]]; then
|
|
33
33
|
IR_NAME=$(echo "$JSON_INPUT" | jq -r '.name')
|
|
@@ -51,7 +51,8 @@ while IFS= read -r row; do
|
|
|
51
51
|
|
|
52
52
|
adapter_script="$SCRIPT_DIR/adapters/${adapter}.sh"
|
|
53
53
|
# shellcheck source=/dev/null
|
|
54
|
-
|
|
54
|
+
# stdin is this loop's target list: an adapter that reads stdin when sourced would swallow it.
|
|
55
|
+
source "$adapter_script" </dev/null
|
|
55
56
|
case "$mode" in
|
|
56
57
|
symlink) wire_context_mode symlink "$file" ;;
|
|
57
58
|
copy) wire_context_mode copy "$file" ;;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/lib/ab-eval.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07
|
|
2
|
+
# story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07 e58s08 e58s09
|
|
3
3
|
"""A/B eval: run the same tasks with and without bigpowers and grade only the outcome.
|
|
4
4
|
|
|
5
5
|
Each run gets a fresh temp git workdir (outside the repo) copied from the task fixture.
|
|
@@ -25,7 +25,9 @@ from pathlib import Path
|
|
|
25
25
|
import yaml
|
|
26
26
|
|
|
27
27
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
-
from ab_metrics import parse_metrics, tap_score # type: ignore[import] # noqa: E402
|
|
28
|
+
from ab_metrics import parse_metrics, tap_score, tap_failures, tap_failed_names # type: ignore[import] # noqa: E402
|
|
29
|
+
from ab_vsm import vsm_from # type: ignore[import] # noqa: E402
|
|
30
|
+
from ab_waste import waste_for # type: ignore[import] # noqa: E402
|
|
29
31
|
from ab_report import ( # type: ignore[import] # noqa: E402
|
|
30
32
|
BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED, aggregate, bigpowers_sha, print_summary, write_reports,
|
|
31
33
|
)
|
|
@@ -180,6 +182,8 @@ def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
|
|
|
180
182
|
|
|
181
183
|
tampered, protected_added = protected_changes(workdir, before)
|
|
182
184
|
diff = changed_lines(workdir, base)
|
|
185
|
+
waste = waste_for(workdir, base, stdout, adapter.get("parser", "none"), task["dir"],
|
|
186
|
+
trace_dir / f"{task['id']}-{arm}-{run_idx}.patch.gz" if trace_dir else None)
|
|
183
187
|
shutil.copytree(task["dir"] / "hidden", workdir, dirs_exist_ok=True)
|
|
184
188
|
cmd, genv = grade_cmd(adapter, task["grade"], env)
|
|
185
189
|
passed = sh(cmd, workdir, genv, timeout=300).returncode == 0
|
|
@@ -187,12 +191,22 @@ def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
|
|
|
187
191
|
if task.get("score"):
|
|
188
192
|
cmd, genv = grade_cmd(adapter, task["score"], env)
|
|
189
193
|
score = tap_score(sh(cmd, workdir, genv, timeout=300).stdout)
|
|
194
|
+
bugs_opened = 0
|
|
195
|
+
bug_details = []
|
|
196
|
+
if task.get("audit"):
|
|
197
|
+
if (task["dir"] / "audit").is_dir():
|
|
198
|
+
shutil.copytree(task["dir"] / "audit", workdir, dirs_exist_ok=True)
|
|
199
|
+
cmd, genv = grade_cmd(adapter, task["audit"], env)
|
|
200
|
+
out = sh(cmd, workdir, genv, timeout=300).stdout
|
|
201
|
+
bugs_opened = tap_failures(out)
|
|
202
|
+
bug_details = tap_failed_names(out)
|
|
190
203
|
metrics = parse_metrics(adapter.get("parser", "none"), stdout)
|
|
191
204
|
return {
|
|
192
|
-
"task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score,
|
|
205
|
+
"task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score,
|
|
206
|
+
"bugs_opened": bugs_opened, "bug_details": bug_details, "tampered": tampered,
|
|
193
207
|
"protected_lines_added": protected_added, "diff_lines": diff, "duration_s": duration, "agent_exit": exit_code,
|
|
194
208
|
"agent_error": timed_out or exit_code != 0 or bool(metrics.pop("agent_reported_error")),
|
|
195
|
-
"timed_out": timed_out, **metrics,
|
|
209
|
+
"timed_out": timed_out, "vsm": vsm_from(adapter.get("parser", "none"), stdout), "waste": waste, **metrics,
|
|
196
210
|
}
|
|
197
211
|
finally:
|
|
198
212
|
shutil.rmtree(home, ignore_errors=True)
|
|
@@ -262,7 +276,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
262
276
|
with live.open("a") as f:
|
|
263
277
|
f.write(json.dumps({**rec, "bigpowers_sha": sha, "model": model}) + "\n")
|
|
264
278
|
print(f" {task['id']:<8} {arm:<10} run {i}: {'PASS' if rec['pass'] else 'FAIL'}"
|
|
265
|
-
f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}")
|
|
279
|
+
f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}", flush=True)
|
|
266
280
|
except ConfigError as exc:
|
|
267
281
|
print(f"ERROR: {exc}")
|
|
268
282
|
return 2
|