pi-orchestrator-config 4.8.5 → 4.10.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/README.md CHANGED
@@ -75,6 +75,7 @@ for `git-expert` to commit.
75
75
  | `/refine-review <url>` | Refine and improve existing PR review comments |
76
76
  | `/coderabbit-rate-limit [number\|url]` | Handle CodeRabbit rate limiting on PRs |
77
77
  | `/create-coms-feature-manager` | Generate a coms feature-manager prompt customized for the current project (source template: `templates/coms-feature-manager-prompt.md`) |
78
+ | `/create-commit <message>` | Commit and push the current change set via git-expert (source template: `templates/commit-prompt.md` — stage exact files, commit, push, report sha) |
78
79
  | `/query-db <command>` | Query the review comments database |
79
80
  | `/external-ai <agent> [--model <model>] [--session-id <id>] [--fix\|--peer\|--resume] <prompt>` | Run prompts via AI CLIs directly (cursor, claude, gemini) — full model access |
80
81
  | `/external-ai-models-refresh` | Refresh cached AI CLI models for autocomplete |
@@ -767,12 +768,30 @@ PI_PIDIFF_ENABLE=false pi
767
768
  | `myk-pi-tools pr claude-md` | Fetch `CLAUDE.md` and `AGENTS.md` content for a PR's repository |
768
769
  | `myk-pi-tools release create` | Create a GitHub release with changelog |
769
770
  | `myk-pi-tools db` | Review database query commands |
770
- | `myk-pi-tools reviews` | Review handling commands |
771
+ | `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store) |
772
+ | `myk-pi-tools reviews address` | Apply replies/statuses to a fetched reviews JSON, then post and store it (see below) |
771
773
  | `myk-pi-tools memory` | Project memory commands — persistent per-repo learning |
772
774
  | `myk-pi-tools ai-cli` | AI CLI commands (cursor, claude, gemini) |
773
775
  | `myk-pi-tools coderabbit` | CodeRabbit commands (check, trigger, retry) |
774
776
  | `myk-pi-tools settings get [key ...]` | Resolve settings (project → global → env → default). No args = all keys |
775
777
 
778
+ `reviews address` — bulk-apply replies to a fetched reviews JSON, then post and store it:
779
+
780
+ ```bash
781
+ myk-pi-tools reviews address [--json <reviews.json>] [--replies <mapping.txt>] [--set <status>]
782
+ ```
783
+
784
+ - `--json`: reviews JSON to process (default: newest `pr-*-reviews.json` in `.pi/tmp`)
785
+ - `--replies`: mapping file, one rule per line — `<path>:<line>|<reply>` or `*|<reply>` (matches all pending findings);
786
+ `#` comments and blank lines ignored
787
+ - `--set`: target status for matched findings — `addressed` (default), `skipped`, `not_addressed`, or `failed`
788
+ - Without `--replies`, pending findings that already carry a reply only get their status flipped (status-only mode)
789
+ - Exit code `0` = replies posted and stored; non-zero = nothing matched, validation failed, or posting failed
790
+ (the JSON is kept for retry — re-running with the same or a corrected mapping resumes unposted entries)
791
+ - Validation failures change nothing: a rule matching several distinct findings at one location, or a status `post`
792
+ rejects for Qodo sticky findings (`not_addressed`/`failed`; `skipped` only with an issue-spec-update reply),
793
+ exits non-zero before the JSON is saved
794
+
776
795
  ### What's protected
777
796
 
778
797
  **Filesystem isolation** — the container cannot access anything outside the mounted volumes:
@@ -135,7 +135,7 @@ Format rules:
135
135
  **Commit changes:**
136
136
 
137
137
  1. `git status` to see changes
138
- 2. `git add <specific files>` for each file (NEVER `git add .`)
138
+ 2. `git add -- <specific files>` for each file (NEVER `git add .`; the `--` terminator prevents paths being read as options)
139
139
  3. Commit with proper format
140
140
  4. Report the result
141
141
 
