pi-orchestrator-config 4.2.0 → 4.3.2
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 +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
* Extracted to allow testing without SDK dependencies.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createLogger } from "../shared/logger.js";
|
|
6
7
|
import { realpathSync } from "node:fs";
|
|
7
8
|
import * as path from "node:path";
|
|
8
9
|
import { join } from "node:path";
|
|
9
|
-
import { DANGEROUS, hasGitSub } from "./git-helpers.js";
|
|
10
|
+
import { DANGEROUS, getCurrentBranch, hasGitSub } from "./git-helpers.js";
|
|
11
|
+
|
|
12
|
+
const enfLog = createLogger("enforcement");
|
|
13
|
+
enfLog.debug("enforcement-helpers module loaded");
|
|
10
14
|
|
|
11
15
|
export type EnforcementResult = { block: true; reason: string } | { autofix: true; modifiedCommand: string; reason: string } | undefined;
|
|
12
16
|
|
|
@@ -33,6 +37,28 @@ export function escapeForSingleQuote(s: string): string {
|
|
|
33
37
|
return s.replace(/'/g, "'\\\''");
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Detect whether a commit command already contains a trailer with the given
|
|
42
|
+
* NAME (e.g. `Assisted-by:`). A commit message must only ever have ONE such
|
|
43
|
+
* trailer, so we match by name — not by the full identity string — to avoid
|
|
44
|
+
* appending a duplicate when the committer (e.g. git-expert) already added one
|
|
45
|
+
* with a DIFFERENT model/identity string (or an unexpanded `$PI_MODEL`).
|
|
46
|
+
*
|
|
47
|
+
* The command embeds the message inside a quoted string where line breaks may
|
|
48
|
+
* appear as REAL newline characters OR as the two-character escaped `\n`
|
|
49
|
+
* sequence (echo -e / printf style). We therefore accept, immediately before
|
|
50
|
+
* the trailer name: start-of-string, a real newline, a literal `\n` two-char
|
|
51
|
+
* sequence, or a quote/whitespace character.
|
|
52
|
+
*/
|
|
53
|
+
export function commandHasTrailerByName(command: string, trailerName: string): boolean {
|
|
54
|
+
const escName = trailerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
// (^|\n real newline|\\n literal backslash-n|quote/space) + name + ":" + space
|
|
56
|
+
const trailerNameRe = new RegExp(String.raw`(^|\n|\\n|["'\s])` + escName + String.raw`:\s`);
|
|
57
|
+
const result = trailerNameRe.test(command);
|
|
58
|
+
enfLog.debug("commandHasTrailerByName", trailerName, "match", result);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
/** Parse bash command for cd target to resolve the effective working directory (worktree support) */
|
|
37
63
|
export function resolveEffectiveCwd(command: string, sessionCwd: string): string {
|
|
38
64
|
// Match the FIRST cd in the command (at start or after &&, ;, ||)
|
|
@@ -159,8 +185,9 @@ export function checkPythonPipBlock(command: string, cmdLower: string): Enforcem
|
|
|
159
185
|
export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
|
|
160
186
|
const cmdForExecCheck = cmdLower.replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*/m, "");
|
|
161
187
|
const remoteExecReason = "\u26d4 Remote script execution is forbidden. Download the script first, audit it with security-auditor, then run if safe.";
|
|
162
|
-
if (/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)
|
|
163
|
-
/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*)*(python[23]?|perl|ruby|node|deno|bun)\b/.test(cmdForExecCheck)) {
|
|
188
|
+
if (/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*|uv\s+run\s+)*(?:\/\S+\/)*(ba|c|da|[akz]|fi|tc)?sh\b/.test(cmdForExecCheck) ||
|
|
189
|
+
/\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*|uv\s+run\s+)*(?:\/\S+\/)*(python[23]?|perl|ruby|node|deno|bun)\b/.test(cmdForExecCheck)) {
|
|
190
|
+
enfLog.debug("remote_exec_block", "pipe");
|
|
164
191
|
return { block: true, reason: remoteExecReason };
|
|
165
192
|
}
|
|
166
193
|
// Match curl/wget anywhere inside process substitution <(...), not just as first token
|
|
@@ -168,21 +195,59 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
|
|
|
168
195
|
if (/\b(?:(?:ba|c|da|[akz]|fi|tc)?sh|python[23]?|perl|ruby|node|deno|bun)\b.*<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
|
|
169
196
|
/\bsource\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
|
|
170
197
|
/(?:^|[\s;&|])\.\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck)) {
|
|
198
|
+
enfLog.debug("remote_exec_block", "proc-sub");
|
|
171
199
|
return { block: true, reason: remoteExecReason };
|
|
172
200
|
}
|
|
173
201
|
// Block when curl/wget is inside a command substitution AND an execution primitive
|
|
174
|
-
// (eval, bash -c, sh -c, etc.)
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
202
|
+
// (eval, bash -c, sh -c, etc.) CONSUMES that curl output — either inline
|
|
203
|
+
// ($(curl ...)/`curl ...` passed directly to the primitive) or via a variable
|
|
204
|
+
// that was assigned from curl.
|
|
205
|
+
//
|
|
206
|
+
// FALSE-POSITIVE FIX: A SAFE capture `VAR=$(curl ...)` at a statement boundary,
|
|
207
|
+
// followed by an UNRELATED exec primitive that does NOT reference that variable,
|
|
208
|
+
// is not a remote exec. Example (now ALLOWED):
|
|
209
|
+
// code=$(curl -s -w "%{http_code}" https://x/json); python3 -c "import json"
|
|
210
|
+
// Here $code never flows into python3, so there is no remote code execution.
|
|
211
|
+
//
|
|
212
|
+
// To avoid opening a bypass we:
|
|
213
|
+
// 1. Record the set of variable names assigned from curl/wget captures.
|
|
214
|
+
// 2. Strip SAFE curl assignments before the inline-substitution exec check, so a
|
|
215
|
+
// captured-but-unused curl output no longer counts as "curl feeding an exec".
|
|
216
|
+
// 3. Separately block when an exec primitive REFERENCES a curl-assigned variable
|
|
217
|
+
// (e.g. x=$(curl ...); eval "$x") — stripping alone would miss this.
|
|
218
|
+
//
|
|
219
|
+
// Allow quoted strings inside $() to handle quoted ) characters. Matching curl/wget
|
|
220
|
+
// as a word anywhere inside the substitution is intentional — it trades rare false
|
|
221
|
+
// positives for stronger security against obfuscated curl invocations.
|
|
222
|
+
//
|
|
223
|
+
// Safe-assignment shape: VAR=$(...) / VAR=`...` at a statement boundary, terminated
|
|
224
|
+
// by a statement separator / comment / end. CRITICAL: the captured value must NOT
|
|
225
|
+
// itself contain a NESTED command substitution ($( or backtick) — even inside quotes.
|
|
226
|
+
// Otherwise `var=$(bash -c "$(curl ...)")` (a REAL remote exec) would be treated as a
|
|
227
|
+
// simple safe capture and stripped, opening a bypass. Quoted branches therefore
|
|
228
|
+
// forbid `$(` and backtick, and the unquoted branch forbids them too.
|
|
229
|
+
const qDouble = /"(?:(?!\$\()[^"`])*"/.source;
|
|
230
|
+
const qSingle = /'(?:(?!\$\()[^'`])*'/.source;
|
|
231
|
+
const dollarSub = String.raw`\$\((?:` + qDouble + `|` + qSingle + String.raw`|(?!\$\()(?!` + "`" + String.raw`)[^)])*\)`;
|
|
232
|
+
const backtickSub = "`(?:(?!\\$\\()[^`])*`";
|
|
233
|
+
const captureValue = `(?:${dollarSub}|${backtickSub})`;
|
|
234
|
+
const safeAssignmentSrc =
|
|
235
|
+
String.raw`(?:^|(?<=[;&|\n({])\s*)(?:export\s+|declare\s+|local\s+|readonly\s+|typeset\s+)?([a-z_]\w*)=(` +
|
|
236
|
+
captureValue + String.raw`)(?=\s*(?:$|[;&|#\n)}]))`;
|
|
237
|
+
// Collect variable names whose SAFE assignment substitution contains curl/wget.
|
|
238
|
+
const curlVars = new Set<string>();
|
|
239
|
+
for (const m of cmdForExecCheck.matchAll(new RegExp(safeAssignmentSrc, "gi"))) {
|
|
240
|
+
if (/\b(curl|wget)\b/.test(m[2])) curlVars.add(m[1]);
|
|
241
|
+
}
|
|
242
|
+
// Strip ALL safe assignments (curl or not) for the inline-substitution exec check,
|
|
243
|
+
// so a curl output safely captured into a variable no longer counts as an inline
|
|
244
|
+
// substitution feeding an exec primitive.
|
|
245
|
+
const strippedForSub = cmdForExecCheck.replace(new RegExp(safeAssignmentSrc, "gi"), " ");
|
|
246
|
+
// Curl still inside a substitution AFTER stripping safe captures = inline/consumed curl.
|
|
247
|
+
const hasCurlSub = /\$\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(strippedForSub) || /`[^`]*\b(curl|wget)\b/.test(strippedForSub);
|
|
183
248
|
// Anchor exec primitives to command position (start-of-string or after statement separator)
|
|
184
249
|
// to avoid matching inside URLs/arguments (e.g., https://host/eval)
|
|
185
|
-
if (hasCurlSub) {
|
|
250
|
+
if (hasCurlSub || curlVars.size > 0) {
|
|
186
251
|
// Allow assignment prefixes (VAR=val, VAR="a b"), sudo, and env before exec primitives.
|
|
187
252
|
// Shell allows VAR="a b" bash -c "cmd" — the assignment sets env for the command.
|
|
188
253
|
const assignPrefix = /(?:[a-z_]\w*=(?:"[^"]*"|'[^']*'|\S+)\s+)*/.source;
|
|
@@ -194,15 +259,59 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
|
|
|
194
259
|
// Allow leading redirections (>file, 2>/dev/null, etc.), shell wrappers (command, builtin, exec)
|
|
195
260
|
const redirections = /(?:(?:[0-9]*>[>&]?|<)\s*\S+\s+)*/.source;
|
|
196
261
|
const pathPrefix = /(?:\/\S+\/)*/.source;
|
|
197
|
-
|
|
262
|
+
// Wrappers/prefixes that may precede the interpreter name at a command position:
|
|
263
|
+
// command/builtin/exec shell builtins, and `uv run` (the test harness rewrites
|
|
264
|
+
// `python3` -> `uv run python3`, so `uv run python3 -c "$x"` must also be detected).
|
|
265
|
+
const wrappers = /(?:(?:command|builtin|exec)\s+|uv\s+run\s+)*/.source;
|
|
198
266
|
const execPrefix = cmdPos + redirections + wrappers;
|
|
267
|
+
// Exec-primitive cores (shell/interpreter with a code-carrying flag or stdin/procsub).
|
|
268
|
+
const shellExec = /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+-c\b|\s+<<<|\s+<[^<])/.source;
|
|
269
|
+
const interpExec = /(?:python[23]?|perl|ruby|node|deno|bun)(?:\s+-[ce]\b|\s+<\()/.source;
|
|
270
|
+
// (a)/(b) INLINE: curl substitution survives safe-assignment stripping AND an exec
|
|
271
|
+
// primitive appears at command position — the curl output feeds the primitive.
|
|
199
272
|
// Match shells with -c flag, stdin (<<<, <), or process substitution <(...)
|
|
200
273
|
// Match interpreters with -c/-e flag or process substitution <(...)
|
|
201
|
-
if (
|
|
202
|
-
new RegExp(execPrefix +
|
|
203
|
-
new RegExp(execPrefix + pathPrefix +
|
|
274
|
+
if (hasCurlSub && (
|
|
275
|
+
new RegExp(execPrefix + /eval(?:\s|$)/.source).test(cmdForExecCheck) ||
|
|
276
|
+
new RegExp(execPrefix + pathPrefix + shellExec).test(cmdForExecCheck) ||
|
|
277
|
+
new RegExp(execPrefix + pathPrefix + interpExec).test(cmdForExecCheck))) {
|
|
278
|
+
enfLog.debug("remote_exec_block", "inline-sub");
|
|
204
279
|
return { block: true, reason: remoteExecReason };
|
|
205
280
|
}
|
|
281
|
+
// (c) CONSERVATIVE VARIABLE-FLOW (SECURITY HARDENING): the previous logic only
|
|
282
|
+
// tracked the DIRECT curl-assigned variable name and was bypassable via aliasing
|
|
283
|
+
// (`x=$(curl); y=$x; bash -c "$y"`), quoted aliasing (`y="$x"; eval "$y"`), and
|
|
284
|
+
// indirect expansion (`bash -c "${!x}"`). We cannot statically track how curl output
|
|
285
|
+
// flows through arbitrary variable aliases/indirection, so we take the conservative
|
|
286
|
+
// direction: once curl output has been CAPTURED into the shell (curlVars.size > 0),
|
|
287
|
+
// ANY exec primitive whose command/argument region references ANY shell variable
|
|
288
|
+
// (contains a `$` — `$x`, `${x}`, `${!x}`, `"$x"`, ...) is assumed to potentially
|
|
289
|
+
// carry the curl output and is BLOCKED. Only an exec whose argument region contains
|
|
290
|
+
// NO `$` at all (e.g. `python3 -c "import json"`) is allowed to pass this rule.
|
|
291
|
+
if (curlVars.size > 0) {
|
|
292
|
+
// Argument/target region after the primitive, within the same statement (stops at
|
|
293
|
+
// ; & | newline), that contains at least one `$` variable reference.
|
|
294
|
+
const dollarArg = /[^\n;&|]*\$/.source;
|
|
295
|
+
if (new RegExp(execPrefix + /eval\b/.source + dollarArg).test(cmdForExecCheck) ||
|
|
296
|
+
new RegExp(execPrefix + pathPrefix + shellExec + dollarArg).test(cmdForExecCheck) ||
|
|
297
|
+
new RegExp(execPrefix + pathPrefix + interpExec + dollarArg).test(cmdForExecCheck)) {
|
|
298
|
+
enfLog.debug("remote_exec_block", "var-flow");
|
|
299
|
+
return { block: true, reason: remoteExecReason };
|
|
300
|
+
}
|
|
301
|
+
// (d) UNTRACKABLE INPUT: when a curl capture exists, a shell/interpreter that reads
|
|
302
|
+
// its program from stdin / a file / process-substitution / here-string is blocked
|
|
303
|
+
// regardless of variable reference — the curl output could have been redirected to a
|
|
304
|
+
// file that is then executed, which cannot be tracked statically. This is the
|
|
305
|
+
// conservative direction: only the inline `-c`/`-e` form (which needs an explicit
|
|
306
|
+
// `$` reference to consume curl output, handled by (c)) is allowed to pass through.
|
|
307
|
+
const shellStdin = /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+<<<|\s+<[^<]|\s+<\()/.source;
|
|
308
|
+
const interpStdin = /(?:python[23]?|perl|ruby|node|deno|bun)\s+<\(/.source;
|
|
309
|
+
if (new RegExp(execPrefix + pathPrefix + shellStdin).test(cmdForExecCheck) ||
|
|
310
|
+
new RegExp(execPrefix + pathPrefix + interpStdin).test(cmdForExecCheck)) {
|
|
311
|
+
enfLog.debug("remote_exec_block", "untrackable");
|
|
312
|
+
return { block: true, reason: remoteExecReason };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
206
315
|
}
|
|
207
316
|
// Block $(curl ...) and `curl ...` UNLESS every occurrence is a safe shell variable assignment.
|
|
208
317
|
// Safe: VAR=$(curl ...), export VAR=$(curl ...) — only when followed by ; && || or end-of-string
|
|
@@ -211,6 +320,7 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
|
|
|
211
320
|
// Block env VAR=$(curl ...) cmd — env runs a command with the var, so curl output could influence execution
|
|
212
321
|
// Note: [a-z_] without /i is fine — cmdLower (the parameter) is already lowercased
|
|
213
322
|
if (/\benv\s+.*[a-z_]\w*=(?:\$\(|`).*\b(curl|wget)\b/.test(cmdForExecCheck)) {
|
|
323
|
+
enfLog.debug("remote_exec_block", "env-var-sub");
|
|
214
324
|
return { block: true, reason: remoteExecReason };
|
|
215
325
|
}
|
|
216
326
|
// Strip safe assignment patterns at statement boundaries only.
|
|
@@ -225,6 +335,7 @@ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
|
|
|
225
335
|
const safeAssignment = /(?:^|(?<=[;&|\n({])\s*)(?:export\s+|declare\s+|local\s+|readonly\s+|typeset\s+)?[a-z_]\w*=(?:\$\((?:"[^"]*"|'[^']*'|(?!\$\()(?!`)[^)])*\)|`(?:(?!\$\()[^`])*`)(?=\s*(?:$|[;&|#\n)}]))/gi;
|
|
226
336
|
const stripped = cmdForExecCheck.replace(safeAssignment, " ");
|
|
227
337
|
if (/\$\(\s*\b(curl|wget)\b/.test(stripped) || /`\s*\b(curl|wget)\b/.test(stripped)) {
|
|
338
|
+
enfLog.debug("remote_exec_block", "bare-sub");
|
|
228
339
|
return { block: true, reason: remoteExecReason };
|
|
229
340
|
}
|
|
230
341
|
}
|
|
@@ -468,6 +579,169 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
|
|
|
468
579
|
return true;
|
|
469
580
|
}
|
|
470
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Detect a REAL `git <sub>` invocation (sub = "commit" or "push") at a shell command
|
|
584
|
+
* position — NOT a substring inside a heredoc body, string argument, or file path.
|
|
585
|
+
*
|
|
586
|
+
* BUG #3 FIX: `hasGitSub` matches the literal text `git ... commit` ANYWHERE, so
|
|
587
|
+
* writing a file whose CONTENT mentions "git commit" (heredoc body), an echo/string
|
|
588
|
+
* arg like `echo 'run git commit later'`, or a path like `node "/tmp/git commit x.mjs"`
|
|
589
|
+
* wrongly triggered the commit/push guard. This helper narrows detection to genuine
|
|
590
|
+
* invocations by (1) stripping heredoc bodies and (2) requiring `git` at a command
|
|
591
|
+
* boundary (start, or after ; && || | & newline ( { or control-flow keywords).
|
|
592
|
+
*
|
|
593
|
+
* Security note: we DO NOT attempt full shell quote parsing. We block conservatively
|
|
594
|
+
* — any `git <flags> <sub>` at a command boundary in the (heredoc-stripped) command
|
|
595
|
+
* still blocks. This narrows false-positives without opening a bypass: a real
|
|
596
|
+
* `git commit`/`git push` cannot avoid appearing at a command boundary.
|
|
597
|
+
*/
|
|
598
|
+
export function isRealGitCommitOrPush(command: string): boolean {
|
|
599
|
+
// Strip heredoc bodies so file CONTENT that mentions "git commit" does not count.
|
|
600
|
+
const stripped = stripHeredocBodies(command);
|
|
601
|
+
// Require git at a command position: start-of-string, or after a statement separator
|
|
602
|
+
// / pipe / background / newline / subshell-open / brace-group / control-flow keyword.
|
|
603
|
+
const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
|
|
604
|
+
// PREFIX BYPASS FIX: real git invocations can be preceded by allowed prefixes that the
|
|
605
|
+
// old boundary-only check missed — `sudo git commit`, `env GIT_DIR=x git commit`,
|
|
606
|
+
// `GIT_DIR=x git commit` (bare VAR=value assignment prefix), `command git commit`,
|
|
607
|
+
// `builtin`/`exec` wrappers. Allow an optional sequence of these before git:
|
|
608
|
+
// - sudo (with flags), env (with flags)
|
|
609
|
+
// - one-or-more VAR=value assignments
|
|
610
|
+
// - command / builtin / exec wrappers
|
|
611
|
+
//
|
|
612
|
+
// SUDO/ENV ARG BYPASS FIX (finding #3): real sudo/env flags TAKE A FOLLOWING
|
|
613
|
+
// ARGUMENT (`sudo -u root git commit`, `sudo -g grp git commit`, `env -u HOME git
|
|
614
|
+
// commit`), and env accepts VAR=value operands (`env FOO=bar git commit`). The old
|
|
615
|
+
// `(?:\s+-\S+)*` only consumed bare flags, so the arg token stopped the prefix early
|
|
616
|
+
// and these BYPASSED the guard. We now consume, after `sudo`/`env`, a sequence of:
|
|
617
|
+
// - `--long ARG` long option taking an argument (`--user root`)
|
|
618
|
+
// - `-x ARG` short option taking an argument (`-u root`, `-g grp`)
|
|
619
|
+
// - `-\S+` bare flag (`-n`, `--preserve-env`, combined `-xyz`)
|
|
620
|
+
// - `VAR=value` env operand (`FOO=bar`)
|
|
621
|
+
// A `(?!git\b)` guard on the arg-consuming forms prevents swallowing the `git` token
|
|
622
|
+
// itself; alternatives are ordered specific-first and backtracking resolves the rest.
|
|
623
|
+
const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
|
|
624
|
+
const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
|
|
625
|
+
const wrapArg = /(?:command|builtin|exec)\s+/.source;
|
|
626
|
+
const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
|
|
627
|
+
// Allow an optional path prefix on git itself: `/usr/bin/git`, `/bin/git`, etc. The
|
|
628
|
+
// executable must END in `/git` so that a path ARGUMENT to another program (e.g.
|
|
629
|
+
// `node "/tmp/git commit test.mjs"`) is NOT matched — there git is not the invoked
|
|
630
|
+
// command (first token is `node`, "git commit" lives inside a quoted arg).
|
|
631
|
+
const gitExe = /(?:\S*\/)?git\b/.source;
|
|
632
|
+
// Then allow git's own flags (git -c k=v, git -C dir, --no-pager, etc.) before the sub.
|
|
633
|
+
const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
|
|
634
|
+
for (const sub of ["commit", "push"]) {
|
|
635
|
+
if (new RegExp(boundary + prefix + gitExe + gitFlags + `\\s+${sub}\\b`).test(stripped)) {
|
|
636
|
+
enfLog.debug("git_invocation_detected", sub);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Inject `--signoff` into the REAL git-commit invocation only (DCO enforcement).
|
|
645
|
+
*
|
|
646
|
+
* BUG THIS FIXES: a naive `command.replace(/\bgit\b...commit\b/, ...)` injects
|
|
647
|
+
* `--signoff` into the FIRST `git ... commit` substring anywhere in the command —
|
|
648
|
+
* including a `git commit` mention INSIDE an echo/printf message body before a pipe
|
|
649
|
+
* (e.g. `echo -e "...sudo -u root git commit..." | git commit -F -`), corrupting the
|
|
650
|
+
* commit MESSAGE instead of the real invocation. This mirrors the trailer-injection
|
|
651
|
+
* scoping in handleCommitTrailer.
|
|
652
|
+
*
|
|
653
|
+
* Behavior:
|
|
654
|
+
* - Pattern A (echo/printf "..." | git commit -F -): the real invocation is AFTER
|
|
655
|
+
* the last pipe. Inject into the gitPart only; never touch the echo/message body.
|
|
656
|
+
* - Pattern B (bare `git commit ...`, `git commit -m/-F ...` with no commit pipe):
|
|
657
|
+
* inject `--signoff` right after the `commit` token of the real invocation, using
|
|
658
|
+
* the same command-position/prefix boundary logic as isRealGitCommitOrPush so a
|
|
659
|
+
* `git commit` substring inside a quoted arg is never matched.
|
|
660
|
+
* - If the real invocation already has `--signoff` or `-s` -> return unchanged.
|
|
661
|
+
* - If there is no real git-commit invocation -> return unchanged.
|
|
662
|
+
*/
|
|
663
|
+
export function injectSignoff(command: string): string {
|
|
664
|
+
const SIGNOFF_RE = /(?:^|\s)--signoff(?:\s|$)/;
|
|
665
|
+
const SHORT_S_RE = /(?:^|\s)-s(?:\s|$)/;
|
|
666
|
+
// Reuse the exact command-position/prefix construction from isRealGitCommitOrPush,
|
|
667
|
+
// capturing everything up to and including the `commit` token so we can insert
|
|
668
|
+
// ` --signoff` immediately after it via a NON-global replace (first real match only).
|
|
669
|
+
const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
|
|
670
|
+
const sudoEnvArg = /(?:sudo|env)(?:\s+(?:--\S+\s+(?!git\b)\S+|-[a-zA-Z]\s+(?!git\b)\S+|-\S+|[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)))*\s+/.source;
|
|
671
|
+
const assignArg = /[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+/.source;
|
|
672
|
+
const wrapArg = /(?:command|builtin|exec)\s+/.source;
|
|
673
|
+
const prefix = `(?:${sudoEnvArg}|${assignArg}|${wrapArg})*`;
|
|
674
|
+
const gitExe = /(?:\S*\/)?git\b/.source;
|
|
675
|
+
const gitFlags = /(?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*/.source;
|
|
676
|
+
const commitRe = new RegExp(`(${boundary}${prefix}${gitExe}${gitFlags}\\s+commit\\b)`);
|
|
677
|
+
|
|
678
|
+
// Pattern A: echo/printf "..." | git commit -F - (real invocation after last pipe)
|
|
679
|
+
const pipeIdx = command.lastIndexOf("|");
|
|
680
|
+
if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
|
|
681
|
+
const echoPart = command.slice(0, pipeIdx);
|
|
682
|
+
const gitPart = command.slice(pipeIdx);
|
|
683
|
+
if (SIGNOFF_RE.test(gitPart) || SHORT_S_RE.test(gitPart)) return command;
|
|
684
|
+
if (!commitRe.test(gitPart)) return command;
|
|
685
|
+
enfLog.debug("injectSignoff", "pattern A (piped git commit -F -)");
|
|
686
|
+
return echoPart + gitPart.replace(commitRe, "$1 --signoff");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Pattern B: bare / inline git commit (no message pipe)
|
|
690
|
+
const m = commitRe.exec(command);
|
|
691
|
+
if (!m) return command;
|
|
692
|
+
// Scope the "already signed" check to the MATCHED real invocation ONLY, not the
|
|
693
|
+
// whole command. The invocation region runs from the start of the match to the
|
|
694
|
+
// next UNQUOTED statement boundary (; && || | & newline) or end-of-string.
|
|
695
|
+
const regionStart = m.index;
|
|
696
|
+
let regionEnd = command.length;
|
|
697
|
+
{
|
|
698
|
+
// Escape-aware quote-state scan. In POSIX sh, backslash escapes the next char
|
|
699
|
+
// ONLY inside double quotes (and unquoted); inside single quotes a backslash is
|
|
700
|
+
// literal and only a `'` ends the string. A `\"` inside a double-quoted string is
|
|
701
|
+
// an escaped quote (stays inside), and `\\` is an escaped backslash (does not
|
|
702
|
+
// escape the following quote). Getting this right prevents a `\"` from wrongly
|
|
703
|
+
// toggling quote state — which would let an inner `;`/`&`/`|` be seen as a real
|
|
704
|
+
// statement boundary (or hide a real one) and miscompute the invocation region.
|
|
705
|
+
let inSingle = false;
|
|
706
|
+
let inDouble = false;
|
|
707
|
+
for (let i = regionStart; i < command.length; i++) {
|
|
708
|
+
const c = command[i];
|
|
709
|
+
if (inSingle) {
|
|
710
|
+
// Single quotes: no backslash processing; only `'` terminates.
|
|
711
|
+
if (c === "'") inSingle = false;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (inDouble) {
|
|
715
|
+
// Double quotes: `\` escapes the next char (e.g. \" or \\) — skip it.
|
|
716
|
+
if (c === "\\") { i++; continue; }
|
|
717
|
+
if (c === '"') inDouble = false;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
// Unquoted: `\` escapes the next char so an escaped quote does not open a string.
|
|
721
|
+
if (c === "\\") { i++; continue; }
|
|
722
|
+
if (c === "'") { inSingle = true; continue; }
|
|
723
|
+
if (c === '"') { inDouble = true; continue; }
|
|
724
|
+
if (c === "\n" || c === ";" || c === "&" || c === "|") { regionEnd = i; break; }
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Strip quoted substrings from the region so a `-s`/`--signoff` INSIDE the
|
|
728
|
+
// -m/-F message value (e.g. `-m "fix -s bug"`) is NOT treated as already-signed.
|
|
729
|
+
// Escape-aware: a `\"` inside a double-quoted span does NOT terminate it, so an
|
|
730
|
+
// inner `-s`/`--signoff` fenced by escaped quotes (`-m "a \" -s \" b"`) is still
|
|
731
|
+
// stripped away — otherwise it would leak out as an unquoted flag and suppress
|
|
732
|
+
// injection on an unsigned real commit (DCO bypass). Double-quoted spans are
|
|
733
|
+
// stripped FIRST so the single-quote regex cannot misfire on an apostrophe that
|
|
734
|
+
// lives literally inside a double-quoted string. Single quotes do NOT process
|
|
735
|
+
// backslashes in POSIX sh, so their regex stays escape-agnostic.
|
|
736
|
+
const region = command
|
|
737
|
+
.slice(regionStart, regionEnd)
|
|
738
|
+
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
|
739
|
+
.replace(/'[^']*'/g, "''");
|
|
740
|
+
if (SIGNOFF_RE.test(region) || SHORT_S_RE.test(region)) return command;
|
|
741
|
+
enfLog.debug("injectSignoff", "pattern B (inline git commit)");
|
|
742
|
+
return command.replace(commitRe, "$1 --signoff");
|
|
743
|
+
}
|
|
744
|
+
|
|
471
745
|
/** Detect git add --force / -f (including combined short options like -fn).
|
|
472
746
|
* Respects -- end-of-options marker and shell separators (&&, ;, |, ||). */
|
|
473
747
|
export function hasGitAddForce(command: string): boolean {
|
|
@@ -512,6 +786,140 @@ export function stripHeredocBodies(cmd: string): string {
|
|
|
512
786
|
.replace(/<<(\s*)['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\2\s*(?=\n|$)/gm, "");
|
|
513
787
|
}
|
|
514
788
|
|
|
789
|
+
/**
|
|
790
|
+
* True when `gh pr|issue create|comment|edit` is a real invocation (command
|
|
791
|
+
* boundary), not a mention inside a string/heredoc body.
|
|
792
|
+
*/
|
|
793
|
+
export function isRealGhBodyCommand(command: string): boolean {
|
|
794
|
+
const stripped = stripHeredocBodies(command);
|
|
795
|
+
const boundary = /(?:^|[;&|\n(){]|&&|\|\||\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source;
|
|
796
|
+
const ghExe = /(?:\S*\/)?gh\b/.source;
|
|
797
|
+
const re = new RegExp(
|
|
798
|
+
boundary + ghExe + String.raw`\s+(?:pr|issue)\s+(?:create|comment|edit)\b`,
|
|
799
|
+
);
|
|
800
|
+
const result = re.test(stripped);
|
|
801
|
+
enfLog.debug("isRealGhBodyCommand", result);
|
|
802
|
+
return result;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function commentSignatureFooter(signature: string): string {
|
|
806
|
+
return `\n\n---\n*${signature}*`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function bodyAlreadySigned(text: string): boolean {
|
|
810
|
+
return /\*Assisted-by:/.test(text) || /(?:^|\n|\\n)Assisted-by:\s/.test(text);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Index of the matching closer for a quote at `openIdx`, honoring POSIX escapes. */
|
|
814
|
+
function matchingQuoteIndex(s: string, openIdx: number): number | null {
|
|
815
|
+
const q = s[openIdx];
|
|
816
|
+
if (q !== '"' && q !== "'") return null;
|
|
817
|
+
if (q === "'") {
|
|
818
|
+
const close = s.indexOf("'", openIdx + 1);
|
|
819
|
+
enfLog.debug("matchingQuoteIndex single", close != null && close >= 0);
|
|
820
|
+
return close < 0 ? null : close;
|
|
821
|
+
}
|
|
822
|
+
for (let i = openIdx + 1; i < s.length; i++) {
|
|
823
|
+
if (s[i] === "\\") {
|
|
824
|
+
i++;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (s[i] === '"') {
|
|
828
|
+
enfLog.debug("matchingQuoteIndex double", i);
|
|
829
|
+
return i;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
enfLog.debug("matchingQuoteIndex unclosed double");
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Last quoted `--body` / `--body=` span in `command`. Conservative: unquoted
|
|
838
|
+
* or unclosed values are skipped (caller no-ops).
|
|
839
|
+
*/
|
|
840
|
+
function findLastQuotedBodySpan(command: string): { open: number; close: number } | null {
|
|
841
|
+
const flagRe = /--body(?:=|\s+)/g;
|
|
842
|
+
let last: { open: number; close: number } | null = null;
|
|
843
|
+
let fm: RegExpExecArray | null;
|
|
844
|
+
while ((fm = flagRe.exec(command)) !== null) {
|
|
845
|
+
const after = fm.index + fm[0].length;
|
|
846
|
+
const q = command[after];
|
|
847
|
+
if (q !== '"' && q !== "'") {
|
|
848
|
+
enfLog.debug("findLastQuotedBodySpan skip unquoted --body");
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const close = matchingQuoteIndex(command, after);
|
|
852
|
+
if (close == null) {
|
|
853
|
+
enfLog.warn("findLastQuotedBodySpan unclosed --body quote");
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
last = { open: after, close };
|
|
857
|
+
}
|
|
858
|
+
enfLog.debug("findLastQuotedBodySpan", last ? "found" : "none");
|
|
859
|
+
return last;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function lastHeredocInSpan(
|
|
863
|
+
command: string,
|
|
864
|
+
spanStart: number,
|
|
865
|
+
spanEnd: number,
|
|
866
|
+
): RegExpExecArray | null {
|
|
867
|
+
const heredocRe = /<<(?:-)?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n([ \t]*)\2\s*(?=\n|$)/g;
|
|
868
|
+
let last: RegExpExecArray | null = null;
|
|
869
|
+
let m: RegExpExecArray | null;
|
|
870
|
+
while ((m = heredocRe.exec(command)) !== null) {
|
|
871
|
+
if (m.index > spanStart && m.index < spanEnd) last = m;
|
|
872
|
+
}
|
|
873
|
+
enfLog.debug("lastHeredocInSpan", last ? last[2] : "none");
|
|
874
|
+
return last;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Append the comment_signature footer to `gh pr|issue create|comment|edit`
|
|
879
|
+
* `--body` / heredoc payloads. Idempotent. Empty signature is a no-op.
|
|
880
|
+
* Only mutates a heredoc that sits inside the `--body` quoted span.
|
|
881
|
+
*/
|
|
882
|
+
export function injectGhBodySignature(command: string, signature: string): string {
|
|
883
|
+
if (!signature) {
|
|
884
|
+
enfLog.debug("injectGhBodySignature skip empty signature");
|
|
885
|
+
return command;
|
|
886
|
+
}
|
|
887
|
+
if (!isRealGhBodyCommand(command)) {
|
|
888
|
+
enfLog.debug("injectGhBodySignature skip not a gh body command");
|
|
889
|
+
return command;
|
|
890
|
+
}
|
|
891
|
+
if (bodyAlreadySigned(command)) {
|
|
892
|
+
enfLog.debug("injectGhBodySignature skip already present");
|
|
893
|
+
return command;
|
|
894
|
+
}
|
|
895
|
+
const footer = commentSignatureFooter(signature);
|
|
896
|
+
const span = findLastQuotedBodySpan(command);
|
|
897
|
+
if (!span) {
|
|
898
|
+
enfLog.debug("injectGhBodySignature no quoted --body found");
|
|
899
|
+
return command;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const last = lastHeredocInSpan(command, span.open, span.close);
|
|
903
|
+
if (last) {
|
|
904
|
+
const delim = last[2];
|
|
905
|
+
const indent = last[4] ?? "";
|
|
906
|
+
const closeToken = `\n${indent}${delim}`;
|
|
907
|
+
const closeIdx = last.index + last[0].lastIndexOf(closeToken);
|
|
908
|
+
if (closeIdx >= last.index) {
|
|
909
|
+
enfLog.debug("injectGhBodySignature heredoc in --body", delim);
|
|
910
|
+
return command.slice(0, closeIdx) + footer + command.slice(closeIdx);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const payload = command.slice(span.open + 1, span.close);
|
|
915
|
+
if (payload.startsWith("$(")) {
|
|
916
|
+
enfLog.warn("injectGhBodySignature --body command subst without heredoc match");
|
|
917
|
+
return command;
|
|
918
|
+
}
|
|
919
|
+
enfLog.debug("injectGhBodySignature --body quoted");
|
|
920
|
+
return command.slice(0, span.close) + footer + command.slice(span.close);
|
|
921
|
+
}
|
|
922
|
+
|
|
515
923
|
/**
|
|
516
924
|
* Detect common test runner commands — require command-start position
|
|
517
925
|
* (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
|
|
@@ -528,3 +936,37 @@ export function isTestRunnerCommand(command: string): boolean {
|
|
|
528
936
|
|| /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
|
|
529
937
|
|| /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command);
|
|
530
938
|
}
|
|
939
|
+
|
|
940
|
+
/** True for release bump branches: chore/bump-version-<digit>... */
|
|
941
|
+
export function isBumpVersionBranch(branch: string | null): boolean {
|
|
942
|
+
return !!branch && /^chore\/bump-version-\d/.test(branch);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Branch cache per cwd — avoids repeated git calls on hot edit/write path. */
|
|
946
|
+
const branchCache = new Map<string, { branch: string | null; at: number }>();
|
|
947
|
+
const BRANCH_CACHE_TTL_MS = 5_000;
|
|
948
|
+
|
|
949
|
+
export function getCachedBranch(cwd: string): string | null {
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
const cached = branchCache.get(cwd);
|
|
952
|
+
if (cached && now - cached.at < BRANCH_CACHE_TTL_MS) return cached.branch;
|
|
953
|
+
const branch = getCurrentBranch(cwd);
|
|
954
|
+
// Only cache non-bump-version branches — bump branches must always be fresh
|
|
955
|
+
// to avoid stale results after switching away from a release branch.
|
|
956
|
+
if (!isBumpVersionBranch(branch)) {
|
|
957
|
+
branchCache.set(cwd, { branch, at: now });
|
|
958
|
+
} else {
|
|
959
|
+
branchCache.delete(cwd);
|
|
960
|
+
}
|
|
961
|
+
return branch;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Clear branch cache (tests). */
|
|
965
|
+
export function clearBranchCache(): void {
|
|
966
|
+
branchCache.clear();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Seed branch cache for testing — inject a value with custom timestamp. */
|
|
970
|
+
export function seedBranchCacheForTests(cwd: string, branch: string | null, at?: number): void {
|
|
971
|
+
branchCache.set(cwd, { branch, at: at ?? Date.now() });
|
|
972
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type EnforcementAction,
|
|
17
17
|
} from "./memory-scoring.js";
|
|
18
18
|
import { readAllTopicEntries } from "./memory-tree.js";
|
|
19
|
+
import { getSetting } from "./project-settings.js";
|
|
19
20
|
|
|
20
21
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -52,7 +53,7 @@ export interface ActionResult {
|
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Load all active memory entries that have enforcement fields.
|
|
55
|
-
* Reads from the existing memory-scores.
|
|
56
|
+
* Reads from the existing memory-scores.jsonl — no separate storage.
|
|
56
57
|
*/
|
|
57
58
|
export function loadEnforcedEntries(cwd: string): EnforcedEntry[] {
|
|
58
59
|
// Load ALL entries with trigger+action, not just active/provisional.
|
|
@@ -287,9 +288,9 @@ export function executeAction(
|
|
|
287
288
|
command: string,
|
|
288
289
|
cwd: string,
|
|
289
290
|
): ActionResult {
|
|
290
|
-
// Optional allowlist — if
|
|
291
|
+
// Optional allowlist — if enforcement_allowed_commands setting is set,
|
|
291
292
|
// only permit exact-match commands (colon-separated)
|
|
292
|
-
const allowedEnv =
|
|
293
|
+
const allowedEnv = getSetting(cwd, "enforcement_allowed_commands") as string;
|
|
293
294
|
if (allowedEnv) {
|
|
294
295
|
const allowlist = allowedEnv.split(":").map(s => s.trim()).filter(Boolean);
|
|
295
296
|
// Exact match only — no prefix matching to prevent shell chaining bypass
|
|
@@ -297,7 +298,7 @@ export function executeAction(
|
|
|
297
298
|
const commandTrimmed = command.trim();
|
|
298
299
|
const permitted = allowlist.some(allowed => commandTrimmed === allowed);
|
|
299
300
|
if (!permitted) {
|
|
300
|
-
return { output: `Blocked: command not in
|
|
301
|
+
return { output: `Blocked: command not in enforcement_allowed_commands allowlist`, success: false };
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
|