pi-orchestrator-config 4.16.0 → 4.17.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
@@ -22,7 +22,7 @@ Single extension that provides:
22
22
  | **Python/pip enforcement** | Auto-fixes `python`/`python3` → `uv run python`; blocks `pip`/`pip3` — requires `uv` |
23
23
  | **Git protection** | Blocks commits/pushes to main/master, merged branches, `--no-verify`, `git add .` |
24
24
  | **Remote script exec block** | Blocks `curl \| bash`, `eval $(curl)`, etc. Allows safe `VAR=$(curl ...)` variable assignments |
25
- | **Dangerous command gate** | Confirms `rm -rf`, `sudo`, `mkfs`, etc. |
25
+ | **Dangerous command gate** | Confirms `rm -rf`, `sudo`, `mkfs`, etc. "Always allow this exact command" stores a pattern in the `dangerous_command_allowlist` setting (wildcards supported) — see [Safety Enforcements](docs/safety-enforcements.md#pre-approve-a-recurring-dangerous-command) |
26
26
  | **Rule injection** | Injects orchestrator routing rules into system prompt |
27
27
  | **Git status** | Live git status in status line with colored icons — updates after every tool call. Shows clickable `#N` when the branch has an open PR. Last-activity clock `⏱ HH:MM (Xm/Xh ago)` shows time since last response |
28
28
  | **Desktop notifications** | Notifies via `notify-send` on task completion, waiting for input, and action required |
@@ -4,9 +4,14 @@
4
4
  */
5
5
 
6
6
  import { createLogger } from "../shared/logger.js";
7
- import { realpathSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { lstatSync, realpathSync, mkdirSync, rmdirSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
8
10
  import * as path from "node:path";
9
11
  import { join } from "node:path";
12
+ import { getProjectDataDir } from "./utils.js";
13
+ import { getGlobalSettingsPath, getSettingsPath, loadGlobalSettings, loadProjectSettings } from "./project-settings.js";
14
+ import { readSettingsFile, writeSettingsFile } from "./settings-tui-helpers.js";
10
15
  import { DANGEROUS, executableText, getCurrentBranch, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
11
16
 
12
17
  const enfLog = createLogger("enforcement");
@@ -881,9 +886,124 @@ export function checkTempFileEnforcement(command: string, cwd: string): Enforcem
881
886
  export const READ_ONLY_COMMANDS = new Set([
882
887
  "grep", "egrep", "fgrep", "rg", "ag", "ack",
883
888
  "cat", "head", "tail", "less", "more", "wc",
884
- "echo", "printf",
889
+ "echo", "printf", "ls",
885
890
  ]);
886
891
 
892
+ /**
893
+ * Replace single-quoted regions with '' so substitutions inside them are
894
+ * invisible to the scanner, while a ' inside double quotes stays literal
895
+ * (Qodo PR #948: the old /'[^']*'/g regex treated an apostrophe inside "..."
896
+ * as an opening quote and swallowed everything up to the next apostrophe,
897
+ * hiding executable substitutions like $(rm -rf ...) from extraction).
898
+ * An UNTERMINATED single-quote region is re-emitted raw: a stray apostrophe
899
+ * ("it's fine $(rm -rf /)") must not hide the substitution that follows.
900
+ */
901
+ function stripSingleQuotedRegions(stmt: string): string {
902
+ let out = "";
903
+ let i = 0;
904
+ let inSingle = false;
905
+ let inDouble = false;
906
+ let singleOpenIndex = -1;
907
+ while (i < stmt.length) {
908
+ const ch = stmt[i];
909
+ if (inSingle) {
910
+ if (ch === "'") {
911
+ inSingle = false;
912
+ out += "''";
913
+ }
914
+ i++;
915
+ continue;
916
+ }
917
+ if (inDouble) {
918
+ if (ch === "\\" && i + 1 < stmt.length) {
919
+ out += stmt.slice(i, i + 2);
920
+ i += 2;
921
+ continue;
922
+ }
923
+ if (ch === '"') inDouble = false;
924
+ out += ch;
925
+ i++;
926
+ continue;
927
+ }
928
+ if (ch === "\\" && i + 1 < stmt.length) {
929
+ // Unquoted \x is a literal pair (bash escapes) — a backslash must never
930
+ // turn the NEXT character into a quote delimiter. Without this, a
931
+ // nested strip of `echo \' $(rm -rf /) \'` would treat \' as opening a
932
+ // single-quoted region and hide the live deletion (Qodo PR #950 R2).
933
+ out += stmt.slice(i, i + 2);
934
+ i += 2;
935
+ continue;
936
+ }
937
+ if (ch === "'") {
938
+ inSingle = true;
939
+ singleOpenIndex = i;
940
+ i++;
941
+ continue;
942
+ }
943
+ if (ch === '"') {
944
+ inDouble = true;
945
+ out += ch;
946
+ i++;
947
+ continue;
948
+ }
949
+ out += ch;
950
+ i++;
951
+ }
952
+ if (inSingle && singleOpenIndex >= 0) out += stmt.slice(singleOpenIndex);
953
+ return out;
954
+ }
955
+
956
+ /**
957
+ * Replace the CONTENT of quoted regions ('...', "...") with a single space,
958
+ * keeping the delimiters, so filename/redirect guards see shell STRUCTURE
959
+ * without prose (Qodo PR #950 R5: `--body "settings => pi-config-settings.json"`
960
+ * must not trip the redirect tripwire). Backslash-escape aware (\" never toggles
961
+ * a quote); an UNTERMINATED quoted region is re-emitted raw — a stray quote
962
+ * must not hide the rest of the command from the guards.
963
+ */
964
+ export function maskQuotedRegions(cmd: string): string {
965
+ let out = "";
966
+ let i = 0;
967
+ let quote: "'" | '"' | null = null;
968
+ let openIndex = -1;
969
+ while (i < cmd.length) {
970
+ const ch = cmd[i];
971
+ if (quote !== null) {
972
+ if (quote === '"' && ch === "\\" && i + 1 < cmd.length) {
973
+ i += 2;
974
+ continue;
975
+ }
976
+ if (ch === quote) {
977
+ // Keep the delimiters, replace only the content — structure survives.
978
+ out += " ";
979
+ out += quote;
980
+ quote = null;
981
+ }
982
+ i++;
983
+ continue;
984
+ }
985
+ if (ch === "\\" && i + 1 < cmd.length) {
986
+ // Unquoted \x is a literal pair — a backslash must never turn the next
987
+ // character into a quote delimiter (same rule as
988
+ // stripSingleQuotedRegions, Qodo PR #950 R2).
989
+ out += cmd.slice(i, i + 2);
990
+ i += 2;
991
+ continue;
992
+ }
993
+ if (ch === "'" || ch === '"') {
994
+ quote = ch;
995
+ openIndex = i;
996
+ out += ch;
997
+ i++;
998
+ continue;
999
+ }
1000
+ out += ch;
1001
+ i++;
1002
+ }
1003
+ if (quote !== null && openIndex >= 0) out += cmd.slice(openIndex + 1);
1004
+ return out;
1005
+ }
1006
+
887
1007
  /**
888
1008
  * Extract command substitutions ($(...), `...`) and process substitutions
889
1009
  * (<(...), >(...)) from a statement, respecting single quotes (which
@@ -892,8 +1012,10 @@ export const READ_ONLY_COMMANDS = new Set([
892
1012
  export function extractSubshells(stmt: string): string[] {
893
1013
  const results: string[] = [];
894
1014
 
895
- // Strip single-quoted regions — POSIX sh has no escape inside single quotes, so [^']* is correct
896
- const withoutSingleQuoted = stmt.replace(/'[^']*'/g, "''");
1015
+ // Strip single-quoted regions — POSIX sh has no escape inside single quotes.
1016
+ // Double-quote-aware so a literal apostrophe inside "..." cannot open a
1017
+ // quoted region (Qodo PR #948).
1018
+ const withoutSingleQuoted = stripSingleQuotedRegions(stmt);
897
1019
 
898
1020
  // Extract $(...) — handle nested parens by counting depth
899
1021
  let i = 0;
@@ -912,7 +1034,7 @@ export function extractSubshells(stmt: string): string[] {
912
1034
  j++;
913
1035
  }
914
1036
  if (depth === 0) {
915
- results.push(withoutSingleQuoted.slice(start, j - 1));
1037
+ results.push(stripSingleQuotedRegions(withoutSingleQuoted.slice(start, j - 1)));
916
1038
  }
917
1039
  i = j;
918
1040
  continue;
@@ -929,7 +1051,7 @@ export function extractSubshells(stmt: string): string[] {
929
1051
  j++;
930
1052
  }
931
1053
  if (j < withoutSingleQuoted.length) {
932
- results.push(withoutSingleQuoted.slice(i + 1, j));
1054
+ results.push(stripSingleQuotedRegions(withoutSingleQuoted.slice(i + 1, j)));
933
1055
  i = j + 1;
934
1056
  continue;
935
1057
  }
@@ -939,13 +1061,444 @@ export function extractSubshells(stmt: string): string[] {
939
1061
  return results;
940
1062
  }
941
1063
 
1064
+ /**
1065
+ * Transparent wrapper prefixes that may precede the real command without
1066
+ * changing its read-only nature (issue #947). Mirrors the git-code wrapper
1067
+ * set (GIT_RTK_SRC et al., PR #916) — the user-level rtk extension rewrites
1068
+ * every bash command through `rtk rewrite` before enforcement evaluates it,
1069
+ * so `grep` arrives as `rtk grep`.
1070
+ *
1071
+ * Deliberately NOT sudo: a sudo-prefixed command must never receive a
1072
+ * silent read-only pass (the DANGEROUS sudo pattern still prompts).
1073
+ */
1074
+ const READ_ONLY_WRAPPER_PREFIX_RE =
1075
+ // env/nice option handling is PRECISE about which flags take values
1076
+ // (PR #948 R2): generic value consumption would let `env -i grep foo`
1077
+ // eat `grep` as -i's "value", while no value consumption lets
1078
+ // `env -u grep rm -rf x` pass `grep` off as the command. env's value-taking
1079
+ // options are -u/-C/-S/-P (and long forms); -i is boolean. nice's only
1080
+ // value option is -n/--adjustment.
1081
+ // `-S`/`--split-string` is deliberately NOT consumable (PR #948 R3): its
1082
+ // "value" is a split command line (`env -S find ls -delete` runs find), so
1083
+ // an env -S statement never gets a read-only pass — it falls through to
1084
+ // the DANGEROUS pattern check instead.
1085
+ /^\s*(?:\S+=\S*\s+|rtk(?:\s+-\S+)*\s+|(?:command|builtin|exec)\s+|env(?:\s+(?:-(?:u|C|P)(?:\s+\S+)?|-i|--ignore-environment|--unset(?:\s+\S+)?|--chdir[= ]\S*|\S+=\S*))*\s+|timeout\s+(?:-\S+\s+)*\S+\s+|nice(?:\s+(?:-n\s+\S+|--adjustment[= ]\S*))*\s+|nohup\s+)+/;
1086
+
1087
+ /**
1088
+ * Split a command into individual statements for the dangerous-command
1089
+ * check: strip heredoc bodies, collapse line continuations, then split on
1090
+ * statement separators AND pipes. Single `&` (background operator) is a
1091
+ * separator too (Qodo PR #948): `echo ok & rm -rf x` must not let a
1092
+ * read-only verdict for `echo` skip the deletion that follows it.
1093
+ * `&&` and `||` are matched before `&`/`|` by alternation order.
1094
+ * NOTE: Pipe split does not respect shell quoting — biases toward false
1095
+ * positives (extra prompts), which is acceptable for security.
1096
+ */
1097
+ export function splitBashStatements(command: string): string[] {
1098
+ // Split on statement separators AND pipes. Single `&` (background operator)
1099
+ // is a separator too (Qodo PR #948), EXCEPT inside a descriptor-duplication
1100
+ // redirect (`2>&1`, `>&2` — PR #948 R3): splitting those at `&` shatters the
1101
+ // redirect and hides a piped shell behind harmless fragments. `&&` and `||`
1102
+ // are matched before `&`/`|` by alternation order.
1103
+ // NOTE: Pipe split does not respect shell quoting — biases toward false
1104
+ // positives (extra prompts), which is acceptable for security.
1105
+ const normalized = stripHeredocBodies(command).replace(/\\\r?\n/g, " ");
1106
+ return normalized
1107
+ .split(/\n|;|&&|(?<!>)&|\|\||\|/)
1108
+ .map((s: string) => s.trim())
1109
+ .filter(Boolean);
1110
+ }
1111
+
1112
+ /**
1113
+ * User-approved allowlist for recurring legitimate dangerous-looking commands
1114
+ * (issue #949, PR #950). Entries live in the `dangerous_command_allowlist`
1115
+ * setting (project `.pi/pi-config-settings.json(c)`, global `~/.pi/...`) so
1116
+ * the user can inspect, edit, and extend them — including wildcards:
1117
+ * `*` matches any run of characters, `?` exactly one, `\` escapes a literal
1118
+ * metachar. Patterns match the FULL command text (anchored, case-sensitive).
1119
+ *
1120
+ * Entries added via the interactive "always allow" prompt option are the
1121
+ * command text with glob metachars ESCAPED — an approval covers exactly the
1122
+ * text that was approved; widening to a wildcard family is a deliberate
1123
+ * human edit of the settings file. Agents cannot write the entries
1124
+ * themselves: the settings files are enforcement-protected (see
1125
+ * enforcement.ts) — approvals flow only through the prompt.
1126
+ */
1127
+
1128
+ /** Glob → anchored RegExp. `*` any run, `?` one char, `\` escapes a literal, rest literal. */
1129
+ export function allowlistGlobToRegExp(pattern: string): RegExp {
1130
+ let re = "";
1131
+ for (let i = 0; i < pattern.length; i++) {
1132
+ const c = pattern[i];
1133
+ if (c === "\\" && i + 1 < pattern.length) {
1134
+ re += (pattern[++i] as string).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1135
+ } else if (c === "*") {
1136
+ re += ".*";
1137
+ } else if (c === "?") {
1138
+ re += ".";
1139
+ } else {
1140
+ re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1141
+ }
1142
+ }
1143
+ // `s` flag: multi-line commands — `*` must span newlines
1144
+ return new RegExp(`^${re}$`, "s");
1145
+ }
1146
+
1147
+ /** Normalize a dangerous statement for allowlist matching and storage:
1148
+ * trailing I/O redirections (`> /dev/null`, `2>&1`, `2>/dev/null`, `&>f`)
1149
+ * are plumbing, not danger — strip them so `bash x 2>&1` matches an entry
1150
+ * (or an approval) of `bash x`. Iterated because tails stack
1151
+ * (`... > /dev/null 2>&1`). User request, PR #950. */
1152
+ export function normalizeDangerousStatement(stmt: string): string {
1153
+ let s = stmt.trim();
1154
+ let prev = "";
1155
+ while (prev !== s) {
1156
+ prev = s;
1157
+ // Strip only BENIGN plumbing tails (Qodo PR #950 R6): descriptor
1158
+ // duplication (`2>&1`) and /dev/null targets. A redirect to any other
1159
+ // target (file, device, $(...)) STAYS part of the statement — it is
1160
+ // side effect, not plumbing, and must not silently inherit an approval
1161
+ // granted to the un-redirected statement.
1162
+ s = s
1163
+ .replace(/\s*\d+>&\d+\s*$/, "")
1164
+ .replace(/\s*\d*(?:>{1,2}|&>)\s*\/dev\/null\s*$/, "")
1165
+ .trim();
1166
+ }
1167
+ return s;
1168
+ }
1169
+
1170
+ /**
1171
+ * True for shell statements that read their SCRIPT from stdin — a bare
1172
+ * `bash`/`sh`/`zsh` (optionally with flags, wrappers like sudo/nohup/env,
1173
+ * and variable assignments) and no script path or -c payload. Such a
1174
+ * statement carries none of the script it will execute: `x | bash`,
1175
+ * `bash <<EOF`, `base64 -d | bash` all reduce to it. Approving it
1176
+ * persistently would approve EVERY future script fed to that shell
1177
+ * (Qodo PR #950 R6), so these statements are never allowlist-matchable
1178
+ * and never offered a persistent approval — only run-once.
1179
+ */
1180
+ export function isStdinFedShellStatement(stmt: string): boolean {
1181
+ return /^(?:[A-Za-z_]\w*=\S*\s+)*(?:(?:sudo|nohup|env|command|builtin|exec)\s+)*(?:(?:timeout|nice)\s+(?:-\S+\s+)?(?:\S+\s+)?)*(?:\S*\/)?(?:ba|da|z|k|c|tc|fi)?sh(?:\s+-\w+)*\s*$/.test(
1182
+ stmt.trim(),
1183
+ );
1184
+ }
1185
+
1186
+ /** SHA-256 of a command (or entry) — hash prefixes for logs, never the text itself. */
1187
+ export function dangerousCommandHash(command: string): string {
1188
+ return createHash("sha256").update(command).digest("hex");
1189
+ }
1190
+
1191
+ export interface DangerousAllowlistMatch {
1192
+ /** The matched settings entry (may contain wildcards). */
1193
+ entry: string;
1194
+ /** Which settings file provided it. */
1195
+ source: "project" | "global";
1196
+ }
1197
+
1198
+ /** Matched allowlist entry for the full command text, or null.
1199
+ * Project settings take precedence over global; no env layer (deliberate:
1200
+ * ambient env must not silently approve dangerous commands).
1201
+ *
1202
+ * Security (Qodo PR #950 R3): a project settings file that is TRACKED by git
1203
+ * is not trusted for the allowlist — a cloned repo must not be able to ship
1204
+ * `{"dangerous_command_allowlist": ["*"]}` and disable the gate for everyone
1205
+ * who opens it. Only the user's own untracked project file (or the global
1206
+ * file on their machine) can approve commands. */
1207
+ const trackedSettingsCache = new Map<string, ProjectSettingsTrust>();
1208
+ /** Expiry for cached "tracked" verdicts (Qodo PR #950 R9): the tracked
1209
+ * verdict used to be cached forever, so a user who untracks the file
1210
+ * (`git rm --cached`) kept losing project approvals until process restart.
1211
+ * A TTL keeps the anti-clone property (a fresh process always runs the git
1212
+ * check; a clone's shipped tracked settings are rejected on first sight)
1213
+ * while honoring untracking within seconds. The unsafe direction —
1214
+ * untracked — is still NEVER cached. */
1215
+ const trackedVerdictExpires = new Map<string, number>();
1216
+ let trackedVerdictTtlMs = 30_000;
1217
+ /** Test hook: shrink the tracked-verdict TTL so expiry is testable. */
1218
+ export function setTrackedVerdictTtlForTests(ms: number): void {
1219
+ trackedVerdictTtlMs = ms;
1220
+ }
1221
+ /** Test hook: clear the tracked-settings cache (git state changed under us). */
1222
+ export function clearTrackedSettingsCache(): void {
1223
+ trackedSettingsCache.clear();
1224
+ trackedSettingsWarned.clear();
1225
+ trackedVerdictExpires.clear();
1226
+ }
1227
+ const trackedSettingsWarned = new Set<string>();
1228
+ /** Current trust verdict for the project settings file (public for the
1229
+ * approval writer: it must know WHERE a new entry can be stored — a
1230
+ * tracked/untrusted project file would swallow approvals that lookup
1231
+ * can never match, Qodo PR #950 R7). */
1232
+ export function projectSettingsTrust(cwd: string): ProjectSettingsTrust {
1233
+ const file = getSettingsPath(cwd);
1234
+ const cached = trackedSettingsCache.get(file);
1235
+ if (cached !== undefined) {
1236
+ const expires = trackedVerdictExpires.get(file);
1237
+ if (expires === undefined || expires > Date.now()) return cached;
1238
+ // Cached tracked verdict expired — re-check (the user may have
1239
+ // untracked the file; Qodo PR #950 R9).
1240
+ trackedSettingsCache.delete(file);
1241
+ trackedVerdictExpires.delete(file);
1242
+ }
1243
+ const verdict = projectSettingsTrustUncached(cwd, file);
1244
+ // Cache the confirmed-untrusted (tracked) verdict WITH a TTL — it can
1245
+ // only become more true, but the user may legitimately untrack the file,
1246
+ // and that must recover without a process restart (R9). A trusted
1247
+ // (untracked) verdict is re-checked on EVERY use so a later `git pull` /
1248
+ // checkout that starts tracking the file is caught on the very next
1249
+ // check, not for the life of the process (Qodo PR #950 R5). An "unknown"
1250
+ // verdict (git failed transiently) is never cached — the next check
1251
+ // retries git (R7). Per-command batching (one git exec per gate
1252
+ // evaluation, not per statement) lives in loadDangerousAllowlists — the
1253
+ // per-CALL re-check semantics stay intact.
1254
+ if (verdict === "tracked") {
1255
+ trackedSettingsCache.set(file, verdict);
1256
+ trackedVerdictExpires.set(file, Date.now() + trackedVerdictTtlMs);
1257
+ }
1258
+ return verdict;
1259
+ }
1260
+ function projectSettingsUntracked(cwd: string): boolean {
1261
+ // Fail closed on "unknown": an unverifiable file is treated as untrusted
1262
+ // for THIS check (global entries still work); the verdict is not cached,
1263
+ // so the next check retries git.
1264
+ return projectSettingsTrust(cwd) === "untracked";
1265
+ }
1266
+ /** Trust verdict for a project settings file:
1267
+ * - "untracked" — CONFIRMED trustable (git exit 1, or no repo here): the
1268
+ * file is not tracked, so it cannot have shipped with a clone.
1269
+ * - "tracked" — CONFIRMED untrusted (git listed it, or the path traverses
1270
+ * a symlink): a cloned repo may have shipped it.
1271
+ * - "unknown" — git failed for an unrelated reason (missing binary,
1272
+ * timeout, dubious ownership, locked index, cwd gone). Treated as
1273
+ * untrusted for the CURRENT check but NEVER cached — the next check
1274
+ * retries (Qodo PR #950 R7: a transient failure must not freeze the
1275
+ * verdict for the life of the process). */
1276
+ type ProjectSettingsTrust = "untracked" | "tracked" | "unknown";
1277
+
1278
+ function projectSettingsTrustUncached(cwd: string, file: string): ProjectSettingsTrust {
1279
+ // A settings path reached through a SYMLINK is not trustable (Qodo PR
1280
+ // #950 R6): a repo can track `.pi` as a symlink to its root and ship a
1281
+ // `*`-allowlist at the target — `git ls-files` misses the lexical path
1282
+ // while the settings loader follows the link. lstat (no follow) catches
1283
+ // both a symlinked settings file and a symlinked `.pi` dir without
1284
+ // false-positiving on symlinked session cwds (/tmp vs /private/tmp).
1285
+ // Each lstat is guarded SEPARATELY (Qodo PR #950 R9): a single try around
1286
+ // both made a MISSING settings file discard the already-read `.pi` dir
1287
+ // verdict — the first approval through a symlinked `.pi` then wrote an
1288
+ // entry the next lookup (file now existing) would reject as untrusted.
1289
+ const dirLstat = (() => { try { return lstatSync(path.dirname(file)); } catch { return null; } })();
1290
+ const fileLstat = (() => { try { return lstatSync(file); } catch { return null; } })();
1291
+ if ((dirLstat && dirLstat.isSymbolicLink()) || (fileLstat && fileLstat.isSymbolicLink())) {
1292
+ enfLog.warn("dangerous_allowlist_settings_symlink", {
1293
+ file,
1294
+ reason: "settings path traverses a symlink — not trusted for dangerous-command approvals",
1295
+ });
1296
+ return "tracked"; // structural, not transient — cacheable untrusted
1297
+ }
1298
+ // Run git from a directory that exists: `path.dirname(file)` may not
1299
+ // exist yet on a first-ever approval (Qodo PR #950 R7 — running from a
1300
+ // missing cwd threw ENOENT, which fail-closed AND cached, freezing the
1301
+ // verdict even after the approval created the file).
1302
+ let gitCwd = path.dirname(file);
1303
+ try {
1304
+ lstatSync(gitCwd);
1305
+ } catch {
1306
+ gitCwd = cwd;
1307
+ }
1308
+ try {
1309
+ const out = execFileSync(
1310
+ "git",
1311
+ ["ls-files", "--error-unmatch", file],
1312
+ { cwd: gitCwd, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] },
1313
+ ).trim();
1314
+ if (out) {
1315
+ if (!trackedSettingsWarned.has(file)) {
1316
+ trackedSettingsWarned.add(file);
1317
+ enfLog.warn("dangerous_allowlist_project_file_tracked", {
1318
+ file,
1319
+ reason: "tracked settings files are not trusted for dangerous-command approvals",
1320
+ });
1321
+ }
1322
+ return "tracked";
1323
+ }
1324
+ return "untracked";
1325
+ } catch (e: unknown) {
1326
+ const err = e as { status?: number; stderr?: string };
1327
+ // Exit 1 = the file is genuinely not tracked — the normal, trustable case.
1328
+ if (err.status === 1) return "untracked";
1329
+ // Exit 128 + "not a git repository" = no repo here, so nothing could have
1330
+ // been cloned in — trustable. Other 128s (e.g. dubious-ownership refusal)
1331
+ // are NOT trustable: an attacker-owned checkout fails exactly like that.
1332
+ if (err.status === 128 && /not a git repository/i.test(String(err.stderr ?? ""))) return "untracked";
1333
+ // git binary missing, timeout, dubious ownership, locked index, cwd
1334
+ // gone — UNKNOWN: untrusted for this check, retried on the next one.
1335
+ enfLog.warn("dangerous_allowlist_git_check_failed", {
1336
+ file,
1337
+ status: err.status,
1338
+ fallback: "untrusted (not cached — next check retries)",
1339
+ });
1340
+ return "unknown";
1341
+ }
1342
+ }
1343
+
1344
+ /** Allowlists loaded once per gate evaluation (Qodo PR #950 R8 perf:
1345
+ * loading per STATEMENT ran one synchronous git exec + two file reads per
1346
+ * statement — now one per command). `project` is null when the project
1347
+ * settings file is not trustable (tracked/symlinked/unverifiable). */
1348
+ export interface DangerousAllowlists {
1349
+ project: string[] | null;
1350
+ global: string[];
1351
+ }
1352
+
1353
+ /** Load both allowlist scopes for a cwd — ONE trust check + ONE read per
1354
+ * scope. Call this once per command and match statements against the
1355
+ * result via matchDangerousAllowlist. */
1356
+ export function loadDangerousAllowlists(cwd: string): DangerousAllowlists {
1357
+ const project = projectSettingsUntracked(cwd)
1358
+ ? (loadProjectSettings(cwd).dangerous_command_allowlist ?? [])
1359
+ : null;
1360
+ const global = loadGlobalSettings().dangerous_command_allowlist ?? [];
1361
+ return { project, global };
1362
+ }
1363
+
1364
+ /** Match one normalized statement against pre-loaded allowlists (no git,
1365
+ * no file I/O — safe to call per statement). */
1366
+ export function matchDangerousAllowlist(lists: DangerousAllowlists, command: string): DangerousAllowlistMatch | null {
1367
+ // Defense in depth (Qodo PR #950 R6): a stdin-fed shell statement (`bash`,
1368
+ // `sh -s`, ...) executes whatever script is fed to it — even a hand-added
1369
+ // settings entry can never approve it. The gate already refuses to offer
1370
+ // persistent approval for such statements.
1371
+ if (isStdinFedShellStatement(command)) return null;
1372
+ if (lists.project) {
1373
+ const p = lists.project.find((e) => allowlistGlobToRegExp(e).test(command));
1374
+ if (p) return { entry: p, source: "project" };
1375
+ }
1376
+ const g = lists.global.find((e) => allowlistGlobToRegExp(e).test(command));
1377
+ return g ? { entry: g, source: "global" } : null;
1378
+ }
1379
+
1380
+ export function findDangerousAllowlistEntry(cwd: string, command: string): DangerousAllowlistMatch | null {
1381
+ // Per-CALL semantics (unchanged since R5): every call re-checks trust, so
1382
+ // a `git pull` that starts tracking the project file is caught on the
1383
+ // very next call. The gate uses the batched loadDangerousAllowlists path
1384
+ // for per-command efficiency; direct callers keep the strict behavior.
1385
+ return matchDangerousAllowlist(loadDangerousAllowlists(cwd), command);
1386
+ }
1387
+
1388
+ /** Result of persisting a dangerous-command approval. `scope` is where the
1389
+ * entry ACTUALLY landed — the caller logs/notifies it, and "global" means
1390
+ * the project file was CONFIRMED untrustworthy (git-tracked or symlinked)
1391
+ * so the approval was stored in the user's global settings instead, where
1392
+ * lookup will honor it (Qodo PR #950 R7/R8). A `trust_unknown` failure is
1393
+ * deliberately NOT persisted anywhere: a transient git failure in one
1394
+ * project must not mint a machine-wide approval (R8) — the approval is
1395
+ * one-time and the user can retry once git answers. */
1396
+ export type DangerousAllowlistWriteResult =
1397
+ | { ok: true; scope: "project" | "global"; file: string }
1398
+ | { ok: false; reason: "corrupt" | "write_failed" | "trust_unknown" | "project_untrusted"; file: string };
1399
+
1400
+ /** Append the exact command text (glob metachars escaped, so the entry stays
1401
+ * literal) to the settings file. Fail-soft (returns a result, never throws).
1402
+ * A mkdir-based lock serializes writers so concurrent approvals cannot drop
1403
+ * each other (read-merge-write race).
1404
+ *
1405
+ * Scope is EXPLICIT (user request, PR #950 R8): the prompt offers "this
1406
+ * project" and "everywhere" as distinct choices. `scope: "project"` writes
1407
+ * the project file and FAILS when it is not trustable (git-tracked or
1408
+ * symlinked — the caller must not have offered the option, but fail safe
1409
+ * anyway). `scope: "global"` writes the user's machine-wide file — the
1410
+ * caller only offers it as an explicit choice, never a silent fallback.
1411
+ * When trust is UNKNOWN (git failed transiently) a project write is
1412
+ * refused: run-once only (R8) — a transient failure must not mint a
1413
+ * machine-wide approval either. */
1414
+ export function addDangerousAllowlistEntry(
1415
+ cwd: string,
1416
+ command: string,
1417
+ scope: "project" | "global" = "project",
1418
+ ): DangerousAllowlistWriteResult {
1419
+ const trust = projectSettingsTrust(cwd);
1420
+ if (scope === "project") {
1421
+ if (trust === "tracked") {
1422
+ // The option should not have been offered — refuse the write rather
1423
+ // than silently redirecting scope (fail safe, no silent fallback).
1424
+ enfLog.warn("dangerous_allowlist_project_untrusted_refused", {
1425
+ trust,
1426
+ reason: "project settings file is not trustable — project-scope approval refused",
1427
+ });
1428
+ return { ok: false, reason: "project_untrusted", file: getSettingsPath(cwd) };
1429
+ }
1430
+ if (trust === "unknown") {
1431
+ // Never persist on an unverifiable state (Qodo PR #950 R8): fail the
1432
+ // write; the gate tells the user this approval is one-time until git
1433
+ // can verify the project file.
1434
+ enfLog.warn("dangerous_allowlist_trust_unknown", {
1435
+ reason: "project settings trust could not be verified (git check failed) — approval is one-time",
1436
+ });
1437
+ return { ok: false, reason: "trust_unknown", file: getSettingsPath(cwd) };
1438
+ }
1439
+ }
1440
+ const file = scope === "project" ? getSettingsPath(cwd) : getGlobalSettingsPath();
1441
+ const lockDir = `${file}.lock`;
1442
+ const entry = command.replace(/[\\*?]/g, (c) => `\\${c}`);
1443
+ let locked = false;
1444
+ try {
1445
+ // The lock dir's parent may not exist yet on a first-ever write
1446
+ mkdirSync(path.dirname(file), { recursive: true });
1447
+ for (let attempt = 0; attempt < 10 && !locked; attempt++) {
1448
+ try {
1449
+ mkdirSync(lockDir);
1450
+ locked = true;
1451
+ } catch {
1452
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // 50ms backoff
1453
+ }
1454
+ }
1455
+ const data = readSettingsFile(file);
1456
+ if (data === null) {
1457
+ // Corrupt/unparseable settings file — NEVER overwrite it with just the
1458
+ // new entry: earlier approvals and every other setting would be lost
1459
+ // (Qodo PR #950 R2). Bail fail-soft and let the user repair the file.
1460
+ enfLog.warn("dangerous_allowlist_settings_unreadable", {
1461
+ file,
1462
+ hashPrefix: dangerousCommandHash(command).slice(0, 12),
1463
+ });
1464
+ return { ok: false, reason: "corrupt", file };
1465
+ }
1466
+ const list = Array.isArray(data.dangerous_command_allowlist)
1467
+ ? data.dangerous_command_allowlist.filter((e: unknown): e is string => typeof e === "string" && e.length > 0)
1468
+ : [];
1469
+ if (!list.includes(entry)) list.push(entry);
1470
+ data.dangerous_command_allowlist = list;
1471
+ writeSettingsFile(file, data);
1472
+ return { ok: true, scope, file };
1473
+ } catch (error: any) {
1474
+ // Hash prefix + path identify the affected approval without copying
1475
+ // command text into global logs (Qodo PR #950).
1476
+ enfLog.warn("dangerous_allowlist_write_failed", {
1477
+ reason: String(error?.message ?? error).slice(0, 200),
1478
+ hashPrefix: dangerousCommandHash(command).slice(0, 12),
1479
+ file,
1480
+ });
1481
+ return { ok: false, reason: "write_failed", file };
1482
+ } finally {
1483
+ if (locked) {
1484
+ try {
1485
+ rmdirSync(lockDir);
1486
+ } catch {
1487
+ // stale lock — the next writer's retry loop handles it
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
942
1493
  /**
943
1494
  * Check if a statement is a read-only command with no dangerous subshells.
944
1495
  * Returns true if the DANGEROUS check should be skipped for this statement.
945
1496
  */
946
1497
  export function isReadOnlyStatement(stmt: string): boolean {
947
- // Extract the base command (first word, strip any leading env vars like VAR=val)
948
- const stripped = stmt.replace(/^\s*(?:\S+=\S*\s+)*/, "");
1498
+ // Strip transparent wrapper prefixes (rtk/env/timeout/nice/nohup/command/
1499
+ // builtin/exec) and VAR=value assignments so `rtk grep` gets the same
1500
+ // read-only pass as `grep` (issue #947).
1501
+ const stripped = stmt.replace(READ_ONLY_WRAPPER_PREFIX_RE, "");
949
1502
  const baseCmd = stripped.split(/\s/)[0]?.replace(/^.*\//, ""); // strip path prefix
950
1503
  if (!baseCmd || !READ_ONLY_COMMANDS.has(baseCmd)) return false;
951
1504