pi-orchestrator-config 4.1.5

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.
Files changed (175) hide show
  1. package/README.md +808 -0
  2. package/agents/api-documenter.md +32 -0
  3. package/agents/bash-expert.md +53 -0
  4. package/agents/code-reviewer-docs.md +145 -0
  5. package/agents/code-reviewer-guidelines.md +97 -0
  6. package/agents/code-reviewer-quality.md +142 -0
  7. package/agents/code-reviewer-security.md +107 -0
  8. package/agents/code-reviewer-spec.md +164 -0
  9. package/agents/debugger.md +63 -0
  10. package/agents/docker-expert.md +40 -0
  11. package/agents/docs-fetcher.md +48 -0
  12. package/agents/git-expert.md +63 -0
  13. package/agents/github-expert.md +83 -0
  14. package/agents/go-expert.md +62 -0
  15. package/agents/issue-reviewer-feasibility.md +60 -0
  16. package/agents/issue-reviewer-scope.md +63 -0
  17. package/agents/issue-reviewer-spec.md +77 -0
  18. package/agents/java-expert.md +31 -0
  19. package/agents/jenkins-expert.md +38 -0
  20. package/agents/kubernetes-expert.md +40 -0
  21. package/agents/planner.md +42 -0
  22. package/agents/python-expert.md +61 -0
  23. package/agents/reviewer.md +26 -0
  24. package/agents/scout.md +40 -0
  25. package/agents/security-auditor.md +254 -0
  26. package/agents/technical-documentation-writer.md +37 -0
  27. package/agents/test-automator.md +55 -0
  28. package/agents/test-runner.md +47 -0
  29. package/agents/ts-expert.md +23 -0
  30. package/agents/worker.md +30 -0
  31. package/extensions/acpx-provider/index.ts +104 -0
  32. package/extensions/acpx-provider/load-runtime.ts +99 -0
  33. package/extensions/acpx-provider/runtime-models.ts +40 -0
  34. package/extensions/cli-provider/agents/claude.ts +128 -0
  35. package/extensions/cli-provider/agents/cursor.ts +84 -0
  36. package/extensions/cli-provider/agents/gemini.ts +100 -0
  37. package/extensions/cli-provider/discover.ts +77 -0
  38. package/extensions/cli-provider/index.ts +57 -0
  39. package/extensions/cli-provider/parsers.ts +304 -0
  40. package/extensions/cli-provider/providers.ts +58 -0
  41. package/extensions/cli-provider/runner.ts +225 -0
  42. package/extensions/cli-provider/runtime-models.ts +32 -0
  43. package/extensions/cli-provider/session-reaper.ts +122 -0
  44. package/extensions/cli-provider/sessions.ts +533 -0
  45. package/extensions/cli-provider/shared/discover-cache.ts +235 -0
  46. package/extensions/cli-provider/types.ts +48 -0
  47. package/extensions/coms/coms-net-server.ts +1642 -0
  48. package/extensions/coms/coms-net-wrapper.ts +521 -0
  49. package/extensions/coms/coms-net.ts +1589 -0
  50. package/extensions/coms/coms-p2p.ts +1879 -0
  51. package/extensions/coms/coms-shared.ts +510 -0
  52. package/extensions/coms/coms-wrapper.ts +138 -0
  53. package/extensions/coms/index.ts +20 -0
  54. package/extensions/coms/themeMap.ts +147 -0
  55. package/extensions/image-gen/image-gen.ts +332 -0
  56. package/extensions/image-gen/index.ts +14 -0
  57. package/extensions/orchestrator/agents.ts +134 -0
  58. package/extensions/orchestrator/ask-user.ts +293 -0
  59. package/extensions/orchestrator/async-agents.ts +1037 -0
  60. package/extensions/orchestrator/async-capability.ts +129 -0
  61. package/extensions/orchestrator/async-runner.ts +324 -0
  62. package/extensions/orchestrator/async-status-parse.ts +31 -0
  63. package/extensions/orchestrator/async-status-ui.ts +201 -0
  64. package/extensions/orchestrator/async-wait.ts +36 -0
  65. package/extensions/orchestrator/btw.ts +210 -0
  66. package/extensions/orchestrator/cron-status-format.ts +126 -0
  67. package/extensions/orchestrator/cron-status-ui.ts +132 -0
  68. package/extensions/orchestrator/cron.ts +730 -0
  69. package/extensions/orchestrator/dreaming.ts +355 -0
  70. package/extensions/orchestrator/enforcement-helpers.ts +530 -0
  71. package/extensions/orchestrator/enforcement-rules.ts +329 -0
  72. package/extensions/orchestrator/enforcement.ts +828 -0
  73. package/extensions/orchestrator/extended-autocomplete.ts +593 -0
  74. package/extensions/orchestrator/git-helpers.ts +420 -0
  75. package/extensions/orchestrator/github-autocomplete.ts +250 -0
  76. package/extensions/orchestrator/icons.ts +17 -0
  77. package/extensions/orchestrator/index.ts +100 -0
  78. package/extensions/orchestrator/memory-embeddings.ts +281 -0
  79. package/extensions/orchestrator/memory-promotion.ts +449 -0
  80. package/extensions/orchestrator/memory-provenance.ts +80 -0
  81. package/extensions/orchestrator/memory-query-class.ts +90 -0
  82. package/extensions/orchestrator/memory-scoring.ts +434 -0
  83. package/extensions/orchestrator/memory-tools.ts +883 -0
  84. package/extensions/orchestrator/memory-tree.ts +212 -0
  85. package/extensions/orchestrator/nvim.ts +169 -0
  86. package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
  87. package/extensions/orchestrator/overlay-dashboard.ts +512 -0
  88. package/extensions/orchestrator/pi-config-review-state.ts +303 -0
  89. package/extensions/orchestrator/preference-extractor.ts +106 -0
  90. package/extensions/orchestrator/project-settings.ts +468 -0
  91. package/extensions/orchestrator/promotion-queue.ts +311 -0
  92. package/extensions/orchestrator/resolve-agent-model.ts +32 -0
  93. package/extensions/orchestrator/review-ui.ts +204 -0
  94. package/extensions/orchestrator/rule-placeholders.ts +12 -0
  95. package/extensions/orchestrator/rules.ts +481 -0
  96. package/extensions/orchestrator/session-search.ts +243 -0
  97. package/extensions/orchestrator/session-validation.ts +374 -0
  98. package/extensions/orchestrator/situation-report.ts +370 -0
  99. package/extensions/orchestrator/status-bar.ts +31 -0
  100. package/extensions/orchestrator/status-line.ts +192 -0
  101. package/extensions/orchestrator/status.ts +110 -0
  102. package/extensions/orchestrator/subagent-tool.ts +1748 -0
  103. package/extensions/orchestrator/task-lifecycle.ts +88 -0
  104. package/extensions/orchestrator/utils.ts +227 -0
  105. package/extensions/pidash/index.ts +15 -0
  106. package/extensions/pidash/pidash.ts +941 -0
  107. package/extensions/pidiff/index.ts +13 -0
  108. package/extensions/pidiff/pidiff.ts +369 -0
  109. package/extensions/providers/acpx-driver.ts +479 -0
  110. package/extensions/providers/built-in-drivers.ts +52 -0
  111. package/extensions/providers/claude-driver.ts +319 -0
  112. package/extensions/providers/cursor-acpx-driver.ts +398 -0
  113. package/extensions/providers/cursor-cli-driver.ts +484 -0
  114. package/extensions/providers/gemini-driver.ts +302 -0
  115. package/extensions/providers/index.ts +513 -0
  116. package/extensions/shared/build-system-prompt.ts +57 -0
  117. package/extensions/shared/create-runtime-provider.ts +190 -0
  118. package/extensions/shared/daemon-manager.ts +248 -0
  119. package/extensions/shared/file-logger.ts +115 -0
  120. package/extensions/shared/managed-refresh.ts +211 -0
  121. package/extensions/shared/provider-driver.ts +295 -0
  122. package/extensions/shared/provider-errors.ts +305 -0
  123. package/extensions/shared/provider-registry.ts +388 -0
  124. package/extensions/shared/stream-builder.ts +275 -0
  125. package/extensions/shared/types.ts +20 -0
  126. package/extensions/shared/ui/README.md +10 -0
  127. package/extensions/shared/ui/badge.tsx +52 -0
  128. package/extensions/shared/ui/button.tsx +58 -0
  129. package/extensions/shared/ui/collapsible.tsx +21 -0
  130. package/extensions/shared/ui/dropdown-menu.tsx +266 -0
  131. package/extensions/shared/ui/input.tsx +20 -0
  132. package/extensions/shared/ui/popover.tsx +114 -0
  133. package/extensions/shared/ui/resizable.tsx +48 -0
  134. package/extensions/shared/ui/scroll-area.tsx +53 -0
  135. package/extensions/shared/ui/select.tsx +201 -0
  136. package/extensions/shared/ui/separator.tsx +25 -0
  137. package/extensions/shared/ui/tooltip.tsx +64 -0
  138. package/extensions/shared/ws-client.ts +74 -0
  139. package/package.json +59 -0
  140. package/prompts/coderabbit-rate-limit.md +95 -0
  141. package/prompts/create-coms-feature-manager.md +186 -0
  142. package/prompts/create-skill.md +128 -0
  143. package/prompts/domain-model.md +109 -0
  144. package/prompts/external-ai.md +665 -0
  145. package/prompts/implement-and-review.md +25 -0
  146. package/prompts/implement.md +26 -0
  147. package/prompts/issue-review.md +256 -0
  148. package/prompts/pr-review.md +753 -0
  149. package/prompts/query-db.md +154 -0
  150. package/prompts/refine-review.md +208 -0
  151. package/prompts/release.md +235 -0
  152. package/prompts/remember.md +33 -0
  153. package/prompts/review-handler-status.md +27 -0
  154. package/prompts/review-handler.md +677 -0
  155. package/prompts/review-local.md +159 -0
  156. package/prompts/scout-and-plan.md +21 -0
  157. package/rules/00-orchestrator-core.md +42 -0
  158. package/rules/05-issue-first-workflow.md +119 -0
  159. package/rules/10-agent-routing.md +58 -0
  160. package/rules/15-mcp-launchpad.md +46 -0
  161. package/rules/20-code-review-loop.md +234 -0
  162. package/rules/25-documentation-updates.md +15 -0
  163. package/rules/30-prompt-templates.md +19 -0
  164. package/rules/35-memory.md +240 -0
  165. package/rules/40-critical-rules.md +124 -0
  166. package/rules/45-file-preview.md +29 -0
  167. package/rules/50-agent-bug-reporting.md +90 -0
  168. package/rules/55-coms-protocol.md +63 -0
  169. package/rules/60-task-tracking.md +66 -0
  170. package/skills/conflict-resolver/SKILL.md +213 -0
  171. package/skills/skill-creator/SKILL.md +85 -0
  172. package/skills/systematic-debugging/SKILL.md +353 -0
  173. package/skills/test-driven-development/SKILL.md +335 -0
  174. package/skills/update-agents-md/SKILL.md +282 -0
  175. package/templates/coms-feature-manager-prompt.md +313 -0
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Pure helper functions for dangerous-command enforcement.
3
+ * Extracted to allow testing without SDK dependencies.
4
+ */
5
+
6
+ import { realpathSync } from "node:fs";
7
+ import * as path from "node:path";
8
+ import { join } from "node:path";
9
+ import { DANGEROUS, hasGitSub } from "./git-helpers.js";
10
+
11
+ export type EnforcementResult = { block: true; reason: string } | { autofix: true; modifiedCommand: string; reason: string } | undefined;
12
+
13
+ /** Whether uv is available on this system (checked at session_start) */
14
+ let uvAvailable = true;
15
+ export function setUvAvailable(val: boolean): void { uvAvailable = val; }
16
+ export function isUvAvailable(): boolean { return uvAvailable; }
17
+
18
+ /** Normalize command for repeat detection: strip cd prefixes, trim whitespace */
19
+ export function normalizeForRepeatCheck(command: string): string {
20
+ return command
21
+ .replace(/^\s*cd\s+\S+\s*&&\s*/g, "")
22
+ .replace(/\s+/g, " ")
23
+ .trim();
24
+ }
25
+
26
+ /** Escape a string for safe inclusion in double-quoted shell strings */
27
+ export function escapeForDoubleQuote(s: string): string {
28
+ return s.replace(/[\\"`$!]/g, "\\$&");
29
+ }
30
+
31
+ /** Escape a string for safe inclusion in single-quoted shell strings */
32
+ export function escapeForSingleQuote(s: string): string {
33
+ return s.replace(/'/g, "'\\\''");
34
+ }
35
+
36
+ /** Parse bash command for cd target to resolve the effective working directory (worktree support) */
37
+ export function resolveEffectiveCwd(command: string, sessionCwd: string): string {
38
+ // Match the FIRST cd in the command (at start or after &&, ;, ||)
39
+ // First cd sets up the working directory before subsequent commands run.
40
+ // Using LAST cd is unsafe — a trailing cd (e.g., git commit && cd /tmp) would
41
+ // misattribute the cwd to the wrong directory.
42
+ const cdMatch = command.match(/(?:^|[;&|]\s*)cd\s+([^\s;&|]+)/);
43
+ if (cdMatch) {
44
+ const target = cdMatch[1].replace(/['"]/g, "");
45
+ if (target.startsWith("/")) return target;
46
+ return join(sessionCwd, target);
47
+ }
48
+ // Match: git -C /path/to/dir ...
49
+ const gitCMatch = command.match(/\bgit\s+-C\s+([^\s]+)/);
50
+ if (gitCMatch) {
51
+ const target = gitCMatch[1].replace(/['"]/g, "");
52
+ if (target.startsWith("/")) return target;
53
+ return join(sessionCwd, target);
54
+ }
55
+ return sessionCwd;
56
+ }
57
+
58
+ /** Auto-fix direct python commands (prepend uv run), block pip commands */
59
+ export function checkPythonPipBlock(command: string, cmdLower: string): EnforcementResult {
60
+ // If uv is not available, skip all python/pip enforcement
61
+ if (!uvAvailable) return undefined;
62
+
63
+ if (!cmdLower.startsWith("uv ") && !cmdLower.startsWith("uvx ")) {
64
+ // Use matchAll to find separator positions, then extract segments with their offsets
65
+ const separatorRe = /\n|;|&&|\|\||\||&/g;
66
+ const segments: { start: number; end: number; text: string; textLower: string }[] = [];
67
+ let lastEnd = 0;
68
+
69
+ for (const m of command.matchAll(separatorRe)) {
70
+ const segText = command.slice(lastEnd, m.index);
71
+ segments.push({
72
+ start: lastEnd,
73
+ end: m.index!,
74
+ text: segText.trim(),
75
+ textLower: segText.trim().toLowerCase(),
76
+ });
77
+ lastEnd = m.index! + m[0].length;
78
+ }
79
+ const lastSegText = command.slice(lastEnd);
80
+ segments.push({
81
+ start: lastEnd,
82
+ end: command.length,
83
+ text: lastSegText.trim(),
84
+ textLower: lastSegText.trim().toLowerCase(),
85
+ });
86
+
87
+ const envVarPrefixRe = /^\s*(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)*/;
88
+
89
+ // Pass 1: if ANY segment is pip/pip3, block the entire command
90
+ for (const seg of segments) {
91
+ if (!seg.textLower) continue;
92
+ const strippedLower = seg.textLower.replace(envVarPrefixRe, "");
93
+ // Extract first token — handle quoted paths: "path/to/python3" or 'path/to/python3'
94
+ const firstTokenMatch = strippedLower.match(/^(["'])(.+?)\1|^(\S+)/);
95
+ const firstToken = firstTokenMatch?.[2] || firstTokenMatch?.[3] || "";
96
+ const baseCmd = firstToken.replace(/^.*[\/\\]/, "");
97
+ if (baseCmd && /^pip3?$/.test(baseCmd)) {
98
+ return {
99
+ block: true,
100
+ reason: "Direct pip/pip3 forbidden. Use: uv add <pkg> / uvx <tool> / uv run --with <pkg> script.py",
101
+ };
102
+ }
103
+ }
104
+
105
+ // Pass 2: rewrite ALL python/python3 segments
106
+ let modifiedCommand = command;
107
+ let anyRewrite = false;
108
+ // Process segments in reverse order so offsets remain valid after each splice
109
+ for (let i = segments.length - 1; i >= 0; i--) {
110
+ const seg = segments[i];
111
+ if (!seg.textLower) continue;
112
+ const strippedLower = seg.textLower.replace(envVarPrefixRe, "");
113
+ // Extract first token — handle quoted paths: "path/to/python3" or 'path/to/python3'
114
+ const firstTokenMatch = strippedLower.match(/^(["'])(.+?)\1|^(\S+)/);
115
+ const firstToken = firstTokenMatch?.[2] || firstTokenMatch?.[3] || "";
116
+ const baseCmd = firstToken.replace(/^.*[\/\\]/, "");
117
+
118
+ if (baseCmd && /^python3?$/.test(baseCmd)) {
119
+ const origText = seg.text;
120
+ const envVarMatch = origText.match(envVarPrefixRe);
121
+ const envPrefix = envVarMatch?.[0] || "";
122
+ const afterEnv = origText.slice(envPrefix.length);
123
+ // Match quoted or unquoted python executable path
124
+ let origExe: string;
125
+ let fixedAfterEnv: string;
126
+ if (afterEnv.match(/^["']/)) {
127
+ // Quoted path: strip quotes and path, keep original exe name
128
+ const qm = afterEnv.match(/^(["'])(.*?)(python3?)\1(.*)/i);
129
+ origExe = qm?.[3] || baseCmd;
130
+ fixedAfterEnv = `uv run ${origExe}` + (qm?.[4] || "");
131
+ } else {
132
+ origExe = afterEnv.match(/^(\S*[\/\\])?(python3?)\b/i)?.[2] || baseCmd;
133
+ fixedAfterEnv = afterEnv.replace(/^(\S*[\/\\])?python3?\b/i, `uv run ${origExe}`);
134
+ }
135
+ const fixedStmt = envPrefix + fixedAfterEnv;
136
+
137
+ // Replace by offset
138
+ const rawSegment = modifiedCommand.slice(seg.start, seg.end);
139
+ const trimStart = rawSegment.indexOf(seg.text);
140
+ const absStart = seg.start + (trimStart >= 0 ? trimStart : 0);
141
+ const absEnd = absStart + seg.text.length;
142
+ modifiedCommand = modifiedCommand.slice(0, absStart) + fixedStmt + modifiedCommand.slice(absEnd);
143
+ anyRewrite = true;
144
+ }
145
+ }
146
+
147
+ if (anyRewrite) {
148
+ return {
149
+ autofix: true,
150
+ modifiedCommand,
151
+ reason: "Auto-fixed: prepended `uv run` to python command",
152
+ };
153
+ }
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /** Block remote script execution (pipe to shell, process substitution, command substitution/eval) */
159
+ export function checkRemoteExecBlock(cmdLower: string): EnforcementResult {
160
+ const cmdForExecCheck = cmdLower.replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*/m, "");
161
+ 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+)*)*(?:\/\S+\/)*(ba|c|da|[akz]|fi|tc)?sh\b/.test(cmdForExecCheck) ||
163
+ /\b(curl|wget)\b.*\|(?!\|)\s*(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*)*(python[23]?|perl|ruby|node|deno|bun)\b/.test(cmdForExecCheck)) {
164
+ return { block: true, reason: remoteExecReason };
165
+ }
166
+ // Match curl/wget anywhere inside process substitution <(...), not just as first token
167
+ // Allow quoted strings to handle quoted ) characters inside <(...)
168
+ if (/\b(?:(?:ba|c|da|[akz]|fi|tc)?sh|python[23]?|perl|ruby|node|deno|bun)\b.*<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
169
+ /\bsource\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) ||
170
+ /(?:^|[\s;&|])\.\s+<\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck)) {
171
+ return { block: true, reason: remoteExecReason };
172
+ }
173
+ // Block when curl/wget is inside a command substitution AND an execution primitive
174
+ // (eval, bash -c, sh -c, etc.) appears anywhere — order-independent.
175
+ // Catches: x=$(curl ...); eval "$x", eval "$x"; x=$(curl ...), etc.
176
+ // Only matches curl/wget actually inside $() or backticks, not bare curl before unrelated $().
177
+ // Detect curl/wget anywhere inside a command substitution, not just as first token.
178
+ // Allow quoted strings inside $() to handle quoted ) characters.
179
+ // Note: this is intentionally conservative — it matches curl/wget as a word anywhere inside
180
+ // the substitution, including in strings like $(echo curl). This trades rare false positives
181
+ // for stronger security against obfuscated curl invocations.
182
+ const hasCurlSub = /\$\((?:"[^"]*"|'[^']*'|[^)"'])*\b(curl|wget)\b/.test(cmdForExecCheck) || /`[^`]*\b(curl|wget)\b/.test(cmdForExecCheck);
183
+ // Anchor exec primitives to command position (start-of-string or after statement separator)
184
+ // to avoid matching inside URLs/arguments (e.g., https://host/eval)
185
+ if (hasCurlSub) {
186
+ // Allow assignment prefixes (VAR=val, VAR="a b"), sudo, and env before exec primitives.
187
+ // Shell allows VAR="a b" bash -c "cmd" — the assignment sets env for the command.
188
+ const assignPrefix = /(?:[a-z_]\w*=(?:"[^"]*"|'[^']*'|\S+)\s+)*/.source;
189
+ // Include (, {, $( as command-start boundaries for subshells/grouping/command substitution.
190
+ // Use quoted-value-capable env prefix to handle env FOO="a b" bash -c ...
191
+ // Include shell control-flow keywords (then, do, else, elif) as command boundaries
192
+ const cmdPos = /(?:^|[;&|\n({]|&&|\|\||\$\(|\bthen\b|\bdo\b|\belse\b|\belif\b)\s*/.source + assignPrefix + /(?:sudo\s+(?:-\S+\s+)*|env\s+(?:-\S+\s+)*)*/.source + assignPrefix;
193
+ // Allow optional path prefix (/bin/, /usr/bin/, etc.) before shell/interpreter names
194
+ // Allow leading redirections (>file, 2>/dev/null, etc.), shell wrappers (command, builtin, exec)
195
+ const redirections = /(?:(?:[0-9]*>[>&]?|<)\s*\S+\s+)*/.source;
196
+ const pathPrefix = /(?:\/\S+\/)*/.source;
197
+ const wrappers = /(?:(?:command|builtin|exec)\s+)*/.source;
198
+ const execPrefix = cmdPos + redirections + wrappers;
199
+ // Match shells with -c flag, stdin (<<<, <), or process substitution <(...)
200
+ // Match interpreters with -c/-e flag or process substitution <(...)
201
+ if (new RegExp(execPrefix + /eval(?:\s|$)/.source).test(cmdForExecCheck) ||
202
+ new RegExp(execPrefix + pathPrefix + /(?:ba|c|da|[akz]|fi|tc)?sh(?:\s+-c\b|\s+<<<|\s+<[^<])/.source).test(cmdForExecCheck) ||
203
+ new RegExp(execPrefix + pathPrefix + /(?:python[23]?|perl|ruby|node|deno|bun)(?:\s+-[ce]\b|\s+<\()/.source).test(cmdForExecCheck)) {
204
+ return { block: true, reason: remoteExecReason };
205
+ }
206
+ }
207
+ // Block $(curl ...) and `curl ...` UNLESS every occurrence is a safe shell variable assignment.
208
+ // Safe: VAR=$(curl ...), export VAR=$(curl ...) — only when followed by ; && || or end-of-string
209
+ // Unsafe: bare $(curl), --flag=$(curl), VAR=$(curl ...) cmd (prefix assignment runs cmd)
210
+ if (/\$\(\s*\b(curl|wget)\b/.test(cmdForExecCheck) || /`\s*\b(curl|wget)\b/.test(cmdForExecCheck)) {
211
+ // Block env VAR=$(curl ...) cmd — env runs a command with the var, so curl output could influence execution
212
+ // Note: [a-z_] without /i is fine — cmdLower (the parameter) is already lowercased
213
+ if (/\benv\s+.*[a-z_]\w*=(?:\$\(|`).*\b(curl|wget)\b/.test(cmdForExecCheck)) {
214
+ return { block: true, reason: remoteExecReason };
215
+ }
216
+ // Strip safe assignment patterns at statement boundaries only.
217
+ // Left boundary: start-of-string or after a statement separator (;, &&, ||, |, &, newline).
218
+ // Right boundary: followed by statement separator, newline, # comment, or end-of-string.
219
+ // This prevents stripping argument-position assignments like echo x=$(curl ...)
220
+ // and prefix assignments like VAR=$(curl ...) cmd.
221
+ // Use negative lookahead to reject nested command substitution (both $( and backticks) inside $() content.
222
+ // Also reject backticks inside $() to prevent var=$(bash -c "`curl ...`") bypass.
223
+ // Allow quoted strings ("..." and '...') inside $() to handle quoted ) characters.
224
+ // Left boundary includes (, { for subshell/brace-group starts. Right boundary includes ), } as terminators.
225
+ const safeAssignment = /(?:^|(?<=[;&|\n({])\s*)(?:export\s+|declare\s+|local\s+|readonly\s+|typeset\s+)?[a-z_]\w*=(?:\$\((?:"[^"]*"|'[^']*'|(?!\$\()(?!`)[^)])*\)|`(?:(?!\$\()[^`])*`)(?=\s*(?:$|[;&|#\n)}]))/gi;
226
+ const stripped = cmdForExecCheck.replace(safeAssignment, " ");
227
+ if (/\$\(\s*\b(curl|wget)\b/.test(stripped) || /`\s*\b(curl|wget)\b/.test(stripped)) {
228
+ return { block: true, reason: remoteExecReason };
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ /** Enforce temp files go to .pi/tmp/ — not bare /tmp/ */
235
+ export function checkTempFileEnforcement(command: string, cwd: string): EnforcementResult {
236
+ if (/(?:^|[;&|$( \t])mktemp\b/.test(command)) {
237
+ const expectedTmpDir = path.join(cwd, ".pi", "tmp");
238
+ const usesEnvVar = /\$\{?PROJECT_TMP_DIR\}?/.test(command);
239
+ const usesExpectedPath = command.includes(expectedTmpDir);
240
+ const usesRelativePath = /(?:^|[\s"'=])\.pi\/tmp(?:\/|[\s"']|$)/.test(command);
241
+ if (!usesEnvVar && !usesExpectedPath && !usesRelativePath) {
242
+ return {
243
+ block: true,
244
+ reason: `\u26d4 mktemp must use project temp dir. Use: mktemp \${PROJECT_TMP_DIR}/XXXXXX (resolves to ${expectedTmpDir}/)`,
245
+ };
246
+ }
247
+ }
248
+ return undefined;
249
+ }
250
+
251
+ /**
252
+ * Read-only commands that cannot modify the filesystem.
253
+ * Intentionally conservative — commands like `sort`, `diff`, `ls` are excluded
254
+ * to keep the allow-list tight and reduce attack surface.
255
+ */
256
+ export const READ_ONLY_COMMANDS = new Set([
257
+ "grep", "egrep", "fgrep", "rg", "ag", "ack",
258
+ "cat", "head", "tail", "less", "more", "wc",
259
+ "echo", "printf",
260
+ ]);
261
+
262
+ /**
263
+ * Extract command substitutions ($(...), `...`) and process substitutions
264
+ * (<(...), >(...)) from a statement, respecting single quotes (which
265
+ * suppress expansion in bash). Returns the extracted command strings.
266
+ */
267
+ export function extractSubshells(stmt: string): string[] {
268
+ const results: string[] = [];
269
+
270
+ // Strip single-quoted regions — POSIX sh has no escape inside single quotes, so [^']* is correct
271
+ const withoutSingleQuoted = stmt.replace(/'[^']*'/g, "''");
272
+
273
+ // Extract $(...) — handle nested parens by counting depth
274
+ let i = 0;
275
+ while (i < withoutSingleQuoted.length) {
276
+ // $( or <( or >(
277
+ if (i < withoutSingleQuoted.length - 1 &&
278
+ ((withoutSingleQuoted[i] === "$" && withoutSingleQuoted[i + 1] === "(") ||
279
+ (withoutSingleQuoted[i] === "<" && withoutSingleQuoted[i + 1] === "(") ||
280
+ (withoutSingleQuoted[i] === ">" && withoutSingleQuoted[i + 1] === "("))) {
281
+ const start = i + 2;
282
+ let depth = 1;
283
+ let j = start;
284
+ while (j < withoutSingleQuoted.length && depth > 0) {
285
+ if (withoutSingleQuoted[j] === "(") depth++;
286
+ else if (withoutSingleQuoted[j] === ")") depth--;
287
+ j++;
288
+ }
289
+ if (depth === 0) {
290
+ results.push(withoutSingleQuoted.slice(start, j - 1));
291
+ }
292
+ i = j;
293
+ continue;
294
+ }
295
+ // Backtick substitution — skip escaped backticks (\`)
296
+ if (withoutSingleQuoted[i] === "`") {
297
+ let j = i + 1;
298
+ while (j < withoutSingleQuoted.length) {
299
+ if (withoutSingleQuoted[j] === "\\" && j + 1 < withoutSingleQuoted.length) {
300
+ j += 2; // skip escaped character
301
+ continue;
302
+ }
303
+ if (withoutSingleQuoted[j] === "`") break;
304
+ j++;
305
+ }
306
+ if (j < withoutSingleQuoted.length) {
307
+ results.push(withoutSingleQuoted.slice(i + 1, j));
308
+ i = j + 1;
309
+ continue;
310
+ }
311
+ }
312
+ i++;
313
+ }
314
+ return results;
315
+ }
316
+
317
+ /**
318
+ * Check if a statement is a read-only command with no dangerous subshells.
319
+ * Returns true if the DANGEROUS check should be skipped for this statement.
320
+ */
321
+ export function isReadOnlyStatement(stmt: string): boolean {
322
+ // Extract the base command (first word, strip any leading env vars like VAR=val)
323
+ const stripped = stmt.replace(/^\s*(?:\S+=\S*\s+)*/, "");
324
+ const baseCmd = stripped.split(/\s/)[0]?.replace(/^.*\//, ""); // strip path prefix
325
+ if (!baseCmd || !READ_ONLY_COMMANDS.has(baseCmd)) return false;
326
+
327
+ // Check subshells for dangerous content
328
+ const subshells = extractSubshells(stmt);
329
+ // Evaluate extracted content against DANGEROUS as raw strings — catches nested subshells too
330
+ return !subshells.some((sub) => DANGEROUS.some((p) => p.test(sub)));
331
+ }
332
+
333
+ /** Pattern matching direct recursive rm commands (subset of DANGEROUS patterns). */
334
+ const RM_PATTERN = /\brm\s+(?:-[a-zA-Z]+\s+)*(-[a-zA-Z]*r[a-zA-Z]*|--recursive)/i;
335
+
336
+ /**
337
+ * Check if a dangerous rm command only targets paths within .pi/tmp/ or /tmp/<something>.
338
+ * Returns true if the command should be silently allowed.
339
+ * Note: bare /tmp (without subpath) is NOT allowed — only /tmp/<file-or-folder>.
340
+ */
341
+ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
342
+ // Only applies to direct rm commands, not find/xargs variants
343
+ if (!RM_PATTERN.test(stmt)) return false;
344
+
345
+ // Ensure rm is the actual command, not an argument to another command (e.g., xargs rm)
346
+ const firstWord = stmt.trim().split(/\s+/)[0];
347
+ if (firstWord !== "rm" && !firstWord?.endsWith("/rm")) return false;
348
+
349
+ // Don't silently allow if the statement also matches other DANGEROUS patterns
350
+ // (e.g., sudo rm -rf .pi/tmp/foo — sudo should still trigger confirmation)
351
+ if (/\bsudo\b/i.test(stmt)) return false;
352
+
353
+ // Reject statements containing subshells or process substitutions — these could
354
+ // embed arbitrary commands that bypass the allowlist check
355
+ if (extractSubshells(stmt).length > 0) return false;
356
+
357
+ // Parse arguments: split on whitespace, skip flags, handle -- separator
358
+ const tokens = stmt.trim().split(/\s+/);
359
+ const paths: string[] = [];
360
+ let pastSeparator = false;
361
+ let pastRm = false;
362
+ let skipNext = false;
363
+
364
+ for (const token of tokens) {
365
+ if (skipNext) {
366
+ skipNext = false;
367
+ continue;
368
+ }
369
+ if (!pastRm) {
370
+ if (token === "rm" || token.endsWith("/rm")) pastRm = true;
371
+ continue;
372
+ }
373
+ if (token === "--") {
374
+ pastSeparator = true;
375
+ continue;
376
+ }
377
+ if (!pastSeparator && token.startsWith("-")) continue;
378
+ // Skip shell redirections — handle both joined (2>/dev/null) and spaced (2> /dev/null)
379
+ if (/^[0-9]*>{1,2}|^&>|^[0-9]*</.test(token)) {
380
+ // If the token is ONLY the operator (no target attached), skip the next token too
381
+ if (/^(?:[0-9]*>{1,2}|&>|[0-9]*<)$/.test(token)) {
382
+ skipNext = true;
383
+ }
384
+ continue;
385
+ }
386
+ // Strip surrounding quotes — prevents false positives on rm -rf ".pi/tmp/foo"
387
+ paths.push(token.replace(/^["']|["']$/g, ""));
388
+ }
389
+
390
+ // Guard against vacuous truth — if no paths extracted, don't silently allow
391
+ if (paths.length === 0) return false;
392
+
393
+ // Resolve and substitute PROJECT_TMP_DIR env var
394
+ const projectTmpDir = path.join(cwd, ".pi", "tmp");
395
+ let resolvedCwd: string | null;
396
+ try {
397
+ resolvedCwd = realpathSync(cwd);
398
+ } catch {
399
+ resolvedCwd = null; // cwd doesn't exist — project tmp checks will all fail
400
+ }
401
+
402
+ for (const p of paths) {
403
+ // Substitute ${PROJECT_TMP_DIR} or $PROJECT_TMP_DIR
404
+ const expanded = p.replace(/\$\{?PROJECT_TMP_DIR\}?/g, projectTmpDir);
405
+ let resolved: string;
406
+ try {
407
+ // Use realpathSync to resolve symlinks — prevents symlink traversal attacks
408
+ resolved = realpathSync(path.resolve(cwd, expanded));
409
+ } catch {
410
+ // Path doesn't exist. For /tmp paths, validate the existing parent via realpathSync
411
+ // to catch symlinked parents (e.g., /tmp/evil-link -> / where evil-link exists).
412
+ const lexical = path.resolve(cwd, expanded);
413
+ if (lexical.startsWith("/tmp/") && lexical !== "/tmp" && lexical.length > 5) {
414
+ // Block paths containing traversal sequences — even if they resolve under /tmp/,
415
+ // they may have escaped and re-entered via ..
416
+ if (expanded.includes("..")) {
417
+ return false;
418
+ }
419
+ // Validate that the existing parent resolves under /tmp
420
+ const parentDir = path.dirname(lexical);
421
+ try {
422
+ const resolvedParent = realpathSync(parentDir);
423
+ // Resolve /tmp itself to handle systems where /tmp is a symlink (e.g., /private/tmp on macOS)
424
+ let resolvedTmp: string;
425
+ try { resolvedTmp = realpathSync("/tmp"); } catch { resolvedTmp = "/tmp"; }
426
+ if (!resolvedParent.startsWith(resolvedTmp + "/") && resolvedParent !== resolvedTmp) {
427
+ return false; // Parent symlinks outside /tmp
428
+ }
429
+ } catch {
430
+ // Parent also doesn't exist — safe (entire path is non-existent)
431
+ }
432
+ resolved = lexical;
433
+ } else if (resolvedCwd !== null &&
434
+ (lexical.startsWith(resolvedCwd + path.sep) || lexical === resolvedCwd) &&
435
+ (lexical.includes(`${path.sep}.pi${path.sep}tmp${path.sep}`) ||
436
+ lexical.endsWith(`${path.sep}.pi${path.sep}tmp`))) {
437
+ // Non-existent path within project .pi/tmp/ — validate parent exists under project
438
+ if (expanded.includes("..")) {
439
+ return false;
440
+ }
441
+ const parentDir = path.dirname(lexical);
442
+ try {
443
+ const resolvedParent = realpathSync(parentDir);
444
+ if (!resolvedParent.startsWith(resolvedCwd + path.sep) && resolvedParent !== resolvedCwd) {
445
+ return false; // Parent symlinks outside project
446
+ }
447
+ } catch {
448
+ // Parent also doesn't exist — safe (entire path is non-existent)
449
+ }
450
+ resolved = lexical;
451
+ } else {
452
+ return false;
453
+ }
454
+ }
455
+
456
+ // Check if path is in an allowed temp location:
457
+ // A) Project .pi/tmp/ — path within project AND goes through .pi/tmp/
458
+ // B) System /tmp/<something> — path under /tmp/ but NOT /tmp itself
459
+ const inProjectTmp = resolvedCwd !== null &&
460
+ (resolved.startsWith(resolvedCwd + path.sep) || resolved === resolvedCwd) &&
461
+ (resolved.includes(`${path.sep}.pi${path.sep}tmp${path.sep}`) ||
462
+ resolved.endsWith(`${path.sep}.pi${path.sep}tmp`));
463
+ const inSystemTmp = resolved.startsWith("/tmp/") && resolved !== "/tmp" && resolved.length > 5;
464
+
465
+ if (!inProjectTmp && !inSystemTmp) return false;
466
+ }
467
+
468
+ return true;
469
+ }
470
+
471
+ /** Detect git add --force / -f (including combined short options like -fn).
472
+ * Respects -- end-of-options marker and shell separators (&&, ;, |, ||). */
473
+ export function hasGitAddForce(command: string): boolean {
474
+ if (!hasGitSub(command, "add")) return false;
475
+ // Split on shell separators to isolate individual statements
476
+ const statements = command.split(/\s*(?:&&|\|\||[;|])\s*/);
477
+ for (const stmt of statements) {
478
+ const addMatch = stmt.match(/\bgit\b.*\badd\b\s+(.*)/);
479
+ if (!addMatch) continue;
480
+ const tokens = addMatch[1].split(/\s+/);
481
+ for (const token of tokens) {
482
+ if (token === "--") break; // Everything after -- is a pathspec
483
+ if (token === "--force") return true;
484
+ // Short option: -f or combined like -fn, -vf (starts with - but not --)
485
+ if (token.startsWith("-") && !token.startsWith("--") && token.includes("f")) return true;
486
+ }
487
+ }
488
+ return false;
489
+ }
490
+
491
+ /** Check if a git add command uses bulk-stage tokens (., -A, --all) before the -- separator. */
492
+ export function hasGitAddBulk(command: string): boolean {
493
+ if (!hasGitSub(command, "add")) return false;
494
+ const addMatch = command.match(/\bgit\b.*\badd\b\s+(.*)/);
495
+ if (!addMatch) return false;
496
+ const args = addMatch[1];
497
+ // Split tokens, only check before -- (end-of-options marker)
498
+ const tokens = args.split(/\s+/);
499
+ for (const token of tokens) {
500
+ if (token === "--") break; // Everything after -- is a pathspec
501
+ if (token === "." || token === "-A" || token === "--all") return true;
502
+ }
503
+ return false;
504
+ }
505
+
506
+ /** Strip heredoc bodies from command string, preserving commands after the closing delimiter */
507
+ export function stripHeredocBodies(cmd: string): string {
508
+ // Match heredoc: <<DELIM ... DELIM and <<-DELIM ... (tab-indented) DELIM
509
+ // For <<- (dash form), the closing delimiter can be preceded by tabs only.
510
+ // Closing delimiter must be on its own line with no trailing content (except newline).
511
+ return cmd.replace(/<<-(\s*)['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n[ \t]*\2\s*(?=\n|$)/gm, "")
512
+ .replace(/<<(\s*)['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\2\s*(?=\n|$)/gm, "");
513
+ }
514
+
515
+ /**
516
+ * Detect common test runner commands — require command-start position
517
+ * (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
518
+ * NOTE: For compound commands (e.g., pytest && other_cmd), if the non-test part fails,
519
+ * isError=true marks tests as failed even though pytest passed. This is the conservative/safe
520
+ * direction — re-run the test command standalone to mark tests_passed.
521
+ * NOTE: `tox` without `-e` args matches (runs default envs = tests). `tox -e lint` does NOT
522
+ * match — we exclude tox with explicit -e to avoid marking lint/docs runs as test passes.
523
+ */
524
+ export function isTestRunnerCommand(command: string): boolean {
525
+ return /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?(?:pytest|vitest|jest|mocha)\b/.test(command)
526
+ || /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?tox\b(?!\s*-e)(?!\s+--(?:help|version|list))/.test(command)
527
+ || /(?:^|[;&|]\s*)go\s+test\b/.test(command)
528
+ || /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
529
+ || /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command);
530
+ }