pi-crew 0.9.10 → 0.9.11
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/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/src/config/role-tools.ts +39 -6
- package/src/runtime/async-runner.ts +70 -74
- package/src/runtime/background-runner.ts +13 -2
- package/src/runtime/role-permission.ts +5 -21
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/state/artifact-store.ts +22 -2
- package/src/utils/redaction.ts +49 -31
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v0.9.11] — Per-run lock path for background-runner (parallel-spawn race) (2026-06-27)
|
|
4
|
+
|
|
5
|
+
Bug caught by an E2E parallel-spawn test in this session, NOT by unit tests (which cannot spawn multiple real processes). Independent of the F1-F5/redaction batches.
|
|
6
|
+
|
|
7
|
+
### Bug fix
|
|
8
|
+
|
|
9
|
+
- **Shared `run.lock` killed concurrent background runners** (`src/runtime/background-runner.ts:417`). The bootstrap call passed a fake manifest `{ stateRoot: "", runId, cwd }` to `withRunLockSync` because the real manifest was not loaded yet. `lockPath()` = `path.join(manifest.stateRoot, "run.lock")` = `path.join("", "run.lock")` = `"run.lock"` — a RELATIVE path at cwd, SHARED across every run regardless of runId. When multiple background agents spawned in the same instant (e.g. parallel `Agent` calls), they raced on the single shared lock: one acquired, the rest failed fast ("Run 'run.lock' is locked by another operation") and exited within 3s. The existing "FIX Issue #3" comment claimed to prevent concurrent runners "for the same runId", but the lock path never contained the runId. Fix: compute the real per-run stateRoot via `createRunPaths(cwd, runId).stateRoot` before locking, so each run locks its own `<cwd>/.crew/state/runs/<runId>/run.lock`. Matches `locks-race.test.ts`.
|
|
10
|
+
|
|
11
|
+
### Verification
|
|
12
|
+
|
|
13
|
+
- `npx tsc --noEmit` EXIT 0
|
|
14
|
+
- 7 lock-related suites pass (locks-race 10, background-runner-console-redirect 4, async-runner 13, api-locks 1, orphan-worker-registry 15, locks-untested 11, team-runner-heartbeat 2)
|
|
15
|
+
- E2E reproduce (decisive): BEFORE the fix, 3 parallel background explorers → 1 pass + 2 fail (background.log: "Failed to acquire lock"). AFTER the fix, same scenario → 3/3 pass, 0 lock errors.
|
|
16
|
+
|
|
17
|
+
### Lesson
|
|
18
|
+
|
|
19
|
+
Concurrency/lock bugs only reproduce when multiple real processes spawn simultaneously — unit tests mocking a single process can never catch them. E2E parallel-spawn smoke tests are the only way to verify. (Reinforces the v0.9.9 lesson: E2E with real extension load is decisive.)
|
|
20
|
+
|
|
21
|
+
## [v0.9.11] — Read-only permission model fixes F1-F5 (2026-06-27)
|
|
22
|
+
|
|
23
|
+
Review of the role permission model (question: "do read-only workflows still persist their task output?") confirmed output persistence is runner-driven and correct, but found 5 findings — one the same defect class as the v0.9.10 writer incident (Fix 5), in the opposite direction.
|
|
24
|
+
|
|
25
|
+
### Bug fixes
|
|
26
|
+
|
|
27
|
+
- **`security-reviewer`/`test-engineer` tool config unreachable (F1, HIGH)** (`src/config/role-tools.ts`). Map keys were `security_reviewer`/`test_engineer` (underscore) while the runtime role strings are hyphenated (`agents/security-reviewer.md` → `security-reviewer`). `getToolConfig` did not normalize, so it returned `{}` and the strictest tool restrictions in the codebase silently never applied. Same defect class as the writer incident, opposite direction (under-enforce vs over-enforce). Tests masked it: they queried only the underscore forms. Fix: quote+hyphen the keys (a bare `security-reviewer:` key parses as subtraction — must be quoted) and normalize in `getToolConfig` (`role.replaceAll("_","-")`); added a regression test that derives role names from the runtime sets and asserts each resolves its intended config.
|
|
28
|
+
- **`critic`/`planner` tool-config gaps (F2)** (`src/config/role-tools.ts`). `critic` had no entry (a custom critic agent had no tool-level read-only enforcement); `planner`'s entry only excluded `ask_question` and did not enforce read-only. Added a `critic` entry and strengthened `planner` to a read-only tool-set.
|
|
29
|
+
- **`planner` kept read-only with deliverable guidance (F3)** (`src/runtime/task-runner/prompt-builder.ts`). `planner` emits deliverables (`output: plan.md`) but moving it to WRITE_ROLES would fire the plan-approval gate BEFORE planning (breaking default/implementation workflows — `team-runner.ts:399` relies on planner being read-only). Fix: keep planner read-only and add a prompt line telling read-only roles their RESULT TEXT is persisted by the runner, so they emit deliverables as text instead of attempting file writes.
|
|
30
|
+
- **`verifier` reclassified read-only → write (F4)** (`src/runtime/role-permission.ts` + `src/config/role-tools.ts`). `verifier`'s task runs tests via bash with redirects/cache writes (`npm test | tee`, `mkdir`, `rm`), all forbidden by the read-only prompt gate — a direct contradiction with `agents/verifier.md`. Moved verifier to WRITE_ROLES; tool-config keeps bash but excludes edit/write so source integrity is preserved. Mirrors `cold-verifier`.
|
|
31
|
+
- **Dead command-enforcement removed (F5)** (`src/runtime/role-permission.ts`). `isReadOnlyCommand`/`checkRolePermission`/`READ_ONLY_COMMANDS` had zero runtime callers (only tests). Real protection lives in the role tool-config + `safe-paths.ts`/`resolveRealContainedPath` (10+ runtime callers). Deleted the dead code.
|
|
32
|
+
|
|
33
|
+
### Verification
|
|
34
|
+
|
|
35
|
+
- `npx tsc --noEmit` EXIT 0
|
|
36
|
+
- 124 tests pass / 0 fail across 13 suites + 1 integration (role-tools 15, role-permission-cov 23, role-permission 2, role-permission.spawn 3, prompt-builder-cov 15, v0-8-0-tool-policy-unification 10, skill-instructions 16, plan-approval-boundary 7, crew-contracts 6, goal-loop-team-roles 5, t9-cold-verifier 5, completion-guard 7, verification-gates 10, role-tools-integration 3)
|
|
37
|
+
- E2E: `research` workflow 3/3 tasks — explorer+analyst (read-only) persisted findings, writer wrote the deliverable file
|
|
38
|
+
|
|
39
|
+
## [v0.9.11] — Secret redaction & env hardening (2026-06-27)
|
|
40
|
+
|
|
41
|
+
Independent security review (review team, 3/4 tasks, ~360K tokens) flagged 3 Medium findings in the secret-redaction and env-passthrough surfaces. All verified by live `npx tsx` repro + source trace before fixing.
|
|
42
|
+
|
|
43
|
+
### Bug fixes
|
|
44
|
+
|
|
45
|
+
- **`redactAuthHeader` leaked credential values (L3/L5)** (`src/utils/redaction.ts`). Two defects: (1) `indexOf` matched only the FIRST `authorization:` occurrence per call, so a second header on a later line leaked verbatim; (2) the word-boundary allow-list excluded `-` and `\t`, so `Proxy-Authorization:` / `X-Authorization:` and tab-indented headers were not recognized. Fix: loop over all occurrences and add `-`/`\t` to a shared `AUTH_HEADER_BOUNDARY_CHARS` set (used by both `redactAuthHeader` and `redactBearerTokens`). Latent weakness caught by repro (NOT by the reviewer's proposed fix): the old code only APPENDED a ` ***` marker without removing the value — `"authorization: Basic abc123"` became `"authorization: Basic abc123 ***"` (credential still visible). The redact branch now blanks the value: `line.substring(authIdx, authIdx+14) + " ***"` → `"authorization: ***"`. Consistent with `redactInlineSecrets`.
|
|
46
|
+
- **`writeArtifact` flat-redaction only (M2)** (`src/state/artifact-store.ts:130`). Applied only `redactSecretString` (flat regex scan), so quoted-JSON secrets (`"api_key":"sk-..."`) and nested keys survived into persisted artifacts (e.g. `startup-evidence.json` holds up to 500 chars of raw child stderr). Fix: structural-then-flat — when content parses as JSON, run `redactSecrets` (recursive) first, then flat `redactSecretString`. Order matters: structural catches quoted keys, flat still catches Bearer/JWT/Auth headers inside JSON string values. Formatting is preserved: the input is re-stringified with the SAME indentation (pretty → indent 2, compact → compact), so pretty-printed artifacts like group-join metadata keep their `"partial": false` whitespace (caught by `test/integration/phase4-runtime.test.ts` regression on CI after the first attempt shipped a compact re-stringify).
|
|
47
|
+
- **Provider API keys leaked into the detached background runner (M1)** (`src/runtime/async-runner.ts:162`). The env allowlist forwarded 14 provider keys (MINIMAX/OPENAI/ANTHROPIC/...) to the background runner, contradicting `child-pi.ts:275` ("API keys are NOT needed — config file"). Keys leaked into V8 fatal-error reports (`--report-on-fatalerror` writes `environmentVariables` unredacted). The inline comment "same as child-pi.ts" was false. Fix: extracted `BACKGROUND_RUNNER_ENV_ALLOWLIST` (exported, unit-testable) and removed the 14 provider keys. Prereq verified: `background-runner.ts` does not read provider keys directly.
|
|
48
|
+
|
|
49
|
+
### Verification
|
|
50
|
+
|
|
51
|
+
- `npx tsc --noEmit` EXIT 0
|
|
52
|
+
- 21 targeted suites pass (~130 tests): redaction-cov (32), redaction-p1f (18), redaction-transcript-roundtrip (3), child-pi-sec1-redaction (8), artifact-store (4), async-runner (13), env-filter (4), env-filter-cov (9), security-hardening (8), round28-otlp-crlf (4), child-pi-compaction-real (9), + others
|
|
53
|
+
- Live repro: `redactSecretString("Proxy-Authorization: Basic c2VjcmV0")` → `"Proxy-Authorization: ***"` (was: unchanged leak)
|
|
54
|
+
|
|
3
55
|
## [v0.9.10 (continued)] — Round 29 follow-ups: BG2 sweep bug fixes, test optimization, E2E verification (2026-06-26)
|
|
4
56
|
|
|
5
57
|
A full-suite verify run (`verify-full2`, 5502 tests, 774 suites) surfaced 4 file-level timeouts and 2 real correctness bugs. This release fixes the 2 real bugs, the underlying cause of 2 of the 4 timeouts (chain-runner + orphan-worker-registry + cleanup-full-flow self-deadlock + HandoffManager interval leak), and adds E2E verification artifacts to prove all fixes hold against the live runtime, not just static analysis.
|
package/package.json
CHANGED
package/src/config/role-tools.ts
CHANGED
|
@@ -22,9 +22,23 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
22
22
|
excludeTools: ["edit", "write", "ask_question"],
|
|
23
23
|
},
|
|
24
24
|
|
|
25
|
-
// Planner -
|
|
25
|
+
// Planner - Read-only planning; emits plans as TEXT (runner persists result).
|
|
26
|
+
// F2/F3: strengthened to a read-only tool-set matching its READ_ONLY_ROLES
|
|
27
|
+
// classification. Deliverables are emitted as RESULT TEXT (consumed by
|
|
28
|
+
// adaptive-plan.ts / runner shared-output), NOT file writes — so the
|
|
29
|
+
// plan-approval gate boundary (planner = read-only) is preserved. Moving
|
|
30
|
+
// planner to WRITE_ROLES would fire the gate before planning, breaking the
|
|
31
|
+
// default/implementation workflows.
|
|
26
32
|
planner: {
|
|
27
|
-
|
|
33
|
+
tools: ["read", "grep", "find", "ls", "glob"],
|
|
34
|
+
excludeTools: ["edit", "write", "bash", "web", "ask_question"],
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Critic - Read-only plan/design critique (F2: was missing from the map,
|
|
38
|
+
// so a custom critic agent had no tool-level read-only enforcement).
|
|
39
|
+
critic: {
|
|
40
|
+
tools: ["read", "grep", "find", "ls", "glob"],
|
|
41
|
+
excludeTools: ["edit", "write", "bash", "web"],
|
|
28
42
|
},
|
|
29
43
|
|
|
30
44
|
// Executor - Full access (default)
|
|
@@ -45,13 +59,26 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
45
59
|
},
|
|
46
60
|
|
|
47
61
|
// Security Reviewer - Strict restrictions
|
|
48
|
-
|
|
62
|
+
// F1: key is hyphenated to match the runtime role string (agents/
|
|
63
|
+
// security-reviewer.md → "security-reviewer"). The underscore form never
|
|
64
|
+
// resolved at runtime (returned {}), silently dropping enforcement.
|
|
65
|
+
"security-reviewer": {
|
|
49
66
|
tools: ["read", "grep", "find"],
|
|
50
67
|
excludeTools: ["edit", "write", "bash", "web", "ask_question"],
|
|
51
68
|
},
|
|
52
69
|
|
|
53
|
-
//
|
|
54
|
-
|
|
70
|
+
// Verifier - Runs tests (needs bash) but must NOT edit source (F4: moved
|
|
71
|
+
// from READ_ONLY_ROLES to WRITE_ROLES — the read-only prompt gate forbids
|
|
72
|
+
// the test-running redirects / cache writes its task requires, contradicting
|
|
73
|
+
// agents/verifier.md). Tool-set keeps bash but excludes edit/write so source
|
|
74
|
+
// integrity is preserved during verification. Mirrors cold-verifier behavior.
|
|
75
|
+
verifier: {
|
|
76
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
77
|
+
excludeTools: ["edit", "write", "web"],
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Test Engineer - Can write tests (F1: hyphenated key)
|
|
81
|
+
"test-engineer": {
|
|
55
82
|
tools: ["read", "edit", "write", "bash", "ls"],
|
|
56
83
|
excludeTools: ["web"],
|
|
57
84
|
},
|
|
@@ -61,7 +88,13 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
61
88
|
* Get tool configuration for a specific role.
|
|
62
89
|
*/
|
|
63
90
|
export function getToolConfig(role: string): RoleToolConfig {
|
|
64
|
-
|
|
91
|
+
// F1: normalize hyphen/underscore. Runtime role strings are hyphenated
|
|
92
|
+
// (agents/security-reviewer.md → "security-reviewer") but map keys were
|
|
93
|
+
// historically underscored, silently returning {} at runtime — the same
|
|
94
|
+
// defect class as the v0.9.10 writer incident (opposite direction:
|
|
95
|
+
// under-enforce instead of over-enforce). Accept both forms.
|
|
96
|
+
const key = role.includes("_") ? role.replaceAll("_", "-") : role;
|
|
97
|
+
return ROLE_TOOL_CONFIGS[key] ?? ROLE_TOOL_CONFIGS[role] ?? {};
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
/**
|
|
@@ -150,6 +150,75 @@ export interface SpawnBackgroundTeamRunResult {
|
|
|
150
150
|
logPath: string;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Env vars explicitly forwarded to the detached background runner.
|
|
155
|
+
*
|
|
156
|
+
* Provider API keys (MINIMAX/OPENAI/ANTHROPIC/...) are INTENTIONALLY OMITTED
|
|
157
|
+
* (security review M1): the background runner only spawns child Pi workers,
|
|
158
|
+
* which read keys from the Pi config file (not env). Passing keys via env
|
|
159
|
+
* leaks them into V8 fatal-error reports (--report-on-fatalerror writes the
|
|
160
|
+
* `environmentVariables` section unredacted). Matches child-pi.ts policy.
|
|
161
|
+
* Exported so the invariant is unit-testable (test/unit/async-runner.test.ts).
|
|
162
|
+
*/
|
|
163
|
+
export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
|
|
164
|
+
// Essential non-secret vars
|
|
165
|
+
"PATH",
|
|
166
|
+
"HOME",
|
|
167
|
+
"USER",
|
|
168
|
+
"SHELL",
|
|
169
|
+
"TERM",
|
|
170
|
+
"LANG",
|
|
171
|
+
"LC_ALL",
|
|
172
|
+
"LC_COLLATE",
|
|
173
|
+
"LC_CTYPE",
|
|
174
|
+
"LC_MESSAGES",
|
|
175
|
+
"LC_MONETARY",
|
|
176
|
+
"LC_NUMERIC",
|
|
177
|
+
"LC_TIME",
|
|
178
|
+
"XDG_CONFIG_HOME",
|
|
179
|
+
"XDG_DATA_HOME",
|
|
180
|
+
"XDG_CACHE_HOME",
|
|
181
|
+
"XDG_RUNTIME_DIR",
|
|
182
|
+
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
183
|
+
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
184
|
+
"NVM_BIN",
|
|
185
|
+
"NVM_DIR",
|
|
186
|
+
"NVM_INC",
|
|
187
|
+
"NODE_PATH",
|
|
188
|
+
"NODE_DISABLE_COLORS",
|
|
189
|
+
"NODE_EXTRA_CA_CERTS",
|
|
190
|
+
"NPM_CONFIG_REGISTRY",
|
|
191
|
+
"NPM_CONFIG_USERCONFIG",
|
|
192
|
+
"NPM_CONFIG_GLOBALCONFIG",
|
|
193
|
+
// PI_CREW_PARENT_PID is needed for parent-guard (liveness check).
|
|
194
|
+
"PI_CREW_DEPTH",
|
|
195
|
+
"PI_CREW_MAX_DEPTH",
|
|
196
|
+
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
197
|
+
"PI_CREW_INHERIT_SKILLS",
|
|
198
|
+
"PI_CREW_PARENT_PID",
|
|
199
|
+
"PI_TEAMS_DEPTH",
|
|
200
|
+
"PI_TEAMS_MAX_DEPTH",
|
|
201
|
+
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
202
|
+
"PI_TEAMS_INHERIT_SKILLS",
|
|
203
|
+
"PI_TEAMS_PI_BIN",
|
|
204
|
+
"PI_TEAMS_MOCK_CHILD_PI",
|
|
205
|
+
"PI_CREW_ALLOW_MOCK",
|
|
206
|
+
// Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
|
|
207
|
+
"PI_CREW_WORKER_ATOMIC_WRITER",
|
|
208
|
+
"PI_TEAMS_WORKER_ATOMIC_WRITER",
|
|
209
|
+
// Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
|
|
210
|
+
"PI_CREW_VERIFICATION_SANITIZE_ENV",
|
|
211
|
+
"PI_TEAMS_VERIFICATION_SANITIZE_ENV",
|
|
212
|
+
"PI_CREW_VERIFICATION_PRESERVE_ENV",
|
|
213
|
+
"PI_TEAMS_VERIFICATION_PRESERVE_ENV",
|
|
214
|
+
// Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16).
|
|
215
|
+
"PI_CREW_VERIFICATION_WORKTREE",
|
|
216
|
+
"PI_TEAMS_VERIFICATION_WORKTREE",
|
|
217
|
+
// Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
|
|
218
|
+
"PI_CREW_BG_REPORT_ON_FATAL",
|
|
219
|
+
"PI_TEAMS_BG_REPORT_ON_FATAL",
|
|
220
|
+
];
|
|
221
|
+
|
|
153
222
|
export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
|
|
154
223
|
const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "background-runner.ts");
|
|
155
224
|
const logPath = path.join(manifest.stateRoot, "background.log");
|
|
@@ -159,80 +228,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
159
228
|
// to prevent leaking all env vars (including secrets) to detached background runner.
|
|
160
229
|
// Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
|
|
161
230
|
const filteredEnv = sanitizeEnvSecrets(process.env, {
|
|
162
|
-
allowList:
|
|
163
|
-
// Model provider API keys (same as child-pi.ts)
|
|
164
|
-
"MINIMAX_API_KEY",
|
|
165
|
-
"MINIMAX_GROUP_ID",
|
|
166
|
-
"OPENAI_API_KEY",
|
|
167
|
-
"OPENAI_ORG_ID",
|
|
168
|
-
"ANTHROPIC_API_KEY",
|
|
169
|
-
"GOOGLE_API_KEY",
|
|
170
|
-
"GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
171
|
-
"AZURE_OPENAI_API_KEY",
|
|
172
|
-
"AZURE_OPENAI_ENDPOINT",
|
|
173
|
-
"AWS_ACCESS_KEY_ID",
|
|
174
|
-
"AWS_SECRET_ACCESS_KEY",
|
|
175
|
-
"AWS_REGION",
|
|
176
|
-
"ZEU_API_KEY",
|
|
177
|
-
"ZERODEV_API_KEY",
|
|
178
|
-
// Essential non-secret vars
|
|
179
|
-
"PATH",
|
|
180
|
-
"HOME",
|
|
181
|
-
"USER",
|
|
182
|
-
"SHELL",
|
|
183
|
-
"TERM",
|
|
184
|
-
"LANG",
|
|
185
|
-
"LC_ALL",
|
|
186
|
-
"LC_COLLATE",
|
|
187
|
-
"LC_CTYPE",
|
|
188
|
-
"LC_MESSAGES",
|
|
189
|
-
"LC_MONETARY",
|
|
190
|
-
"LC_NUMERIC",
|
|
191
|
-
"LC_TIME",
|
|
192
|
-
"XDG_CONFIG_HOME",
|
|
193
|
-
"XDG_DATA_HOME",
|
|
194
|
-
"XDG_CACHE_HOME",
|
|
195
|
-
"XDG_RUNTIME_DIR",
|
|
196
|
-
// Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
|
|
197
|
-
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
198
|
-
"NVM_BIN",
|
|
199
|
-
"NVM_DIR",
|
|
200
|
-
"NVM_INC",
|
|
201
|
-
"NODE_PATH",
|
|
202
|
-
"NODE_DISABLE_COLORS",
|
|
203
|
-
"NODE_EXTRA_CA_CERTS",
|
|
204
|
-
"NPM_CONFIG_REGISTRY",
|
|
205
|
-
"NPM_CONFIG_USERCONFIG",
|
|
206
|
-
"NPM_CONFIG_GLOBALCONFIG",
|
|
207
|
-
// FIX: explicit list matches child-pi.ts to prevent regression.
|
|
208
|
-
// PI_CREW_PARENT_PID is needed for parent-guard (liveness check).
|
|
209
|
-
"PI_CREW_DEPTH",
|
|
210
|
-
"PI_CREW_MAX_DEPTH",
|
|
211
|
-
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
212
|
-
"PI_CREW_INHERIT_SKILLS",
|
|
213
|
-
"PI_CREW_PARENT_PID",
|
|
214
|
-
"PI_TEAMS_DEPTH",
|
|
215
|
-
"PI_TEAMS_MAX_DEPTH",
|
|
216
|
-
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
217
|
-
"PI_TEAMS_INHERIT_SKILLS",
|
|
218
|
-
"PI_TEAMS_PI_BIN",
|
|
219
|
-
"PI_TEAMS_MOCK_CHILD_PI",
|
|
220
|
-
"PI_CREW_ALLOW_MOCK",
|
|
221
|
-
// Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
|
|
222
|
-
"PI_CREW_WORKER_ATOMIC_WRITER",
|
|
223
|
-
"PI_TEAMS_WORKER_ATOMIC_WRITER",
|
|
224
|
-
// Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
|
|
225
|
-
"PI_CREW_VERIFICATION_SANITIZE_ENV",
|
|
226
|
-
"PI_TEAMS_VERIFICATION_SANITIZE_ENV",
|
|
227
|
-
"PI_CREW_VERIFICATION_PRESERVE_ENV",
|
|
228
|
-
"PI_TEAMS_VERIFICATION_PRESERVE_ENV",
|
|
229
|
-
// Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16).
|
|
230
|
-
"PI_CREW_VERIFICATION_WORKTREE",
|
|
231
|
-
"PI_TEAMS_VERIFICATION_WORKTREE",
|
|
232
|
-
// Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
|
|
233
|
-
"PI_CREW_BG_REPORT_ON_FATAL",
|
|
234
|
-
"PI_TEAMS_BG_REPORT_ON_FATAL",
|
|
235
|
-
],
|
|
231
|
+
allowList: BACKGROUND_RUNNER_ENV_ALLOWLIST,
|
|
236
232
|
});
|
|
237
233
|
// FIX: removed delete workarounds — with explicit allowlist, these vars
|
|
238
234
|
// are no longer auto-leaked. Matches child-pi.ts.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
withRunLockSync,
|
|
8
8
|
} from "../state/locks.ts";
|
|
9
9
|
import {
|
|
10
|
+
createRunPaths,
|
|
10
11
|
loadRunManifestById,
|
|
11
12
|
saveRunManifest,
|
|
12
13
|
updateRunStatus,
|
|
@@ -411,11 +412,21 @@ async function main(): Promise<void> {
|
|
|
411
412
|
);
|
|
412
413
|
// FIX Issue #3: Wrap in withRunLockSync to prevent concurrent background-runners
|
|
413
414
|
// for the same runId from reading stale manifest state. If lock cannot be
|
|
414
|
-
// acquired within 5s, fail immediately rather than proceeding with stale data.
|
|
415
|
+
// be acquired within 5s, fail immediately rather than proceeding with stale data.
|
|
416
|
+
//
|
|
417
|
+
// BUGFIX (caught by E2E parallel-spawn, 2026-06-27): the lock manifest must
|
|
418
|
+
// carry the REAL per-run stateRoot, NOT an empty string. lockPath() derives
|
|
419
|
+
// `<stateRoot>/run.lock`, so `stateRoot: ""` collapses every concurrent
|
|
420
|
+
// background-runner (different runIds, same spawn instant) onto a SINGLE
|
|
421
|
+
// shared `run.lock` at cwd — 1 acquires, the rest fail-fast and die. Compute
|
|
422
|
+
// the per-run stateRoot from (cwd, runId) via createRunPaths (same helper
|
|
423
|
+
// resolveRunStateRoot uses internally), so each run locks its own
|
|
424
|
+
// `<cwd>/.crew/state/runs/<runId>/run.lock`. Matches locks-race.test.ts.
|
|
425
|
+
const bootstrapStateRoot = createRunPaths(cwd, runId).stateRoot;
|
|
415
426
|
let loaded: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
|
|
416
427
|
try {
|
|
417
428
|
loaded = withRunLockSync(
|
|
418
|
-
{ stateRoot:
|
|
429
|
+
{ stateRoot: bootstrapStateRoot, runId, cwd } as TeamRunManifest,
|
|
419
430
|
() => loadRunManifestById(cwd, runId),
|
|
420
431
|
{ staleMs: 30_000 },
|
|
421
432
|
);
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { isSensitivePath } from "./sensitive-paths.ts";
|
|
2
|
-
|
|
3
1
|
export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
// Read-only roles: cannot mutate files/source. `verifier` is NOT here — it runs
|
|
4
|
+
// tests (bash + cache writes) so it is a WRITE role (F4). `planner` stays
|
|
5
|
+
// read-only to preserve the plan-approval gate boundary (F3).
|
|
6
|
+
const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "analyst", "critic", "planner"]);
|
|
7
|
+
const WRITE_ROLES = new Set(["executor", "test-engineer", "writer", "verifier"]);
|
|
9
8
|
export interface PermissionCheckResult {
|
|
10
9
|
allowed: boolean;
|
|
11
10
|
mode: RolePermissionMode;
|
|
@@ -18,21 +17,6 @@ export function permissionForRole(role: string): RolePermissionMode {
|
|
|
18
17
|
return "workspace_write";
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
export function isReadOnlyCommand(command: string): boolean {
|
|
22
|
-
const first = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop() ?? "";
|
|
23
|
-
return READ_ONLY_COMMANDS.has(first) && !/\s(-i|--in-place)\b|\s>{1,2}\s|\brm\b|\bmv\b|\bcp\b|\b(?:npm|pnpm|yarn|bun)\s+(install|add|ci|remove)\b|\bgit\s+(commit|push|merge|rebase|reset|checkout|clean)\b/.test(command);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function checkRolePermission(role: string, command: string, filePath?: string): PermissionCheckResult {
|
|
27
|
-
const mode = permissionForRole(role);
|
|
28
|
-
// Also block access to known sensitive paths even for read-only commands
|
|
29
|
-
if (filePath && isSensitivePath(filePath)) {
|
|
30
|
-
return { allowed: false, mode, reason: `Path '${filePath}' is sensitive (credentials, SSH keys, etc.) — access denied for all roles.` };
|
|
31
|
-
}
|
|
32
|
-
if (mode === "read_only" && !isReadOnlyCommand(command)) return { allowed: false, mode, reason: `Role '${role}' is read-only and command may modify state.` };
|
|
33
|
-
return { allowed: true, mode };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
20
|
export function currentCrewRole(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
37
21
|
return env.PI_CREW_ROLE?.trim() || env.PI_TEAMS_ROLE?.trim() || undefined;
|
|
38
22
|
}
|
|
@@ -30,6 +30,7 @@ function readOnlyRoleInstructions(role: string): string {
|
|
|
30
30
|
"- Do not use shell redirects, heredocs, in-place edits, package installs, git commit/merge/rebase/reset/checkout, or other state-mutating commands.",
|
|
31
31
|
"- If implementation changes are needed, report exact recommendations instead of applying them.",
|
|
32
32
|
"- Prefer read/grep/find/listing tools and read-only git inspection commands.",
|
|
33
|
+
"- Your final RESULT TEXT is persisted automatically by the runner (as a result artifact and, if the step declares `output:`, to a shared file). To deliver a plan, report, or findings, EMIT THEM AS TEXT in your final result — do NOT try to write a file yourself.",
|
|
33
34
|
].join("\n");
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import type { ArtifactDescriptor } from "./types.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
6
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
|
-
import { redactSecretString } from "../utils/redaction.ts";
|
|
7
|
+
import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
|
|
8
8
|
|
|
9
9
|
function hashContent(content: string): string {
|
|
10
10
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -127,7 +127,27 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
127
127
|
const filePath = resolveInside(artifactsRoot, options.relativePath);
|
|
128
128
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
129
129
|
resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
|
|
130
|
-
|
|
130
|
+
let content = options.content;
|
|
131
|
+
// Structural JSON redaction first — catches quoted-JSON secrets
|
|
132
|
+
// ("api_key":"sk-...") and nested keys that flat redactSecretString misses.
|
|
133
|
+
// The flat scan below still catches free-text patterns (Bearer/JWT/Auth
|
|
134
|
+
// headers) that may live inside JSON string values. See security review M2.
|
|
135
|
+
//
|
|
136
|
+
// Formatting preservation: re-stringify with the SAME indentation as the
|
|
137
|
+
// input so pretty-printed artifacts (e.g. group-join metadata expected by
|
|
138
|
+
// test/integration/phase4-runtime.test.ts to contain `"partial": false`)
|
|
139
|
+
// keep their whitespace. Detect pretty-vs-compact from the raw input.
|
|
140
|
+
const trimmed = content.trim();
|
|
141
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed: unknown = JSON.parse(content);
|
|
144
|
+
const isPretty = /\n|"\s*:\s/.test(content);
|
|
145
|
+
content = JSON.stringify(redactSecrets(parsed), null, isPretty ? 2 : undefined);
|
|
146
|
+
} catch {
|
|
147
|
+
// not valid JSON — fall through to flat redaction only
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
content = redactSecretString(content);
|
|
131
151
|
atomicWriteFile(filePath, content);
|
|
132
152
|
// Compute hash on written bytes for integrity verification.
|
|
133
153
|
// Read back the actual file content to handle atomicWrite fallback path
|
package/src/utils/redaction.ts
CHANGED
|
@@ -97,34 +97,54 @@ export function isSecretKey(keyName: string): boolean {
|
|
|
97
97
|
return false;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
//
|
|
100
|
+
// Boundary chars that may precede an "authorization:" or "Bearer " keyword.
|
|
101
|
+
// Includes '-' so prefixed headers (Proxy-Authorization, X-Authorization) and
|
|
102
|
+
// '\t' so tab-indented headers are recognized. See security review L5.
|
|
103
|
+
const AUTH_HEADER_BOUNDARY_CHARS = new Set([" ", ",", "{", "[", "\"", "\r", "\n", "-", "\t"]);
|
|
104
|
+
function isAuthHeaderBoundary(ch: string | undefined): boolean {
|
|
105
|
+
return ch !== undefined && AUTH_HEADER_BOUNDARY_CHARS.has(ch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Linear-time Authorization header redaction.
|
|
109
|
+
// L3 fix: scan ALL occurrences (previously first-only via indexOf, so a second
|
|
110
|
+
// "authorization:" on a later line leaked). L5 fix: boundary set includes '-'
|
|
111
|
+
// and '\t' so Proxy-Authorization / X-Authorization / tab-indented headers are
|
|
112
|
+
// redacted. Bearer values are left for redactBearerTokens.
|
|
101
113
|
export function redactAuthHeader(line: string): string {
|
|
102
114
|
const lower = line.toLowerCase();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
115
|
+
let result = "";
|
|
116
|
+
let i = 0; // emit cursor into the original `line`
|
|
117
|
+
let searchFrom = 0; // cursor for the next indexOf scan
|
|
118
|
+
for (;;) {
|
|
119
|
+
const authIdx = lower.indexOf("authorization:", searchFrom);
|
|
120
|
+
if (authIdx === -1) {
|
|
121
|
+
result += line.substring(i);
|
|
122
|
+
return result;
|
|
111
123
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
124
|
+
// Emit the unchanged span up to this occurrence.
|
|
125
|
+
result += line.substring(i, authIdx);
|
|
126
|
+
const isBoundary = authIdx === 0 || isAuthHeaderBoundary(line[authIdx - 1]);
|
|
127
|
+
const afterAuth = lower.substring(authIdx + 14).trimStart();
|
|
128
|
+
if (isBoundary && !afterAuth.startsWith("bearer ")) {
|
|
129
|
+
// Regular Authorization header — blank the credential value (the rest
|
|
130
|
+
// of the line is the credential). Appending only a marker would leave
|
|
131
|
+
// the secret bytes visible; replace them with "***". Bearer values are
|
|
132
|
+
// left intact here for redactBearerTokens. See security review L3/L5.
|
|
133
|
+
let end = authIdx + 14;
|
|
134
|
+
while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
|
|
135
|
+
end++;
|
|
136
|
+
}
|
|
137
|
+
result += line.substring(authIdx, authIdx + 14) + " ***";
|
|
138
|
+
i = end;
|
|
139
|
+
searchFrom = end; // entire line consumed; resume after the line break
|
|
140
|
+
} else {
|
|
141
|
+
// Bearer token (handled by redactBearerTokens) OR not a boundary —
|
|
142
|
+
// keep the "authorization:" literal and continue scanning.
|
|
143
|
+
result += line.substring(authIdx, authIdx + 14);
|
|
144
|
+
i = authIdx + 14;
|
|
145
|
+
searchFrom = authIdx + 14;
|
|
122
146
|
}
|
|
123
|
-
return line.substring(0, end) + " ***" + (end < line.length ? line.substring(end) : "");
|
|
124
147
|
}
|
|
125
|
-
|
|
126
|
-
// It's a Bearer token format - don't redact here, let redactBearerTokens handle it
|
|
127
|
-
return line;
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
// Linear-time Bearer token redaction
|
|
@@ -135,14 +155,12 @@ export function redactBearerTokens(line: string): string {
|
|
|
135
155
|
|
|
136
156
|
while (i < line.length) {
|
|
137
157
|
if (upper.startsWith("BEARER ", i)) {
|
|
138
|
-
// Check word boundary:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
158
|
+
// Check word boundary: start-of-string or a boundary char. Includes '-'
|
|
159
|
+
// and '\t' (L5) so "Proxy-Authorization: Bearer ..." is redacted.
|
|
160
|
+
if (i > 0 && !isAuthHeaderBoundary(line[i - 1])) {
|
|
161
|
+
result.push(line[i]);
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
// Found "Bearer " - now find the token
|