pi-orchestrator-config 4.9.0 → 4.11.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/extensions/orchestrator/dreaming.ts +6 -2
- package/extensions/orchestrator/enforcement-helpers.ts +429 -84
- package/extensions/orchestrator/enforcement-rules.ts +21 -5
- package/extensions/orchestrator/enforcement.ts +39 -29
- package/extensions/orchestrator/git-helpers.ts +0 -6
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/memory-embeddings.ts +12 -17
- package/extensions/orchestrator/memory-promotion.ts +37 -17
- package/extensions/orchestrator/memory-provenance.ts +17 -17
- package/extensions/orchestrator/memory-scoring.ts +378 -112
- package/extensions/orchestrator/memory-tools.ts +111 -310
- package/extensions/orchestrator/memory-tree.ts +96 -81
- package/extensions/orchestrator/memory-write.ts +728 -0
- package/extensions/orchestrator/pi-config-review-state.ts +14 -6
- package/extensions/orchestrator/preference-extractor.ts +54 -66
- package/extensions/orchestrator/project-settings.ts +4 -0
- package/extensions/orchestrator/promotion-queue.ts +2 -1
- package/extensions/orchestrator/review-learning.ts +1228 -0
- package/extensions/orchestrator/rules.ts +47 -9
- package/extensions/orchestrator/session-search.ts +96 -29
- package/extensions/orchestrator/situation-report.ts +38 -7
- package/extensions/orchestrator/state-jsonl.ts +119 -3
- package/extensions/orchestrator/utils.ts +67 -16
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/shared/openai-compatible-discovery.ts +26 -11
- package/package.json +1 -1
- package/rules/35-memory.md +15 -0
- package/settings-keys.json +43 -0
|
@@ -26,7 +26,7 @@ import { setSlot } from "./status-bar.js";
|
|
|
26
26
|
import { rebuildAndOrganize } from "./situation-report.js";
|
|
27
27
|
import { runPromotionPass } from "./memory-promotion.js";
|
|
28
28
|
import { mergeProvenancePending } from "./memory-provenance.js";
|
|
29
|
-
import { getProjectTmpDir, shouldSkipOneshotShutdownDream } from "./utils.js";
|
|
29
|
+
import { getProjectTmpDir, getProjectMemoryDir, shouldSkipOneshotShutdownDream } from "./utils.js";
|
|
30
30
|
import { fileLog } from "../shared/file-logger.js";
|
|
31
31
|
|
|
32
32
|
// Scoring rebuild runs every 30 minutes (cheap, no LLM — just rescores and reorganizes)
|
|
@@ -101,7 +101,7 @@ export function registerDreaming(
|
|
|
101
101
|
updateDreamStatus();
|
|
102
102
|
currentDreamId = ""; // Reset until we get the ID from spawnAsyncAgent
|
|
103
103
|
const { agents } = discoverAgents(cwd, "user");
|
|
104
|
-
const topicsDir = path.join(cwd, "
|
|
104
|
+
const topicsDir = path.join(getProjectMemoryDir(cwd), "topics");
|
|
105
105
|
const { id } = spawnAsyncAgent(
|
|
106
106
|
"worker",
|
|
107
107
|
`Memory dreaming — analyze session and maintain topic files.\n` +
|
|
@@ -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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
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
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* `git commit
|
|
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
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
//
|
|
1147
|
-
|
|
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 (
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
|
1532
|
+
if (pipeIdx !== -1) {
|
|
1195
1533
|
const echoPart = command.slice(0, pipeIdx);
|
|
1196
1534
|
const gitPart = command.slice(pipeIdx);
|
|
1197
|
-
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
for (const
|
|
1266
|
-
const
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
}
|