@@ -122,6 +122,9 @@ export function registerDreaming(
122
122
  ` - Completed features/PRs merged → [done] → completions.md\n` +
123
123
  ` - Patterns or conventions → [pattern] → patterns.md\n` +
124
124
  ` - Architectural/design decisions → [decision] → decisions.md\n` +
125
+ ` QUALITY GATE for extraction: one-off permissions granted in a single session\n` +
126
+ ` ("you can merge this PR", "skip tests just this once") are session instructions,\n` +
127
+ ` NOT memories — never extract them. Only standing, repeated rules are durable.\n` +
125
128
  ` Do NOT add duplicates of existing entries.\n` +
126
129
  `4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
127
130
  ` If it does, read the timestamp — only process sessions newer than that.\n` +
@@ -131,6 +134,7 @@ export function registerDreaming(
131
134
  `5. Reorganize each topic file:\n` +
132
135
  ` - Remove duplicate or near-duplicate entries\n` +
133
136
  ` - Remove stale/useless entries\n` +
137
+ ` - Remove session-scoped or expired entries — they are transient by design\n` +
134
138
  ` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
135
139
  ` - NEVER remove or modify entries marked with *(pinned)*\n` +
136
140
  ` - NEVER remove or modify entries marked with *(enforced)* — these have code-enforced triggers/actions that are keyed by text hash. ANY text change destroys the enforcement binding.\n` +
@@ -7,7 +7,7 @@ import { createLogger } from "../shared/logger.js";
7
7
  import { realpathSync } from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { join } from "node:path";
10
- import { DANGEROUS, executableText, getCurrentBranch, hasGitSub, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
10
+ import { DANGEROUS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
11
11
 
12
12
  const enfLog = createLogger("enforcement");
13
13
  enfLog.debug("enforcement-helpers module loaded");
@@ -1094,59 +1094,398 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1094
1094
  }
1095
1095
 
1096
1096
  /**
1097
- * Detect a REAL `git <sub>` invocation (sub = "commit" or "push") at a shell command
1098
- * position — NOT a substring inside a heredoc body, string argument, or file path.
1097
+ * ── Real git-invocation detection (single source of truth) ─────────────────
1099
1098
  *
1100
- * BUG #3 FIX: `hasGitSub` matches the literal text `git ... commit` ANYWHERE, so
1101
- * writing a file whose CONTENT mentions "git commit" (heredoc body), an echo/string
1102
- * arg like `echo 'run git commit later'`, or a path like `node "/tmp/git commit x.mjs"`
1103
- * wrongly triggered the commit/push guard. This helper narrows detection to genuine
1104
- * invocations by (1) stripping heredoc bodies and (2) requiring `git` at a command
1105
- * boundary (start, or after ; && || | & newline ( { or control-flow keywords).
1099
+ * Shared by isRealGitCommitOrPush (git-expert-only guard), injectSignoff (DCO),
1100
+ * and checkGitProtection's sub-detection in enforcement.ts. The constructions
1101
+ * must never drift: a divergence is either a guard bypass or a corrupted
1102
+ * injection.
1106
1103
  *
1107
- * Security note: we DO NOT attempt full shell quote parsing. We block conservatively
1108
- * — any `git <flags> <sub>` at a command boundary in the (heredoc-stripped) command
1109
- * still blocks. This narrows false-positives without opening a bypass: a real
1110
- * `git commit`/`git push` cannot avoid appearing at a command boundary.
1104
+ * Wrapper prefixes: sudo/env (incl. argument-taking flags), VAR=value
1105
+ * assignments, command/builtin/exec — AND rtk. The user-level rtk extension
1106
+ * rewrites every bash command through `rtk rewrite` BEFORE enforcement
1107
+ * evaluates it (`git commit -m x` arrives as `rtk git commit -m x`), so
1108
+ * without the rtk alternative every wrapped invocation is invisible to the
1109
+ * guards (live incident: the git-expert-only commit guard never fired on a
1110
+ * direct orchestrator commit). Boundary includes backtick because unescaped
1111
+ * backticks are command substitution and DO execute git.
1112
+ *
1113
+ * Qodo #8 (PR #916): boundaries also cover shell condition keywords
1114
+ * (if/while/until/!), negation, and exec-wrappers that take arguments
1115
+ * (timeout/nice/nohup/xargs/time), and an opening quote after `bash -c` /
1116
+ * `sh -c` / `eval` opens a nested command position — `timeout 60 git push`,
1117
+ * `if git commit ...`, `time git commit`, and `bash -c "git commit"` are all
1118
+ * real invocations the old boundary set missed.
1119
+ *
1120
+ * Qodo round 2 (PR #916): the shell alternative accepts ANY c-containing
1121
+ * short-flag cluster (`bash -lc`, `sh -ec`, `bash -xc`) plus long flags
1122
+ * (`bash --login -c`); and quoted PROSE is masked before matching
1123
+ * (maskQuotedProse) so `!`/`if`/`while` inside a `-m` message or an echoed
1124
+ * string can never look like a command boundary.
1111
1125
  */
1112
- export function isRealGitCommitOrPush(command: string): boolean {
1113
- // Strip heredoc bodies so file CONTENT that mentions "git commit" does not count.
1126
+ const GIT_BOUNDARY_SRC =
1127
+ /(?:^|[;&|\n(){`]|!|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b|\bif\b|\bwhile\b|\buntil\b|(?:\b(?:bash|sh|zsh|dash|ksh)\s+(?:-{1,2}[a-zA-Z][a-zA-Z-]*\s+)*-[a-zA-Z]*c[a-zA-Z]*\s*|\beval\s*)(?:["'`]\s*)?)\s*/.source;
1128
+ const GIT_WRAPPER_SRC = /(?:sudo|env|timeout|nice|nohup|xargs|time)(?:\s+(?!git\b)\S+)*\s+/.source;
1129
+ const GIT_ASSIGN_SRC = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
1130
+ const GIT_WRAP_SRC = /(?:command|builtin|exec)\s+/.source;
1131
+ const GIT_RTK_SRC = /rtk(?:\s+-\S+)*\s+/.source;
1132
+ const GIT_PREFIX_SRC = `(?:${GIT_WRAPPER_SRC}|${GIT_ASSIGN_SRC}|${GIT_WRAP_SRC}|${GIT_RTK_SRC})*`;
1133
+ const GIT_EXE_SRC = /(?:\S*\/)?git\b/.source;
1134
+ const GIT_FLAGS_SRC = /(?:\s+(?:-[a-zA-Z]\s+\S*|-\S+))*/.source;
1135
+
1136
+ /** Prefix that opens a nested command string: `bash -lc "..."`, `sh -c '...'`, `eval "..."`. */
1137
+ const SHELL_C_PREFIX_RE =
1138
+ /(?:\b(?:bash|sh|zsh|dash|ksh)\s+(?:-{1,2}[a-zA-Z][a-zA-Z-]*\s+)*-[a-zA-Z]*c[a-zA-Z]*|\beval)\s*$/;
1139
+
1140
+ /**
1141
+ * Mask quoted PROSE so it can never look like a command boundary (Qodo
1142
+ * rounds 2–4, PR #916). A scanner implementing SHELL quote semantics, not a
1143
+ * plain regex and not heuristics:
1144
+ * - a BACKSLASH outside quotes escapes the next character, so `\"` is a
1145
+ * literal and `echo \"; git push; echo \"x\"` still executes git push;
1146
+ * - an apostrophe ALWAYS opens a single-quote string outside double quotes
1147
+ * (shell grammar has no contractions — `foo' ; git push main'`
1148
+ * concatenates `foo` with the QUOTED text ` ; git push main`, so the
1149
+ * quoted part is prose, never code). An apostrophe inside a `#` comment
1150
+ * is irrelevant because comment tails are blanked first;
1151
+ * - a `#` at a word start begins a comment — blanked to end of line so its
1152
+ * quotes cannot pair with code;
1153
+ * - inside a quoted span only COMMAND SUBSTITUTION (`$(...)`, backticks)
1154
+ * executes, so a span is blanked EXCEPT its substitution regions —
1155
+ * `echo "if git commit fails $(date)"` keeps only `$(date)` visible;
1156
+ * - a span that directly follows `bash -c`/`sh -c`/`eval` is a nested
1157
+ * command string and stays fully visible;
1158
+ * - an UNTERMINATED span is a shell syntax error — nothing executes — so it
1159
+ * is blanked entirely.
1160
+ * The result is LENGTH-PRESERVING so match indices on the masked text are
1161
+ * valid on the original string.
1162
+ */
1163
+ function maskQuotedProse(command: string): string {
1164
+ const out = command.split("");
1165
+ let maskedSpans = 0;
1166
+ /** Sentinel written in place of a substitution's CLOSING delimiter (`)` or
1167
+ * backtick) in the masked output. It lets consumers distinguish the
1168
+ * delimiter from a quoted `)` inside the substitution's content — both are
1169
+ * restored verbatim, but only the delimiter becomes SENTINEL (Qodo round
1170
+ * 8, PR #916). NUL cannot occur in a real command string. */
1171
+ const SUBST_CLOSE_SENTINEL = "\x00";
1172
+ const blankRange = (from: number, to: number): void => {
1173
+ for (let k = from; k < to; k++) out[k] = " ";
1174
+ };
1175
+ /** Blank [from, to) except its $(...) and backtick substitution regions,
1176
+ * which are restored verbatim from the original command. The close-paren
1177
+ * scan is QUOTE-AWARE (Qodo round 5, PR #916): a `)` inside a quoted
1178
+ * argument of the substitution (`$(printf ')'; git push main)`) does not
1179
+ * close it — only an unquoted, unescaped paren changes depth. The closing
1180
+ * delimiter itself is restored as SUBST_CLOSE_SENTINEL so downstream
1181
+ * parsers can tell it apart from content parens (Qodo round 8). */
1182
+ const blankExceptSubstitutions = (from: number, to: number): void => {
1183
+ blankRange(from, to);
1184
+ let k = from;
1185
+ while (k < to) {
1186
+ const c = command[k]!;
1187
+ if (c === "$" && command[k + 1] === "(") {
1188
+ let depth = 1;
1189
+ let m = k + 2;
1190
+ let q: string | null = null;
1191
+ const closers: number[] = [];
1192
+ while (m < to && depth > 0) {
1193
+ const d = command[m]!;
1194
+ if (q) {
1195
+ // Inside quotes: only the matching quote closes (double quotes
1196
+ // process backslash escapes; single quotes do not).
1197
+ if (q === '"' && d === "\\") {
1198
+ m += 2;
1199
+ continue;
1200
+ }
1201
+ if (d === q) q = null;
1202
+ m++;
1203
+ continue;
1204
+ }
1205
+ if (d === "'" || d === '"') {
1206
+ q = d;
1207
+ m++;
1208
+ continue;
1209
+ }
1210
+ if (d === "\\") {
1211
+ m += 2;
1212
+ continue;
1213
+ }
1214
+ if (d === "(") depth++;
1215
+ else if (d === ")") {
1216
+ // EVERY unquoted `)` that decrements depth closes a (possibly
1217
+ // nested) substitution — outer closer and inner closers alike —
1218
+ // so all of them become sentinels (Qodo round 8: a quoted `)`
1219
+ // stays live content).
1220
+ closers.push(m);
1221
+ depth--;
1222
+ }
1223
+ m++;
1224
+ }
1225
+ const stop = depth === 0 ? m : to;
1226
+ for (let x = k; x < stop; x++) out[x] = command[x]!;
1227
+ for (const p of closers) {
1228
+ if (p < stop) out[p] = SUBST_CLOSE_SENTINEL;
1229
+ }
1230
+ k = stop;
1231
+ continue;
1232
+ }
1233
+ if (c === "`") {
1234
+ let m = k + 1;
1235
+ while (m < to && command[m] !== "`") m++;
1236
+ const stop = m < to ? m + 1 : to;
1237
+ for (let x = k; x < stop; x++) out[x] = command[x]!;
1238
+ if (m < to) out[stop - 1] = SUBST_CLOSE_SENTINEL;
1239
+ k = stop;
1240
+ continue;
1241
+ }
1242
+ k++;
1243
+ }
1244
+ };
1245
+ let i = 0;
1246
+ while (i < command.length) {
1247
+ const ch = command[i]!;
1248
+ // Comment tail: `#` at a word start (${...} and =# forms excluded) is a
1249
+ // comment — blank to end of line, quotes inside cannot pair with code.
1250
+ if (ch === "#" && (i === 0 || /[\s(;&|`]/.test(command[i - 1]!))) {
1251
+ let j = i;
1252
+ while (j < command.length && command[j] !== "\n") j++;
1253
+ blankRange(i, j);
1254
+ maskedSpans++;
1255
+ i = j;
1256
+ continue;
1257
+ }
1258
+ // A backslash outside quotes escapes the next char — it can never open
1259
+ // a span (handles \\" and \\').
1260
+ if (ch === "\\") {
1261
+ i += 2;
1262
+ continue;
1263
+ }
1264
+ if (ch === '"' || ch === "'") {
1265
+ const quote = ch;
1266
+ let j = i + 1;
1267
+ let closed = false;
1268
+ while (j < command.length) {
1269
+ const c = command[j]!;
1270
+ // Inside double quotes a backslash escapes the next char (\" and \\).
1271
+ // Inside single quotes a backslash is literal; only ' terminates.
1272
+ if (quote === '"' && c === "\\") {
1273
+ j += 2;
1274
+ continue;
1275
+ }
1276
+ if (c === quote) {
1277
+ closed = true;
1278
+ break;
1279
+ }
1280
+ j++;
1281
+ }
1282
+ const end = closed ? j + 1 : command.length;
1283
+ if (closed && SHELL_C_PREFIX_RE.test(command.slice(0, i))) {
1284
+ // Nested command string — keep fully visible for the boundary regex.
1285
+ } else {
1286
+ // Prose span (or unterminated = syntax error, nothing executes):
1287
+ // blank it except command substitutions, which still execute.
1288
+ blankExceptSubstitutions(i, end);
1289
+ maskedSpans++;
1290
+ }
1291
+ i = end;
1292
+ continue;
1293
+ }
1294
+ i++;
1295
+ }
1296
+ if (maskedSpans > 0) enfLog.debug("maskQuotedProse", "masked", maskedSpans, "chars", command.length);
1297
+ return out.join("");
1298
+ }
1299
+
1300
+ /** Regex matching a REAL `git <sub>` invocation at a shell command position. */
1301
+ export function realGitSubRegex(sub: string, capture = false): RegExp {
1302
+ const body = `${GIT_BOUNDARY_SRC}${GIT_PREFIX_SRC}${GIT_EXE_SRC}${GIT_FLAGS_SRC}\\s+${sub}\\b`;
1303
+ const re = new RegExp(capture ? `(${body})` : body);
1304
+ enfLog.debug("realGitSubRegex built", sub, `capture=${capture}`);
1305
+ return re;
1306
+ }
1307
+
1308
+ /**
1309
+ * True when the command really invokes `git <sub>` — command position only,
1310
+ * any wrapper prefix (sudo/env/assignments/command/builtin/exec/rtk),
1311
+ * heredoc bodies stripped. Replaces the old loose matcher, which matched
1312
+ * `git ... <sub>` ANYWHERE, false-positiving on sub-command names inside -m
1313
+ * messages ("git commit -m 'add sites'" tripped the git-add checks) and in
1314
+ * other statements of a compound command ("git log | grep checkout" tripped
1315
+ * the use_worktrees checkout block).
1316
+ */
1317
+ export function realGitSub(command: string, sub: string): boolean {
1318
+ const result = realGitSubRegex(sub).test(maskQuotedProse(stripHeredocBodies(command)));
1319
+ enfLog.debug("realGitSub", sub, result);
1320
+ return result;
1321
+ }
1322
+
1323
+ /**
1324
+ * Argument regions of every real `git <sub>` invocation: the statement-scoped
1325
+ * text after the subcommand, up to the next ; & | or newline — so arguments
1326
+ * of OTHER statements in a compound command are never included
1327
+ * ("git add f.ts && bash .dev/x.sh" must not treat .dev/x.sh as staged).
1328
+ * Regions are raw (quotes intact); consumers inspecting FLAGS must strip
1329
+ * quoted spans first (a -m message is not an argument list).
1330
+ */
1331
+ export function gitSubArgRegions(command: string, sub: string): string[] {
1332
+ // Boundaries are matched on PROSE-MASKED text (a quoted "Done! git push"
1333
+ // must not look like a command position), but the region CONTENT comes
1334
+ // from the original string — masking is length-preserving, so match
1335
+ // indices on the masked text are valid on the original, and quoted VALUES
1336
+ // (pathspecs, "main" refspecs) stay intact for consumers (Qodo round 2,
1337
+ // PR #916).
1338
+ return subArgSpans(command, sub).map((s) => s.text);
1339
+ }
1340
+
1341
+ /** Argument-region spans of every real `git <sub>` invocation, with start/end
1342
+ * offsets into the heredoc-stripped command and the aligned masked text —
1343
+ * consumers that must distinguish substitution syntax from refspec content
1344
+ * use the masked slice (Qodo round 6, PR #916). */
1345
+ function subArgSpans(command: string, sub: string): Array<{ start: number; end: number; text: string; maskedText: string; stripped: string; masked: string }> {
1114
1346
  const stripped = stripHeredocBodies(command);
1115
- // Require git at a command position: start-of-string, or after a statement separator
1116
- // / pipe / background / newline / subshell-open / brace-group / control-flow keyword.
1117
- const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
1118
- // PREFIX BYPASS FIX: real git invocations can be preceded by allowed prefixes that the
1119
- // old boundary-only check missed — `sudo git commit`, `env GIT_DIR=x git commit`,
1120
- // `GIT_DIR=x git commit` (bare VAR=value assignment prefix), `command git commit`,
1121
- // `builtin`/`exec` wrappers. Allow an optional sequence of these before git:
1122
- // - sudo (with flags), env (with flags)
1123
- // - one-or-more VAR=value assignments
1124
- // - command / builtin / exec wrappers
1125
- //
1126
- // SUDO/ENV ARG BYPASS FIX (finding #3): real sudo/env flags TAKE A FOLLOWING
1127
- // ARGUMENT (`sudo -u root git commit`, `sudo -g grp git commit`, `env -u HOME git
1128
- // commit`), and env accepts VAR=value operands (`env FOO=bar git commit`). The old
1129
- // `(?:\s+-\S+)*` only consumed bare flags, so the arg token stopped the prefix early
1130
- // and these BYPASSED the guard. We now consume, after `sudo`/`env`, a sequence of:
1131
- // - `--long ARG` long option taking an argument (`--user root`)
1132
- // - `-x ARG` short option taking an argument (`-u root`, `-g grp`)
1133
- // - `-\S+` bare flag (`-n`, `--preserve-env`, combined `-xyz`)
1134
- // - `VAR=value` env operand (`FOO=bar`)
1135
- // A `(?!git\b)` guard on the arg-consuming forms prevents swallowing the `git` token
1136
- // itself; alternatives are ordered specific-first and backtracking resolves the rest.
1137
- const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
1138
- const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
1139
- const wrapArg = /(?:command|builtin|exec)\s+/.source;
1140
- const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
1141
- // Allow an optional path prefix on git itself: `/usr/bin/git`, `/bin/git`, etc. The
1142
- // executable must END in `/git` so that a path ARGUMENT to another program (e.g.
1143
- // `node "/tmp/git commit test.mjs"`) is NOT matched — there git is not the invoked
1144
- // command (first token is `node`, "git commit" lives inside a quoted arg).
1145
- const gitExe = /(?:\S*\/)?git\b/.source;
1146
- // Then allow git's own flags (git -c k=v, git -C dir, --no-pager, etc.) before the sub.
1147
- const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
1347
+ const masked = maskQuotedProse(stripped);
1348
+ const re = new RegExp(
1349
+ `${GIT_BOUNDARY_SRC}${GIT_PREFIX_SRC}${GIT_EXE_SRC}${GIT_FLAGS_SRC}\\s+${sub}\\b`,
1350
+ "g",
1351
+ );
1352
+ const spans: Array<{ start: number; end: number; text: string; maskedText: string; stripped: string; masked: string }> = [];
1353
+ for (const m of masked.matchAll(re)) {
1354
+ const start = (m.index ?? 0) + m[0].length;
1355
+ const rest = stripped.slice(start);
1356
+ const end = rest.search(/[;&|\n]/);
1357
+ const stop = end === -1 ? stripped.length : start + end;
1358
+ spans.push({
1359
+ start,
1360
+ end: stop,
1361
+ text: stripped.slice(start, stop),
1362
+ maskedText: masked.slice(start, stop),
1363
+ stripped,
1364
+ masked,
1365
+ });
1366
+ }
1367
+ enfLog.debug("gitSubArgRegions", sub, `regions=${spans.length}`);
1368
+ return spans;
1369
+ }
1370
+
1371
+ /** Remove quoted spans so message CONTENT never looks like arguments. */
1372
+ export function stripQuotedSpans(s: string): string {
1373
+ return s.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'[^']*'/g, "''");
1374
+ }
1375
+
1376
+ /** Tokenize a git-add argument region: quote-aware tokens, redirects dropped. */
1377
+ function gitAddTokens(region: string): string[] {
1378
+ // Empty tokens (an unterminated residual quote yields one) carry no
1379
+ // pathspec and must not reach consumers (Qodo live battery, PR #916).
1380
+ return tokenize(region).filter((t) => t && !/[<>]/.test(t));
1381
+ }
1382
+
1383
+ /** Argument regions of every real `git <sub>` invocation with
1384
+ * command-substitution CLOSING delimiters neutralized (Qodo rounds 6+8,
1385
+ * PR #916): parses the ORIGINAL text (quoted values survive) but blanks
1386
+ * every character the masked text replaced with the substitution-close
1387
+ * sentinel — the `)`/backtick that ENDS a `$(...)`. A `)` inside the
1388
+ * substitution's quoted content (a legal branch-name character, e.g.
1389
+ * `$(git push origin 'foo)')`) is restored verbatim in the masked text
1390
+ * and therefore STAYS, so the ref `foo)` is compared exactly; a `)` in a
1391
+ * top-level quoted span (prose-blanked in the masked text) also stays.
1392
+ * Consumers that TOKENIZE arguments use this instead of
1393
+ * gitSubArgRegions, so a bulk `git add .` inside `$(...)` is not read as
1394
+ * the pathspec `.)` while a quoted `foo)` ref is not truncated. */
1395
+ export function gitSubParseRegions(command: string, sub: string): string[] {
1396
+ return subArgSpans(command, sub).map((s) =>
1397
+ s.text
1398
+ .split("")
1399
+ .map((ch, idx) => (s.maskedText[idx] === "\x00" ? " " : ch))
1400
+ .join(""),
1401
+ );
1402
+ }
1403
+
1404
+ /** Destination refs of every real `git push` invocation (Qodo #5, PR #916).
1405
+ * Quote-aware tokenization (a quoted "main" is still a refspec), flags and
1406
+ * remote URLs skipped, and each refspec reduced to its DESTINATION side —
1407
+ * `HEAD:main`, `feature:main`, `+main`, `refs/heads/main`, and plain `main`
1408
+ * all yield `main`. Returns the first ref that names a protected branch
1409
+ * (full or suffix match), or null when no push target is protected. */
1410
+ export function pushTargetsProtectedBranch(command: string, protectedBranches: Iterable<string>): string | null {
1411
+ const pbSet = new Set(protectedBranches);
1412
+ for (const parseRegion of gitSubParseRegions(command, "push")) {
1413
+ // Quoted refspec values ("main", 'release)') survive because the region
1414
+ // comes from the ORIGINAL text; live substitution `)`s are already
1415
+ // neutralized by gitSubParseRegions (Qodo rounds 5-6, PR #916).
1416
+ let pastSep = false;
1417
+ for (const token of tokenize(parseRegion)) {
1418
+ if (token === "--") {
1419
+ pastSep = true;
1420
+ continue;
1421
+ }
1422
+ if (!pastSep && token.startsWith("-")) continue; // flag
1423
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(token)) continue; // remote URL (https://, ssh://)
1424
+ // Forced-push leading + is part of the refspec syntax, not the ref.
1425
+ const spec = token.startsWith("+") ? token.slice(1) : token;
1426
+ const colon = spec.lastIndexOf(":");
1427
+ const raw = (colon >= 0 ? spec.slice(colon + 1) : spec).trim();
1428
+ if (!raw) continue;
1429
+ const dest = raw.replace(/^refs\/heads\//, "");
1430
+ if (pbSet.has(dest)) {
1431
+ enfLog.debug("pushTargetsProtectedBranch", `protected=${dest}`);
1432
+ return dest;
1433
+ }
1434
+ }
1435
+ }
1436
+ return null;
1437
+ }
1438
+
1439
+ /**
1440
+ * Pathspec tokens of every real `git add` invocation — quote-aware (a quoted
1441
+ * path with spaces is one token), flags and redirects excluded, everything
1442
+ * after `--` included. Empty when the command stages nothing; null when it
1443
+ * invokes no real `git add` at all. Consumers: the gitignore staging guard.
1444
+ */
1445
+ export function gitAddPathspecs(command: string): string[] | null {
1446
+ const regions = gitSubParseRegions(command, "add");
1447
+ if (regions.length === 0) return null;
1448
+ const paths: string[] = [];
1449
+ for (const region of regions) {
1450
+ let pastSeparator = false;
1451
+ for (const token of gitAddTokens(region)) {
1452
+ if (token === "--") {
1453
+ pastSeparator = true;
1454
+ continue;
1455
+ }
1456
+ if (!pastSeparator && token.startsWith("-")) continue; // flag
1457
+ paths.push(token);
1458
+ }
1459
+ }
1460
+ enfLog.debug("gitAddPathspecs", `paths=${paths.length}`);
1461
+ return paths;
1462
+ }
1463
+
1464
+ /**
1465
+ * Detect a REAL `git <sub>` invocation (sub = "commit" or "push") at a shell
1466
+ * command position — NOT a substring inside a heredoc body, string argument,
1467
+ * or file path.
1468
+ *
1469
+ * BUG #3 FIX: the old loose matcher matched the literal text `git ... commit`
1470
+ * ANYWHERE, so writing a file whose CONTENT mentions "git commit" (heredoc body), an
1471
+ * echo/string arg like `echo 'run git commit later'`, or a path like
1472
+ * `node "/tmp/git commit x.mjs"` wrongly triggered the commit/push guard.
1473
+ * This helper narrows detection to genuine invocations by (1) stripping
1474
+ * heredoc bodies and (2) requiring `git` at a command boundary (start, or
1475
+ * after ; && || | & newline ( { backtick or control-flow keywords).
1476
+ *
1477
+ * Security note: we DO NOT attempt full shell quote parsing. We block
1478
+ * conservatively — any `git <flags> <sub>` at a command boundary in the
1479
+ * (heredoc-stripped) command still blocks. This narrows false-positives
1480
+ * without opening a bypass: a real `git commit`/`git push` cannot avoid
1481
+ * appearing at a command boundary.
1482
+ */
1483
+ export function isRealGitCommitOrPush(command: string): boolean {
1484
+ // Strip heredoc bodies so file CONTENT that mentions "git commit" does not count,
1485
+ // and mask quoted prose so "if git commit" inside a -m message does not either.
1486
+ const stripped = maskQuotedProse(stripHeredocBodies(command));
1148
1487
  for (const sub of ["commit", "push"]) {
1149
- if (new RegExp(boundary + prefix + gitExe + gitFlags + `\\s+${sub}\\b`).test(stripped)) {
1488
+ if (realGitSubRegex(sub).test(stripped)) {
1150
1489
  enfLog.debug("git_invocation_detected", sub);
1151
1490
  return true;
1152
1491
  }
@@ -1180,28 +1519,35 @@ export function injectSignoff(command: string): string {
1180
1519
  // Reuse the exact command-position/prefix construction from isRealGitCommitOrPush,
1181
1520
  // capturing everything up to and including the `commit` token so we can insert
1182
1521
  // ` --signoff` immediately after it via a NON-global replace (first real match only).
1183
- const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
1184
- const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
1185
- const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
1186
- const wrapArg = /(?:command|builtin|exec)\s+/.source;
1187
- const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
1188
- const gitExe = /(?:\S*\/)?git\b/.source;
1189
- const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
1190
- const commitRe = new RegExp(`(${boundary}${prefix}${gitExe}${gitFlags}\\s+commit\\b)`);
1191
-
1192
- // Pattern A: echo/printf "..." | git commit -F - (real invocation after last pipe)
1522
+ // Shared command-position matcher (rtk/wrapper aware) — must stay in lockstep
1523
+ // with isRealGitCommitOrPush so a guarded invocation is always an injectable one.
1524
+ const commitRe = realGitSubRegex("commit", true);
1525
+
1526
+ // Pattern A: echo/printf "..." | git commit -F - (real invocation after last pipe).
1527
+ // Matching runs on PROSE-MASKED text so a `git commit` mention inside the
1528
+ // echoed string cannot absorb the signoff (Qodo round 3, PR #916); the
1529
+ // splice uses the masked match's index, valid on the raw string because
1530
+ // masking is length-preserving.
1193
1531
  const pipeIdx = command.lastIndexOf("|");
1194
- if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
1532
+ if (pipeIdx !== -1) {
1195
1533
  const echoPart = command.slice(0, pipeIdx);
1196
1534
  const gitPart = command.slice(pipeIdx);
1197
- if (SIGNOFF_RE.test(gitPart) || SHORT_S_RE.test(gitPart)) return command;
1198
- if (!commitRe.test(gitPart)) return command;
1199
- enfLog.debug("injectSignoff", "pattern A (piped git commit -F -)");
1200
- return echoPart + gitPart.replace(commitRe, "$1 --signoff");
1535
+ const maskedGit = maskQuotedProse(gitPart);
1536
+ if (/git\s+commit\s+.*-F\s*-/.test(maskedGit)) {
1537
+ if (SIGNOFF_RE.test(gitPart) || SHORT_S_RE.test(gitPart)) return command;
1538
+ const mA = commitRe.exec(maskedGit);
1539
+ if (!mA) return command;
1540
+ enfLog.debug("injectSignoff", "pattern A (piped git commit -F -)");
1541
+ const cutA = pipeIdx + mA.index + mA[1]!.length;
1542
+ return command.slice(0, cutA) + " --signoff" + command.slice(cutA);
1543
+ }
1201
1544
  }
1202
1545
 
1203
- // Pattern B: bare / inline git commit (no message pipe)
1204
- const m = commitRe.exec(command);
1546
+ // Pattern B: bare / inline git commit (no message pipe) — matched on the
1547
+ // masked text so prose mentions (echo "if git commit" && git commit -m x)
1548
+ // cannot take the injection; spliced at the masked index (length-preserving).
1549
+ const masked = maskQuotedProse(command);
1550
+ const m = commitRe.exec(masked);
1205
1551
  if (!m) return command;
1206
1552
  // Scope the "already signed" check to the MATCHED real invocation ONLY, not the
1207
1553
  // whole command. The invocation region runs from the start of the match to the
@@ -1253,20 +1599,20 @@ export function injectSignoff(command: string): string {
1253
1599
  .replace(/'[^']*'/g, "''");
1254
1600
  if (SIGNOFF_RE.test(region) || SHORT_S_RE.test(region)) return command;
1255
1601
  enfLog.debug("injectSignoff", "pattern B (inline git commit)");
1256
- return command.replace(commitRe, "$1 --signoff");
1602
+ // Splice at the masked match position — a raw replace could pick a prose
1603
+ // mention instead of the real invocation (Qodo round 3, PR #916).
1604
+ const cut = m.index + m[1]!.length;
1605
+ return command.slice(0, cut) + " --signoff" + command.slice(cut);
1257
1606
  }
1258
1607
 
1259
1608
  /** Detect git add --force / -f (including combined short options like -fn).
1260
1609
  * Respects -- end-of-options marker and shell separators (&&, ;, |, ||). */
1261
1610
  export function hasGitAddForce(command: string): boolean {
1262
- if (!hasGitSub(command, "add")) return false;
1263
- // Split on shell separators to isolate individual statements
1264
- const statements = command.split(/\s*(?:&&|\|\||[;|])\s*/);
1265
- for (const stmt of statements) {
1266
- const addMatch = stmt.match(/\bgit\b.*\badd\b\s+(.*)/);
1267
- if (!addMatch) continue;
1268
- const tokens = addMatch[1].split(/\s+/);
1269
- for (const token of tokens) {
1611
+ // Statement-scoped and invocation-scoped: a `-f` in another statement
1612
+ // (rm -f) or inside a -m message ("git commit -m 'add -f flag'") is not a
1613
+ // `git add --force`.
1614
+ for (const region of gitSubParseRegions(command, "add")) {
1615
+ for (const token of gitAddTokens(region)) {
1270
1616
  if (token === "--") break; // Everything after -- is a pathspec
1271
1617
  if (token === "--force") return true;
1272
1618
  // Short option: -f or combined like -fn, -vf (starts with - but not --)
@@ -1278,15 +1624,14 @@ export function hasGitAddForce(command: string): boolean {
1278
1624
 
1279
1625
  /** Check if a git add command uses bulk-stage tokens (., -A, --all) before the -- separator. */
1280
1626
  export function hasGitAddBulk(command: string): boolean {
1281
- if (!hasGitSub(command, "add")) return false;
1282
- const addMatch = command.match(/\bgit\b.*\badd\b\s+(.*)/);
1283
- if (!addMatch) return false;
1284
- const args = addMatch[1];
1285
- // Split tokens, only check before -- (end-of-options marker)
1286
- const tokens = args.split(/\s+/);
1287
- for (const token of tokens) {
1288
- if (token === "--") break; // Everything after -- is a pathspec
1289
- if (token === "." || token === "-A" || token === "--all") return true;
1627
+ // Statement-scoped: only the arguments of a REAL `git add` invocation count.
1628
+ // The old loose match captured across statements and into -m message text
1629
+ // ("git commit -m 'add .'" wrongly counted as a bulk add).
1630
+ for (const region of gitSubParseRegions(command, "add")) {
1631
+ for (const token of gitAddTokens(region)) {
1632
+ if (token === "--") break; // Everything after -- is a pathspec
1633
+ if (token === "." || token === "-A" || token === "--all") return true;
1634
+ }
1290
1635
  }
1291
1636
  return false;
1292
1637
  }