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 +1 -1
- package/extensions/orchestrator/enforcement-helpers.ts +561 -8
- package/extensions/orchestrator/enforcement.ts +285 -18
- package/extensions/orchestrator/git-helpers.ts +38 -1
- package/extensions/orchestrator/graft.ts +34 -3
- package/extensions/orchestrator/project-settings.ts +29 -3
- package/extensions/orchestrator/settings-tui-helpers.ts +31 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +127 -0
- package/extensions/orchestrator/settings-tui.ts +44 -6
- package/extensions/orchestrator/status-bar.ts +25 -2
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/reload-flag/index.ts +404 -0
- package/package.json +2 -1
- package/settings-keys.json +9 -3
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 {
|
|
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
|
|
896
|
-
|
|
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
|
-
//
|
|
948
|
-
|
|
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
|
|