diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e0285e38..91100634 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -30,7 +30,7 @@ - [ ] **Phase 1: PTY Token Accounting** — live, mid-turn token accounting for a PTY turn (post-hoc is too late; a TUI turn can run for many minutes). - [x] **Phase 2: PTY Pre-Flight Gate** — a programmatic turn cannot be written to the PTY without passing the full gate chain. -- [ ] **Phase 3: Governed-Automation Guard** — `humanOnlyPtyGate` → `governedAutomationPtyGate`, new flag, default OFF. +- [x] **Phase 3: Governed-Automation Guard** — `humanOnlyPtyGate` → `governedAutomationPtyGate`, new flag, default OFF. - [ ] **Phase 4: Lifetime Inject Counter + Kill Switch** — rate ceilings don't stop a slow grind; add a lifetime per-task counter and a real kill switch. - [ ] **Phase 5: Throwaway-Repo Due→PR Proof** — prove the thing actually works, once, on a repo nobody cares about. - [ ] **Phase 6: Hardening + Docs + Release** — real-Postgres e2e, docs, version bump, ship, smoke-verify. @@ -114,6 +114,12 @@ ceiling (and the programmatic-credit halt). 3. Alarm emails the owner at 60% of the daily token ceiling. 4. Default daily token cap lowered from 50M to a survivable figure (~20M). +**Hand-off from Phase 3**: DELIVERED in Phase 4. The fail-closed `kill_switch` and `lifetime_counter` governor bodies in `hub/src/dispatch/governed-pty.ts` are now real; an admitted automation PTY turn is COUNTED in a durable append-only ledger (`automation_inject_ledger`) by an atomic reserve-before-send. See [docs/usage-cost.md](../docs/usage-cost.md) "PTYCAP Phase 4". + +**Delivery notes (Phase 4)**: the lifetime cap is LITERAL (default 1000 per counter key, env-raisable, owner reset, one-shot warning page at 80 percent and an exhaustion page); `POST /api/sessions/heal` is classified a machine surface; with `REMO_GOVERNED_AUTOMATION_PTY` ON the ask, work and telegram governed lists carry no counter key, so the `lifetime_counter` governor refuses `lifetime_counter_key_missing` until Phase 5/7 supply keys (fail closed, flag default OFF). The 20M default token cap applies only where `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP` is unset. + +**Deferred to Milestone GOV (from Phase 4)**: a Telegram or UI kill-switch trigger; opt-in `stop_running` cancellation of active runs once automation ownership is tracked; a counter policy for QC keys (Phases 7/8) beyond the heal key; the cap-hit QC kind (Phase 9). + ### Phase 5: Throwaway-Repo Due→PR Proof **Goal**: The orchestrator produces one reviewable PR, unattended, on a repo nobody cares about. @@ -124,6 +130,21 @@ ceiling (and the programmatic-credit halt). 2. It produces a reviewable PR with a green CI check, or it cleanly does nothing and says why. 3. This has **never once happened**. Until it does, the orchestrator is unproven and unsellable. +**Plans:** 9 plans (the live proof is OWNER-GATED; these plans build, test and document the machinery and the arming runbook, they arm nothing) + +Plans: +- [x] 05-01-PLAN.md -- tracer (armed run to terminal outcome through every layer, flag OFF a true no-op) + orchestrator_proof_runs state layer + ProofGithub contract and fake (wave 1) +- [x] 05-02-PLAN.md -- read-only GitHub adapter + protection assertion/freeze/drift (ruleset shape, zero bypass, permission probe) (wave 2) +- [x] 05-03-PLAN.md -- hub verifier: default-branch advance via compare, PR scope, CI/green decision (wave 2) +- [x] 05-04-PLAN.md -- read-only GET /api/orchestrator/proof + OpenAPI + docs:sync (wave 2) +- [x] 05-05-PLAN.md -- proofBudgetGate + proofProtectionGate in both orchestrator gate lists, coverage scans, merge-routing refusal (wave 3) +- [x] 05-06-PLAN.md -- ordered evaluators, tick-side budget termination, inject-outcome mapping, counter-key pin (wave 3) +- [x] 05-07-PLAN.md -- arm/disarm/enable cores + operator CLI (exactly-one allowlist, frozen budget and protection) (wave 3) +- [x] 05-08-PLAN.md -- flag-OFF differential matrix, real-Postgres proof helper (CI only), baseline (wave 4) +- [x] 05-09-PLAN.md -- owner runbook (permissions, repo + ruleset, ordered rollback), CLAUDE.md/docs, runbook pin test (wave 4) + +**Delivery notes (Phase 5)**: the machinery is built and tested against a fake GitHub client and, in CI, against real Postgres; nothing was armed and no flag, GitHub or prod system was touched. The live run is OWNER-GATED (`docs/ptycap-05-proof-runbook.md`); Phase 5 stays unchecked until SC-3 closes live, so SC-2/SC-3 are "armed-ready, live proof pending owner GO", never PASS. Deferred: the automation `term.input` PTY producer (D-01, drives the stream-json macro path instead). Recorded dependency: arming refuses `proof_protection_unverifiable` (`bypass_actors_not_visible`) unless the GitHub App token can see ruleset bypass actors (GitHub shows them only to a caller with write access); that is an owner decision about the App's permission level, with no override path. + ### Phase 6: Hardening + Docs + Release **Goal**: Shipped, documented, verified live. @@ -190,9 +211,9 @@ fix the CAUSE, and escalates to the human when it cannot. |-------|--------| | 1. PTY Token Accounting | In Progress| | 2. PTY Pre-Flight Gate | Done | -| 3. Governed-Automation Guard | Not started | -| 4. Lifetime Counter + Kill Switch | Not started | -| 5. Throwaway-Repo Due→PR Proof | Not started | +| 3. Governed-Automation Guard | Complete (4/4 plans) - PR #505 open | +| 4. Lifetime Counter + Kill Switch | Executed (8/8 plans) - QC BLOCKED (2 open races), draft PR #506 | +| 5. Throwaway-Repo Due→PR Proof | Executed (9/9 plans) - QC BLOCKED (2 open MAJOR), draft PR - live proof pending owner GO | | 6. Hardening + Docs + Release | Not started | | 7. Unified Self-Heal Routing | Specced, not started (blocked on #346 + Phase 4) | | 8. Periodic Task QC + Optimization | Specced, not started (blocked on Phase 4) | diff --git a/.planning/STATE.md b/.planning/STATE.md index c292e656..74c7c1c3 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,19 +1,21 @@ --- -gsd_state_version: 1.0 +gsd_state_version: "1.0" milestone: PTYCAP milestone_name: Milestone PTYCAP (Token-Gate the Interactive PTY Path) -current_phase: 02 -current_phase_name: pty-preflight-gate +current_phase: 04 +current_phase_name: lifetime-inject-counter-kill-switch status: executing -last_updated: "2026-09-28T00:00:00.000Z" +stopped_at: PTYCAP-04 executed 8/8 plans; QC BLOCKED after 2 fix rounds (2 open HIGH races); draft PR #506 +last_updated: "2026-10-13T18:00:00.000Z" last_activity: 2026-09-28 -last_activity_desc: BLEED milestone reconciled CLOSED (all four fixers verified shipped on main); milestone pointer advanced to PTYCAP; Phase 1 confirmed shipped (#395); Phase 2 execution started +last_activity_desc: "BLEED milestone reconciled CLOSED (all four fixers verified shipped on main); milestone pointer advanced to PTYCAP; Phase 1 confirmed shipped (#395); Phase 2 execution started" +state_head: a745ab9efb8e26c340e87a1f56cc55d3b2f35497 progress: total_phases: 9 - completed_phases: 1 - total_plans: 4 - completed_plans: 4 - percent: 11 + completed_phases: 3 + total_plans: 17 + completed_plans: 16 + percent: 33 --- > **BLEED milestone reconciled CLOSED 2026-09-28.** This file and `.planning/PROJECT.md` had @@ -89,9 +91,9 @@ connection is the Tauri Supervisor MSI. ## Current position -Phase: PTYCAP-02 (pty-preflight-gate) — EXECUTING -Status: Executing Phase PTYCAP-02 (Phase 1, pty-token-accounting, shipped complete via #395) -Last activity: 2026-09-28 — BLEED reconciled closed; PTYCAP-02 execution started +Phase: PTYCAP-04 (lifetime-inject-counter-kill-switch) - EXECUTED, QC BLOCKED (stacked on PTYCAP-03 / PR #505) +Status: 8/8 plans executed (branch phase-ptycap-04-lifetime-counter-kill-switch, draft PR #506 base main for Woodpecker postgres). /qc: 2 fix rounds done (B1+5 MAJOR+4 minor, then R1-R3); Codex still OPEN on 2 HIGH races: (a) orphan-resume reopen UPDATE...NOT EXISTS needs the per-user advisory lock launchOrchestrator holds; (b) supervisor/teab/work senders rearm once tasks on schedule_kind alone, must gate on onceClaimed. Verifier NO-SHIP at 8447cdf (CI red: 2 e2e fixture defects, fixed in bf3f51d, CI re-run pending). +Last activity: 2026-10-13 - PTYCAP-04 executed + QC'd; BLOCKED on findings (a)(b) per the 2-round QC cap ### OBSRV phase ledger (`.planning/phases/`) @@ -144,3 +146,19 @@ reset 2026-05-29 to unblock owner login. `jamie@theleadingpractice.com` is NOT a | Date | Slug | Summary | Commits | |---|---|---|---| | 2026-09-27 | cloud-sessions | Chat with claude.ai cloud sessions: `claude -p --cloud` send + Stop-hook reply webhook | 4f9b35b..83abd55 | + +## Performance Metrics + +| Plan | Duration | Tasks | Files | +|------|----------|-------|-------| +| Phase PTYCAP-03 P01 | 25min | 3 tasks | 10 files | + +## Decisions + +- [Phase PTYCAP-03]: 03-01: governedAutomationPtyGate hard-renamed (no alias); flag REMO_GOVERNED_AUTOMATION_PTY default OFF is a proven no-op; kill_switch and lifetime_counter are fail-closed Phase-4 slots — Differential vs frozen legacy oracle; D-02/D-04/D-05/D-06 + +## Session + +**Last session:** 2026-10-07T23:32:22.340Z +**Stopped at:** Completed PTYCAP-03-01-PLAN.md +**Resume file:** None diff --git a/.planning/architecture/SESSION-ASK-API-SPEC.md b/.planning/architecture/SESSION-ASK-API-SPEC.md index dc7af260..04683a97 100644 --- a/.planning/architecture/SESSION-ASK-API-SPEC.md +++ b/.planning/architecture/SESSION-ASK-API-SPEC.md @@ -46,7 +46,7 @@ Two independent walls, both live in prod (`REMO_PTY_INTERACTIVE=1` since the 202 **Wall A — the gate.** `sessions.runner_type` is `'stream-json' | 'pty-interactive'` (`hub/src/db/schema.sql:615–621`). The human web/phone surface runs `pty-interactive`. `humanOnlyRejectsActor(actor, runnerType)` (`hub/src/dispatch/gates.ts:371–374`) returns true -for **any** actor ≠ `'human'` when `runnerType === 'pty-interactive'`, and `humanOnlyPtyGate` +for **any** actor ≠ `'human'` when `runnerType === 'pty-interactive'`, and `governedAutomationPtyGate` (gates.ts:388–401) turns that into `automation_blocked_on_pty:`. An external ask is by definition non-human (server-inferred from an api_key), so it is rejected before send. @@ -71,7 +71,7 @@ prod (#247) and Telegram outbound falls back to the host-agnostic stream-json ev `~/.claude/projects/.../memory/project_direction_pty_orchestrator_monetize.md` (2026-07-12): the owner has **deliberately** decided automation will eventually drive the interactive PTY, with a **mandatory ordering**: (1) caps proven on all dispatch paths, (2) **cap the PTY path -FIRST** (it has no token gate today; usage is post-hoc), (3) *then* relax `humanOnlyPtyGate` +FIRST** (it has no token gate today; usage is post-hoc), (3) *then* relax `humanOnlyPtyGate` (renamed governedAutomationPtyGate in PTYCAP Phase 3) into a governed-automation gate (new flag, default OFF, server-inferred actor), (4) prove due→PR, (5) 30-night receipts. @@ -164,12 +164,12 @@ Desktop doesn't have to memorize UUIDs. - `token: 'ask:'` (queue token + finalize key). - **Gates, in order — all non-bypassable:** - `thresholdGate` → `dailyCostCapGate` → **`dailyTokenCapGate`** → `humanOnlyPtyGate` → + `thresholdGate` → `dailyCostCapGate` → **`dailyTokenCapGate`** → `governedAutomationPtyGate` → `askRateGate` (new: N asks/hour/session, default 10) → `concurrencyGate(supervisorId)` when autospawning. `dailyTokenCapGate` is mandatory on every gate list and is scanned by `hub/test/token-cap-coverage.test.ts` — a new `gates: [...]` array missing it **fails CI**. - `humanOnlyPtyGate` stays in the list: if the resolved target is a `pty-interactive` row, the + `governedAutomationPtyGate` stays in the list: if the resolved target is a `pty-interactive` row, the ask is **rejected** (`automation_blocked_on_pty:external-ask`) rather than silently redirected — the redirect to a stream-json ask-session is an explicit resolution step (§2.1), decided *before* dispatch, not a gate bypass. @@ -229,7 +229,7 @@ guard test (`hub/test/automation-routing-guard.test.ts`) still passes. 2. Supervisor→hub **transcript streaming** over `/ws/agent` (new `transcript_delta` / `turn_complete` frames) so the hub can extract a reply from a PTY without reading the supervisor's disk. Requires a new signed MSI. -3. `humanOnlyPtyGate` → `governedAutomationGate` (new flag, default OFF, server-inferred +3. `humanOnlyPtyGate` → `governedAutomationPtyGate` (new flag, default OFF, server-inferred actor, + lifetime per-ask counter + kill switch). Until all three exist, **a PTY-native ask is not implementable and must not be attempted.** @@ -292,7 +292,7 @@ only escalate to `remo_ask` when the free reads are inconclusive. | **1. Read surface (do this first)** | External api-key-authed READ of a session's transcript tail + memory + state. Zero tokens, zero PTY writes, works for PTY *and* stream-json sessions. | supervisor: new allowlisted `session_transcript_tail` / `session_memory` commands (registry alongside `teab_run`); reuse `hub/src/telegram/transcript/{claude,codex}-adapter.ts` for parsing. hub: `hub/src/api/ext.ts` (`/sessions`, `/:id/transcript`, `/:id/memory`, `/:id/state`), mount in `hub/src/index.ts` after L248, `api_keys.scopes` DDL in `schema.sql`, `hub/test/mount-order.test.ts` case. | **Needs a new signed supervisor MSI.** | | **2. Ask** | `POST /api/ext/sessions/:id/ask` + poll/long-poll, answered by a stream-json ask-session bound to the target's `project_dir`. | `hub/src/ask/{dispatch,result-schema,prompt,reaper}.ts` (mirroring `hub/src/revanote/*`), `session_asks` DDL, ask-session resolution/autospawn reusing `launchSessionForUser`, gates list incl. `dailyTokenCapGate` + new `askRateGate` in `hub/src/dispatch/gates.ts`, finalize hook wired via existing `onSessionReply` (no change to `hub/src/ws/agent.ts:729`). | Hub-only. No supervisor change, no PTY change, no gate relaxation. | | **3. MCP server** | `mcp-servers/apps/remo-code/` (5 tools above) + gateway registration + `docs/api.md` regen (`bun run docs:sync`). | `mcp-servers/apps/remo-code/*`, remo-code `hub/src/api/_openapi.ts` + `docs/api.md`. | — | -| **4. PTY-native ask (OWNER-GATED — do not start early)** | Ask the *live* human PTY session and read its reply. | (a) token gate on the PTY path; (b) supervisor `transcript_delta`/`turn_complete` frames on `/ws/agent` + hub-side consumer replacing the disk tail; (c) `humanOnlyPtyGate` → `governedAutomationGate` (new flag, default OFF, lifetime ask counter, kill switch). | Blocked on the owner's mandatory ordering in `project_direction_pty_orchestrator_monetize`. | +| **4. PTY-native ask (OWNER-GATED — do not start early)** | Ask the *live* human PTY session and read its reply. | (a) token gate on the PTY path; (b) supervisor `transcript_delta`/`turn_complete` frames on `/ws/agent` + hub-side consumer replacing the disk tail; (c) `humanOnlyPtyGate` → `governedAutomationPtyGate` (new flag, default OFF, lifetime ask counter, kill switch). | Blocked on the owner's mandatory ordering in `project_direction_pty_orchestrator_monetize`. | ### Test strategy diff --git a/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md b/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md new file mode 100644 index 00000000..722fc30f --- /dev/null +++ b/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md @@ -0,0 +1,77 @@ +--- +phase: PTYCAP-01-pty-token-accounting +verified: 2026-10-07 +status: gaps_found +score: 3/3 success criteria implemented; 1 MAJOR accuracy gap (G-1), 1 MINOR evidence gap (G-2); 0 BLOCKERs +verifier: independent goal-backward verification (SUMMARY.md self-reports not trusted) +merged_as: "22cb187 feat(PTYCAP-01) (#395)" +worktree: C:\Users\artic\GitHub\remo-code-gsd (branch phase-ptycap-03-governed-automation-guard) +--- + +# Phase PTYCAP-01: PTY Token Accounting - Verification Report + +**Goal:** The hub knows what a PTY turn is spending while it spends it, not after. + +Method: read implementing code directly, ran each proving test file in isolation (one bun process per file), and +checked one claim against a real on-disk Claude transcript. No source, test, or config file was modified. + +## Success criteria + +| SC | Criterion | Verdict | Evidence | +|----|-----------|---------|----------| +| SC-1 | A live PTY turn's token usage is observable mid-turn, not only on completion | PASS | Tailer: `supervisor/src/usage/pty-usage-emitter.ts` `pinAndTail` L321-386 calls `tailJsonl(..., {fromStart:true})` and emits one `usage_event` per assistant record (L350-362), no exit/kill gating. Lifecycle owner `supervisor/src/runners/session-bridge.ts` L539 (pre-spawn snapshot), L570-579 (`new PtyUsageEmitter().start`, `emit: sendToHub`), stop at L267/L348/L557. Hub ingest `hub/src/ws/agent.ts` L790-840 `recordTokenUsage({runnerType: msg.runner_type ?? 'stream-json'})`. Proof test `supervisor/test/pty-usage-tail.test.ts` 28 pass / 0 fail (incl. "live-appended assistant usage record emits exactly one tagged frame while the file is still open"). Wired into CI: `.woodpecker/qc.yaml` L52. | +| SC-2 | Interactive and programmatic usage remain in separate buckets | PASS | `hub/src/db/schema.sql` L1351 `ADD COLUMN IF NOT EXISTS runner_type ... DEFAULT 'stream-json'`, L1353 guarded CHECK, L1354 `idx_token_usage_user_runner_created` (additive/idempotent; later 'cloud' widening at L1584-1597 is guarded and from another milestone). Zod `hub/src/ws/agent-protocol.ts` L201 `runner_type` (exact key name verified; absent => 'stream-json'). DAL `hub/src/db/token-usage-dal.ts` L27/L38-49 threads runnerType; `getTodayTokenTotalByRunner` L231-252 splits the buckets for observability. `token_usage_daily` PK deliberately unchanged (documented). Tests: `token-usage-runner-type` 7/0, `usage-event-handler` 5/0 (untagged frame => stream-json). Live-Postgres CHECK-rejection case in `hub/test/e2e/schema-double-apply.e2e.test.ts` is DB-gated: 0 pass / 10 skip locally (see G-2). | +| SC-3 | A long-running TUI turn that crosses the ceiling mid-flight is detectable | PASS (caveat G-2) | `getTodayTokenTotal()` (`token-usage-dal.ts` L209-222) sums all four buckets (input+output+cache_creation+cache_read) from `token_usage`, live, no close event required; PTY rows land per assistant record (SC-1), so the total climbs mid-turn. Cap stays token-denominated. Mid-flight proof `hub/test/pty-usage-midflight-visibility.test.ts` is `REMO_E2E_DB_URL`-gated: 0 pass / 6 skip / 0 fail locally; only executes in Woodpecker postgres:16. I could not observe it pass. Phase deliberately does NOT gate (RECORD only; gating is Phase 2). `token-cap-coverage` 3/0, `token-cap-gate-fires` 8/0 still green. | + +## Plan must_haves (existence + wiring, read from code) + +| Plan | Artifact / link | Status | +|------|-----------------|--------| +| 01-01 | `supervisor/src/usage/pty-transcript-tail.ts` (144 lines), `pty-usage-emitter.ts` (405 lines) exist, substantive | VERIFIED | +| 01-01 | session-bridge `ensurePtyRunner` -> `PtyUsageEmitter.start` -> `sendToHub` -> zod -> `recordTokenUsage` chain | WIRED (all hops read) | +| 01-01 | Malformed line never fatal (try/catch L363, `onParseError`); uuid dedupe bounded (cap 5000); capture-once locator fails closed on ambiguity/unreliable snapshot; claude-only (codex no-op L240) | VERIFIED in code + tests | +| 01-01 | No hub module reads homedir transcript path | `hub/test/no-hub-side-transcript-fs.test.ts` 4/0 | +| 01-02 | `hub/test/token-usage-runner-type.test.ts`, usage-event-handler cases, e2e CHECK case | exist; e2e case skipped locally | +| 01-03 | midflight test, hub-fs canary, `supervisor/test/pty-usage-path-containment.test.ts` | exist; containment 6/0, canary 4/0, midflight skipped locally | +| 01-04 | `.woodpecker/qc.yaml` L52 runs both supervisor tests; `tools/regression-baseline.json` has `_skip_note_ptycap` with stated reasons; `docs/usage-cost.md` L241-345 PTYCAP Phase 1 section (flow, 4 decisions, tests); `CLAUDE.md` L392 usage-ledger row names the PTY source | VERIFIED; docs match code (index name `idx_token_usage_user_runner_created`, key `runner_type`) | + +## Hard invariants + +| Invariant | Verdict | Evidence | +|-----------|---------|----------| +| No provider API key on PTY path / argv allowlist-of-one | PASS | Emitter is read-only transcript tail; touches no env/argv. Guards: `no-api-key-no-streamjson-pty` 5/0, `no-apikey-fallback-guard` 7/0, `default-backend-selector` 22/0. | +| Caps denominated in tokens | PASS | `getTodayTokenTotal` 4-bucket token sum; cost_usd from frames is 0/estimated, hub estimates. | +| Additive idempotent DDL only | PASS | `ADD COLUMN IF NOT EXISTS`, pg_constraint-guarded CHECK, `CREATE INDEX IF NOT EXISTS`; no backfill. | +| RECORD only, never gates/writes PTY | PASS | No gate imports; emit only. | + +## Test runs (each file isolated, own bun process) + +| File | Pass | Fail | Skip | +|------|------|------|------| +| supervisor/test/pty-usage-tail.test.ts | 28 | 0 | 0 | +| supervisor/test/pty-usage-path-containment.test.ts | 6 | 0 | 0 | +| supervisor/test/no-api-key-no-streamjson-pty.test.ts | 5 | 0 | 0 | +| supervisor/test/no-apikey-fallback-guard.test.ts | 7 | 0 | 0 | +| supervisor/test/default-backend-selector.test.ts | 22 | 0 | 0 | +| hub/test/token-usage-runner-type.test.ts | 7 | 0 | 0 | +| hub/test/usage-event-handler.test.ts | 5 | 0 | 0 | +| hub/test/no-hub-side-transcript-fs.test.ts | 4 | 0 | 0 | +| hub/test/token-cap-coverage.test.ts | 3 | 0 | 0 | +| hub/test/token-cap-gate-fires.test.ts | 8 | 0 | 0 | +| hub/test/pty-usage-midflight-visibility.test.ts | 0 | 0 | 6 (REMO_E2E_DB_URL unset) | +| hub/test/e2e/schema-double-apply.e2e.test.ts | 0 | 0 | 10 (REMO_E2E_DB_URL unset) | + +## Gaps + +### G-1 (MAJOR) - Per-record uuid dedupe overcounts: one API response is split across several transcript records +- Evidence: `pty-usage-emitter.ts` L340-349 dedupes only on the record `uuid`, and emits every assistant record's `message.usage`. Checked against a real local Claude transcript (read-only analysis, largest-3rd session in `~/.claude/projects/C--Users-artic-GitHub-remo-code`): 165 assistant records with usage, 165 unique uuids, but only 89 unique `message.id`. Summing every record = 39,436,414 tokens vs 21,419,447 when counting each `message.id` once, a 1.84x overcount (dominated by repeated cache_read). Neither the emitter, `extractUsage`, the tests, nor RESEARCH/REVIEW docs mention `message.id` / `requestId`. +- Impact: the ledger and `getTodayTokenTotal()` run high for PTY sessions (fail-safe direction for a cap, but it falsifies "what a PTY turn is spending", trips the token cap early in Phase 2, and skews the interactive-vs-programmatic early-warning comparison). SC-1..3 observability is unaffected; accuracy is. +- Proposed minimal fix: key the dedupe on `record.message.id` (fallback `uuid` when absent), and when the same id reappears keep only the max/last usage per id (emit a delta rather than a repeat). Add a test fixture with three records sharing one `message.id`. + +### G-2 (MINOR) - Live-Postgres proofs not observed in this run +- Evidence: `pty-usage-midflight-visibility.test.ts` (6) and the `token_usage_runner_type_check` case in `schema-double-apply.e2e.test.ts` skip without `REMO_E2E_DB_URL`; no disposable Postgres in this environment. SC-3's mid-flight climb and SC-2's DB-level CHECK rejection are therefore verified by code reading (SQL and DDL inspected) plus the Woodpecker wiring claim, not by an observed green run. +- Proposed minimal fix: confirm the PR #395 / latest main `qc` pipeline ran these un-skipped against postgres:16 (or run them once with `REMO_E2E_DB_URL`). Not a code change. + +## Summary + +Goal is achieved in code: PTY transcript spend is tailed supervisor-side, tagged `pty-interactive`, recorded per assistant record while the session is live, stored in a CHECK-constrained `runner_type` column, and summed live by the existing 4-bucket token total. All invariants hold and all runnable proofs pass (0 fail). Status is `gaps_found` solely because of G-1 (token accuracy: roughly 1.8x overcount on a real transcript); G-2 is an evidence caveat. No BLOCKERs. diff --git a/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md b/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md new file mode 100644 index 00000000..c35608d5 --- /dev/null +++ b/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md @@ -0,0 +1,74 @@ +--- +phase: PTYCAP-02-pty-preflight-gate +verified: 2026-10-07 +status: passed +score: 3/3 success criteria verified (0 BLOCKER, 0 MAJOR, 3 MINOR gaps) +verifier: independent goal-backward verification (not derived from 02-01-SUMMARY.md) +merged_as: PR #493 (commit 10dcc44) +--- + +# PTYCAP Phase 2 - PTY Pre-Flight Gate - Verification + +**Goal:** "No programmatic turn reaches the PTY without passing the full gate chain." + +Worktree: C:\Users\artic\GitHub\remo-code-gsd (branch phase-ptycap-03-governed-automation-guard). +`hub/src/dispatch/pty-preflight.ts` and `hub/src/ws/client.ts` are byte-identical (index) to the +merge commit 10dcc44 (`git diff 10dcc44 HEAD` empty for both). + +## Success criteria + +| SC | Criterion | Verdict | Evidence | +|----|-----------|---------|----------| +| SC-1 | A programmatic write to the PTY passes [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate] or does not happen | PASS | `hub/src/dispatch/pty-preflight.ts:35-37` `ptyPreflightDispatchConfig.gates = [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]` (exact ROADMAP order); `PTY_AUTOMATION_TURN_GATES` is the same array object (line ~40). `runChain` (line ~155) iterates it first-block-wins; fail-closed on throw (`pty_preflight_error`) and 5s timeout (`pty_preflight_timeout`) in `checkPtyTurnPreflight`. Test `pty-preflight.test.ts` "SC-1: automation chain is EXACTLY the ROADMAP order", "non-human over inject-rate IS blocked", "first block wins", fail-closed tests: all pass. Structural half ("or it does not happen"): no hub path emits a PTY write for a non-human actor today (see audit below), so the chain is a built+proven seam, enforced vacuously-true until Phase 3 opens a door. | +| SC-2 | token-cap-coverage.test.ts extended to cover the PTY path | PASS | `hub/test/token-cap-coverage.test.ts:87-90` adds `'dispatch/pty-preflight.ts'` to the known-dispatchers list; the scan (bracket-balanced `gates: [` finder) parses the literal in pty-preflight.ts and the "EVERY gates[] list carries dailyTokenCapGate AND dailyCostCapGate" test passes. `pty-preflight.test.ts` "SC-2: scanned literal IS the array that runs" proves it is not decorative. | +| SC-3 | Human turns unaffected: never gated by an inject-rate ceiling | PASS | `PTY_HUMAN_TURN_GATES = automation.filter(g => g !== sessionInjectRateGate)` (pty-preflight.ts ~43); `runChain` selects it when `actor==='human'` and sets `humanInteractive: true`, read only at `gates.ts:139-140` (programmatic-credit halt exemption; cost/token caps still apply). `client.ts:279-295` calls `checkPtyTurnPreflight({actor:'human'})` (hard-coded, server-inferred from authenticated cookie connection) only when `classifyPtyInput(...).submit`. Tests: "SC-3 (the absolute): human NEVER blocked by inject-rate ceiling", "human over cost/token/threshold IS blocked", "halt exemption never exempts from daily cost cap" pass. Trust boundary pinned by `human-interactive-flag-guard.test.ts` (see MINOR-1). | + +## Implementation claims (checked in code, not SUMMARY) + +- Wiring in `hub/src/ws/client.ts` `term.input` relay: lines 262-295. Submit classification (`classifyPtyInput`, pty-preflight.ts ~95-150): CR/LF, U+0085/U+2028/U+2029, C1 CSI/SS3, any ESC sequence not on `SAFE_ESCAPES`, OSC/DCS/SOS/PM/APC introducers, `ESC E`, invalid base64/UTF-8 -> submit (fail closed). Plain typing, Ctrl-C, Esc, arrows, Alt+printable -> not gated. Split escapes carried per session in `ptyEscTail` (client.ts:235). VERIFIED. +- Refusal path sends `send_refused` on the term channel (client.ts:288-293); write-chain ordering and single-writer re-check after the await (client.ts:296-327). VERIFIED. +- `docs/usage-cost.md` section "PTYCAP Phase 2" present (line 346) with tests list (line 470); matches code (see MINOR-2 for one over-claim). +- Hard invariants: no provider API key on PTY path - supervisor guard tests pass (below); caps are in tokens (`dailyTokenCapGate`, 4 buckets, unchanged); PR #493 touches no `schema.sql` / migration file (`git show --name-only` has no sql/schema/migrat paths) - additive DDL invariant trivially held. + +## Tests run (one file at a time, this worktree) + +| File | Result | +|------|--------| +| hub/test/pty-preflight.test.ts | 33 pass / 0 fail | +| hub/test/ws-client-pty-preflight.test.ts | 22 pass / 0 fail | +| hub/test/token-cap-coverage.test.ts | 3 pass / 0 fail | +| hub/test/human-interactive-flag-guard.test.ts | 1 pass / 1 FAIL (CRLF checkout artifact, MINOR-1) | +| web/test/term-refusal.test.ts | 4 pass / 0 fail | +| supervisor/test/no-api-key-no-streamjson-pty.test.ts | 5 pass / 0 fail | +| supervisor/test/no-apikey-fallback-guard.test.ts | 7 pass / 0 fail | +| supervisor/test/default-backend-selector.test.ts | 22 pass / 0 fail | + +Not run: the full check-baseline suite (out of scope for a single-question verification). + +## Audit: can any NON-human actor reach a PTY without pty-preflight? + +Method: grep of every hub emitter of `term.*` frames and every supervisor consumer. + +- Supervisor: the only inputs that write the PTY are `term.input` and `term.attach_file` + (`supervisor/src/runners/session-bridge.ts:398-441`, flag `REMO_PTY_INTERACTIVE=1`). `user_message` + (what orchestrator inject, scheduler senders, error-capture, feedback, revanote, ext ask/work, and + Telegram free-text all emit via the dispatch pipeline) goes to `ensureRunner()` = the stream-json + `ClaudeRunner` (session-bridge.ts:444-465), never the PTY. +- Hub emitters of `term.input`: exactly two. (1) `ws/client.ts` relay - authenticated `/ws/client` + cookie connection, actor hard-coded human, preflighted on every submit. (2) + `telegram/transcript/pty-inject.ts:34` `injectPtyKeystroke`, called only from + `api/telegram-webhook.ts:556` for a human's Telegram permission-button tap; NOT preflighted (see + MINOR-3). `term.attach_file` is relayed by client.ts only, types a path with no Enter, and is + deliberately unchecked (tested). `/ws/agent` rejects inbound `term.input` (ws/agent.ts ~221). +- Conclusion: the claim "no admission path yet exists for a non-human actor to reach a PTY" is TRUE in + effect, but for a different reason than the doc states (MINOR-2). + +## Gaps + +| ID | Severity | Gap | Evidence | Proposed minimal fix | +|----|----------|-----|----------|----------------------| +| MINOR-1 | MINOR | `human-interactive-flag-guard.test.ts` second test fails on a Windows checkout with `core.autocrlf=true` (worktree CRLF; index is LF). The test splits source on `'\n'`, so the matched line carries a trailing `\r`. Passes on Linux CI, so it is not a CI/prod defect; local `bun test` on Windows reports a false red. | `git ls-files --eol`: `i/lf w/crlf`; assertion diff shows only the trailing CR. | Split on `/\r?\n/` in that test (one-line change). | +| MINOR-2 | MINOR | Doc/ROADMAP over-claim of mechanism: `docs/usage-cost.md` (~line 370) says `humanOnlyPtyGate`/`humanOnlyRejectsActor` "reject every automation source before a write ever reaches a pty-interactive session". Those gates key on `sessions.runner_type`, which defaults to `stream-json` and which client.ts:268-270 itself calls dead in prod. The real reason automation cannot reach the PTY is structural (supervisor routes `user_message` to the stream-json runner; PTY takes only `term.*`). Phase 3 must not rely on the runner_type gate as the door. | pty-preflight consumers: client.ts:216-217; session-bridge.ts:444. | Reword the doc sentence to name the structural reason and flag the runner_type caveat for Phase 3. | +| MINOR-3 | MINOR | Telegram permission-answer keystrokes (`injectPtyKeystroke`) write `term.input` to the PTY - often digit/y/n plus Enter - with no pty-preflight. It is human-initiated and documented as exempt ("answering a prompt is not a new turn"), and lies on a path the Phase 2 scope did not name, but it is a hub-side PTY write that bypasses the new chokepoint. Phase 3's governed-automation guard should account for it. | pty-inject.ts:34-45; telegram-webhook.ts:556. | No change required for Phase 2; record as an explicit exemption in the Phase 3 plan (or route through `checkPtyTurnPreflight(actor:'human')` if answering a permission prompt after the cap should be refused). | + +No BLOCKER or MAJOR gaps. Phase goal is achieved: the full gate chain exists in the exact ROADMAP order, is single-sourced with the coverage scan, fails closed, is wired on the only human PTY write path that can submit a prompt, and no programmatic path to the PTY exists to bypass it. diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-PLAN.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-PLAN.md new file mode 100644 index 00000000..c93c5fdb --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-PLAN.md @@ -0,0 +1,305 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - hub/src/dispatch/governed-pty.ts + - hub/src/dispatch/gates.ts + - hub/src/telegram/dispatch.ts + - hub/src/ask/dispatch.ts + - hub/src/work/dispatch.ts + - hub/test/governed-pty-gate.test.ts + - hub/test/telegram-human-only-guard.test.ts + - hub/test/ext-ask-gates.test.ts + - hub/test/ext-work-containment.test.ts + - hub/test/human-only-guard.test.ts +autonomous: true +requirements: [SC-1, SC-3] +estimate: + tokens: 95000 + raw_tokens: 95000 + tasks: 3 + confidence: low +must_haves: + truths: + - "With REMO_GOVERNED_AUTOMATION_PTY unset or set to any value outside 1|true|yes|on (case-insensitive, trimmed), governedAutomationPtyGate returns exactly the frozen legacy decision AND reason string for every (actor x runnerType) pair in the matrix, calls its resolver exactly once per check, and invokes zero governors (SC-3, D-04)" + - "external-ask, external-work, empty string and unknown actor strings targeting a pty-interactive session are denied with automation_blocked_on_pty: even with the flag ON and every governor passing via the test setter (D-06)" + - "With the flag ON and the PRODUCTION governor list, every AUTOMATION_ACTORS member targeting a pty-interactive session is blocked with automation_blocked_on_pty::governor_not_implemented:kill_switch -- nothing is admitted in Phase 3 production (D-02)" + - "Governors run in the fixed order kill_switch, token_cap, inject_rate, lifetime_counter; the first block wins and later governors are not invoked (D-02)" + - "token_cap blocks governor_unconfigured:token_cap when REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED is truthy; inject_rate blocks governor_unconfigured:inject_rate when REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR is 0 or negative; the dollar cost cap is not a governor (D-03)" + - "A non-PTY runner type returns ok without consulting the flag or any governor (D-17); a governor that throws inside the gate form yields a typed denial, never a pass (D-14)" + - "The old gate symbol and its gate name no longer exist in hub/src; ask, work and telegram dispatch compose governedAutomationPtyGate whose name is governed_automation_pty (D-05)" + - "governedAutomationPtyGate takes exactly one parameter (the resolver); governors are injectable ONLY through _setGovernorsForTests / _resetGovernorsForTests (D-02)" + artifacts: + - path: hub/src/dispatch/governed-pty.ts + provides: "flag reader, eligible-actor rule, four governors (two fail-closed slots), test-only DI setter, governedAutomationPtyGate" + contains: "governedAutomationEnabled" + - path: hub/test/governed-pty-gate.test.ts + provides: "OFF==legacy differential matrix, eligibility, governor order/enabled/fail-closed tests" + contains: "legacyDecision" + key_links: + - from: hub/src/telegram/dispatch.ts + to: hub/src/dispatch/governed-pty.ts + via: "governedAutomationPtyGate(...) inside the telegram gates list" + pattern: "governedAutomationPtyGate\\(" + - from: hub/src/ask/dispatch.ts + to: hub/src/dispatch/governed-pty.ts + via: "governedAutomationPtyGate(...) resolver returning EXT_ACTOR" + pattern: "governedAutomationPtyGate\\(" + - from: hub/src/work/dispatch.ts + to: hub/src/dispatch/governed-pty.ts + via: "governedAutomationPtyGate(...) resolver returning EXT_WORK_ACTOR" + pattern: "governedAutomationPtyGate\\(" + - from: hub/src/dispatch/governed-pty.ts + to: hub/src/dispatch/gates.ts + via: "imports AUTOMATION_ACTORS, humanOnlyRejectsActor, maxInjectsPerHour, isDailyTokenCapEnforced" + pattern: "isDailyTokenCapEnforced" + prohibitions: + - statement: "No alias or re-export of the old gate name may remain (hard rename, D-05)" + status: recalled + verification: backstop + - statement: "No optional public governors parameter on governedAutomationPtyGate (a public param is a bypass handle, D-02)" + status: recalled + verification: backstop + - statement: "No gates: [ literal in governed-pty.ts (token-cap-coverage requires both caps in every such literal, D-07)" + status: recalled + verification: backstop +--- + + +Tracer-first slice of PTYCAP Phase 3: ship the governance primitives module `hub/src/dispatch/governed-pty.ts` (flag `REMO_GOVERNED_AUTOMATION_PTY`, eligible-actor rule, four governors with the Phase-4 slots failing closed, test-only DI) and hard-rename the dispatch gate to `governedAutomationPtyGate` at all three call sites, proven end-to-end through the real Telegram dispatch pipeline and by a differential test that keeps a frozen copy of the legacy decision as an oracle. + +Purpose: SC-1 (governed admission defined; structurally met, admit path provable only under test DI per CONTEXT honest-scope statement) and SC-3 (flag default OFF is a true no-op, proven byte-identical to the legacy gate). Implements D-02, D-03, D-04, D-05, D-06, D-14, D-17. +Output: new module + new test file; three call sites renamed; existing tests updated to the new name. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md +@hub/src/dispatch/gates.ts +@hub/src/dispatch/pipeline.ts +@hub/src/telegram/dispatch.ts +@hub/src/orchestrator/controller.ts + +Honest scope (carry into SUMMARY and VERIFICATION, from 03-CONTEXT.md): the three call-site gates are NOT the door. Every automation dispatch adapter sends `user_message`, which the supervisor routes to the stream-json runner, never the PTY. SC-1 is met structurally: admit path proven under test DI, production slots fail closed, door pinned by tests (plan 03-03). ROADMAP SC-1 text is NOT edited. + +Design decisions locked for this plan (planner discretion, documented): +- The dispatch-gate form carries the four governors only. Per D-03 a governor is "carried" when ENABLED; the "over the limit" token check rides each call-site list (dailyTokenCapGate is present at all three sites) and the over-limit inject-rate check rides the preflight automation chain (plan 03-02, the door). The three call sites never pass an eligible actor in production (ask/work constants are ineligible per D-06; Telegram passes 'human', pinned in 03-03). +- Gate decision order: resolver once -> non-PTY runner ok (D-17) -> actor exactly 'human' ok -> flag OFF legacy block -> ineligible actor legacy block (D-06) -> governors in order, prefixed block (D-08). +- Reason contract (D-08): `automation_blocked_on_pty:` for OFF / ineligible (byte-identical to legacy); `automation_blocked_on_pty::` for a governor block; governor reasons `governor_not_implemented:`, `governor_unconfigured:`, `governor_error:` (gate-form throw). + + + + + + Task 1: Tracer -- governed-pty module wired end-to-end through the Telegram dispatch pipeline, with the OFF==legacy differential + hub/src/dispatch/governed-pty.ts, hub/src/dispatch/gates.ts, hub/src/telegram/dispatch.ts, hub/test/governed-pty-gate.test.ts, hub/test/telegram-human-only-guard.test.ts + + - hub/src/dispatch/gates.ts (L150-200 token-cap env helpers incl. private isTokenCapExplicitlyDisabled; L276-312 maxInjectsPerHour and sessionInjectRateGate; L440-500 AUTOMATION_ACTORS, humanOnlyRejectsActor and the gate being renamed) + - hub/src/dispatch/pipeline.ts (L36-54 DispatchRequest and DispatchGate types) + - hub/src/orchestrator/controller.ts (L56-70 call-time env flag reader pattern isOrchestratorEnabled) + - hub/src/dispatch/pty-preflight.ts (L49-52 test-only setter pattern _setPtyPreflightTimeoutForTests) + - hub/src/telegram/dispatch.ts (L40-80 imports and DispatchInput; L160-235 guard construction, gates list and the automation_blocked_on_pty prefix mapper) + - hub/test/telegram-human-only-guard.test.ts (whole file: mocks and the four existing cases) + - hub/test/human-only-guard.test.ts (legacy expectations the oracle must reproduce) + - .planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md (section for governed-pty.ts) + + + - Differential (D-04/SC-3): for each OFF flag value in [unset, '', ' ', '0', 'false', 'off', 'no', '2', 'enabled', 'tru', 'y'] and every pair in actors ['human', 'scheduler', 'orchestrator-background', 'auto-dev', 'error-capture', 'agent', 'external-ask', 'external-work', 'unknown-actor', '', 'Human', 'HUMAN', 'human '] x runnerTypes ['pty-interactive', 'stream-json', 'codex', '', 'PTY-INTERACTIVE', 'unknown'], governedAutomationPtyGate(resolver).check(req) deep-equals the frozen oracle legacyDecision(actor, runnerType); the resolver call count is exactly 1 per check; a spy governor list installed via _setGovernorsForTests records zero calls. + - Flag parsing: governedAutomationEnabled() is true for '1', 'true', 'TRUE', ' yes ', 'On' and false for every OFF value above. + - Flag ON + production governors: actor 'scheduler' on 'pty-interactive' -> { ok: false, reason: 'automation_blocked_on_pty:scheduler:governor_not_implemented:kill_switch' }. + - Flag ON + all-pass spy governors: 'external-ask' and 'external-work' on 'pty-interactive' -> legacy reason with no suffix, zero governor calls (D-06). + - End-to-end through dispatchToSession (Telegram): flag ON, source 'scheduler', pty-interactive -> kind 'automation_blocked', reason exactly 'automation_blocked_on_pty:scheduler:governor_not_implemented:kill_switch', nothing sent, nothing inserted; flag ON, source 'human' -> kind 'dispatched'. + + + + + Write the tests in the behavior block FIRST (RED), then implement (GREEN). Each behavior bullet is its own test() so the pass count is meaningful. + + (1) hub/src/dispatch/gates.ts: add one export directly below the private isTokenCapExplicitlyDisabled(): `export function isDailyTokenCapEnforced(): boolean` returning the negation of isTokenCapExplicitlyDisabled() (per D-03; the private function stays private). Do NOT touch any other gates.ts code in this task (the old gate is deleted in Task 3 once all call sites have moved; keeping it until then is a transitional state inside this plan, not an alias). Update the AUTOMATION_ACTORS doc comment to say it is now consulted by the eligible-actor rule in governed-pty.ts. + + (2) Create hub/src/dispatch/governed-pty.ts (per D-02, D-03, D-04, D-06, D-14, D-17). Module header comment: what it is, the honest-scope statement (the call-site gates are not the door; the door is checkPtyTurnPreflight with actor 'automation', plan 03-02), and that Phase 4 replaces the two fail-closed slot bodies. Exports, exactly: + - `GOVERNED_AUTOMATION_PTY_ENV` = the string 'REMO_GOVERNED_AUTOMATION_PTY' (this module must be the ONLY hub/src file containing that literal; plan 03-03 pins it). + - `governedAutomationEnabled(): boolean` -- reads process.env[GOVERNED_AUTOMATION_PTY_ENV] at CALL time, default '0', trim + lowercase, true only for 1|true|yes|on (copy the controller.ts isOrchestratorEnabled shape). Never cached. + - `type GovernorName = 'kill_switch' | 'token_cap' | 'inject_rate' | 'lifetime_counter'`; `GOVERNOR_ORDER: readonly GovernorName[]` in exactly that order; `interface GovernorGate extends DispatchGate { name: GovernorName }`. + - `killSwitchGovernor` (name 'kill_switch'): always `{ ok: false, reason: 'governor_not_implemented:kill_switch' }`. + - `tokenCapGovernor` (name 'token_cap'): `{ ok: false, reason: 'governor_unconfigured:token_cap' }` when isDailyTokenCapEnforced() is false, else ok. Evaluated at call time. It does NOT evaluate "over the limit" (that stays with dailyTokenCapGate, D-03). + - `injectRateGovernor` (name 'inject_rate'): `{ ok: false, reason: 'governor_unconfigured:inject_rate' }` when maxInjectsPerHour() is non-finite or <= 0, else ok (inverts the fail-open test at gates.ts sessionInjectRateGate; reuse the exported maxInjectsPerHour, do not re-parse the env). + - `lifetimeCounterGovernor` (name 'lifetime_counter'): always `{ ok: false, reason: 'governor_not_implemented:lifetime_counter' }`. + - `PRODUCTION_GOVERNORS: readonly GovernorGate[]` = the four above in GOVERNOR_ORDER. Declare it as a typed const array; do NOT use any identifier or property spelled with a lowercase `gates:` followed by `[` anywhere in this file (token-cap-coverage scans that pattern and would demand both caps). + - module-level `let governors: readonly GovernorGate[] = PRODUCTION_GOVERNORS`; `activeGovernors(): readonly GovernorGate[]` returns it; `_setGovernorsForTests(list: readonly GovernorGate[]): void` and `_resetGovernorsForTests(): void` (doc comment: Test-only; copy the pty-preflight.ts setter precedent). NO optional public governors parameter anywhere. + - `isGovernedEligibleActor(actor: string): boolean` = AUTOMATION_ACTORS.has(actor) (imported from gates.ts; D-06). + - `automationBlockedReason(actor: string, governorReason?: string): string` -> `automation_blocked_on_pty:${actor}` plus `:${governorReason}` only when governorReason is given (D-08). + - `governedAutomationPtyGate(resolveActorAndRunnerType: (req: DispatchRequest) => Promise<{ actor: string; runnerType: string }>): DispatchGate` with `name: 'governed_automation_pty'` (D-05). check(req): await the resolver exactly once; if humanOnlyRejectsActor(actor, runnerType) is false return ok (this covers non-PTY runner types per D-17 and the exact 'human' actor, byte-identical to legacy); if !governedAutomationEnabled() return `{ ok: false, reason: automationBlockedReason(actor) }`; if !isGovernedEligibleActor(actor) return the same legacy reason (D-06); then iterate activeGovernors() in order, wrapping EACH governor check in try/catch: a thrown governor returns `{ ok: false, reason: automationBlockedReason(actor, 'governor_error:' + governor.name) }` (D-14, typed fail-closed denial), a blocking governor returns `{ ok: false, reason: automationBlockedReason(actor, result.reason) }`; all pass -> ok. Governors are NON-CONSUMING checks (D-14): no writes, no counters. + - Do not mention the identifier humanInteractive anywhere in this file (hub/test/human-interactive-flag-guard.test.ts pins every file that does). Plan 03-03's door pins scan RAW source text including comments, so comments in this file must describe the door in prose only: never write the preflight function name followed by an opening parenthesis, never write an actor property assignment for the automation actor, and never write the env-var literal a second time (refer to it via GOVERNED_AUTOMATION_PTY_ENV). + + (3) hub/src/telegram/dispatch.ts: replace the gates.ts import of the old gate with `import { governedAutomationPtyGate } from "../dispatch/governed-pty.ts"`; construct `guard` with governedAutomationPtyGate using the SAME resolver body (actor: source, runnerType via getSessionRunnerType). Keep the gates list order `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, guard]` unchanged. Update the R-TG-11 comments at the guard and gates list to name governedAutomationPtyGate (flag-gated; OFF is the legacy human-only decision). Do NOT change the `input.source` default line in this plan (plan 03-03 owns it). The prefix mapper `outcome.reason.startsWith("automation_blocked_on_pty")` stays as-is (append-only reasons are safe, D-08). + + (4) Create hub/test/governed-pty-gate.test.ts (DB-free; no describe.skip, D-13). Contains a FROZEN local function `legacyDecision(actor, runnerType)` copied by value from the legacy semantics (runnerType !== 'pty-interactive' -> ok; actor !== 'human' -> `{ ok: false, reason: 'automation_blocked_on_pty:' + actor }`; else ok) -- it must NOT import humanOnlyRejectsActor (the oracle must be independent of production code). Save and restore process.env for REMO_GOVERNED_AUTOMATION_PTY, REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED and REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR in beforeEach/afterEach and call _resetGovernorsForTests() in afterEach. Spy governors are plain objects `{ name, check }` that push their name into a calls array. Implement the Differential, Flag parsing, production kill_switch block and D-06 cases from the behavior block. + + (5) hub/test/telegram-human-only-guard.test.ts: add two cases in a new describe 'governed-automation flag ON (PTYCAP Phase 3)' that set process.env.REMO_GOVERNED_AUTOMATION_PTY = '1' and delete it in an afterEach: the scheduler-source case and the human-source case from the behavior block. Leave the four existing cases unchanged in this plan. + + + cd hub; bun test test/governed-pty-gate.test.ts + non-zero exit, or a summary line reporting 1 or more fail, or fewer than 6 pass + cd hub; bun test test/telegram-human-only-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 6 pass (4 existing + 2 new) + cd hub; bun test test/token-cap-coverage.test.ts + non-zero exit, or any fail in the summary (an unparsed or cap-less gates list was introduced) + + + - hub/src/dispatch/governed-pty.ts exists and contains `export function governedAutomationEnabled(`, `export function governedAutomationPtyGate(`, `name: 'governed_automation_pty'`, `governor_not_implemented:kill_switch`, `governor_not_implemented:lifetime_counter`, `governor_unconfigured:token_cap`, `governor_unconfigured:inject_rate`, `export function _setGovernorsForTests(`, `export function _resetGovernorsForTests(` + - `grep -c -E "gates:[[:space:]]*\[" hub/src/dispatch/governed-pty.ts` prints 0 (the exact pattern token-cap-coverage.test.ts scans; a match in a comment would also be scanned, so comments must not contain it either) + - `grep -n "humanInteractive" hub/src/dispatch/governed-pty.ts` prints nothing + - hub/src/dispatch/gates.ts contains `export function isDailyTokenCapEnforced(` + - hub/src/telegram/dispatch.ts contains `governedAutomationPtyGate(` and imports it from `../dispatch/governed-pty.ts` + - hub/test/governed-pty-gate.test.ts contains `function legacyDecision(` and does not contain `import { humanOnlyRejectsActor` (oracle independence) + - `cd hub; bun test test/governed-pty-gate.test.ts` exits 0; `cd hub; bun test test/telegram-human-only-guard.test.ts` exits 0 with 6 pass + + Gate name and env-var contract are imported by Phases 7/8/9 and documented publicly (D-02, D-04); renaming later is a multi-phase change. + The governed module exists; Telegram dispatch runs the renamed gate end-to-end; with the flag OFF the gate is proven byte-identical to the frozen legacy oracle over the full matrix; with the flag ON a scheduler-sourced Telegram dispatch is refused by the fail-closed kill_switch slot. + + + + Task 2: Expand -- rename the ask and work call sites and their composition tests + hub/src/ask/dispatch.ts, hub/src/work/dispatch.ts, hub/test/ext-ask-gates.test.ts, hub/test/ext-work-containment.test.ts + + - hub/src/ask/dispatch.ts (L1-40 header comment and imports; L125-145 gates list with the resolver returning EXT_ACTOR) + - hub/src/work/dispatch.ts (L1-40 header comment and imports; L290-310 gates list with the resolver returning EXT_WORK_ACTOR) + - hub/test/ext-ask-gates.test.ts (L15-65 gate-block slice and actor tests) + - hub/test/ext-work-containment.test.ts (L238-255 gate list wiring test) + - hub/src/dispatch/governed-pty.ts (created in Task 1) + + + - ext-ask-gates: the sliced `gates: [` ... `store,` block of ask/dispatch.ts includes 'governedAutomationPtyGate' (renamed entry in the existing for-loop list). + - ext-work-containment: work/dispatch.ts source contains 'governedAutomationPtyGate' (renamed entry). + - New in ext-ask-gates: with the flag ON and all-pass spy governors installed via _setGovernorsForTests, `governedAutomationPtyGate(async () => ({ actor: EXT_ACTOR, runnerType: 'pty-interactive' }))` blocks with exactly 'automation_blocked_on_pty:external-ask' (D-06: an external ask never becomes admissible). + + + + Per D-05 (hard rename, no alias): + (1) hub/src/ask/dispatch.ts: remove the old gate from the gates.ts import list; add `import { governedAutomationPtyGate } from '../dispatch/governed-pty.ts'`; replace the call in the gates list with governedAutomationPtyGate keeping the resolver body byte-identical (same SQL `SELECT runner_type FROM sessions WHERE id = ${req.sessionId} LIMIT 1`, same `actor: EXT_ACTOR`, same 'stream-json' fallback). Keep list order threshold, cost cap, token cap, governed gate, askRateGate. Update the header comment (around L13) and the inline comment (around L129) to name governedAutomationPtyGate and state that an external actor is never PTY-eligible even with REMO_GOVERNED_AUTOMATION_PTY on (D-06). + (2) hub/src/work/dispatch.ts: same change (import, call, comments around L7 and L11), resolver returns `actor: EXT_WORK_ACTOR` unchanged; list order unchanged. + (3) hub/test/ext-ask-gates.test.ts: rename the list entry to 'governedAutomationPtyGate' and the failure-message wording 'human-only-PTY guard' to 'governed-automation PTY guard'; add the D-06 case from the behavior block in the 'the actor is server-inferred automation' describe (import governedAutomationPtyGate, _setGovernorsForTests, _resetGovernorsForTests from '../src/dispatch/governed-pty.ts'; save/restore REMO_GOVERNED_AUTOMATION_PTY; reset governors after). Keep the two existing humanOnlyRejectsActor assertions (that predicate is unchanged, D-05). + (4) hub/test/ext-work-containment.test.ts: rename the list entry to 'governedAutomationPtyGate' and the test title wording to 'cost + token + governedAutomationPty + rate + allowlist'. + + + cd hub; bun test test/ext-ask-gates.test.ts + non-zero exit, or any fail in the summary, or fewer than 12 pass (11 existing + 1 new) + cd hub; bun test test/ext-work-containment.test.ts + non-zero exit, or any fail in the summary, or a '0 pass' summary + + + - hub/src/ask/dispatch.ts and hub/src/work/dispatch.ts each contain `governedAutomationPtyGate(` and `from '../dispatch/governed-pty.ts'` + - hub/src/ask/dispatch.ts still contains `actor: EXT_ACTOR`; hub/src/work/dispatch.ts still contains `actor: EXT_WORK_ACTOR` + - `git grep -n -e humanOnlyPtyGate -- hub/src/ask hub/src/work` prints nothing (code and comments are both renamed in these two files by this task) + - hub/test/ext-ask-gates.test.ts contains `'governedAutomationPtyGate'` and `automation_blocked_on_pty:external-ask` + - both test commands exit 0 + + All three dispatch call sites compose governedAutomationPtyGate; external-ask is proven ineligible with the flag ON and every governor passing. + + + + Task 3: Delete the old gate and pin full governor semantics (order, enabled rule, fail-closed slots, throw, non-PTY, no-alias) + hub/src/dispatch/gates.ts, hub/test/human-only-guard.test.ts, hub/test/governed-pty-gate.test.ts + + - hub/src/dispatch/gates.ts (L436-500: the doc comment block and the old gate function to delete) + - hub/test/human-only-guard.test.ts (whole file) + - hub/test/governed-pty-gate.test.ts (created in Task 1) + - hub/src/dispatch/governed-pty.ts (created in Task 1) + + + - Order (D-02): flag ON, actor 'scheduler', 'pty-interactive', four all-pass spies -> ok and calls toEqual ['kill_switch','token_cap','inject_rate','lifetime_counter']; with the token_cap spy blocking 'x' -> reason 'automation_blocked_on_pty:scheduler:x' and calls toEqual ['kill_switch','token_cap']. + - PRODUCTION_GOVERNORS.map(name) toEqual GOVERNOR_ORDER toEqual ['kill_switch','token_cap','inject_rate','lifetime_counter']; none is named 'daily_cost_cap' (D-03: cost cap is advisory, not a governor). + - Every AUTOMATION_ACTORS member admitted (ok) with the flag ON and four all-pass spies (the admit path exists under DI, SC-1 structural). + - Enabled rule (D-03) with list [pass-spy kill_switch, tokenCapGovernor, injectRateGovernor, pass-spy lifetime_counter]: default env -> ok; REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED in '1','true','yes','on' -> reason ends ':governor_unconfigured:token_cap'; REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR '0' and '-1' -> reason ends ':governor_unconfigured:inject_rate'. + - isDailyTokenCapEnforced(): unset true; '1','TRUE',' on ' false; '0' and 'garbage' true. + - Fail-closed slots: with [pass kill_switch, real token, real inject, lifetimeCounterGovernor] -> reason 'automation_blocked_on_pty:scheduler:governor_not_implemented:lifetime_counter'. + - Throw (D-14): a spy kill_switch that throws -> { ok: false, reason: 'automation_blocked_on_pty:scheduler:governor_error:kill_switch' }. + - Non-PTY (D-17): flag ON, actor 'scheduler', runnerType 'stream-json' -> ok, zero governor calls. + - Setter: after _resetGovernorsForTests(), activeGovernors() is the PRODUCTION_GOVERNORS object (toBe); governedAutomationPtyGate.length === 1 (no public governors parameter). + - No alias (D-05): the gates.ts module namespace has no key named humanOnlyPtyGate and governed-pty.ts exports no key named humanOnlyPtyGate. + - human-only-guard.test.ts dispatch-pipeline-form describe now targets governedAutomationPtyGate: scheduler on pty -> 'automation_blocked_on_pty:scheduler' (flag OFF), human allowed, scheduler on stream-json allowed, gate.name === 'governed_automation_pty' and not 'daily_cost_cap'. + + + + + Each behavior bullet is its own test() so the pass count is meaningful. + (1) hub/src/dispatch/gates.ts: delete the old dispatch-gate function and its doc comment (the block starting "DispatchGate form for the pipeline"), per D-05 (no alias, no re-export). KEEP AUTOMATION_ACTORS and humanOnlyRejectsActor unchanged (the web relay at ws/client.ts still uses the predicate). Edit the "Human-only PTY gate (Phase 16 ...)" doc comment above AUTOMATION_ACTORS so it says the dispatch-gate form now lives in dispatch/governed-pty.ts as governedAutomationPtyGate and that humanOnlyRejectsActor remains the shared legacy decision. + (2) hub/test/human-only-guard.test.ts: import governedAutomationPtyGate from '../src/dispatch/governed-pty' (keep humanOnlyRejectsActor and AUTOMATION_ACTORS imports from gates); rename the second describe to 'PTYCAP Phase 3 -- governedAutomationPtyGate (dispatch pipeline form, flag OFF = legacy)'; keep its four cases with the new gate and assert the new name 'governed_automation_pty'. Ensure REMO_GOVERNED_AUTOMATION_PTY is deleted in a beforeEach so the file runs flag-OFF regardless of ambient env. + (3) hub/test/governed-pty-gate.test.ts: add every case in this task's behavior block (import killSwitchGovernor, tokenCapGovernor, injectRateGovernor, lifetimeCounterGovernor, PRODUCTION_GOVERNORS, GOVERNOR_ORDER, activeGovernors from governed-pty; isDailyTokenCapEnforced and AUTOMATION_ACTORS from gates). For the no-alias check use `import * as gatesNs from '../src/dispatch/gates.ts'` and `Object.keys(gatesNs)` (runtime check, independent of comments). + + + cd hub; bun test test/governed-pty-gate.test.ts + non-zero exit, or any fail in the summary, or fewer than 15 pass + cd hub; bun test test/human-only-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 8 pass + cd hub; bun test test/programmatic-hard-halt.test.ts + non-zero exit, or any fail in the summary (humanOnlyRejectsActor semantics changed) + + + - `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src/dispatch hub/src/telegram/dispatch.ts hub/src/ask hub/src/work` prints nothing (total rename in every file this plan owns; remaining comment mentions in ext.ts, ext-api-key-middleware.ts, client.ts and pty-preflight.ts are renamed by plans 03-02 and 03-04) + - hub/src/dispatch/gates.ts still contains `export const AUTOMATION_ACTORS` and `export function humanOnlyRejectsActor(` + - hub/test/human-only-guard.test.ts contains `'governed_automation_pty'` + - `cd hub; bun test test/governed-pty-gate.test.ts` exits 0 with at least 15 pass; human-only-guard and programmatic-hard-halt exit 0 + + The old gate is gone with no alias; every governor rule (order, enabled, fail-closed slots, throw, non-PTY, DI-only) is pinned by a DB-free test. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| process env -> hub gate decision | REMO_GOVERNED_AUTOMATION_PTY and the cap env vars are operator-controlled; a typo must never open the PTY | +| dispatch caller -> governedAutomationPtyGate | the actor arrives only via the caller's server-side resolver closure; DispatchRequest carries no actor | +| untrusted inbound (client email /api/ext/work, external ask) -> PTY | least-authenticated inputs; must stay unconditionally denied on a PTY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-03-01 | Elevation of Privilege | governedAutomationPtyGate eligible-actor rule | critical | mitigate | Task 1/2/3: only AUTOMATION_ACTORS are eligible; external-ask, external-work, '' and unknown strings denied with flag ON and all governors passing (tests in governed-pty-gate.test.ts and ext-ask-gates.test.ts) | +| T-03-02 | Elevation of Privilege | governor failing OPEN | high | mitigate | kill_switch and lifetime_counter bodies fail closed (governor_not_implemented); disabled token cap or inject cap <= 0 blocks governor_unconfigured; a thrown governor is a typed denial governor_error (Task 3 tests) | +| T-03-03 | Tampering | flag misread / typo | high | mitigate | single call-time reader governedAutomationEnabled(), strict 1/true/yes/on set, every other value OFF; differential matrix over 11 OFF values proves OFF == legacy (Task 1) | +| T-03-04 | Elevation of Privilege | DI setter as a production bypass handle | high | mitigate | no public governors parameter (governedAutomationPtyGate.length === 1 test); setter named _setGovernorsForTests; plan 03-03 pins that no other hub/src file references it | +| T-03-05 | Repudiation | reason strings | low | mitigate | prefix contract automation_blocked_on_pty:[:] kept; Telegram prefix mapper unchanged; exact-string assertions in tests | +| T-03-06 | Denial of Service | token-cap-coverage blind spot | medium | mitigate | governed-pty.ts contains no gates: literal (grep 0); token-cap-coverage.test.ts run in Task 1 verify | +| T-03-SC | Tampering | package installs | low | accept | this plan installs no packages (no npm/pip/cargo install task) | + + + +- `cd hub; bun test test/governed-pty-gate.test.ts` (>= 15 pass, 0 fail) +- `cd hub; bun test test/telegram-human-only-guard.test.ts`, `test/ext-ask-gates.test.ts`, `test/ext-work-containment.test.ts`, `test/human-only-guard.test.ts`, `test/programmatic-hard-halt.test.ts`, `test/token-cap-coverage.test.ts` -- each exits 0, one process per file +- `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src/dispatch hub/src/ask hub/src/work hub/src/telegram/dispatch.ts` prints nothing + + + +- SC-3: flag OFF is proven byte-identical (decision + reason + resolver call count, zero governor calls) to a frozen legacy oracle. +- SC-1 (structural): an eligible actor is admitted only when all four governors pass (proven under DI); production slots fail closed; external actors never eligible. +- Hard rename complete with no alias; three call sites use governed_automation_pty. + + +## Artifacts this phase produces + +- New file `hub/src/dispatch/governed-pty.ts` with exports: `GOVERNED_AUTOMATION_PTY_ENV`, `governedAutomationEnabled()`, `type GovernorName`, `GOVERNOR_ORDER`, `interface GovernorGate`, `killSwitchGovernor`, `tokenCapGovernor`, `injectRateGovernor`, `lifetimeCounterGovernor`, `PRODUCTION_GOVERNORS`, `activeGovernors()`, `_setGovernorsForTests()`, `_resetGovernorsForTests()`, `isGovernedEligibleActor()`, `automationBlockedReason()`, `governedAutomationPtyGate()` (gate name `governed_automation_pty`). +- New export in `hub/src/dispatch/gates.ts`: `isDailyTokenCapEnforced()`. +- New env flag: `REMO_GOVERNED_AUTOMATION_PTY` (default OFF). +- New reason strings: `governor_not_implemented:kill_switch`, `governor_not_implemented:lifetime_counter`, `governor_unconfigured:token_cap`, `governor_unconfigured:inject_rate`, `governor_error:`, composed as `automation_blocked_on_pty::`. +- New test file `hub/test/governed-pty-gate.test.ts` (local oracle `legacyDecision`). +- Removed symbol: the legacy dispatch-gate function in gates.ts (hard rename, D-05). + + +Create `.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md` when done + diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md new file mode 100644 index 00000000..b3526aea --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md @@ -0,0 +1,188 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 01 +subsystem: dispatch +tags: [pty, governance, gates, feature-flag, telegram, ask, work] +requires: + - phase: PTYCAP-02-pty-preflight-gate + provides: PTY pre-flight gate chain and the shared humanOnlyRejectsActor decision +provides: + - hub/src/dispatch/governed-pty.ts governance primitives (flag reader, eligible-actor rule, four governors, test-only DI, governedAutomationPtyGate) + - isDailyTokenCapEnforced() export in gates.ts + - hard rename of the dispatch gate at ask, work and telegram call sites (gate name governed_automation_pty) + - OFF==legacy differential test against a frozen oracle +affects: [PTYCAP-03 plans 02-04, PTYCAP-04 (replaces the two fail-closed slots), PTYCAP-07, PTYCAP-08, PTYCAP-09] +actuals: + tokens: 10000 + tasks: 3 + commits: 3 + plan_head_before: daf10d60627243f775801e3f6b58867e794f9fd5 +plan_head_before: daf10d60627243f775801e3f6b58867e794f9fd5 +commits: 3 +tech-stack: + added: [] + patterns: + - "Call-time env flag reader (1|true|yes|on), default OFF" + - "Frozen-copy differential oracle for byte-identical no-op proof" + - "Test-only DI setter (_setGovernorsForTests); no public bypass parameter" +key-files: + created: + - hub/src/dispatch/governed-pty.ts + - hub/test/governed-pty-gate.test.ts + modified: + - hub/src/dispatch/gates.ts + - hub/src/telegram/dispatch.ts + - hub/src/ask/dispatch.ts + - hub/src/work/dispatch.ts + - hub/test/telegram-human-only-guard.test.ts + - hub/test/ext-ask-gates.test.ts + - hub/test/ext-work-containment.test.ts + - hub/test/human-only-guard.test.ts +key-decisions: + - "Gate decision order: resolver once -> non-PTY or exact 'human' ok -> flag OFF legacy block -> ineligible actor legacy block -> governors in fixed order" + - "A thrown governor yields automation_blocked_on_pty::governor_error:, never a pass" + - "The old gate was deleted from gates.ts only after all three call sites moved (no alias at any commit boundary of the final state)" +requirements-completed: [SC-1, SC-3] +coverage: + - id: D1 + description: "With the flag OFF (11 off values) the new gate equals a frozen legacy oracle over the full 13-actor x 6-runner matrix, resolver called once, zero governors invoked" + requirement: SC-3 + verification: + - kind: unit + ref: "hub/test/governed-pty-gate.test.ts#flag differential (11 tests)" + status: pass + human_judgment: false + - id: D2 + description: "Flag ON with production governors blocks every automation actor on a PTY (kill_switch fail-closed slot); external-ask, external-work, empty and unknown actors keep the legacy deny even with every governor passing" + requirement: SC-1 + verification: + - kind: unit + ref: "hub/test/governed-pty-gate.test.ts#PTYCAP Phase 3 -- production governors with the flag ON" + status: pass + - kind: unit + ref: "hub/test/ext-ask-gates.test.ts#PTYCAP Phase 3 (D-06)" + status: pass + human_judgment: false + - id: D3 + description: "Governor order, first-block-wins, enabled rule (token_cap / inject_rate unconfigured), fail-closed slots, throw -> typed denial, non-PTY ok, DI-only setter, no alias" + requirement: SC-1 + verification: + - kind: unit + ref: "hub/test/governed-pty-gate.test.ts (28 pass)" + status: pass + human_judgment: false + - id: D4 + description: "End-to-end through the real Telegram dispatch pipeline: flag ON scheduler source refused by kill_switch slot, human source dispatched" + requirement: SC-1 + verification: + - kind: integration + ref: "hub/test/telegram-human-only-guard.test.ts#governed-automation flag ON (PTYCAP Phase 3)" + status: pass + human_judgment: false +duration: 25min +completed: 2026-10-07 +status: complete +--- + +# Phase PTYCAP-03 Plan 01: Governed-automation gate (tracer) Summary + +**Flag-gated `governedAutomationPtyGate` (REMO_GOVERNED_AUTOMATION_PTY, default OFF) with four ordered governors (two fail-closed Phase-4 slots), hard-renamed at the ask, work and telegram call sites and proven byte-identical to the legacy human-only gate when OFF by a frozen-oracle differential.** + +## Performance + +- **Duration:** ~25 min +- **Completed:** 2026-10-07T23:31:14Z +- **Tasks:** 3 (1 tracer, 2 auto) +- **Files modified:** 10 (2 created, 8 modified) + +## Accomplishments + +- New `hub/src/dispatch/governed-pty.ts`: `governedAutomationEnabled()` (call-time, strict 1|true|yes|on), `GovernorName`/`GOVERNOR_ORDER`, `killSwitchGovernor` and `lifetimeCounterGovernor` (fail closed, `governor_not_implemented:`), `tokenCapGovernor` and `injectRateGovernor` (`governor_unconfigured:` unless enabled), `PRODUCTION_GOVERNORS`, `_setGovernorsForTests`/`_resetGovernorsForTests`, `isGovernedEligibleActor`, `automationBlockedReason`, and `governedAutomationPtyGate` (name `governed_automation_pty`, exactly one parameter). +- SC-3: with the flag unset or any of 10 other off values, decision and reason string equal the frozen legacy oracle for all 78 (actor x runnerType) pairs, the resolver is called exactly once per check and zero governors run. +- SC-1 (structural): every `AUTOMATION_ACTORS` member is admitted only when all four governors pass (provable under DI); in production with the flag ON nothing is admitted (kill_switch slot). external-ask / external-work / '' / unknown actors are denied with the legacy reason even with the flag ON and all governors passing. +- Hard rename done: the old gate is deleted from `gates.ts` (no alias, no re-export; runtime `Object.keys` test), ask, work and telegram compose `governedAutomationPtyGate`. + +## Task Commits + +1. **Task 1: Tracer - governed-pty module wired through Telegram dispatch with the OFF==legacy differential** - `1d55def` (feat) +2. **Task 2: Rename ask and work call sites and their composition tests** - `e98d271` (feat) +3. **Task 3: Delete the old gate and pin full governor semantics** - `a745ab9` (feat) + +**Plan metadata:** committed with this SUMMARY (docs). + +## Verify results (real counts) + +| File | pass / fail | +|------|-------------| +| hub/test/governed-pty-gate.test.ts | 28 / 0 (floor 15) | +| hub/test/telegram-human-only-guard.test.ts | 6 / 0 (floor 6) | +| hub/test/token-cap-coverage.test.ts | 3 / 0 | +| hub/test/ext-ask-gates.test.ts | 12 / 0 (floor 12) | +| hub/test/ext-work-containment.test.ts | 18 / 0 | +| hub/test/human-only-guard.test.ts | 8 / 0 (floor 8) | +| hub/test/programmatic-hard-halt.test.ts | 10 / 0 | + +Tracer gate: `` for Task 1 re-run end to end and green before expansion. `grep -c -E "gates:[[:space:]]*\["` on governed-pty.ts prints 0; no `humanInteractive` in it; the env literal appears once in `hub/src`. `bun run typecheck` shows no errors in any file this plan changed except one pre-existing `WorkSite` fixture error at `hub/test/ext-work-containment.test.ts(24,7)` (untouched line). + +## Files Created/Modified + +- `hub/src/dispatch/governed-pty.ts` - governance primitives and the renamed gate +- `hub/src/dispatch/gates.ts` - adds `isDailyTokenCapEnforced()`, deletes the old gate, doc comment updated +- `hub/src/telegram/dispatch.ts`, `hub/src/ask/dispatch.ts`, `hub/src/work/dispatch.ts` - compose `governedAutomationPtyGate`, comments updated +- `hub/test/governed-pty-gate.test.ts` - differential + flag + eligibility + governor tests (28) +- `hub/test/telegram-human-only-guard.test.ts`, `ext-ask-gates.test.ts`, `ext-work-containment.test.ts`, `human-only-guard.test.ts` - renamed assertions plus new cases + +## Decisions Made + +- Followed the plan's locked decision order and reason contract; a thrown governor is caught inside the gate form and returned as `governor_error:`. +- The differential is split into one test per OFF flag value (11 tests) so the pass count is meaningful and a failure names the offending value. +- `human-only-guard.test.ts` deletes `REMO_GOVERNED_AUTOMATION_PTY` in a `beforeEach` so it always runs flag-OFF (it does not restore a pre-existing ambient value; ambient value is unset in this repo's test runs). + +## Deviations from Plan + +### Plan inconsistency (not auto-fixed, handed to plan 03-02) + +**1. [Scope] One remaining `humanOnlyPtyGate` mention in `hub/src/dispatch/pty-preflight.ts:20` (comment only)** +- **Found during:** Task 3 acceptance check +- **Issue:** Task 3's acceptance command `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src/dispatch ...` is not empty because of a header comment in `pty-preflight.ts` ("Phase 3 will call this seam"). The same task text states that comment mentions in `pty-preflight.ts` are renamed by plans 03-02/03-04, and `pty-preflight.ts` is not in this plan's `files_modified`; plan 03-02 acceptance (`git grep -n humanOnlyPtyGate -- hub/src/dispatch/pty-preflight.ts hub/test/pty-preflight.test.ts` prints nothing) owns it. +- **Fix:** none here; left for 03-02. All code symbols and every file owned by this plan are clean (`git grep` over hub/src/ask, hub/src/work, hub/src/telegram/dispatch.ts and the rest of hub/src/dispatch prints only that one comment line). +- **Files modified:** none + +**Total deviations:** 0 auto-fixed, 1 scoped hand-off. **Impact:** none on behavior. + +## Issues Encountered + +None. RED was confirmed before implementation (module not found), then GREEN. No test-infrastructure errors; no AgentAutofix tickets filed. The agent-branch-namespace commit assertion (`agent-*`) was not applied because this run commits on the designated phase branch `phase-ptycap-03-governed-automation-guard` in the phase worktree per the orchestrator's run rules; the protected-branch check (not main/master/etc.) holds. + +## Known Stubs + +The `kill_switch` and `lifetime_counter` governors are intentional fail-closed slots (`governor_not_implemented:*`), not data stubs. Phase 4 replaces their bodies; with the flag ON nothing is admitted in production until then. This is the plan's design (D-02). + +## Honest scope (carry into VERIFICATION) + +The three call-site gates are NOT the door onto a PTY: every automation dispatch adapter sends `user_message`, which the supervisor routes to the stream-json runner. SC-1 is met structurally (admit path under test DI, production slots fail closed, door pinned by plan 03-03). ROADMAP SC-1 text was not edited. + +## Threat Flags + +None. No new endpoints, auth paths, file access or schema changes. No DDL, no prod env changes, no packages installed. + +## User Setup Required + +None - no external service configuration required. The new flag `REMO_GOVERNED_AUTOMATION_PTY` defaults OFF and was not set anywhere. + +## Next Phase Readiness (for plans 03-02..04) + +- 03-02 can import `PRODUCTION_GOVERNORS`/`activeGovernors()` and the reason helpers from `governed-pty.ts`; it must also rename the `humanOnlyPtyGate` comment at `pty-preflight.ts:20`. +- `governed-pty.ts` is the ONLY `hub/src` file containing the env literal (via `GOVERNED_AUTOMATION_PTY_ENV`); its comments deliberately avoid the preflight function name followed by a paren, an automation-actor property assignment, a second env literal, `humanInteractive`, and any `gates: [` pattern, so 03-03's raw-text pins stay green. +- Remaining `humanOnlyPtyGate`/`human_only_pty` mentions outside this plan's files: `pty-preflight.ts` comment, ext.ts, ext-api-key-middleware.ts, client.ts, docs (plans 03-02/03-04). +- Telegram `dispatchToSession` still has the `input.source ?? "human"` default (plan 03-03 owns it). + +--- +*Phase: PTYCAP-03-governed-automation-guard* +*Completed: 2026-10-07* + +## Self-Check: PASSED + +- Created files present: hub/src/dispatch/governed-pty.ts, hub/test/governed-pty-gate.test.ts +- Commits present: 1d55def, e98d271, a745ab9 (3 measured via rev-list from the ledger base daf10d6) +- All task acceptance criteria re-run and green except the documented pty-preflight.ts comment hand-off diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-PLAN.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-PLAN.md new file mode 100644 index 00000000..d7fe6a7e --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-PLAN.md @@ -0,0 +1,194 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 02 +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - hub/src/dispatch/pty-preflight.ts + - hub/test/pty-preflight.test.ts + - hub/test/human-interactive-flag-guard.test.ts +autonomous: true +requirements: [SC-1, SC-3] +estimate: + tokens: 60000 + raw_tokens: 60000 + tasks: 2 + confidence: low +must_haves: + truths: + - "With REMO_GOVERNED_AUTOMATION_PTY OFF, checkPtyTurnPreflight({actor:'automation'}) returns exactly { ok: false, reason: 'automation_blocked_on_pty:automation' } BEFORE any DB-backed gate runs (a throwing threshold gate does not change the result) (D-04, SC-3)" + - "With the flag ON, the automation branch runs PTY_AUTOMATION_TURN_GATES (threshold, token cap, cost cap, inject-rate) and THEN the active governors in order; a governor block returns automation_blocked_on_pty:automation: (D-07, D-08)" + - "With the flag ON and the production governor list, an automation turn under every ceiling is refused with automation_blocked_on_pty:automation:governor_not_implemented:kill_switch (D-02)" + - "A governor that throws in the preflight yields pty_preflight_error and one that never settles yields pty_preflight_timeout (D-14, reusing checkPtyTurnPreflight)" + - "The HUMAN chain contains no governor: with the flag ON and the fail-closed production governors installed, a human turn under every ceiling passes and zero governor checks run (D-07)" + - "The scanned literal ptyPreflightDispatchConfig.gates and the human filter are byte-unchanged; token-cap-coverage stays green (D-07)" + - "human-interactive-flag-guard.test.ts passes on a Windows CRLF checkout (D-11)" + artifacts: + - path: hub/src/dispatch/pty-preflight.ts + provides: "automation-only governed branch inside runChain (flag first, governors after the spend gates)" + contains: "governedAutomationEnabled" + - path: hub/test/pty-preflight.test.ts + provides: "governed automation branch tests + human-chain-has-no-governor tests" + contains: "governor_not_implemented:kill_switch" + key_links: + - from: hub/src/dispatch/pty-preflight.ts + to: hub/src/dispatch/governed-pty.ts + via: "runChain automation branch calls governedAutomationEnabled(), activeGovernors(), automationBlockedReason()" + pattern: "activeGovernors\\(\\)" + prohibitions: + - statement: "Do not append governors to the ptyPreflightDispatchConfig gates literal or to PTY_HUMAN_TURN_GATES (would refuse every human web Enter, D-07)" + status: recalled + verification: backstop + - statement: "Do not change the line that sets humanInteractive in runChain (pinned verbatim by human-interactive-flag-guard.test.ts)" + status: recalled + verification: backstop +--- + + +Extend the PTY preflight (the real door a future automation producer must call) with the automation-only governed branch: the flag is checked first, before any DB gate; then the existing automation spend chain; then the governors from plan 03-01, with prefixed reasons. Prove the human chain never sees a governor and fold in the Phase-2 CRLF fix. + +Purpose: SC-1 at the door (an automation PTY turn passes the spend chain AND the governors or does not happen) and SC-3 (flag OFF admits nothing to a PTY). Implements D-07 (critical trap: human chain untouched), D-08, D-14, D-04 (flag first), D-11 (CRLF). +Output: modified runChain, extended pty-preflight tests, CRLF-safe guard test. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md +@hub/src/dispatch/pty-preflight.ts +@hub/src/dispatch/governed-pty.ts + +Interface from plan 03-01 (hub/src/dispatch/governed-pty.ts): governedAutomationEnabled(): boolean; activeGovernors(): readonly GovernorGate[]; automationBlockedReason(actor, governorReason?): string; _setGovernorsForTests(list) / _resetGovernorsForTests(); PRODUCTION_GOVERNORS; tokenCapGovernor; injectRateGovernor; GovernorGate = DispatchGate with name in kill_switch|token_cap|inject_rate|lifetime_counter. + +Preflight actor note: PtyPreflightActor is the generic 'automation' (not an AUTOMATION_ACTORS member). Eligibility at the door is enforced by the caller pin in plan 03-03 (only hub/src/orchestrator/ may pass actor 'automation', D-01c, D-12), so runChain does not re-run the eligible-actor rule; its reasons use the actor string 'automation'. + + + + + + Task 1: Flag-first governed automation branch in runChain + hub/src/dispatch/pty-preflight.ts, hub/test/pty-preflight.test.ts + + - hub/src/dispatch/pty-preflight.ts (L1-52 header, chain exports, timeout setter; L170-214 runChain and checkPtyTurnPreflight) + - hub/src/dispatch/governed-pty.ts (exports listed in context) + - hub/test/pty-preflight.test.ts (L19-94 mocks/state/imports; L162-219 automation and programmatic-halt describes that must be updated) + - hub/test/human-interactive-flag-guard.test.ts (L35-39: the exact runChain line it pins) + + + - Flag OFF (env deleted), actor 'automation', state.thresholdMode = 'throw' -> toEqual { ok: false, reason: 'automation_blocked_on_pty:automation' } (flag precedes every DB gate; otherwise the throw would surface as pty_preflight_error). + - Flag OFF, actor 'automation', every ceiling passing -> same block (OFF admits nothing). + - Flag ON, production governors, every ceiling passing -> reason 'automation_blocked_on_pty:automation:governor_not_implemented:kill_switch'. + - Flag ON, four all-pass spy governors -> ok; spy calls toEqual ['kill_switch','token_cap','inject_rate','lifetime_counter']. + - Flag ON, spies, state.injectsInWindow = 999_999 -> reason contains 'over_session_inject_rate' (unprefixed, existing gate) and spy calls toEqual [] (spend gates run before governors). + - Flag ON, list [pass kill_switch, tokenCapGovernor, injectRateGovernor, pass lifetime_counter], REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR='0' -> reason 'automation_blocked_on_pty:automation:governor_unconfigured:inject_rate' (sessionInjectRateGate is fail-open at cap 0; the governor is what refuses). + - Same list, REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED='1' -> reason 'automation_blocked_on_pty:automation:governor_unconfigured:token_cap'. + - Flag ON, spy kill_switch throws -> toEqual { ok: false, reason: 'pty_preflight_error' }. + - Flag ON, spy kill_switch never settles, _setPtyPreflightTimeoutForTests(20) -> toEqual { ok: false, reason: 'pty_preflight_timeout' }. + - The existing Phase-2 automation cases (full chain passes; inject-rate blocks; cost cap blocks; threshold-first ordering; programmatic-credit halt stops automation) keep their exact expectations, now run with the flag ON and four all-pass spies. + + + Write/adjust the tests first (RED), then change runChain (GREEN). Per D-07 the change is confined to runChain's automation path: + (1) hub/src/dispatch/pty-preflight.ts: import governedAutomationEnabled, activeGovernors and automationBlockedReason from './governed-pty.ts'. In runChain, immediately after `const human = input.actor === 'human'`, add: when not human and governedAutomationEnabled() is false, return `{ ok: false, reason: automationBlockedReason(input.actor) }` (D-04: flag first, before any DB gate). Leave the DispatchRequest construction byte-identical (the line ` ...(human ? { humanInteractive: true as const } : {}),` must remain exactly as-is). Leave the existing for-loop over `human ? PTY_HUMAN_TURN_GATES : PTY_AUTOMATION_TURN_GATES` unchanged. After that loop, when not human, iterate activeGovernors() in order WITHOUT try/catch (a throw must propagate to checkPtyTurnPreflight's existing catch -> pty_preflight_error, and a hang is caught by its existing timeout -> pty_preflight_timeout, D-14); a blocking governor returns `{ ok: false, reason: automationBlockedReason(input.actor, result.reason) }` (D-08). Do NOT touch the `ptyPreflightDispatchConfig` literal (L33-35), `PTY_AUTOMATION_TURN_GATES`, or the `PTY_HUMAN_TURN_GATES` filter (D-07 critical trap: appending governors there would refuse every human web Enter, and the literal is what token-cap-coverage scans). Rewrite the header comment lines that describe "No path admits automation to a PTY yet" so they say: the automation branch is gated by REMO_GOVERNED_AUTOMATION_PTY (default OFF = refuse) and, when ON, runs the spend chain then the governors from governed-pty.ts; the human chain never runs a governor. The rewritten header must not contain the legacy gate identifier, must not spell the env-var literal (say "the governed-automation flag (governed-pty.ts)"), and must not contain an actor property assignment for the automation actor in call syntax (plan 03-03's door pins scan raw source including comments; only hub/src/orchestrator/ may contain that). + (2) hub/test/pty-preflight.test.ts: import _setGovernorsForTests, _resetGovernorsForTests, PRODUCTION_GOVERNORS, tokenCapGovernor, injectRateGovernor from '../src/dispatch/governed-pty.ts'. In the top-level beforeEach also delete REMO_GOVERNED_AUTOMATION_PTY, REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR and REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED (restore original values in an afterEach) and call _resetGovernorsForTests(). In the two existing describes that call actor 'automation' (the 'non-human ("automation") turns (SC-1)' and the 'programmatic-credit halt' automation case), set the flag ON and install four all-pass spy governors in a describe-scoped beforeEach; replace the stale comment that cites the legacy gate with one that cites governedAutomationPtyGate and plan 03-03's caller pin. Add a new describe 'PTYCAP Phase 3 -- governed automation branch' with every new behavior above, each its own test(). + + + cd hub; bun test test/pty-preflight.test.ts + non-zero exit, or any fail in the summary, or fewer than 42 pass (33 existing + at least 9 new) + cd hub; bun test test/token-cap-coverage.test.ts + non-zero exit, or any fail in the summary (the scanned pty-preflight literal changed or lost a cap) + cd hub; bun test test/ws-client-pty-preflight.test.ts + non-zero exit, or any fail in the summary (relay wiring regressed by the import change) + + + - hub/src/dispatch/pty-preflight.ts still contains the exact line ` gates: [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate],` and the exact line ` ...(human ? { humanInteractive: true as const } : {}),` + - hub/src/dispatch/pty-preflight.ts contains `governedAutomationEnabled()` and `activeGovernors()` and does not contain `PTY_AUTOMATION_TURN_GATES, ...` (no spread of governors into a chain constant) + - `git grep -n humanOnlyPtyGate -- hub/src/dispatch/pty-preflight.ts hub/test/pty-preflight.test.ts` prints nothing + - hub/test/pty-preflight.test.ts contains `automation_blocked_on_pty:automation:governor_not_implemented:kill_switch` and `pty_preflight_timeout` + - all three verify commands exit 0 + + An automation PTY turn is refused when the flag is OFF before any DB call, and with the flag ON must pass the spend chain and then every governor; governor failures are fail-closed through the existing error/timeout path. + + + + Task 2: Prove the human chain never runs a governor; make the humanInteractive guard CRLF-safe + hub/test/pty-preflight.test.ts, hub/test/human-interactive-flag-guard.test.ts + + - hub/test/pty-preflight.test.ts (as modified in Task 1; L96-160 chain-shape and human describes) + - hub/test/human-interactive-flag-guard.test.ts (whole file; L37 is the split to fix) + - hub/src/dispatch/pty-preflight.ts (as modified in Task 1) + + + - Chain shape: for each governor in PRODUCTION_GOVERNORS, PTY_HUMAN_TURN_GATES does not contain it and PTY_AUTOMATION_TURN_GATES does not contain it (governors are never members of a chain constant); PTY_HUMAN_TURN_GATES names still toEqual ['threshold','daily_token_cap','daily_cost_cap']. + - Flag ON with PRODUCTION governors (fail closed): a human turn under every ceiling -> ok true (a leaked governor would refuse every web Enter). + - Flag ON with four spy governors: a human turn -> ok and spy calls toEqual []. + - Flag ON: a human turn with state.injectsInWindow = 999_999 -> ok (Phase-2 SC-3 absolute preserved under the flag). + - human-interactive-flag-guard: both existing tests pass on a CRLF working tree. + + + (1) hub/test/pty-preflight.test.ts: add a describe 'PTYCAP Phase 3 -- the human chain never runs a governor (D-07)' with each behavior above as its own test(); set REMO_GOVERNED_AUTOMATION_PTY='1' inside these tests (the top-level beforeEach/afterEach from Task 1 resets it). + (2) hub/test/human-interactive-flag-guard.test.ts: per D-11 change the split in the second test from a plain newline split to a regex split on optional CR plus LF (`/\r?\n/`). Change nothing else; the expected pinned line stays byte-identical. Do NOT add governed-pty.ts to its ALLOWED set (that module must not mention the identifier; if it does, fix the module, not the guard). + + + cd hub; bun test test/pty-preflight.test.ts + non-zero exit, or any fail in the summary, or fewer than 46 pass (Task 1 count + 4 new) + cd hub; bun test test/human-interactive-flag-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 2 pass (on this Windows CRLF worktree the second test failed before the fix) + + + - hub/test/human-interactive-flag-guard.test.ts contains `split(/\r?\n/)` + - hub/test/pty-preflight.test.ts contains a test asserting the human chain with PRODUCTION_GOVERNORS installed and the flag ON returns ok true + - `cd hub; bun test test/human-interactive-flag-guard.test.ts` reports 2 pass 0 fail on this Windows worktree + + The self-inflicted-outage trap (governors in the human chain) is pinned behaviorally and structurally; the Phase-2 MINOR-1 CRLF false-red is gone. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| future automation producer -> checkPtyTurnPreflight | the only sanctioned door for an automation PTY turn; must refuse unless flag ON and spend chain + governors pass | +| human web relay -> checkPtyTurnPreflight(actor 'human') | every human Enter; must never be refused by a governor | +| env -> preflight | flag and cap env vars read at call time | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-03-07 | Denial of Service | governors leaking into the HUMAN preflight chain (self-inflicted outage of every web Enter) | critical | mitigate | Task 1 keeps the scanned literal and human filter untouched; Task 2 asserts no governor is a member of either chain constant and that a human turn passes with fail-closed production governors installed and the flag ON | +| T-03-08 | Elevation of Privilege | automation admitted with flag OFF | high | mitigate | flag checked first in runChain; test proves the OFF block precedes a throwing DB gate | +| T-03-09 | Elevation of Privilege | governor failing OPEN in the preflight (throw, hang, disabled ceiling) | high | mitigate | no try/catch around governors in runChain: throw -> pty_preflight_error, hang -> pty_preflight_timeout; governor_unconfigured tests for cap 0 and token-cap-disabled | +| T-03-10 | Tampering | token-cap-coverage blind spot (governors in a variable list) | medium | mitigate | governors iterated from activeGovernors(), never written into a gates literal; token-cap-coverage.test.ts in verify | +| T-03-11 | Spoofing | humanInteractive exemption widened to automation | high | mitigate | pinned line unchanged; guard test made CRLF-safe so it runs green locally; governed-pty.ts must not mention the identifier | +| T-03-SC | Tampering | package installs | low | accept | no package installs in this plan | + + + +- `cd hub; bun test test/pty-preflight.test.ts` (>= 46 pass, 0 fail) +- `cd hub; bun test test/human-interactive-flag-guard.test.ts` (2 pass, 0 fail) +- `cd hub; bun test test/token-cap-coverage.test.ts` and `cd hub; bun test test/ws-client-pty-preflight.test.ts` (0 fail) + + + +- The automation preflight branch admits nothing with the flag OFF and, with the flag ON, requires spend chain + all four governors (production: refused by the kill_switch slot). +- The human chain is provably governor-free. +- Phase-2 MINOR-1 resolved. + + +## Artifacts this phase produces + +- Modified `runChain` in `hub/src/dispatch/pty-preflight.ts`: automation-only governed branch (flag-first refusal `automation_blocked_on_pty:automation`; governor refusals `automation_blocked_on_pty:automation:`). No new exports. +- New test describes in `hub/test/pty-preflight.test.ts`: 'PTYCAP Phase 3 -- governed automation branch' and 'PTYCAP Phase 3 -- the human chain never runs a governor (D-07)'. +- CRLF-safe split in `hub/test/human-interactive-flag-guard.test.ts`. + + +Create `.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md` when done + diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md new file mode 100644 index 00000000..a9fb4b99 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md @@ -0,0 +1,118 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 02 +subsystem: dispatch +tags: [pty, preflight, governance, feature-flag, crlf] +requires: + - phase: PTYCAP-03-governed-automation-guard + provides: "governed-pty.ts primitives (flag reader, activeGovernors, automationBlockedReason, PRODUCTION_GOVERNORS, test DI)" +provides: + - "automation-only governed branch in runChain (flag first, spend gates, then governors)" + - "pinned proof that the human chain never runs a governor" + - "CRLF-safe humanInteractive guard test" +affects: [PTYCAP-03 plans 03-03, 03-04, PTYCAP-04] +actuals: + tokens: 9000 + tasks: 2 + commits: 2 + plan_head_before: 3ffb7f2a0d7fabed867cab734a4273883e9f9acb +plan_head_before: 3ffb7f2a0d7fabed867cab734a4273883e9f9acb +commits: 2 +tech-stack: + added: [] + patterns: + - "Flag-first refusal ahead of any DB-backed gate" + - "Governors iterated from activeGovernors(), never placed in a chain constant or gates literal" +key-files: + created: [] + modified: + - hub/src/dispatch/pty-preflight.ts + - hub/test/pty-preflight.test.ts + - hub/test/human-interactive-flag-guard.test.ts +key-decisions: + - "No try/catch around governors in runChain: a throw reaches checkPtyTurnPreflight's catch (pty_preflight_error), a hang its timeout (pty_preflight_timeout)" + - "runChain does not re-run the eligible-actor rule; reasons use the actor string 'automation' (door pin is plan 03-03)" +requirements-completed: [SC-1, SC-3] +coverage: + - id: D1 + description: "Flag OFF: automation refused with automation_blocked_on_pty:automation before any DB gate (a throwing threshold gate does not change the result)" + requirement: SC-3 + verification: + - kind: unit + ref: "hub/test/pty-preflight.test.ts#PTYCAP Phase 3 -- governed automation branch" + status: pass + human_judgment: false + - id: D2 + description: "Flag ON: spend chain then governors in fixed order; production governors refuse via kill_switch slot; governor_unconfigured for cap 0 and disabled token cap; throw -> pty_preflight_error; hang -> pty_preflight_timeout" + requirement: SC-1 + verification: + - kind: unit + ref: "hub/test/pty-preflight.test.ts#PTYCAP Phase 3 -- governed automation branch (9 tests)" + status: pass + human_judgment: false + - id: D3 + description: "Human chain contains no governor; human turn passes with fail-closed production governors and the flag ON; zero governor calls" + requirement: SC-3 + verification: + - kind: unit + ref: "hub/test/pty-preflight.test.ts#PTYCAP Phase 3 -- the human chain never runs a governor (D-07) (4 tests)" + status: pass + human_judgment: false +duration: 10min +completed: 2026-10-07 +status: complete +--- + +# Phase PTYCAP-03 Plan 02: Governed automation branch in the PTY preflight Summary + +**The PTY preflight now refuses every automation turn unless the governed-automation flag is ON (checked before any DB gate), and with it ON requires the spend chain then all four governors, while the human chain stays provably governor-free.** + +## Accomplishments + +- `runChain` in `hub/src/dispatch/pty-preflight.ts`: flag check first for non-human actors (`automation_blocked_on_pty:automation`), the existing for-loop unchanged, then `activeGovernors()` iterated for automation only with prefixed reasons (`automation_blocked_on_pty:automation:`). No try/catch, so governor throw/hang fail closed through the existing error/timeout path. +- The scanned `ptyPreflightDispatchConfig.gates` literal, `PTY_AUTOMATION_TURN_GATES`, the `PTY_HUMAN_TURN_GATES` filter and the `humanInteractive` line are byte-unchanged. +- Header comment rewritten (no legacy gate identifier, no env literal, no actor-assignment call syntax); this closes 03-01's hand-off (the stale `humanOnlyPtyGate` mention). +- `human-interactive-flag-guard.test.ts` now splits on `/\r?\n/` (Phase-2 MINOR-1): 2 pass on this CRLF worktree (1 of 2 failed before). + +## Task Commits + +1. **Task 1: Flag-first governed automation branch in runChain** - `b7c9ac5` (feat) +2. **Task 2: Human chain governor-free proof + CRLF-safe guard** - `4c06cc9` (test) + +## Verify results (real counts) + +| File | pass / fail | +|------|-------------| +| hub/test/pty-preflight.test.ts | 46 / 0 (floor 46; 42 after Task 1, floor 42) | +| hub/test/human-interactive-flag-guard.test.ts | 2 / 0 (floor 2) | +| hub/test/token-cap-coverage.test.ts | 3 / 0 | +| hub/test/ws-client-pty-preflight.test.ts | 22 / 0 | + +`git grep -n humanOnlyPtyGate -- hub/src/dispatch/pty-preflight.ts hub/test/pty-preflight.test.ts` prints nothing. + +## Deviations from Plan + +None - plan executed as written. TDD note: Task 1 RED confirmed (8 fail before implementation, 34 pass); Task 2's four human-chain tests pass immediately by design because they pin untouched behavior (they are regression guards, not drivers of new code). + +## Issues Encountered + +None. No test-infrastructure errors; no AgentAutofix tickets filed. + +## Known Stubs + +None added. The two fail-closed governor slots (`kill_switch`, `lifetime_counter`) remain 03-01's intentional Phase-4 placeholders. + +## Threat Flags + +None. No new endpoints, auth paths, file access or schema changes; no DDL, no env set, no packages installed. + +## Next (plans 03-03, 03-04) + +- The env-var literal is still only in `governed-pty.ts`; `pty-preflight.ts` contains no `humanOnlyPtyGate`, no env literal, no automation-actor property assignment in call syntax, and exactly one `humanInteractive :` line. +- The 'automation' actor is still accepted by `checkPtyTurnPreflight`'s type from any caller; the caller pin (only `hub/src/orchestrator/`) is plan 03-03's. +- Remaining `humanOnlyPtyGate` / `human_only_pty` mentions: ext.ts, ext-api-key-middleware.ts, client.ts, docs (plans 03-03/03-04). + +## Self-Check: PASSED + +- Modified files present; commits b7c9ac5 and 4c06cc9 present (2 measured via rev-list from ledger base 3ffb7f2). +- All verify commands re-run and green. diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-PLAN.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-PLAN.md new file mode 100644 index 00000000..d2816de1 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-PLAN.md @@ -0,0 +1,231 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 03 +type: execute +wave: 3 +depends_on: ["03-01", "03-02"] +files_modified: + - hub/src/telegram/dispatch.ts + - hub/src/api/telegram-webhook.ts + - hub/test/telegram-human-only-guard.test.ts + - hub/test/telegram-dispatch.test.ts + - hub/test/ws-client-pty-preflight.test.ts + - hub/test/pty-door-guard.test.ts +autonomous: true +requirements: [SC-2, SC-1] +estimate: + tokens: 85000 + raw_tokens: 85000 + tasks: 3 + confidence: low +must_haves: + truths: + - "Telegram dispatchToSession requires source at compile time; the only production caller (the authenticated webhook) passes the literal 'human'; a call that omits source at runtime is refused on a pty-interactive session (fail closed), never defaulted to human (D-09 iv)" + - "A /ws/client term.input frame carrying extra keys source:'scheduler' and actor:'orchestrator-background' is preflighted with the server-inferred actor 'human' and forwarded with only type, session_id and bytes -- the extra keys are stripped (D-09 ii)" + - "The existing agent-socket spoof test (term-relay-human-guard.test.ts) still passes unchanged (D-09 iii)" + - "Exactly two hub/src modules emit hub->agent term.* frames (ws/client.ts and telegram/transcript/pty-inject.ts); the scanner proves it found both and fails on any third emitter (D-01a)" + - "The supervisor user_message and permission_response branches in session-bridge.ts never reference the PTY runner or a PTY write, while the term.input branch does (non-vacuous canary, D-01b)" + - "Caller pins hold: actor 'automation' only under hub/src/orchestrator/; actor 'human' only in ws/client.ts; the preflight function only in pty-preflight.ts, ws/client.ts or orchestrator/; injectPtyKeystroke only in pty-inject.ts and telegram-webhook.ts; dispatchToSession only in telegram/dispatch.ts and telegram-webhook.ts (D-01c, D-12, D-16)" + - "The flag env literal and the governor test setter appear only in hub/src/dispatch/governed-pty.ts; nothing under hub/src/dispatch reads a request source or actor field; DispatchRequest declares no source/actor field (D-04, D-02, D-09 i)" + - "Telegram permission-answer keystrokes and term.attach_file are explicit, test-pinned exemptions from the preflight, each with a one-line justification (D-10)" + artifacts: + - path: hub/test/pty-door-guard.test.ts + provides: "door pins: emitter allowlist with self-check, supervisor canary, caller pins, flag/DI pins, actor-inference pins, exemption pins" + contains: "ALLOWED_EMITTERS" + key_links: + - from: hub/src/api/telegram-webhook.ts + to: hub/src/telegram/dispatch.ts + via: "dispatchToSession({ ..., source: \"human\" }) at the authenticated webhook boundary" + pattern: "source: \"human\"" + - from: hub/test/pty-door-guard.test.ts + to: hub/src/ws/term-protocol.ts + via: "imports CLIENT_TO_HUB_TERM_TYPES to derive the frame-type set it scans for" + pattern: "CLIENT_TO_HUB_TERM_TYPES" + prohibitions: + - statement: "Supervisor guard test files under supervisor/test/ must not be edited (SC-4)" + status: recalled + verification: backstop + - statement: "A pin that flags a comment-only mention is fixed by rewording the comment, never by widening the allowlist" + status: recalled + verification: backstop +--- + + +Pin the real door and close the spoofing surface (SC-2): make Telegram's dispatch source a required compile-time parameter with no human default, prove /ws/client strips client-asserted actor keys, and add one DB-free structural test file that pins every hub->agent term.* emitter, the supervisor routing that keeps automation off the PTY, every privileged caller, the flag/DI literals, and the two human-initiated exemptions. + +Purpose: SC-2 ("Actor remains server-inferred; a spoofed source cannot impersonate a human") and the door half of SC-1 (any future producer that bypasses the governed preflight fails CI). Implements D-01, D-09, D-10, D-12 (orchestrator is the only permitted automation caller), D-15 (no change to legacy-JWT human classification), D-16. +Output: Telegram source required; two new spoof tests; new hub/test/pty-door-guard.test.ts. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md + +Measured at planning time (2026-10-07, this worktree): files under hub/src containing a quoted CLIENT_TO_HUB term frame type AND an agent-channel send token (`.ws.send(` or `getChannel(`) are exactly `telegram/transcript/pty-inject.ts` and `ws/client.ts`; files containing a quoted frame type at all are db/dal.ts, dispatch/gates.ts, dispatch/pty-preflight.ts, telegram/approvals.ts, telegram/transcript/keystroke-map.ts, telegram/transcript/pty-inject.ts, ws/client.ts, ws/term-protocol.ts; zero files contain an automation actor assignment; the preflight function name followed by an opening parenthesis appears only in dispatch/pty-preflight.ts and ws/client.ts; no file under hub/src/dispatch reads a request source/actor field. Re-run the detection at implementation time; if plans 03-01/03-02 introduced a comment-only hit, reword that comment. + +D-15 (binding): legacy-JWT /ws/client connections stay `human`; do NOT add an authMethod check. Residual documented in plan 03-04. + + + + + + Task 1: Telegram source becomes a required compile-time parameter with no human default + hub/src/telegram/dispatch.ts, hub/src/api/telegram-webhook.ts, hub/test/telegram-human-only-guard.test.ts, hub/test/telegram-dispatch.test.ts + + - hub/src/telegram/dispatch.ts (L59-80 TelegramDispatchSource and DispatchInput; L160-176 source resolution and guard) + - hub/src/api/telegram-webhook.ts (L380-400 the dispatchToSession call; enough surrounding context to confirm the user is the linked, authenticated Telegram user) + - hub/test/telegram-human-only-guard.test.ts (whole file, including the two flag-ON cases plan 03-01 added) + - hub/test/telegram-dispatch.test.ts (every dispatchToSession call, L75-170) + + + - A call with every field except source (cast through `as any`) to a pty-interactive session returns kind 'automation_blocked', and nothing is sent or inserted (the fail-open default is gone). + - source 'human' to a pty-interactive session -> 'dispatched' (unchanged); source 'scheduler' -> 'automation_blocked' (unchanged); the two flag-ON cases from plan 03-01 still pass. + - telegram-dispatch.test.ts: all existing cases pass with source 'human' passed explicitly. + + + Per D-09 (iv): + (1) hub/src/telegram/dispatch.ts: change the DispatchInput field to a required `source: TelegramDispatchSource;` and rewrite its doc comment to say: required, server-asserted by the caller at an authenticated boundary; the Telegram webhook passes the literal 'human'; there is no default (a missing source is never treated as human). Change the source resolution line to read the field with no nullish fallback (keep the explicit TelegramDispatchSource annotation). Keep the TelegramDispatchSource union unchanged. The replay thunk (`await dispatchToSession(input)`) passes the already-typed input and needs no change. + (2) hub/src/api/telegram-webhook.ts: in the single dispatchToSession call (around L390) add `source: "human",` with a one-line comment: the actor is server-inferred at this authenticated webhook boundary (a message from the linked Telegram user), never read from the update payload. Change nothing else in this file. + (3) hub/test/telegram-human-only-guard.test.ts: replace the test titled 'default source is human (no source field => allowed)' with 'a missing source is NOT treated as human -- fail closed (PTYCAP Phase 3, D-09)' implementing the first behavior bullet (assert kind 'automation_blocked', sent.length 0, inserted.length 0). Keep every other case. + (4) hub/test/telegram-dispatch.test.ts: add `source: "human",` to every dispatchToSession call object (the file's sessions are stream-json, so runtime results are unchanged; this keeps the calls type-correct against the now-required field). + + + cd hub; bun test test/telegram-human-only-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 6 pass + cd hub; bun test test/telegram-dispatch.test.ts + non-zero exit, or any fail in the summary, or a '0 pass' summary + + + - hub/src/telegram/dispatch.ts contains ` source: TelegramDispatchSource;` and does not contain `source?: TelegramDispatchSource` + - `grep -n -E "source \?\? .human." hub/src/telegram/dispatch.ts` prints nothing + - hub/src/api/telegram-webhook.ts contains `source: "human",` inside the dispatchToSession call + - hub/test/telegram-human-only-guard.test.ts contains `fail closed` and no longer contains `default source is human` + - both verify commands exit 0 + + The only fail-open human default on a PTY-adjacent dispatch path is removed; the human actor is asserted by server code at the authenticated boundary. + + + + Task 2: Prove /ws/client strips client-asserted actor keys and preflights as human + hub/test/ws-client-pty-preflight.test.ts + + - hub/test/ws-client-pty-preflight.test.ts (L1-160: mocks, preflightCalls capture, humanClient(), input() helper, the 'what gets checked' describe including the term.attach_file case near L135 for the exact attach_file frame shape) + - hub/src/ws/term-protocol.ts (L24-79 TermInput / TermAttachFile schemas: plain z.object, default strip) + - hub/src/ws/client.ts (L174-240 parse, frame = tf.data, forward sends JSON.stringify(frame)) + - hub/test/term-relay-human-guard.test.ts (L101-118: existing human-forward and agent-spoof cases, kept unchanged) + + + - A human-connection frame `{ type: 'term.input', session_id: SESSION, bytes: btoa('\r'), source: 'scheduler', actor: 'orchestrator-background' }` -> preflightCalls toEqual [{ userId: USER, sessionId: SESSION, actor: 'human' }]; exactly one forwarded term.input; Object.keys(forwarded).sort() toEqual ['bytes', 'session_id', 'type']. + - A human-connection term.attach_file frame (same shape as the existing attach_file test) carrying extra `source: 'scheduler', actor: 'agent'` -> preflightCalls toEqual [] (exempt by design, D-10) and the forwarded frame has neither a source nor an actor key. + + + Per D-09 (ii) (zod strip is inferred, so PROVE it) and D-10: add the two behavior cases to the 'PTYCAP Phase 2 -- what gets checked' describe (or a new adjacent describe titled 'PTYCAP Phase 3 -- client-asserted actor keys are stripped (D-09)'), each its own test(), reusing humanClient(), the fwd capture and preflightCalls. Do NOT modify hub/test/term-relay-human-guard.test.ts (its agent-socket spoof case at L110-118 is the D-09 (iii) negative test and must stay as-is); run it to prove it still passes. + + + cd hub; bun test test/ws-client-pty-preflight.test.ts + non-zero exit, or any fail in the summary, or fewer than 24 pass (22 existing + 2 new) + cd hub; bun test test/term-relay-human-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 3 pass + + + - hub/test/ws-client-pty-preflight.test.ts contains `actor: 'orchestrator-background'` inside a test frame and an assertion `toEqual(['bytes', 'session_id', 'type'])` + - `git diff --quiet -- hub/test/term-relay-human-guard.test.ts` exits 0 (unchanged) + - both verify commands exit 0 + + Key stripping and server-side actor inference on /ws/client are executed facts, not inferences. + + + + Task 3: Door pins -- hub/test/pty-door-guard.test.ts + hub/test/pty-door-guard.test.ts + + - hub/test/human-interactive-flag-guard.test.ts (ALLOWED-set walk pattern, path normalization) + - hub/test/token-cap-coverage.test.ts (L73-94: scanner self-check pattern, cannot pass vacuously) + - hub/test/automation-routing-guard.test.ts (L55-79: reading supervisor source from hub/test) + - hub/src/ws/term-protocol.ts (L84-110: CLIENT_TO_HUB_TERM_TYPES export) + - supervisor/src/runners/session-bridge.ts (L391-475: term.* branches, user_message, permission_response, question_response, cancel) + - hub/src/dispatch/pipeline.ts (L36-48 DispatchRequest) + - hub/src/auth/ext-api-key-middleware.ts (EXT_ACTOR, EXT_WORK_ACTOR constants) + - hub/src/telegram/transcript/pty-inject.ts (whole file) and hub/src/ws/client.ts (L274-295 inputClass / preflight call) + + + - Emitter allowlist (D-01a): FRAME_TYPES = [...CLIENT_TO_HUB_TERM_TYPES] (self-check: includes 'term.input' and 'term.attach_file'). A hub/src .ts file is an EMITTER when its raw text contains a quoted occurrence of any FRAME_TYPES value (single, double or backtick quotes) AND matches /\.ws\.send\(|getChannel\(/. ALLOWED_EMITTERS = {'ws/client.ts', 'telegram/transcript/pty-inject.ts'}. Assert emitters minus ALLOWED_EMITTERS toEqual []; self-check: the emitter set contains BOTH allowed files (a broken regex fails instead of passing vacuously). + - Supervisor canary (D-01b): read supervisor/src/runners/session-bridge.ts, split on /\r?\n/; extract the block from the line containing `msg.type === 'user_message'` up to (not including) the next line containing `msg.type ===`; same for `msg.type === 'permission_response'`. Self-checks: user_message block contains 'sendMessage', permission_response block contains 'respondToPermission', and the line range for `anyMsg.type === 'term.input'` contains 'pty?.write'. Assert neither block contains 'ensurePtyRunner', 'ptyRunner', 'pty?.write' or 'pty.write'. + - Caller pins (D-01c, D-12, D-16), each with a positive self-check where a caller exists today: /actor:\s*['"]automation['"]/ only in files under 'orchestrator/' (regex self-check against a synthetic string); /actor:\s*['"]human['"]/ only in 'ws/client.ts' (self-check found it); 'checkPtyTurnPreflight(' only in {'dispatch/pty-preflight.ts','ws/client.ts'} or under 'orchestrator/' (self-check found ws/client.ts); 'injectPtyKeystroke(' only in {'telegram/transcript/pty-inject.ts','api/telegram-webhook.ts'} (self-check found both); 'dispatchToSession(' only in {'telegram/dispatch.ts','api/telegram-webhook.ts'} (self-check found both). + - Flag/DI pins (D-04, D-02): 'REMO_GOVERNED_AUTOMATION_PTY' and '_setGovernorsForTests' each appear in hub/src only in 'dispatch/governed-pty.ts' (self-check found it). + - Actor-inference pins (D-09 i): no file under hub/src/dispatch matches /\b(req|request)\.(source|actor)\b/; the DispatchRequest interface body in pipeline.ts has no /\b(source|actor)\??\s*:/ member; ask/dispatch.ts contains 'actor: EXT_ACTOR' and work/dispatch.ts contains 'actor: EXT_WORK_ACTOR'; EXT_ACTOR === 'external-ask' and EXT_WORK_ACTOR === 'external-work'. + - Telegram pins (D-09 iv): telegram/dispatch.ts contains 'source: TelegramDispatchSource;' and does not match /source\s*\?\?\s*['"]human['"]/; the text slice of api/telegram-webhook.ts from 'dispatchToSession({' to the next '});' contains 'source: "human"'. + - Exemption pins (D-10): an EXEMPTIONS record with exactly two entries and a one-line justification each -- injectPtyKeystroke: 'answers an existing pending permission/question prompt raised by an already-gated turn; one-shot, approver-authorized via takePendingPrompt; a quota check must not wedge a mid-turn prompt' and term.attach_file: 'types a file path into the prompt box, never Enter; cannot submit a turn'. Assert telegram/transcript/pty-inject.ts contains neither 'checkPtyTurnPreflight' nor 'pty-preflight'; assert in ws/client.ts that the line containing 'inputClass = classifyPtyInput(' is directly preceded by a line containing "if (frame.type === 'term.input')" (attach_file never computes a submit class), and that 'checkPtyTurnPreflight(' occurs after a line containing 'inputClass?.submit'. + + + Create hub/test/pty-door-guard.test.ts implementing every behavior bullet above as its own test() (per D-01, D-02, D-04, D-09, D-10, D-12, D-16). Header comment: what the file pins and why (the three call-site gates are not the door; the door is the hub term.* emitters + the supervisor frame router; precedent human-interactive-flag-guard.test.ts and token-cap-coverage.test.ts). Implementation constraints: DB-free; walk hub/src recursively for .ts files with readdirSync/statSync; normalize relative paths with split on backslash joined by '/'; split source on /\r?\n/; read the supervisor file via join(import.meta.dir, '..', '..', 'supervisor', 'src', 'runners', 'session-bridge.ts'); import CLIENT_TO_HUB_TERM_TYPES from '../src/ws/term-protocol.ts' and EXT_ACTOR, EXT_WORK_ACTOR from '../src/auth/ext-api-key-middleware.ts' (both already imported DB-free elsewhere in hub/test). Pins scan raw text INCLUDING comments; if a pin flags a comment-only mention in a hub/src file, reword that comment in the same commit and record it in the SUMMARY -- never widen an allowlist to accommodate a comment. Do NOT edit any file under supervisor/ (SC-4). No describe.skip / test.skip (D-13). + + + cd hub; bun test test/pty-door-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 14 pass + git diff --name-only $(git merge-base HEAD origin/main) -- supervisor/ + any path is printed (a supervisor file was modified, committed or not, relative to the branch base) + + + - hub/test/pty-door-guard.test.ts exists and contains `ALLOWED_EMITTERS`, `CLIENT_TO_HUB_TERM_TYPES`, `session-bridge.ts`, `EXEMPTIONS`, `REMO_GOVERNED_AUTOMATION_PTY`, `_setGovernorsForTests` + - `grep -c -E "describe\.skip|test\.skip|it\.skip" hub/test/pty-door-guard.test.ts` prints 0 + - `cd hub; bun test test/pty-door-guard.test.ts` exits 0 with at least 14 pass + - temporary negative proof, reverted before commit and recorded in the SUMMARY: adding a line containing a quoted 'term.input' and a getChannel( call to a scratch file under hub/src makes the emitter test fail; deleting the scratch file restores green + + Any future producer that writes term.* frames, calls the preflight, passes an automation actor, or routes user_message/permission_response into the PTY outside the pinned modules fails CI. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser /ws/client -> hub relay | client frames are untrusted JSON; actor must be inferred from the authenticated connection | +| Telegram webhook -> telegram/dispatch.ts | update payload untrusted; the linked-user authentication is the trust anchor | +| hub -> supervisor (/ws/agent frames) | the supervisor trusts hub term.* frames and routes user_message to stream-json; the hub must be the only place automation is gated | +| future code -> PTY | a new producer added in a later phase must not bypass the governed preflight | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-03-12 | Spoofing | actor spoofing into the governed chain via client-asserted source/actor keys | high | mitigate | Task 2 proves zod strips the keys and the preflight runs with actor 'human'; Task 3 pins that no hub/src/dispatch code reads a request source/actor and DispatchRequest has no such field | +| T-03-13 | Spoofing | Telegram default-human source | high | mitigate | Task 1 makes source required, removes the nullish default, passes the literal at the authenticated boundary; missing source fails closed (test); Task 3 pins both facts | +| T-03-14 | Elevation of Privilege | a future producer bypassing the door | high | mitigate | Task 3 emitter allowlist with non-vacuous self-check; caller pins for the preflight function, the automation actor (orchestrator/ only), injectPtyKeystroke and dispatchToSession | +| T-03-15 | Elevation of Privilege | supervisor routing a user_message or permission_response into the PTY | high | mitigate | Task 3 supervisor canary with positive self-checks on all three slices | +| T-03-16 | Elevation of Privilege | external-ask/external-work becoming admissible | critical | mitigate | Task 3 pins the ask/work resolvers return the compile-time constants and their values; eligibility rule tested in 03-01 | +| T-03-17 | Tampering | flag literal / DI setter used outside the governed module | medium | mitigate | Task 3 pins both literals to dispatch/governed-pty.ts | +| T-03-18 | Repudiation | exemptions silently growing | low | mitigate | EXEMPTIONS record has exactly two entries with written justifications; pty-inject.ts must not import the preflight | +| T-03-19 | Spoofing | legacy-JWT /ws/client treated as human (credential identity, not typing) | low | accept | D-15: legacy JWT is the prod login path; documented residual in 03-04; any future bearer-automation lane must use /ws/agent or an ext key | +| T-03-SC | Tampering | package installs | low | accept | no package installs in this plan | + + + +- `cd hub; bun test test/telegram-human-only-guard.test.ts`, `test/telegram-dispatch.test.ts`, `test/ws-client-pty-preflight.test.ts`, `test/term-relay-human-guard.test.ts`, `test/pty-door-guard.test.ts` -- each exits 0, one process per file +- `git diff --name-only $(git merge-base HEAD origin/main) -- supervisor/` prints nothing + + + +- SC-2: spoofed source/actor keys cannot impersonate a human or an automation actor; the Telegram human default is gone; negative tests exist for /ws/client (strip), /ws/agent (existing) and Telegram (missing source). +- The door is pinned: two emitters, supervisor routing canary, privileged-caller allowlists, flag/DI literals, two documented exemptions. + + +## Artifacts this phase produces + +- `DispatchInput.source` in `hub/src/telegram/dispatch.ts` becomes a required field (`source: TelegramDispatchSource;`). +- New test file `hub/test/pty-door-guard.test.ts` with constants `ALLOWED_EMITTERS`, `FRAME_TYPES`, `EXEMPTIONS` (test-local symbols). +- New test cases in `hub/test/ws-client-pty-preflight.test.ts` (client-asserted key stripping) and `hub/test/telegram-human-only-guard.test.ts` (missing source fails closed). + + +Create `.planning/phases/PTYCAP-03-governed-automation-guard/03-03-SUMMARY.md` when done + diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-SUMMARY.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-SUMMARY.md new file mode 100644 index 00000000..3a02d194 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-03-SUMMARY.md @@ -0,0 +1,102 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 03 +subsystem: testing +tags: [pty, telegram, ws-client, door-guard, spoofing, structural-test] +status: complete + +requires: + - phase: PTYCAP-03-governed-automation-guard + provides: 03-01 governed preflight chain + flag/DI module (dispatch/governed-pty.ts); 03-02 human chain pinned governor-free +provides: + - Telegram DispatchInput.source is a required compile-time field; the webhook asserts the literal "human"; a missing source fails closed on a pty-interactive session + - Executed proof that /ws/client strips client-asserted source/actor keys and preflights as the server-inferred human + - hub/test/pty-door-guard.test.ts (21 tests): emitter allowlist, supervisor routing canary, privileged-caller pins, flag/DI pins, actor-inference pins, Telegram pins, exemption pins +affects: [PTYCAP-03-04, orchestrator automation lane, any future term.* producer] + +actuals: + tokens: 5100 + tasks: 3 + commits: 3 +plan_head_before: 627cbe9e9257ac8e595a09087a10dd83bef931db + +tech-stack: + added: [] + patterns: + - "DB-free structural source-scan tests with non-vacuous scanner self-checks (human-interactive-flag-guard / token-cap-coverage precedent)" + - "Required-at-compile-time actor/source param asserted at the authenticated boundary; no nullish default" + +key-files: + created: + - hub/test/pty-door-guard.test.ts + modified: + - hub/src/telegram/dispatch.ts + - hub/src/api/telegram-webhook.ts + - hub/src/telegram/doctor.ts + - hub/test/telegram-human-only-guard.test.ts + - hub/test/telegram-dispatch.test.ts + - hub/test/ws-client-pty-preflight.test.ts + +key-decisions: + - "Telegram source has no default: missing source is refused on a pty-interactive session (D-09 iv)" + - "attach_file and injectPtyKeystroke stay preflight-exempt, pinned with written justifications (D-10)" + - "No allowlist widening: dispatchToSession( pin needed no doctor.ts entry because doctor.ts only references the symbol (import / typeof / default), never calls it textually" + +requirements-completed: [SC-2, SC-1] +completed: 2026-10-13 +--- + +# Phase PTYCAP-03 Plan 03: Pin the PTY door and close the spoofing surface Summary + +Telegram dispatch source is now required with no human default, /ws/client actor-key stripping is an executed fact, and one DB-free test file pins every hub->agent term.* emitter, the supervisor routing, every privileged caller, the flag/DI literals and the two preflight exemptions. + +## Tasks and commits + +| Task | Name | Commit | Files | +| ---- | ---- | ------ | ----- | +| 1 | Telegram source required, fail closed when missing | 87d92c8 `feat(PTYCAP-03-03): make Telegram dispatch source required, fail closed when missing` | hub/src/telegram/dispatch.ts, hub/src/api/telegram-webhook.ts, hub/src/telegram/doctor.ts, hub/test/telegram-human-only-guard.test.ts, hub/test/telegram-dispatch.test.ts | +| 2 | /ws/client strips client-asserted actor keys | 8f5cfad `test(PTYCAP-03-03): prove /ws/client strips client-asserted actor keys and preflights as human` | hub/test/ws-client-pty-preflight.test.ts | +| 3 | Door pins | 9e7d93e `test(PTYCAP-03-03): pin the PTY door -- term.* emitters, supervisor routing, privileged callers, flag/DI, exemptions` | hub/test/pty-door-guard.test.ts | + +Task 1 was executed in an earlier session (replay in `telegram/doctor.ts` passes `source: 'human'` through its injected `dispatch` param for the buffered authenticated-webhook message). Tasks 2 and 3 in this session. + +## Verification (one bun process per file) + +| File | Result | +| ---- | ------ | +| test/telegram-human-only-guard.test.ts | 6 pass, 0 fail | +| test/telegram-dispatch.test.ts | 6 pass, 0 fail | +| test/ws-client-pty-preflight.test.ts | 24 pass, 0 fail (22 existing + 2 new) | +| test/term-relay-human-guard.test.ts | 3 pass, 0 fail; file byte-unchanged (D-09 iii agent-socket spoof case intact) | +| test/pty-door-guard.test.ts | 21 pass, 0 fail (plan floor 14) | + +`git diff --name-only $(merge-base HEAD origin/main) -- supervisor/` prints nothing (SC-4 holds; the canary only READS supervisor/src/runners/session-bridge.ts). + +`bunx tsc --noEmit -p tsconfig.json`: 427 errors, ALL pre-existing and unrelated to this plan (src/api/_openapi.ts zod-openapi overloads, src/telegram/doctor.ts L100/L206 bigint/RowList casts, `unknown` body typings across hub/test). None in telegram/dispatch.ts, api/telegram-webhook.ts, or any test file this plan touched; no missing-`source` error anywhere, i.e. the required field type-checks at every call site. The tsc gate is not green repo-wide independent of this plan; flagged for the orchestrator. + +## Negative proof (temporary, reverted) + +Created scratch `hub/src/zz-scratch-emitter.ts` containing a quoted `'term.input'` and a `getChannel("s")` call. `bun test test/pty-door-guard.test.ts` -> 20 pass, 1 FAIL: "no emitter outside the allowlist" listing `zz-scratch-emitter.ts`. Deleted the scratch file -> 21 pass, 0 fail. `git status` clean of the scratch file; nothing of it committed. + +## Deviations from Plan + +None - plan executed as written. + +Notes (not deviations): +- Door-pin detection re-run at implementation time found no comment-only hits from plans 03-01/03-02; no comment rewording was needed and no allowlist was widened. +- The executor brief anticipated that `telegram/doctor.ts` might need adding to the `dispatchToSession(` caller pin. It does not: doctor.ts references `dispatchToSession` only as an import, a `typeof` type and a default value (no textual `dispatchToSession(`), so the pin stays at exactly {telegram/dispatch.ts, api/telegram-webhook.ts}. If doctor.ts ever gains a literal call, add it with the justification "buffered replay of an authenticated-webhook message". +- Commit-count ledger: the on-disk ledger `gsd-plan-head-before-PTYCAP-03-03` holds a pre-rebase SHA (b492904, not an ancestor of HEAD; `rev-list` yields a meaningless 18). `commits: 3` and `plan_head_before` are measured against the true pre-task-1 parent 627cbe9 (`git rev-list --count 627cbe9..HEAD` = 3). + +## Known Stubs + +None. + +## Threat Flags + +None - no new network endpoints, auth paths or schema changes. Threats mitigated: T-03-12 (key strip proven, no dispatch code reads request source/actor), T-03-13 (Telegram default-human removed), T-03-14 (emitter + caller pins), T-03-15 (supervisor canary), T-03-16 (EXT constants pinned), T-03-17 (flag/DI literals confined), T-03-18 (EXEMPTIONS pinned at exactly two). T-03-19 accepted per D-15 (legacy JWT stays human; residual documented in 03-04). + +## Self-Check: PASSED + +- hub/test/pty-door-guard.test.ts: FOUND +- Commits 87d92c8, 8f5cfad, 9e7d93e: FOUND on branch phase-ptycap-03-governed-automation-guard +- STATE.md / ROADMAP.md intentionally not touched (orchestrator-owned) diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-PLAN.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-PLAN.md new file mode 100644 index 00000000..3c391d8a --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-PLAN.md @@ -0,0 +1,284 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 04 +type: execute +wave: 4 +depends_on: ["03-01", "03-02", "03-03"] +files_modified: + - docs/usage-cost.md + - CLAUDE.md + - .planning/ROADMAP.md + - docs/session-ask.md + - docs/remo-work.md + - docs/scheduled-tasks.md + - docs/telegram-bridge.md + - .planning/architecture/SESSION-ASK-API-SPEC.md + - hub/src/api/_openapi.ts + - hub/src/api/ext.ts + - hub/src/auth/ext-api-key-middleware.ts + - hub/src/ws/client.ts + - docs/openapi.json + - docs/api.md + - tools/regression-baseline.json +autonomous: true +requirements: [SC-4, SC-1, SC-2, SC-3] +estimate: + tokens: 75000 + raw_tokens: 75000 + tasks: 4 + confidence: low +must_haves: + truths: + - "docs/usage-cost.md has a 'PTYCAP Phase 3' section documenting the flag, the gate, the eligible-actor rule, the four governors with order and reason strings, the automation-only preflight branch, the door pins, the two exemptions (with the documented non-hole inconsistency), the honest-scope statement and the residuals (D-11, D-10, D-15, D-12)" + - "docs/usage-cost.md no longer claims the runner_type gate is why automation cannot reach the PTY; it names the structural reason (user_message goes to the stream-json runner; the PTY takes only term.* frames) and states Telegram plain text goes via user_message (D-11, Phase-2 MINOR-2)" + - "docs-supersession.test.ts still passes (R-PTY-24 supersession strings intact) (D-11)" + - "CLAUDE.md documents REMO_GOVERNED_AUTOMATION_PTY (default OFF, 1|true|yes|on, true no-op when off, ON still admits nothing until Phase 4) and no longer says the token cap fails open" + - "No hub/src file and no docs/*.md or CLAUDE.md line names the old gate except as an explicit 'formerly'/'renamed' note; the drifted name governedAutomationGate is gone from docs and the SESSION-ASK spec (D-05)" + - "OpenAPI reason descriptions cite governedAutomationPtyGate; bun run docs:sync leaves no drift (D-08)" + - "ROADMAP Phase 4 records that it replaces the fail-closed kill_switch/lifetime_counter slots, that an admitted automation PTY turn must be counted before the chain is trusted, and that reserve-before-send consumption is Phase 4's design; SC text of Phases 3 and 4 is unchanged (D-02, D-12, D-14)" + - "bun run check-baseline reports fail=0 and skip<=260 (baseline skip 259, no new skips) (D-13)" + - "The five supervisor no-API-key / argv-allowlist guard test files pass and no file under supervisor/ changed relative to the branch base (SC-4)" + artifacts: + - path: docs/usage-cost.md + provides: "PTYCAP Phase 3 section + Phase-2 doc corrections" + contains: "PTYCAP Phase 3" + - path: tools/regression-baseline.json + provides: "re-measured informational counts + _skip_note_ptycap_phase3" + contains: "_skip_note_ptycap_phase3" + key_links: + - from: hub/src/api/_openapi.ts + to: docs/openapi.json + via: "bun run docs:sync regenerates docs/openapi.json and docs/api.md" + pattern: "governedAutomationPtyGate" + prohibitions: + - statement: "Do not edit ROADMAP SC text for Phase 3 or Phase 4 (SC-1 is scored structurally met)" + status: recalled + verification: backstop + - statement: "Do not set or document a production value for REMO_GOVERNED_AUTOMATION_PTY (owner-only live cutover)" + status: recalled + verification: backstop + - statement: "Do not edit any file under supervisor/ (SC-4: guards pass UNCHANGED)" + status: recalled + verification: backstop +--- + + +Close the phase: document the governed-automation guard where PTYCAP lives (docs/usage-cost.md) and in CLAUDE.md, correct the Phase-2 doc mechanism claims, finish the rename across docs/comments/OpenAPI, record the Phase-4 hand-off in ROADMAP, then run the full gate (check-baseline, docs:sync drift, schema-lint, the five supervisor guards unchanged) and re-measure the regression baseline. + +Purpose: SC-4 (no-API-key and argv-allowlist guards pass unchanged) plus durable documentation of SC-1..SC-3. Implements D-02 (ROADMAP note), D-05 (docs rename), D-08 (OpenAPI + termRefusal check), D-10/D-15 (documented exemptions and residuals), D-11 (doc fold-ins), D-12/D-14 (Phase-4 entry criteria), D-13 (no new skips). +Output: docs, comments, regenerated OpenAPI artifacts, re-measured baseline. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-01-SUMMARY.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-02-SUMMARY.md +@.planning/phases/PTYCAP-03-governed-automation-guard/03-03-SUMMARY.md +@docs/usage-cost.md +@CLAUDE.md + +Baseline facts (measured by the orchestrator on the untouched tree): `bun run check-baseline` pass=2304 skip=259 fail=1, the 1 failure being hub/test/human-interactive-flag-guard.test.ts (CRLF), fixed in plan 03-02. Target after this phase: fail=0, skip<=260 (expected 259). tools/regression-baseline.json informational fields currently read pass=2273 skip=259 total=2532 (stale vs the measured 2304). + +ASCII-only punctuation in every line this plan adds (use "->" and "--"; no curly quotes, no unicode arrows/dashes). Existing non-ASCII text in edited files may stay untouched. + + + + + + Task 1: usage-cost.md Phase 3 section + Phase-2 corrections, CLAUDE.md env/invariant/docs-map, ROADMAP Phase-4 hand-off + docs/usage-cost.md, CLAUDE.md, .planning/ROADMAP.md + + - docs/usage-cost.md (L346-500: the whole PTYCAP Phase 2 section, especially L373-377 and L455-468; L113-123 R-PTY-19 routing invariant) + - hub/test/docs-supersession.test.ts (strings it requires in docs/usage-cost.md) + - CLAUDE.md (Environment Variables section from L182; the REMO_ORCHESTRATOR_DAILY_TOKEN_CAP stale fail-open sentence around L265; docs-map rows L392-395; Cross-cutting invariants from L402) + - .planning/ROADMAP.md (L95-115 Phase 3 and Phase 4 sections) + - hub/src/dispatch/governed-pty.ts and hub/test/pty-door-guard.test.ts (facts to document; read, do not edit) + + + + (1) docs/usage-cost.md, per D-11: rewrite the paragraph at L373-377 to state that no automation path reaches the PTY because every automation dispatch adapter sends `user_message`, which the supervisor routes to the stream-json runner (supervisor/src/runners/session-bridge.ts), while the PTY accepts only `term.*` frames whose only hub writers are the web relay and Telegram permission keystrokes; note that keying on `sessions.runner_type` is dead in prod (client.ts comment) and is NOT the door; point to the Phase 3 section. Rewrite the Telegram plain-text bullet at L462-465 to say it sends `user_message` (stream-json runner, not the PTY) and runs threshold/cost/token caps plus governedAutomationPtyGate. Replace the "Phase 3 admission ... is NOT built here" bullet with a pointer to the Phase 3 section. Do not remove or reword any sentence containing R-PTY-24, R-TG-01, 'supersed', 'transcript', 'read-only', 'programmatic' or 'api key' (docs-supersession.test.ts). + Then append a new top-level section `## PTYCAP Phase 3 -- governed-automation guard (hub/src/dispatch/governed-pty.ts)` after the Phase 2 Tests list, with subsections: Flag (`REMO_GOVERNED_AUTOMATION_PTY`, call-time, 1|true|yes|on, anything else OFF, default OFF = true no-op proven by the OFF==legacy differential test); The gate (`governedAutomationPtyGate`, name `governed_automation_pty`, formerly the human-only PTY gate, composed at ask/work/telegram dispatch; decision order; eligible actors = AUTOMATION_ACTORS only; external-ask/external-work/unknown always denied); Governors (table: order kill_switch, token_cap, inject_rate, lifetime_counter; Phase-3 body and block reason for each: governor_not_implemented:kill_switch, governor_unconfigured:token_cap when REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED is truthy, governor_unconfigured:inject_rate when REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR <= 0, governor_not_implemented:lifetime_counter; cost cap is NOT a governor; governors are non-consuming; DI only via _setGovernorsForTests; with the flag ON Phase 3 production admits NOTHING by design); The door (checkPtyTurnPreflight automation branch: flag first, then spend chain, then governors; reasons automation_blocked_on_pty:[:]; throw -> pty_preflight_error, hang -> pty_preflight_timeout; the human chain never runs a governor); Door pins (hub/test/pty-door-guard.test.ts: two term.* emitters, supervisor canary, caller pins incl. orchestrator-only automation caller); Exemptions (Telegram permission-answer keystrokes via injectPtyKeystroke and term.attach_file, one-line justification each, plus the non-hole inconsistency that the web relay would refuse a permission-answer Enter over cap while Telegram would not, D-10); Honest scope (the three call-site gates are not the door; SC-1 is met structurally; admit path proven only under test DI); Residuals and hand-offs (D-15 cookie/legacy-JWT => human is credential identity, not typing, and any future bearer-automation lane must use /ws/agent or an ext key; D-12 an admitted automation PTY turn must be counted before the chain is trusted -- Phase 4/5 entry criterion; D-14 reserve-before-send is Phase 4); Tests (governed-pty-gate, pty-preflight additions, pty-door-guard, ws-client strip case, telegram missing-source case). + (2) CLAUDE.md: in Environment Variables add a bullet for **`REMO_GOVERNED_AUTOMATION_PTY`** (default OFF; accepts 1|true|yes|on, anything else OFF; read at call time by `governedAutomationEnabled()` in hub/src/dispatch/governed-pty.ts; OFF = byte-identical legacy human-only decision and the automation PTY preflight refuses everything; ON in PTYCAP Phase 3 still admits nothing because the kill_switch and lifetime_counter slots fail closed until Phase 4; flipping it in prod is an owner-only cutover; see docs/usage-cost.md PTYCAP Phase 3). Fix the stale REMO_ORCHESTRATOR_DAILY_TOKEN_CAP sentence that says non-positive/non-finite disables the cap (fail-open): it falls back to the default and the hub refuses to boot; only REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED=1 disables it. In the docs map add a row `Governed-automation PTY guard` -> docs/usage-cost.md with a one-line summary (flag default OFF, governedAutomationPtyGate, four governors with two fail-closed Phase-4 slots, door pins in hub/test/pty-door-guard.test.ts). In the Session-Ask API row replace the old gate name with governedAutomationPtyGate and 'human-only-PTY' with 'governed-automation PTY guard (external actors never eligible)'. In Cross-cutting invariants add one bullet: automation reaches a PTY only through the governed preflight (checkPtyTurnPreflight automation branch, flag-gated, governors fail closed); the human chain never runs a governor; new hub->agent term.* emitters or privileged callers fail hub/test/pty-door-guard.test.ts. + (3) .planning/ROADMAP.md, per D-02/D-12/D-14: under the Phase 4 section, after its Success Criteria list, add a short ASCII-only note block: "Hand-off from Phase 3: Phase 4 replaces the fail-closed `kill_switch` and `lifetime_counter` governor bodies in hub/src/dispatch/governed-pty.ts (today they block `governor_not_implemented:`). Entry criteria: an admitted automation PTY turn must be COUNTED (durable ledger) before the governed chain is trusted; Phase-3 governors are non-consuming checks -- atomic reserve-before-send consumption is Phase 4's design." Do NOT change any Success Criteria text in Phase 3 or Phase 4 and do not change phase checkboxes (the orchestrator owns them). + + + cd hub; bun test test/docs-supersession.test.ts + non-zero exit, or any fail in the summary, or a '0 pass' summary + grep -c "PTYCAP Phase 3" docs/usage-cost.md + prints 0 (the section heading is missing) + grep -c "REMO_GOVERNED_AUTOMATION_PTY" CLAUDE.md + prints 0 (the env flag is undocumented) + + + - docs/usage-cost.md contains `## PTYCAP Phase 3`, `governor_not_implemented:kill_switch`, `governor_unconfigured:inject_rate`, `user_message`, `injectPtyKeystroke`, `term.attach_file` + - `grep -n "reject every automation" docs/usage-cost.md` prints nothing (the over-claim sentence is rewritten) + - CLAUDE.md contains `REMO_GOVERNED_AUTOMATION_PTY` and `Governed-automation PTY guard`; `grep -n "disabled/fail-open" CLAUDE.md` prints nothing + - .planning/ROADMAP.md contains `governor_not_implemented` and `reserve-before-send`; `git diff -U0 .planning/ROADMAP.md` shows only added lines (no removed or modified lines) + - `cd hub; bun test test/docs-supersession.test.ts` exits 0 + + The PTYCAP home doc, CLAUDE.md and ROADMAP describe Phase 3 accurately, including the honest scope and the Phase-4 hand-off. + + + + Task 2: Finish the rename across the remaining docs and the SESSION-ASK spec + docs/session-ask.md, docs/remo-work.md, docs/scheduled-tasks.md, docs/telegram-bridge.md, .planning/architecture/SESSION-ASK-API-SPEC.md + + - docs/session-ask.md (L20-35 and L150-160) + - docs/remo-work.md (L35-45, L115-125, L240-252) + - docs/scheduled-tasks.md (L395-405) + - docs/telegram-bridge.md (L105-120) + - .planning/architecture/SESSION-ASK-API-SPEC.md (L45-52, L70-76, L160-175, L220-235, L290-298, L318-323) + + + + + Per D-05: in docs/session-ask.md, docs/remo-work.md, docs/scheduled-tasks.md and docs/telegram-bridge.md, replace every present-tense reference to the old gate name with `governedAutomationPtyGate` and, at the first mention in each file, add the parenthetical "(formerly humanOnlyPtyGate; flag REMO_GOVERNED_AUTOMATION_PTY, default OFF; external-ask/external-work are never PTY-eligible)". Where docs/session-ask.md uses the drifted name governedAutomationGate (around L155), replace it with governedAutomationPtyGate. In docs/telegram-bridge.md (~L114) also state that the dispatch source is a required parameter and the webhook passes 'human' (no default). In .planning/architecture/SESSION-ASK-API-SPEC.md replace both drifted-name occurrences (around L232 and L295) with governedAutomationPtyGate and rename the old gate in the present-tense gate-list lines (around L167 and L172); leave the historical narrative lines but add "(renamed governedAutomationPtyGate in PTYCAP Phase 3)" after the first remaining old-name mention. ASCII-only additions. + + + git grep -n governedAutomationGate -- docs .planning/architecture CLAUDE.md + any line is printed (the drifted name survives); note git grep exits 1 when nothing matches, which is the PASS case + git grep -n humanOnlyPtyGate -- docs CLAUDE.md + any printed line lacks the word "formerly" or "renamed" (a present-tense reference to the old name survives) + + + - each of docs/session-ask.md, docs/remo-work.md, docs/scheduled-tasks.md, docs/telegram-bridge.md, .planning/architecture/SESSION-ASK-API-SPEC.md contains `governedAutomationPtyGate` + - `git grep -n governedAutomationGate -- docs .planning/architecture CLAUDE.md` prints nothing + - every line printed by `git grep -n humanOnlyPtyGate -- docs CLAUDE.md` contains `formerly` or `renamed` + + No doc presents the old gate as current or uses the drifted name. + + + + Task 3: OpenAPI reason descriptions, remaining source comments, docs:sync, termRefusal check + hub/src/api/_openapi.ts, hub/src/api/ext.ts, hub/src/auth/ext-api-key-middleware.ts, hub/src/ws/client.ts, docs/openapi.json, docs/api.md + + - hub/src/api/_openapi.ts (L840-850 and L1050-1060 reason descriptions) + - hub/src/api/ext.ts (L225-235 comment) + - hub/src/auth/ext-api-key-middleware.ts (L8-35 comments) + - hub/src/ws/client.ts (L165-175 relay comment block) + - web/src/lib/termRefusal.ts (whole file, read only) + - hub/test/pty-door-guard.test.ts (pins that scan these source files' raw text) + + + + Per D-08 and D-05: + (1) hub/src/api/_openapi.ts: in the two reason-list descriptions change `automation_blocked_on_pty:external-ask` to `automation_blocked_on_pty:external-ask (governedAutomationPtyGate -- an external actor is never eligible to drive a pty-interactive session)` and the work one likewise with `external-work`. No other OpenAPI change (no new route in this phase). + (2) Comment-only renames to governedAutomationPtyGate: hub/src/api/ext.ts (~L230), hub/src/auth/ext-api-key-middleware.ts (~L13 and ~L29), hub/src/ws/client.ts (~L171: say the relay applies the shared humanOnlyRejectsActor decision, which is the legacy rule governedAutomationPtyGate applies when its flag is OFF; the relay actor is always the server-inferred human). Do not change any code line in these three files; new comment text must not add a pinned token (no env-var literal, no preflight call syntax, no actor property assignment, no term frame-type literal next to a send). + (3) Run `bun run docs:sync` from the repo root to regenerate docs/openapi.json and docs/api.md; commit both generated files. + (4) web/src/lib/termRefusal.ts: verify (do not edit) that no mapping is needed -- the term-channel send_refused only carries HUMAN-chain reasons, and plan 03-02 pins that the human chain never runs a governor, so no governed reason can reach the web terminal; record this finding in the SUMMARY. + + + bun run docs:sync + non-zero exit, or docs/openapi.json does not contain the string governedAutomationPtyGate afterwards + git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src + any line is printed (code or comment; the rename is total in hub/src per D-05, including comments); git grep exits 1 on no match, which is the PASS case + cd hub; bun test test/pty-door-guard.test.ts + non-zero exit, or any fail in the summary (a comment edit tripped a door pin) + + + - hub/src/api/_openapi.ts contains `governedAutomationPtyGate` exactly in the two reason descriptions + - docs/openapi.json and docs/api.md contain `governedAutomationPtyGate` + - `git diff --quiet -- web/src/lib/termRefusal.ts` exits 0 (unchanged by design) + - `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src` prints nothing + + The rename is total in hub/src; OpenAPI reflects the governed gate with no drift; the web refusal mapping is verified unaffected. + + + + Task 4: Phase gate -- check-baseline, supervisor guards unchanged, schema-lint, no docs drift, re-measured baseline + tools/regression-baseline.json + + - tools/regression-baseline.json (top-level pass/skip/total and the existing _skip_note_* conventions; tolerance block) + - .planning/phases/PTYCAP-03-governed-automation-guard/03-VALIDATION.md (sampling contract) + + + Run the full gate from the repo root, foreground, one command at a time with explicit timeouts (check-baseline can take several minutes): + (1) `bun run check-baseline` -- must report fail 0 and skip <= 260 (expected 259; D-13: zero new skips). If any test fails, STOP and fix the owning plan's code on the merits (never skip or loosen a test). + (2) The five SC-4 supervisor guard files, each in its own process from the repo root: `bun test supervisor/test/no-api-key-no-streamjson-pty.test.ts`, `bun test supervisor/test/no-apikey-fallback-guard.test.ts`, `bun test supervisor/test/default-backend-selector.test.ts`, `bun test supervisor/test/pty-spawn-interception.test.ts`, `bun test supervisor/test/no-legacy-agent-spawn.test.ts`; then `git diff --name-only $(git merge-base HEAD origin/main) -- supervisor/` must print nothing. + (3) `bun run schema-lint` (this phase adds no DDL; must still print OK). + (4) `bun run docs:sync` then `git status --porcelain -- docs/openapi.json docs/api.md` must print nothing (no drift after Task 3's commit). + (5) `bunx tsc --noEmit -p hub/tsconfig.json` (informational in CI): confirm no error line references hub/src/dispatch/governed-pty.ts, hub/src/dispatch/pty-preflight.ts, hub/src/telegram/dispatch.ts or hub/src/api/telegram-webhook.ts (the required Telegram source is a compile-time contract; a pre-existing unrelated error elsewhere is recorded in the SUMMARY, not fixed here). + (6) tools/regression-baseline.json: set the informational `pass`, `skip`, `total` to the values measured in step 1 and add a key `_skip_note_ptycap_phase3` with an honest accounting: files added (hub/test/governed-pty-gate.test.ts, hub/test/pty-door-guard.test.ts) with their pass counts, cases added to pty-preflight, ws-client-pty-preflight, telegram-human-only-guard and ext-ask-gates, the CRLF fix turning the 1 local failure into a pass, the measured untouched-tree starting point pass=2304 skip=259 fail=1, and "zero new skips". Leave `tolerance` (pass_min 1850, skip_max 260, fail_max 0) unchanged. + + + bun run check-baseline + non-zero exit, or the printed summary shows fail greater than 0, or skip greater than 260 + bun test supervisor/test/no-api-key-no-streamjson-pty.test.ts + non-zero exit, or any fail in the summary, or fewer than 5 pass + bun test supervisor/test/no-apikey-fallback-guard.test.ts + non-zero exit, or any fail in the summary, or fewer than 7 pass + bun test supervisor/test/default-backend-selector.test.ts + non-zero exit, or any fail in the summary, or fewer than 22 pass + bun test supervisor/test/pty-spawn-interception.test.ts + non-zero exit, or any fail in the summary, or fewer than 3 pass + bun test supervisor/test/no-legacy-agent-spawn.test.ts + non-zero exit, or any fail in the summary, or fewer than 1 pass + git diff --name-only $(git merge-base HEAD origin/main) -- supervisor/ + any path is printed + bun run schema-lint + non-zero exit, or output lacks the word OK + + + - `bun run check-baseline` exits 0 with fail 0 and skip <= 260 + - all five supervisor guard files exit 0 with the pass counts above; no supervisor/ path differs from the branch base + - `bun run schema-lint` prints OK + - after `bun run docs:sync`, `git status --porcelain -- docs/openapi.json docs/api.md` prints nothing + - tools/regression-baseline.json contains `_skip_note_ptycap_phase3` and its `tolerance.skip_max` is still 260 + + The phase is green end-to-end on this Windows worktree: hub suite fail 0 with no new skips, SC-4 guards pass unchanged, no docs drift, baseline re-measured and explained. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| docs -> operators and future agents | wrong mechanism claims lead to a wrong prod flag flip or a producer that bypasses the door | +| OpenAPI -> external API consumers | reason strings are a published contract | +| supervisor PTY spawn path -> provider credentials | the no-API-key invariant must not regress | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-03-20 | Elevation of Privilege | no-API-key / argv allowlist on the PTY path | critical | mitigate | Task 4 runs the five supervisor guard files unchanged and proves no supervisor/ diff vs the branch base | +| T-03-21 | Tampering | operator flips REMO_GOVERNED_AUTOMATION_PTY believing it is safe or useful | medium | mitigate | CLAUDE.md and usage-cost.md state default OFF, owner-only cutover, and that ON admits nothing until Phase 4 | +| T-03-22 | Information Disclosure / Repudiation | stale docs claiming the runner_type gate is the door | medium | mitigate | Task 1 rewrites the Phase-2 over-claim with the structural reason and documents the honest scope | +| T-03-23 | Repudiation | silent skip or failure laundering in the suite | medium | mitigate | Task 4 requires fail 0, skip <= 260 and an honest _skip_note_ptycap_phase3 accounting; tolerance unchanged | +| T-03-24 | Tampering | docs:sync drift failing docs-drift CI | low | mitigate | Task 3 regenerates; Task 4 re-runs and requires no porcelain diff | +| T-03-SC | Tampering | npx widdershins@4.0.1 fetched by the existing docs:sync script | low | accept | existing pinned repo tooling already used by docs-drift CI; this phase adds no new package or version | + + + +- `bun run check-baseline` fail 0, skip <= 260 +- five supervisor guard files green, no supervisor/ diff vs merge-base +- `bun run schema-lint` OK; `bun run docs:sync` leaves no drift +- `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src` prints nothing + + + +- SC-4: no-API-key and argv-allowlist guard tests pass unchanged. +- Documentation of SC-1..SC-3 is accurate, including the honest-scope statement and Phase-4 hand-off. +- Full hub suite green on Windows (the Phase-2 CRLF false red is gone) with zero new skips. + + +## Artifacts this phase produces + +- New doc section `## PTYCAP Phase 3 -- governed-automation guard` in `docs/usage-cost.md`. +- New CLAUDE.md env bullet `REMO_GOVERNED_AUTOMATION_PTY`, docs-map row `Governed-automation PTY guard`, and a cross-cutting invariant bullet. +- ROADMAP Phase 4 hand-off note (no SC text change). +- Regenerated `docs/openapi.json` and `docs/api.md` (reason descriptions cite `governedAutomationPtyGate`). +- New baseline key `_skip_note_ptycap_phase3` in `tools/regression-baseline.json`. + + +Create `.planning/phases/PTYCAP-03-governed-automation-guard/03-04-SUMMARY.md` when done + diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-SUMMARY.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-SUMMARY.md new file mode 100644 index 00000000..5a2feefb --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-04-SUMMARY.md @@ -0,0 +1,111 @@ +--- +phase: PTYCAP-03-governed-automation-guard +plan: 04 +subsystem: docs +tags: [pty, governed-automation, docs, openapi, regression-baseline, supervisor-guards] +status: complete + +requires: + - phase: PTYCAP-03-governed-automation-guard + provides: 03-01 governed-pty module, 03-02 preflight automation branch, 03-03 door pins and Telegram source +provides: + - docs/usage-cost.md "PTYCAP Phase 3" section plus Phase-2 mechanism corrections (structural user_message routing, Telegram plain text via user_message) + - CLAUDE.md REMO_GOVERNED_AUTOMATION_PTY env bullet, docs-map row, cross-cutting invariant, token-cap fail-open sentence fixed + - ROADMAP Phase 4 hand-off note (no SC text or checkbox change) + - humanOnlyPtyGate -> governedAutomationPtyGate rename finished across docs, SESSION-ASK spec and hub/src comments + - Regenerated OpenAPI artifacts and re-measured regression baseline with phase-3 accounting +affects: [PTYCAP-04, PTYCAP-05] + +actuals: + tokens: 4500 + tasks: 4 + commits: 4 +plan_head_before: a7c2e3a + +tech-stack: + added: [] + patterns: + - "Honest-scope documentation: name which gates are NOT the door and what is proven only under test DI" + +key-files: + created: [] + modified: + - docs/usage-cost.md + - CLAUDE.md + - .planning/ROADMAP.md + - docs/session-ask.md + - docs/remo-work.md + - docs/scheduled-tasks.md + - docs/telegram-bridge.md + - .planning/architecture/SESSION-ASK-API-SPEC.md + - hub/src/api/_openapi.ts + - hub/src/api/ext.ts + - hub/src/auth/ext-api-key-middleware.ts + - hub/src/ws/client.ts + - docs/openapi.json + - docs/api.md + - tools/regression-baseline.json + +key-decisions: + - "No governed reason can reach the web terminal, so web/src/lib/termRefusal.ts needs no mapping (left unedited)" + - "Baseline informational counts re-measured to pass=2378 skip=259 total=2637; tolerance block untouched" + +requirements-completed: [SC-4, SC-1, SC-2, SC-3] +completed: 2026-10-13 +--- + +# Phase PTYCAP-03 Plan 04: Docs close-out and full phase gate Summary + +The governed-automation guard is documented where PTYCAP lives (with the honest-scope statement and Phase-4 hand-off), the rename is total in docs and hub/src, and the full gate is green: check-baseline fail 0 / skip 259, all five SC-4 supervisor guards pass with no supervisor/ diff. + +## Tasks and commits + +| Task | Name | Commit | Files | +| ---- | ---- | ------ | ----- | +| 1 | usage-cost Phase 3 section, Phase-2 corrections, CLAUDE.md, ROADMAP hand-off | 5676b12 `docs(PTYCAP-03-04): document governed-automation guard, correct Phase-2 mechanism claims, Phase-4 hand-off` | docs/usage-cost.md, CLAUDE.md, .planning/ROADMAP.md | +| 2 | Finish rename across remaining docs and SESSION-ASK spec | da575ed `docs(PTYCAP-03-04): finish humanOnlyPtyGate -> governedAutomationPtyGate rename ...` | docs/session-ask.md, docs/remo-work.md, docs/scheduled-tasks.md, docs/telegram-bridge.md, .planning/architecture/SESSION-ASK-API-SPEC.md | +| 3 | OpenAPI reason descriptions, source comments, docs:sync | a7aa1d7 `docs(PTYCAP-03-04): OpenAPI reason descriptions cite governedAutomationPtyGate; finish rename in hub/src comments` | hub/src/api/_openapi.ts, hub/src/api/ext.ts, hub/src/auth/ext-api-key-middleware.ts, hub/src/ws/client.ts, docs/openapi.json, docs/api.md | +| 4 | Re-measured regression baseline | bf56f4f `chore(PTYCAP-03-04): re-measure regression baseline (pass 2378 skip 259 fail 0) with phase-3 accounting` | tools/regression-baseline.json | + +## Phase gate results + +| Gate | Result | +| ---- | ------ | +| `bun run check-baseline` | exit 0, "OK within tolerance": pass=2378 skip=259 fail=0 total=2637 (untouched-tree start pass=2304 skip=259 fail=1; net +74 pass, 0 new skips; the 1 CRLF failure is now a pass) | +| Supervisor guards (each own process) | no-api-key-no-streamjson-pty 5 pass; no-apikey-fallback-guard 7 pass; default-backend-selector 22 pass; pty-spawn-interception 3 pass; no-legacy-agent-spawn 1 pass; 0 fail anywhere | +| `git diff --name-only $(merge-base HEAD origin/main) -- supervisor/` | prints nothing (SC-4: guards pass unchanged) | +| `bun run schema-lint` | OK, no un-pragma'd mutating statements (5 allowed) | +| `bun run docs:sync` then `git status --porcelain -- docs/openapi.json docs/api.md` | prints nothing (no drift) | +| `git grep -e humanOnlyPtyGate -e human_only_pty -- hub/src` | prints nothing | +| `git grep governedAutomationGate -- docs .planning/architecture CLAUDE.md` | prints nothing | +| `git grep humanOnlyPtyGate -- docs CLAUDE.md` minus lines with formerly/renamed | 0 lines | +| `hub/test/docs-supersession.test.ts` | 4 pass | +| `hub/test/pty-door-guard.test.ts` after comment edits | 21 pass | +| `git diff -U0 .planning/ROADMAP.md` | added lines only (the Phase-4 hand-off paragraph) | +| tsc (`cd hub; bunx tsc --noEmit -p tsconfig.json`) | 427 errors before and after; sorted error lines identical (no new error by file:line). The only governed-pty/preflight/telegram-matching lines are pre-existing errors in test/telegram-webhook.test.ts; none in hub/src/dispatch/governed-pty.ts, dispatch/pty-preflight.ts, telegram/dispatch.ts or api/telegram-webhook.ts. | + +## termRefusal check (Task 3 step 4) + +`web/src/lib/termRefusal.ts` maps only over_daily_cost_cap, over_daily_token_cap, quota_threshold_reached, pty_preflight_timeout/error, term_backpressure and not_current_writer, and falls back to `Prompt not sent ()`. The term-channel `send_refused` only carries human-chain reasons and plan 03-02 pins that the human chain never runs a governor, so no `automation_blocked_on_pty:*` governed reason can reach the web terminal. No mapping needed; file unedited. + +## Deviations from Plan + +None - plan executed as written, with these notes (not deviations): +- CLAUDE.md edits were applied with a byte-preserving script for three single-token replacements (file is CRLF in the working tree); line endings verified consistent. The Session-Ask row rename also covered the Inbound-email (`remo_work`) row's "human-only-PTY" wording so no stale present-tense gate name remains. +- docs/telegram-bridge.md: one additional line ("human-only-PTY" in the pipeline ordering sentence) was updated to "governed-automation PTY guard", and the required-source sentence was added next to the guard paragraph. +- The ledger file `gsd-plan-head-before-PTYCAP-03-04` was not created before the first commit; `plan_head_before` is the spawn-time HEAD a7c2e3a and `commits: 4` is `git rev-list --count a7c2e3a..HEAD` measured at SUMMARY write (before the SUMMARY's own commit). +- `_skip_note_ptycap_phase3` itemises 68 of the +74 new passes (28 governed-pty-gate, 21 pty-door-guard, 13 pty-preflight, 2 ws-client-pty-preflight, 2 net telegram-human-only-guard, 1 ext-ask-gates, 1 CRLF fix); the other 6 are loop-generated cases a source-line diff undercounts, and the note says so rather than inventing attribution. + +## Known Stubs + +None. + +## Threat Flags + +None - docs, comment-only source edits and regenerated artifacts; no new endpoints, auth paths or schema changes. T-03-20 mitigated (five supervisor guards pass, no supervisor/ diff), T-03-21/22 mitigated (default OFF, owner-only cutover and honest scope documented; no production value for REMO_GOVERNED_AUTOMATION_PTY documented), T-03-23 mitigated (fail 0, skip 259 <= 260, tolerance unchanged), T-03-24 mitigated (no docs drift). + +## Self-Check: PASSED + +- 03-04 modified files: FOUND (git status clean after commit bf56f4f) +- Commits 5676b12, da575ed, a7aa1d7, bf56f4f: FOUND on branch phase-ptycap-03-governed-automation-guard +- STATE.md intentionally not touched; ROADMAP only received the Phase-4 hand-off note (orchestrator owns checkboxes) diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md new file mode 100644 index 00000000..35be9509 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md @@ -0,0 +1,168 @@ +# Phase 3: Governed-Automation Guard - Context + +**Gathered:** 2026-10-07 +**Status:** Ready for planning +**Decision record:** `GATES/design-fork-1.md` (3-seat panel: claude + codex + agy, VERDICT PASS, amendments adopted below). Grey areas were +answered by that panel, not by a human (unattended batch run). + + +## Phase Boundary + +Phase 3 delivers the **governance primitives** that decide whether a NON-HUMAN actor may drive an interactive PTY, behind a flag that is +**default OFF and a true no-op when off**. It replaces `humanOnlyPtyGate` with `governedAutomationPtyGate`, defines the four required +governors (kill switch, token cap, inject-rate ceiling, lifetime counter), extends the automation PTY preflight chain, and pins the real +"door" (the hub `term.*` emitters and the supervisor frame router) with structural tests. + +**In scope:** flag + reader; `governedAutomationPtyGate` (hard rename at the 3 call sites); eligible-actor rule; governors module with +two fail-closed placeholder slots; automation-only extension of the PTY preflight chain; door pins (emitter allowlist, supervisor canary, +caller pins); spoof/negative tests; explicit exemptions; Phase-2 MINOR fold-ins (CRLF test fix, docs corrections); docs/OpenAPI. + +**Out of scope (explicitly):** +- Any automation `term.input` / `term.attach_file` byte-producer (typing a prompt into a TUI) -> Phase 5 (first unattended PTY proof). +- The real lifetime counter, its durable ledger, and the real kill switch -> Phase 4 (this phase ships fail-closed slots for them). +- Kill-switch UI -> Milestone GOV. Any prod env change / flag flip -> owner-only live cutover (not done by this run). +- Re-classifying legacy-JWT `/ws/client` connections (they stay `human`: legacy login is the prod login path). + +**Honest scope statement (carry into VERIFICATION):** the three call-site gates are NOT the door. Every automation dispatch adapter sends +`user_message`, which the supervisor routes to the stream-json runner, never the PTY (supervisor/src/runners/session-bridge.ts ~398-465); the +only hub-to-PTY writers are the web relay and Telegram permission keystrokes. SC-1 is therefore met STRUCTURALLY: the admit path is proven +under test dependency-injection, production slots fail closed, and the door is pinned by tests. ROADMAP SC-1 text is NOT edited. + + + + +## Implementation Decisions + +### Door scope and structural pins +- **D-01 (F1 A+):** Governance primitives only; build NO byte-producer. Add three additive pins, all DB-free hub unit tests (they run in `check-baseline`): + (a) an allowlist of hub modules that may emit hub->agent `term.*` frames (today exactly `ws/client.ts` relay and `telegram/transcript/pty-inject.ts`) with a + scanner self-check that the scan FINDS both (cannot pass vacuously, pattern: token-cap-coverage.test.ts:77-94); + (b) a supervisor canary (static read of `supervisor/src/runners/session-bridge.ts`) that the `user_message` and `permission_response` branches never reference + the PTY runner or `pty.write`; + (c) module-allowlist caller pins for `actor: 'automation'` (only the future orchestrator module), `actor: 'human'` preflight calls (only `ws/client.ts`), + `injectPtyKeystroke(` (only `api/telegram-webhook.ts`) and `dispatchToSession(` (webhook + its own replay). + Precedent: `hub/test/human-interactive-flag-guard.test.ts` ALLOWED-set pattern. **Reversibility:** reversible - tests only. +- **D-16:** `injectPtyKeystroke` is pinned (single production caller), not made private. + +### Governors (SC-1) +- **D-02 (F2 A):** New module `hub/src/dispatch/governed-pty.ts`. Closed union `GovernorName = 'kill_switch' | 'token_cap' | 'inject_rate' | 'lifetime_counter'`, fixed order + kill_switch, token_cap, inject_rate, lifetime_counter (the flag check precedes all; first block wins, cheapest/most authoritative first). `kill_switch` and + `lifetime_counter` are real exported `DispatchGate`s whose Phase-3 bodies **FAIL CLOSED**: block reason `governor_not_implemented:`. Phase 4 replaces the bodies + (add a "replaces the fail-closed slots" line under Phase 4 in ROADMAP; leave SC-1 text alone). Dependency injection ONLY through a test-only setter + (`_setGovernorsForTests` / reset, precedent `pty-preflight.ts` `_setPtyPreflightTimeoutForTests`); NO optional public `governors` parameter (a public param is a bypass handle). + With the flag ON in Phase 3 production nothing is admitted - by design. **Reversibility:** costly - Phases 7/8/9 import these gate names. +- **D-03 (F3 A):** A governor is "carried" only when ENABLED. The governed chain blocks `governor_unconfigured:` when the inject-rate ceiling is <= 0 / non-finite + (reuse exported `maxInjectsPerHour()`) or the token cap is explicitly disabled (add tiny export `isDailyTokenCapEnforced()` in gates.ts; `isTokenCapExplicitlyDisabled` + is private). The cost cap is NOT part of the enabled rule (dollars are advisory). Evaluate at call time. The "over the limit" evaluation stays with the existing gates. +- **D-14 (Codex MF-3/MF-6):** Governors in this phase are NON-CONSUMING checks. A thrown/timed-out governor yields a typed fail-closed denial (reuse runChain's existing + `pty_preflight_error` / `pty_preflight_timeout` behaviour). Atomic reserve-before-send consumption is Phase 4's design (note in 04 context). +- **D-12 (M4):** `sessionInjectRateGate` counts only orchestrator `routine_run_log` rows, so an admitted non-orchestrator turn would be uncounted. Phase 3 pins the + orchestrator as the ONLY permitted `actor: 'automation'` caller (D-01c) and records "an admitted automation PTY turn must be counted before this chain is trusted" as a + Phase 4/5 entry criterion (the durable ledger is Phase 4's). + +### Flag, naming, eligibility +- **D-04 (F4 A):** Flag `REMO_GOVERNED_AUTOMATION_PTY`, env var, read at CALL time, truthy only for `1|true|yes|on` (case-insensitive), any other/garbage value = OFF. ONE reader + function `governedAutomationEnabled()` in `governed-pty.ts`; a guard test pins the env-var name to that module. Checked FIRST in the automation branch, before any DB gate. + OFF => no automation turn admitted to a PTY and the 3 call sites behave byte-identically to today: a **differential test** keeps a frozen copy of the legacy decision as an + oracle and proves new==legacy over the full (actor x runnerType) matrix including the reason strings. **Reversibility:** costly - published env contract once documented. +- **D-05 (F5 A):** Hard rename `humanOnlyPtyGate` -> `governedAutomationPtyGate` (gate `name` = `'governed_automation_pty'`); NO alias. Update the 3 call sites (`ask/dispatch.ts`, + `work/dispatch.ts`, `telegram/dispatch.ts`), their tests, and docs (incl. the drifted name `governedAutomationGate` in `.planning/architecture/SESSION-ASK-API-SPEC.md` and + `docs/session-ask.md`). `humanOnlyRejectsActor` and `AUTOMATION_ACTORS` stay unchanged (the web relay still uses the predicate). +- **D-06 (M2, critical):** Eligible-actor rule: only actors in `AUTOMATION_ACTORS` (`scheduler`, `orchestrator-background`, `auto-dev`, `error-capture`, `agent`) can ever be admitted. + `external-ask`, `external-work` and unknown strings are denied EVEN with the flag ON and every governor passing (test). Without this the rename would WEAKEN today's + unconditional deny for untrusted-email-driven actors once Phase 4 fills the slots. +- **D-17:** For a non-PTY `runner_type` the gate returns ok immediately (unchanged semantics, agy MF-3). + +### Chain composition (critical trap) +- **D-07 (M1, critical):** `pty-preflight.ts` derives the human chain as "automation minus inject-rate" (L41-43). Do NOT append governors to the scanned literal at L34 + (it would put fail-closed stubs into the HUMAN chain and refuse every web Enter). Keep the L34 literal and all Phase-2 pins untouched; define an automation-only governed list + and run `[...PTY_AUTOMATION_TURN_GATES, ...governed]` only in `runChain`'s automation branch. Add an explicit test that the human chain contains no governor gate. Never write a + governors list as a `gates: [` literal lacking both caps (token-cap-coverage.test.ts); a list built in a variable is invisible to that scan. +- **D-08 (M3):** Reason contract: keep the prefix `automation_blocked_on_pty:`; a governed block appends `:`; the OFF path is byte-identical. The Telegram + mapper matches by prefix (telegram/dispatch.ts ~218-227). Update the `_openapi.ts` reason lists, run `bun run docs:sync`, and check `web/src/lib/termRefusal.ts` mapping. + +### Actor inference and spoofing (SC-2) +- **D-09 (F6 A+):** Actor stays closure-resolved from server context; `DispatchRequest` stays actor-free. Tests: (i) static pin that the ask/work resolvers return the compile-time + constants and that nothing under `hub/src/dispatch/` (or the gates) reads `req.source` / `req.actor`; (ii) a `/ws/client` test that a `term.input` frame carrying extra + `{source:'scheduler', actor:'orchestrator-background'}` is still treated as `human` and the keys are stripped (zod strip is inferred, so PROVE it); (iii) keep the existing + agent-socket spoof test (`term-relay-human-guard.test.ts:110-118`); (iv) make `source` a REQUIRED compile-time parameter of Telegram `dispatchToSession` and pass the literal + `'human'` at the authenticated webhook boundary (removes the fail-open `input.source ?? "human"` default at telegram/dispatch.ts ~161). +- **D-15:** Codex MF-4 (cookie-only human) is REJECTED: legacy-JWT `/ws/client` is the prod login path and stays `human`. Residual (document): cookie/JWT => human is credential identity, + not typing; any future bearer-automation lane must use `/ws/agent` or an ext key, never `/ws/client`. + +### Exemptions and fold-ins +- **D-10 (F7 A):** Telegram permission-answer keystrokes (`injectPtyKeystroke`, one-shot, approver-authorized via `takePendingPrompt`) and `term.attach_file` (types a path, never + Enter) are explicit, test-pinned human-initiated exemptions with a one-line justification each - NOT routed through preflight (a quota check must not wedge a mid-turn permission prompt). + Document the (non-hole) inconsistency that the web relay would refuse a permission-answer Enter over cap while Telegram would not. +- **D-11 (F8 A):** Fold into this PR: make `hub/test/human-interactive-flag-guard.test.ts` CRLF-safe (`split(/\r?\n/)`); correct `docs/usage-cost.md` (L373-377 wrong mechanism - the real + reason is that `user_message` goes to the stream-json runner; L462-465 Telegram text goes via `user_message`, not the PTY). Keep the R-PTY-24 supersession strings that + `hub/test/docs-supersession.test.ts` reads. +- **D-13 (M6):** No new skipped tests: baseline is skip=259 vs `skip_max=260`. Use DB-free unit tests (mock leaf modules like `pty-preflight.test.ts`); no new e2e that skips locally. + +### Claude's Discretion +Test file names; exact helper signatures; task ordering; whether the governed list lives next to or inside `runChain`; wording of block reasons beyond the contract in D-08. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase text and invariants +- `.planning/ROADMAP.md` lines 14-28 (hard invariants), 95-115 (Phase 3 and 4) +- `.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.md` - the binding panel record (read the DECISION section) +- `.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md` - file:line facts (sections 1, 2, 3, 4, 8, 9, 10, DESIGN SEAMS) +- `.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md` - MINOR-1/2/3 inputs; `02-01-SUMMARY.md` +- `.planning/phases/PTYCAP-07-self-heal-routing/07-SPEC.md`, `PTYCAP-08-task-qc/08-SPEC.md`, `PTYCAP-09-cap-hit-qc-trigger/09-SPEC.md` - later consumers of the governor names/semantics + +### Code +- `hub/src/dispatch/gates.ts` (L458-500 actors + gate; L163-310 caps and inject-rate), `hub/src/dispatch/pty-preflight.ts`, `hub/src/dispatch/pipeline.ts` (L36-54) +- `hub/src/ws/client.ts` (term.input relay ~L174-358), `hub/src/telegram/transcript/pty-inject.ts`, `hub/src/api/telegram-webhook.ts` (~L386-399, 515-565) +- `hub/src/{ask,work,telegram}/dispatch.ts`, `supervisor/src/runners/session-bridge.ts` (L380-470) + +### Tests and docs +- `hub/test/{human-only-guard,term-relay-human-guard,telegram-human-only-guard,ext-ask-gates,ext-work-containment,pty-preflight,ws-client-pty-preflight,token-cap-coverage,human-interactive-flag-guard,automation-routing-guard}.test.ts` +- `supervisor/test/{no-api-key-no-streamjson-pty,no-apikey-fallback-guard,default-backend-selector,pty-spawn-interception,no-legacy-agent-spawn}.test.ts` (must stay green UNCHANGED, SC-4) +- `docs/usage-cost.md` (PTYCAP Phase 2 section), `CLAUDE.md` (env section + docs map), `hub/src/api/_openapi.ts` + `bun run docs:sync` + + + + +## Existing Code Insights + +### Reusable Assets +- `checkPtyTurnPreflight({userId, sessionId, actor})` and `runChain` (fail-closed, 5s timeout) - the seam the future producer will call. +- `humanOnlyRejectsActor`, `AUTOMATION_ACTORS`, gate factories bound to an identity (`askRateGate`, `workRateGate`), `maxInjectsPerHour()`, `getTokenCapStatus()`. +- Test pattern: leaf-module mocks + `?t=${Date.now()}` cache-busting imports (`pty-preflight.test.ts:35-68`). + +### Established Patterns +- Call-time `process.env` flags with `1|true|yes|on` (controller.ts `isOrchestratorEnabled`); `_set...ForTests` setters for DI; ALLOWED-set structural guard tests. +- Spawn ceilings fail CLOSED (07/09 specs); spend caps fail closed too in current code (gates.ts L194-222) although two specs still say fail-open (stale - fix in docs touched). + +### Integration Points +- 3 call sites swap the gate; `pty-preflight.ts` `runChain` gains the automation-only governed list; `gates.ts` gains `isDailyTokenCapEnforced()`; Telegram `dispatchToSession` gains a required `source`. + + + + +## Specific Ideas + +No UI. Block reasons must stay greppable: `automation_blocked_on_pty:[:]`, `governor_not_implemented:`, `governor_unconfigured:`. + + + + +## Deferred Ideas + +- Automation `term.input` producer + orchestrator wiring -> Phase 5. Real lifetime counter / kill switch / reserve-before-send atomic consumption / 60% alarm / cap 50M->20M -> Phase 4. +- Kill-switch UI -> Milestone GOV. Repairing the dead `runner_type` key (runtime PTY capability state) -> candidate for Milestone DEBT. +- Cookie/JWT-as-human residual (credential identity) - documented, not fixed. +- P1 verification gap G-1 (PTY usage double count by `message.id`) -> fixed in the Phase 4 PR (token accuracy matters before lowering the ceiling). + + + +--- + +*Phase: 3-governed-automation-guard* +*Context gathered: 2026-10-07* diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md new file mode 100644 index 00000000..c9064af3 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-PATTERNS.md @@ -0,0 +1,192 @@ +# Phase 3: Governed-Automation Guard - Pattern Map + +**Mapped:** 2026-10-07 +**Files analyzed:** 22 (2 new src/test pairs + modifications) +**Analogs found:** 22 / 22 (all paths below are git-tracked source under hub/ or supervisor/) + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match | +|---|---|---|---|---| +| `hub/src/dispatch/governed-pty.ts` (NEW) | gate module + env flag reader | request-response | `hub/src/dispatch/gates.ts` (L173-312, L458-500) + `orchestrator/controller.ts` L56-70 | exact | +| `hub/src/dispatch/gates.ts` (MOD) | gate lib | request-response | self (`isTokenCapExplicitlyDisabled` L173-177) | exact | +| `hub/src/dispatch/pty-preflight.ts` (MOD, `runChain` L170-184 only) | chain runner | request-response | self | exact | +| `hub/src/ask/dispatch.ts` L131-143, `hub/src/work/dispatch.ts` L293-306 | call site | request-response | self | exact | +| `hub/src/telegram/dispatch.ts` L79, L161-176 | call site | request-response | self | exact | +| `hub/src/api/telegram-webhook.ts` L390 | caller | request-response | self | exact | +| `docs/usage-cost.md`, `CLAUDE.md`, `hub/src/api/_openapi.ts` (L847, L1057), `web/src/lib/termRefusal.ts` | docs/config | - | self | exact | +| `hub/test/governed-pty-gate.test.ts` (NEW) | test (DB-free gate + differential) | request-response | `hub/test/pty-preflight.test.ts` L19-94 | exact | +| `hub/test/pty-door-guard.test.ts` (NEW) | test (static ALLOWED-set + canary) | file-I/O scan | `human-interactive-flag-guard.test.ts` + `token-cap-coverage.test.ts` + `automation-routing-guard.test.ts` L55-79 | exact (3 analogs) | +| `hub/test/term-relay-human-guard.test.ts` (MOD) | test (/ws/client frame) | event-driven | self L15-118 / `ws-client-pty-preflight.test.ts` L14-118 | exact | +| `hub/test/{human-only-guard,telegram-human-only-guard,ext-ask-gates,ext-work-containment}.test.ts` (MOD) | tests | - | self (rename `humanOnlyPtyGate`/`human_only_pty` -> new names) | exact | +| `hub/test/pty-preflight.test.ts` (MOD) | test | request-response | self L96-126 | exact | +| `hub/test/human-interactive-flag-guard.test.ts` (MOD) | test | file-I/O scan | self L37 | exact | + +## Pattern Assignments + +### `hub/src/dispatch/governed-pty.ts` (NEW) + +**Analog A - gate shape + closure-resolved actor:** `hub/src/dispatch/gates.ts` L458-500 (to be REMOVED/replaced; copy structure) +```typescript +export const AUTOMATION_ACTORS: ReadonlySet = new Set([ + 'scheduler','orchestrator-background','auto-dev','error-capture','agent', +]) +export function humanOnlyRejectsActor(actor: string, runnerType: string): boolean { + if (runnerType !== 'pty-interactive') return false + return actor !== 'human' +} +export function humanOnlyPtyGate( + resolveActorAndRunnerType: (req: DispatchRequest) => Promise<{ actor: string; runnerType: string }>, +): DispatchGate { + return { + name: 'human_only_pty', + async check(req: DispatchRequest) { + const { actor, runnerType } = await resolveActorAndRunnerType(req) + if (humanOnlyRejectsActor(actor, runnerType)) { + return { ok: false, reason: `automation_blocked_on_pty:${actor}` } + } + return { ok: true } + }, + } +} +``` +New gate: same signature, `name: 'governed_automation_pty'`. Decision order (D-04/D-06/D-17): non-PTY runner -> ok; actor `human` -> ok; flag OFF -> legacy block `automation_blocked_on_pty:${actor}` (byte-identical); actor not in `AUTOMATION_ACTORS` (incl. external-ask/external-work/unknown) -> same block even with flag ON; then run governors (fixed order kill_switch, token_cap, inject_rate, lifetime_counter), block `automation_blocked_on_pty:${actor}:${governorReason}`. Keep `AUTOMATION_ACTORS`, `humanOnlyRejectsActor` in gates.ts (D-05) - import them. + +**Analog B - call-time flag reader** (`hub/src/orchestrator/controller.ts` L67-70; also L56-59): +```typescript +export function isOrchestratorEnabled(): boolean { + const raw = (process.env.REMO_ORCHESTRATOR_ENABLED ?? '0').trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} +``` +Copy as `governedAutomationEnabled()` reading `REMO_GOVERNED_AUTOMATION_PTY`; garbage = OFF. + +**Analog C - governor gate bodies / reasons / enabled test:** +- inject-rate disabled test `gates.ts` L301-311: `const cap = maxInjectsPerHour(); if (!Number.isFinite(cap) || cap <= 0) return {ok:true}`. Governed chain must INVERT this for the enabled rule: cap<=0/non-finite -> block `governor_unconfigured:inject_rate` (reuse exported `maxInjectsPerHour()` L284). +- token cap: add next to `isTokenCapExplicitlyDisabled()` (L173-177, private): +```typescript +export function isDailyTokenCapEnforced(): boolean { return !isTokenCapExplicitlyDisabled() } +``` + -> block `governor_unconfigured:token_cap` when false. "Over the limit" stays with `dailyTokenCapGate` / `sessionInjectRateGate` in `PTY_AUTOMATION_TURN_GATES`. +- fail-closed slot bodies: `kill_switch`, `lifetime_counter` return `{ ok:false, reason:'governor_not_implemented:' }`. + +**Analog D - test-only DI setter** (`pty-preflight.ts` L49-52): +```typescript +let timeoutMs = 5_000 +/** Test-only - shorten the fail-closed timeout. */ +export function _setPtyPreflightTimeoutForTests(ms: number): void { timeoutMs = ms } +export function _resetPtyPreflightTimeoutForTests(): void { timeoutMs = 5_000 } +``` +Copy as `_setGovernorsForTests(list)` / `_resetGovernorsForTests()`; module-level `let governors = DEFAULT`. No public `governors` param (D-02). + +**Closed union / gate factory precedent:** `askRateGate(apiKeyId)`, `workRateGate(userId)` (gates.ts L326-373) - exported `DispatchGate` objects with `name` + `check`. + +### `hub/src/dispatch/pty-preflight.ts` - `runChain` automation branch only (D-07) + +**Current code (L170-184) to modify:** +```typescript +const human = input.actor === 'human' +const req: DispatchRequest = { userId, sessionId, token: `pty-preflight:${input.sessionId}`, prompt: '', + ...(human ? { humanInteractive: true as const } : {}), } // L177 line pinned verbatim by human-interactive-flag-guard +for (const gate of human ? PTY_HUMAN_TURN_GATES : PTY_AUTOMATION_TURN_GATES) { + const result = await gate.check(req) + if (!result.ok) return { ok: false, reason: result.reason } +} +``` +Change only the loop iterable to `human ? PTY_HUMAN_TURN_GATES : [...PTY_AUTOMATION_TURN_GATES, ...governedAutomationGovernors()]`. DO NOT touch L34 literal or L41-43 filter (D-07 trap; token-cap-coverage scans the L34 literal and needs both caps; a variable list is invisible to it). Flag check FIRST in the automation branch (before any DB gate). Thrown/timeout already handled by `checkPtyTurnPreflight` L196-214 (`pty_preflight_error`/`pty_preflight_timeout`) - D-14, no new code. + +### Call sites (rename + `source`) + +`hub/src/ask/dispatch.ts` L131-143 (same shape at `work/dispatch.ts` L293-306): +```typescript +humanOnlyPtyGate(async (req) => { + const rows = await sql<{ runner_type: string }[]>`SELECT runner_type FROM sessions WHERE id = ${req.sessionId} LIMIT 1` + // The actor is SERVER-INFERRED from the api_key - never client-asserted. + return { actor: EXT_ACTOR, runnerType: rows[0]?.runner_type ?? 'stream-json' } +}), +``` +Swap identifier + import (ask L32, work L33, telegram L47). Also fix comments at ask L13/L129, work L7/L11, ext.ts L230, ext-api-key-middleware L13/L29, client.ts L171. + +`hub/src/telegram/dispatch.ts`: L79 `source?: TelegramDispatchSource;` -> required; L161 delete `?? "human"` default: +```typescript +const source: TelegramDispatchSource = input.source ?? "human"; // L161 -> const source = input.source +``` +`telegram-webhook.ts` L390 `dispatchToSession({userId,sessionId,chatId,updateId,text,images})` add `source: 'human'` literal. L183 (`await dispatchToSession(input)`) is the replay; passes through the already-typed input. Reason-prefix mapper at ~L218-227 matches by prefix - append-only reasons are safe (D-08). + +### `hub/test/governed-pty-gate.test.ts` (NEW) - DB-free gate test + +**Analog:** `hub/test/pty-preflight.test.ts` L19-94. Copy verbatim: JWT env line, `state` object, leaf `mock.module` calls for `usage/threshold.ts`, `usage/store.ts`, `db/postgres.ts`, `db/token-usage-dal.ts`, `db/orchestrator-rows-dal.ts`, then: +```typescript +const modUrl = `../src/dispatch/pty-preflight.ts?t=${Date.now()}${Math.random()}` +const { checkPtyTurnPreflight, _setPtyPreflightTimeoutForTests, _resetPtyPreflightTimeoutForTests } = await import(modUrl) +const { sessionInjectRateGate } = await import('../src/dispatch/gates.ts') +beforeEach(() => { /* reset state + env flags + _resetGovernorsForTests() + _resetPtyPreflightTimeoutForTests() */ }) +``` +Cover: eligible-actor matrix (external-ask/external-work/unknown denied with flag ON + all governors passing via `_setGovernorsForTests`); production slots fail closed `governor_not_implemented:*`; order; `governor_unconfigured:*` (set `REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR=0`, `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED=1`); thrown/hung governor -> `pty_preflight_error`/`pty_preflight_timeout` (pattern: `state.thresholdMode` 'throw'|'hang' L32-47). Differential test: frozen copy of legacy `humanOnlyRejectsActor`+reason as oracle over (actor x runnerType) matrix with flag OFF. Restore env in afterEach (flag is call-time). +Must be DB-free; add no `describe.skip` (baseline skip=259/260, D-13). + +### `hub/test/pty-door-guard.test.ts` (NEW) - structural pins + +**Analog 1 (ALLOWED-set walk)** `human-interactive-flag-guard.test.ts` L8-33: +```typescript +const SRC = join(import.meta.dir, '..', 'src') +const ALLOWED = new Set(['dispatch/pipeline.ts', 'dispatch/gates.ts', 'dispatch/pty-preflight.ts']) +function walk(dir: string): string[] { return readdirSync(dir).flatMap((name) => { + const p = join(dir, name) + return statSync(p).isDirectory() ? walk(p) : p.endsWith('.ts') ? [p] : [] }) } +const offenders = walk(SRC).filter((f) => readFileSync(f,'utf8').includes('humanInteractive')) + .map((f) => relative(SRC, f).split('\\').join('/')).filter((f) => !ALLOWED.has(f)) +expect(offenders).toEqual([]) +``` +Use for: hub->agent `term.*` emitters (expected today: `ws/client.ts`, `telegram/transcript/pty-inject.ts`; `ws/term-protocol.ts` only declares schemas - exclude it explicitly), `actor: 'automation'` callers, `actor: 'human'` preflight callers (`ws/client.ts` only), `injectPtyKeystroke(` (def `pty-inject.ts` L34; caller `api/telegram-webhook.ts` L556), `dispatchToSession(` (webhook L390 + `telegram/dispatch.ts` L183 replay), env name `REMO_GOVERNED_AUTOMATION_PTY` (only `dispatch/governed-pty.ts`). + +**Analog 2 (scanner self-check, cannot pass vacuously)** `token-cap-coverage.test.ts` L77-94: +```typescript +test('at least the known dispatchers are present (the scan actually found them)', () => { + const files = new Set(gateLists().lists.map((g) => g.file)) + for (const expected of [ ... ]) { expect(files.has(expected)).toBe(true) } +}) +``` +Assert the emitter scan FINDS both expected files. Also fail on a scanner that cannot parse (L73-75 `unparsed` toEqual([]) pattern). Note: any new `gates: [` literal must include both caps (L96-101); prefer not adding one. + +**Analog 3 (static read of supervisor source from hub/test)** `automation-routing-guard.test.ts` L55-67: +```typescript +const runnersDir = join(import.meta.dir, '..', '..', 'supervisor', 'src', 'runners') +const src = readFileSync(join(runnersDir, f), 'utf-8') +``` +Canary: read `supervisor/src/runners/session-bridge.ts`; slice the `msg.type === 'user_message'` block (L444-465) and `permission_response` block (L466+) and assert neither contains `ptyRunner`/`ensurePtyRunner`/`pty?.write`/`pty.write`. Self-check that the slices are non-empty and that `term.input` branch (L396-402) DOES contain `pty?.write` (non-vacuous). Use `/\r?\n/` splits (Windows CRLF). + +### `hub/test/term-relay-human-guard.test.ts` (MOD) - /ws/client extra-keys frame + +**Analog:** self L52-78 (`humanClient()`), L101-108 (genuine human forwarded), L110-118 (agent spoof). Add case mirroring L103-107 but with `JSON.stringify({ type:'term.input', session_id: SESSION, bytes: btoa('y\n'), source:'scheduler', actor:'orchestrator-background' })`; assert forwarded frame count 1 and `JSON.parse(fwdClient[0])` has NO `source`/`actor` keys (zod strip, `ws/term-protocol.ts` L24-79 plain `z.object`). Note preflight is mocked `ok:true` here (L37-41), so also assert via `ws-client-pty-preflight.test.ts` pattern (L58-66, `preflightCalls`) that the call is `{userId, sessionId, actor:'human'}` - put that case in `ws-client-pty-preflight.test.ts` L120-126 style if the preflight mock is needed (`input('\r')` helper L91; submit requires Enter). + +### Renames in existing tests +- `human-only-guard.test.ts` L72-76 gate name assertion `human_only_pty` -> `governed_automation_pty`. +- `ext-ask-gates.test.ts` L29 name list `'humanOnlyPtyGate'` -> new; `ext-work-containment.test.ts` L240-245 same; `telegram-human-only-guard.test.ts` L79-80 reason substring `automation_blocked_on_pty` still valid. Telegram tests must now pass `source` explicitly. +- `pty-preflight.test.ts` add after L123: human chain contains no governor (names toEqual threshold,daily_token_cap,daily_cost_cap); automation branch includes governors only when flag ON. +- `human-interactive-flag-guard.test.ts` L37: `src.split('\n')` -> `src.split(/\r?\n/)`. + +## Shared Patterns + +### Fail-closed +`checkPtyTurnPreflight` (`pty-preflight.ts` L196-214): race against `timeoutMs`, `.catch` -> `pty_preflight_error`. All governor failures reuse it. + +### Env flag truthiness +`['1','true','yes','on'].includes(raw.trim().toLowerCase())` (`gates.ts` L173-177, `controller.ts` L67-70). Read at call time, never cached. + +### Server-inferred actor +Actor only via closure return value (ask L139-140; ws/client.ts L217/L286 literal `'human'`); `DispatchRequest` (`pipeline.ts` L36-48) has no actor/source field. Never read `req.source`/`req.actor` under `hub/src/dispatch/`. + +### Test isolation +One process per hub test file (`mock.module` is process-global); cache-bust real-module imports with `?real=${Date.now()}`; spread real module then override (`...realPreflight`). + +## No Analog Found +| File | Reason | +|---|---| +| Hub-to-agent `term.*` emitter scanner | No existing scanner for emitters; compose from the three analogs above. Needs a decision on the pattern (`type: 'term.` object literals plus `getChannel(`/`ws.send` co-occurrence) - self-check against the two known emitters is mandatory. | +| Docs sections (PTYCAP Phase 3 in `docs/usage-cost.md`) | Add after the Phase 2 section (L346-480); keep R-PTY-24 supersession strings (read by `hub/test/docs-supersession.test.ts`). Fix L373-377 and L462-465 per D-11. | + +## Metadata +**Analog search scope:** hub/src/{dispatch,ws,telegram,ask,work,orchestrator,api}, hub/test, supervisor/src/runners +**Pattern extraction date:** 2026-10-07 +**Tracked-source gate:** all analog paths are repo source (hub/, supervisor/); none are .gsd mirrors. diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md new file mode 100644 index 00000000..42f786aa --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md @@ -0,0 +1,306 @@ + + +# PTYCAP Phases 3+4 -- landscape (read-only recon) + +Worktree: C:\Users\artic\GitHub\remo-code-gsd | branch `phase-ptycap-03-governed-automation-guard` | HEAD 008c276 | date 2026-10-07. +Facts only. `path:line` are relative to the worktree root. Quotes are ASCII-normalized (source has unicode arrows/dashes). +No repo file was modified. Test commands in section 9 were actually run (results recorded). + +--------------------------------------------------------------------------------------------------- +## 1. humanOnlyPtyGate -- definition, callers, tests, actor derivation, spoof surface, PTY write paths +--------------------------------------------------------------------------------------------------- + +### 1.1 Definition and exports (hub/src/dispatch/gates.ts) +- `gates.ts:458-464` `export const AUTOMATION_ACTORS: ReadonlySet = new Set(['scheduler','orchestrator-background','auto-dev','error-capture','agent'])`. Referenced ONLY by tests; the decision below does not consult it. +- `gates.ts:470-473` `export function humanOnlyRejectsActor(actor: string, runnerType: string): boolean { if (runnerType !== 'pty-interactive') return false; return actor !== 'human' // any non-human actor (incl. all AUTOMATION_ACTORS) is rejected }` -- default-deny, so 'external-ask'/'external-work' are rejected without being listed. +- `gates.ts:487-500` `export function humanOnlyPtyGate(resolveActorAndRunnerType: (req: DispatchRequest) => Promise<{ actor: string; runnerType: string }>): DispatchGate`; `name: 'human_only_pty'`; blocks with `reason: automation_blocked_on_pty:${actor}`. +- `DispatchRequest` has NO actor/source field (`hub/src/dispatch/pipeline.ts:36-48`: userId, sessionId, token, prompt, images?, attachments?, humanInteractive?). The actor reaches the gate only through the resolver closure the caller builds. +- `pipeline.ts:44-47` `/** Set ONLY by the PTY preflight for a server-inferred human keystroke turn. ... Never populated from client input. */ humanInteractive?: true`. + +### 1.2 Every caller +| Caller | file:line | actor passed | runnerType source | +|---|---|---|---| +| ext ask dispatch | hub/src/ask/dispatch.ts:135-141 (list L131-143) | `EXT_ACTOR` ('external-ask'), L140 | `SELECT runner_type FROM sessions WHERE id = ${req.sessionId}` L136-138 | +| ext work dispatch | hub/src/work/dispatch.ts:297-303 (list L293-306) | `EXT_WORK_ACTOR` ('external-work'), L302 | same SQL L298-300 | +| telegram inbound dispatch | hub/src/telegram/dispatch.ts:168-171 (`guard`, list L176) | `source = input.source ?? "human"` L161 | `getSessionRunnerType(input.sessionId, input.userId)` L170 | +| term.input relay | hub/src/ws/client.ts:215-223 uses `humanOnlyRejectsActor('human', runnerType)` (L217) | literal `'human'` -- tautology, can never reject ("Unreachable for a human actor by construction", L219) | `getSessionRunnerType` L216 | + +NOT carrying `humanOnlyPtyGate` (no actor/runner check at all): orchestrator/inject.ts (L262, L390), scheduler/senders/agent.ts:367, scheduler/senders/triage.ts:142, error-capture/dispatcher.ts:167, feedback/dispatcher.ts:130, revanote/dispatcher.ts:317, cloud/send.ts:31, dispatch/pty-preflight.ts:34. + +### 1.3 Tests of humanOnlyPtyGate / humanOnlyRejectsActor (all PASS locally, section 9) +- hub/test/human-only-guard.test.ts (8 tests): every AUTOMATION_ACTORS member rejected on 'pty-interactive' (L29-33); 'human' allowed (L35-37); L39-44 "a client-asserted 'human' string from an agent connection still fails -- actor is server-inferred"; stream-json unaffected (L46-51); gate name `human_only_pty` (L72-76). +- hub/test/term-relay-human-guard.test.ts (3 tests): L101-108 genuine human-connection term.input forwarded; **L110-118 spoof negative test**: `handleAgentMessage(agentSocket, {type:'term.input', ..., source:'human', ...})` => `expect(fwdAgent.length).toBe(0)`; L120-126 agent CAN emit term.data. Mocks pty-preflight to always pass (L37-41). +- hub/test/telegram-human-only-guard.test.ts (4): `automation_blocked` + reason contains `automation_blocked_on_pty` (L79-80). +- hub/test/automation-routing-guard.test.ts (15): data-driven over UNATTENDED_SOURCES (L24) + static check that supervisor runner sources scrub ANTHROPIC_API_KEY (L55-79). +- hub/test/ext-ask-gates.test.ts (11): gate-name list includes 'humanOnlyPtyGate' (L29), `humanOnlyRejectsActor(EXT_ACTOR,'pty-interactive')===true` (L60). +- hub/test/ext-work-containment.test.ts:240-245 asserts dispatchWork composes cost+token+humanOnlyPty+rate+allowlist. +- hub/test/programmatic-hard-halt.test.ts:108-109 (`humanOnlyRejectsActor` human false / scheduler true). +- hub/test/pty-preflight.test.ts:164 comment only. + +### 1.4 How `actor` is determined server-side today +| Principal | Evidence | Resulting actor | +|---|---|---| +| /ws/client with opaque session cookie | client.ts:372-382 (`data.authMethod = 'session_cookie'`) | relay hard-codes `'human'` (client.ts:217, 286) | +| /ws/client with legacy JWT (`config.allowLegacyLogin`) | client.ts:384-397 (`data.authMethod = 'legacy_jwt'`) | ALSO 'human' -- authMethod is never consulted by the relay | +| /ws/client with api_key | not accepted: auth branch only handles cookie or `verifyJwt` | n/a | +| /ws/agent api_key (`agent` scope) | ws/agent.ts:308 `verifyApiKeyWithScope(keyHash,'agent')`; L235-236 `if (!isAgentToHubTermType(frame.type)) return` | cannot emit term.input at all (term-protocol.ts:96-98 AGENT_TO_HUB = {'term.data'}) | +| /api/ext api_key | auth/ext-api-key-middleware.ts:79 `c.set('actor', c.req.path.includes('/work') ? EXT_WORK_ACTOR : EXT_ACTOR)` (EXT_ACTOR L25, EXT_WORK_ACTOR L32) | route-inferred 'external-ask'/'external-work' (substring path match) | +| Telegram webhook | api/telegram-webhook.ts:390-397 calls `dispatchToSession({userId,sessionId,chatId,updateId,text,images})` with NO `source` | defaults to 'human' (dispatch.ts:161) | +| orchestrator / scheduler / error-capture / feedback / revanote / triage / cloud | no actor evaluated anywhere in their paths | none | +| REST (cookie vs bearer) | auth/middleware.ts:16-36 sets authMethod 'session_cookie' | 'legacy_jwt' | not used for any PTY decision | + +### 1.5 Every place a client-supplied `source` / actor-like field is read (hub/src/ws, api, dispatch, orchestrator) +- hub/src/dispatch/*: NONE (DispatchRequest has no such field; no `.source`/`.actor` reads). +- hub/src/orchestrator/*: NONE. +- hub/src/ws/client.ts: `actor: 'human'` literal (L286); `source: 'web'` (L572) is a server-set audit tag in a log record, not read from the frame. `ClientInbound` (ws/protocol.ts) has no source/actor key. `TermFrame` schemas (ws/term-protocol.ts:24-79) are plain `z.object` (no `.strict()/.passthrough()`), so unknown keys like `source` are dropped by `safeParse` and the forwarded frame is `JSON.stringify(tf.data)` (client.ts:232). (Not executed; inferred from zod default strip semantics.) +- hub/src/ws/agent.ts:1315 `budgetSource: msg.source` -- agent-supplied (api_key authed) budget-origin enum in `host_resources`; not an actor. +- hub/src/ws/supervisor-protocol.ts:80 `source: z.string().max(120)` (commands_sync metadata) and :98 `source: z.enum(['cgroup_v2','cgroup_v1','host_fallback'])`; not actors. +- hub/src/api/ext.ts:364-369 `WorkBody.source = z.object({kind:z.literal('email'), from, subject?, message_id?})` -- client-supplied email provenance; used for a TRUST decision (`isKnownSender(site, body.source.from)` ext.ts:441 -- sender allowlist) but never for actor. +- hub/src/revanote/payload-schema.ts:34 `source: z.literal('revanote').optional().nullable()` -- payload tag, not an actor. +- hub/src/telegram/dispatch.ts:67,79,161 `TelegramDispatchSource` / `DispatchInput.source` -- in-process param; the only production call site (telegram-webhook.ts:390) omits it. A future caller that passes it would feed `humanOnlyPtyGate` directly. +- No header-based actor (`x-actor`/`x-source`) anywhere in hub/src (grep empty). + +### 1.6 Every path by which a programmatic (non-human) caller can cause prompts/bytes to reach a PTY or PTY-backed session +KEY STRUCTURAL FACT: on a supervisor with `REMO_PTY_INTERACTIVE=1`, hub `user_message` frames go to the STREAM-JSON runner, not the PTY; the PTY takes only `term.*` frames. +- supervisor/src/runners/session-bridge.ts:31-33 `ptyInteractiveEnabled(){ return process.env.REMO_PTY_INTERACTIVE === '1' }`; :382-383 `if (ptyInteractiveEnabled()) this.ensurePtyRunner() else this.ensureRunner()`. +- :398-402 `term.input` => `pty?.write(Buffer.from(String(anyMsg.bytes ?? ''), 'base64').toString('binary'))`. +- :411-441 `term.attach_file` => writes file under repoPath then `pty?.write(abs + ' ')`. +- :444-465 `user_message` => `const runner = this.ensureRunner()` (ClaudeRunner, stream-json) `runner.sendMessage(prompt, msg.images)`. +- :518 `resolveHumanBackend({ isHuman: true }, ...)` hard-coded; supervisor/src/runners/backend-selector.ts:120 `if (ctx?.isHuman !== true) throw`. The supervisor never learns the hub-side actor; term.* frames carry only {type, session_id, bytes|filename,data_b64}. + +| # | Path (producer) | Frame sent | Gated by pty-preflight? | Gated by humanOnlyPtyGate? | Other gating | +|---|---|---|---|---|---| +| P1 | Web xterm `term.input` -> ws/client.ts relay (L174-358) | term.input | YES, on submit only (L262-295, `actor:'human'` L286; `classifyPtyInput`) | tautology only (L217) | ownership (subscribed + canWriteTerminal L200-204), license (L206), single-writer + turn lock (L253-261, L296-326), per-writer chain bound 256 (L336-343) | +| P2 | Web `term.attach_file` (same relay) | term.attach_file | NO by design (hub/test/ws-client-pty-preflight.test.ts:135 "term.attach_file is never checked (it types a path, it does not submit)") | tautology only | same relay gates; `isWriteTurn` includes it (client.ts:183) | +| P3 | Telegram permission/question callback => `injectPtyKeystroke` (telegram/transcript/pty-inject.ts:34-48; call telegram-webhook.ts:556) | term.input (bytes from keystroke-map.ts, e.g. `ENTER='\r'` L27) | NO | NO | `takePendingPrompt(requestId, user.id)` (webhook L525) + linked Telegram user; documented exempt in docs/usage-cost.md:455-461; flagged MINOR-3 in .planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md | +| P4 | `dispatch()` adapters -- all send `user_message`: orchestrator inject.ts:179-187; scheduler agent.ts:398; triage.ts:156; error-capture dispatcher.ts:188; feedback dispatcher.ts:147; revanote dispatcher.ts:360; ext ask ask/dispatch.ts:178; ext work work/dispatch.ts:340; telegram dispatch.ts:150 | user_message (=> stream-json runner, NOT PTY bytes) | NO (preflight only runs in client.ts) | only ask/work/telegram | their own gate lists (section 3) | +| P5 | Web `send_message` (ws/client.ts:777-786) | user_message | NO | NO | ONLY `checkUserThreshold` (L624-640); no cost/token cap (documented pre-existing gap, docs/usage-cost.md:448-454) | +| P6 | permission_response / question_response (client.ts:558-600, telegram-webhook.ts:590,649) | permission_response/question_response (=> stream-json runner `respondToPermission`) | NO | NO | MUTATING_WS_TYPES license gate (client.ts:462-463) | +| P7 | /ws/agent -> hub term.* | term.data only | n/a | n/a | direction allowlist agent.ts:235-236 | +| P8 | Spawn-without-prompt: orchestrator/orphan-resume.ts:226 (`session.start`), launchSessionForUser (telegram/launch.ts), scheduler senders/supervisor.ts:65, teab.ts | session.start / run_command | n/a (no prompt) | n/a | reserveSessionSlot concurrency, restart cap 10, 24h age cap (orphan-resume header) | +=> Today NO automation producer emits `term.input`/`term.attach_file`. `docs`/Phase 2 SUMMARY agree: ".planning/phases/PTYCAP-02-pty-preflight-gate/02-01-SUMMARY.md:67-73" "no automation write to a PTY was possible at all, by construction". The ONLY raw hub->agent term.input writers are P1 (cookie human) and P3 (Telegram answer keystrokes). + +### 1.7 The runner_type column cannot be the door (must be known by Phase 3) +- `hub/src/ws/client.ts:268-270` "runner_type defaults to 'stream-json' and nothing in the web client sets it, while the supervisor writes every term.input to the PTY on its own env flag -- keying on it left the gate dead in prod." +- hub/src/db/schema.sql:646 `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS runner_type TEXT NOT NULL DEFAULT 'stream-json'`; hub/src/db/dal.ts:100-110 `getSessionRunnerType` defaults to 'stream-json'. +- hub/src/api/ext.ts:257-259 "a default prod install is PTY-interactive; the precondition is a stream-json session"; ask/dispatch.ts:65 `AND runner_type = 'stream-json'` (ask target filter). +- 02-VERIFICATION.md MINOR-2: "Phase 3 must not rely on the runner_type gate as the door." +- ROADMAP invariant L19-20: "The actor is server-inferred, never client-asserted (cookie => human, api_key => agent). A spoofed `source` field must never be able to impersonate a human turn." + +--------------------------------------------------------------------------------------------------- +## 2. hub/src/dispatch/pty-preflight.ts +--------------------------------------------------------------------------------------------------- +- Exports: `ptyPreflightDispatchConfig` (L33-35, `gates: [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]`); `PTY_AUTOMATION_TURN_GATES` (L38, same array object); `PTY_HUMAN_TURN_GATES` (L41-43 `PTY_AUTOMATION_TURN_GATES.filter((g) => g !== sessionInjectRateGate)`); types `PtyPreflightActor = 'human' | 'automation'` (L45), `PtyPreflightResult` (L47), `PtyInputClass` (L95-101); `_setPtyPreflightTimeoutForTests`/`_reset...` (L51-52); `classifyPtyInput(bytesB64, pendingTail='')` (L117); `isPtySubmit` (L166); `checkPtyTurnPreflight({userId,sessionId,actor})` (L191-214). +- Chains: automation = threshold -> daily token cap -> daily cost cap -> per-session inject-rate (L34). Human = same MINUS inject-rate (L41-43) and `runChain` sets `humanInteractive:true` (L177) which exempts the programmatic-credit halt in `dailyCostCapGate` (gates.ts:140) -- never the cost/token caps. +- Header L18-20: "No path admits automation to a PTY yet (`humanOnlyPtyGate`); Phase 3 will call this seam." +- `runChain` L170-184: builds `DispatchRequest{userId,sessionId,token:'pty-preflight:'+sessionId,prompt:''}` and runs gates in order; first block wins. FAIL CLOSED: thrown gate => `{ok:false,reason:'pty_preflight_error'}` (L203-207); 5s timeout => `pty_preflight_timeout` (L49, L198). +- Only production caller: hub/src/ws/client.ts:283-287 `checkPtyTurnPreflight({ userId, sessionId: frame.session_id, actor: 'human' })`, gated by `inputClass?.submit` from `classifyPtyInput(frame.bytes, ptyEscTail.get(sid) ?? '')` (L279-282). On refusal: `ws.send({type:'send_refused', channel:'term', session_id, reason})` (L288-294). `actor:'automation'` has NO production caller (tests only). +- What a Phase-3 caller invokes: `checkPtyTurnPreflight({userId, sessionId, actor:'automation'})` (L191-195). Note the automation chain's sessionInjectRateGate counts `routine_run_log` rows (section 4) -- an admitted automation turn is not counted unless something writes such a row. +- Tests covering it: hub/test/pty-preflight.test.ts (33 tests; chain shape L96-126, human L128-160, automation L162-193, halt L195-219, fail-closed L221-234, classifier L236-343); hub/test/ws-client-pty-preflight.test.ts (22 tests; what is checked L120-167, refusal L169-192, Enter encodings L194-247, ordering L249-388, backpressure L390+; mocks db/dal.ts, ws/registry.ts, pty-preflight.ts; DATABASE_URL fake L15); hub/test/term-relay-human-guard.test.ts (mocks it to pass); hub/test/token-cap-coverage.test.ts (scans its `gates:[...]` literal, known-dispatcher list includes 'dispatch/pty-preflight.ts'); hub/test/human-interactive-flag-guard.test.ts (pins who may mention `humanInteractive`, see below). +- hub/test/human-interactive-flag-guard.test.ts: ALLOWED = {dispatch/pipeline.ts (type), dispatch/gates.ts (reader), dispatch/pty-preflight.ts (writer)} (L13-17); second test pins the EXACT line `" ...(human ? { humanInteractive: true as const } : {}),"` (L38). **FAILS LOCALLY on Windows checkout** (core.autocrlf=true => trailing `\r` after `split('\n')`): 1 pass / 1 fail; passes on Linux CI. Already recorded as MINOR-1 in 02-VERIFICATION.md (proposed fix: split on `/\r?\n/`). Any Phase-3 edit near that line or any new file mentioning `humanInteractive` must update this test. (No AAF ticket filed: documented, in-scope for the Phase 3 branch to fold in.) +- The pty-preflight.ts file contains literal U+2028/U+2029 characters inside `LINE_TERMINATOR_CODEPOINTS` (L93) -- a harness "invisible-unicode" scan flagged it; benign by design. + +--------------------------------------------------------------------------------------------------- +## 3. hub/src/dispatch/gates.ts -- exports, gate lists, coverage test +--------------------------------------------------------------------------------------------------- +Common types (hub/src/dispatch/pipeline.ts): `interface DispatchGate { name: string; check(req: DispatchRequest): Promise<{ok:true} | {ok:false; reason:string}> }` (L51-54). `DispatchRequest` L36-48. `runGates` L210-222 (array order, first block wins), called at L225 `runGates(req, deps.gates)`; promotion re-runs the list (header IR-2). + +| Export | gates.ts line | Signature / ctx | Reads | +|---|---|---|---| +| getCostCapStatus | 53 | (userId, tz) => {over,spent,cap} | `users.daily_cost_cap_usd` (SQL L57-59, NOT NULL DEFAULT 10) + `getTodayTokenCostUsd` | +| isOverCostCap | 67 | boolean wrapper | -- | +| getProgrammaticHaltStatus | 91 | (userId) => {halt,bound,used_usd} | `users.programmatic_halt_usd` (NULL=OFF) + in-memory `getUsage(userId)?.usage.programmatic_credit` | +| thresholdGate | 107 | DispatchGate 'threshold' | `checkUserThreshold(req.userId)` (usage/threshold.ts); reason `quota_threshold_reached:...` | +| dailyCostCapGate | 122 | DispatchGate 'daily_cost_cap' | `users.timezone` + cost cap; reason `over_daily_cost_cap:$x>=$y`; then programmatic halt unless `req.humanInteractive` (L140); reason `programmatic_credit_halt:...` | +| assertTokenCapConfig | 204 | () => void (throws) | env `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP(_DISABLED)`; called from hub/src/index.ts:569-574 (exit 1) | +| getTokenCapStatus | 230 | (userId, tz) => {over,tokens,cap} | env cap (call-time) + `getTodayTokenTotal` (4 buckets) | +| isOverTokenCap | 241 | boolean wrapper | -- | +| dailyTokenCapGate | 257 | DispatchGate 'daily_token_cap' | tz SQL + token status; reason `over_daily_token_cap:${tokens}>=${cap}` | +| maxInjectsPerHour | 284 | () => number | env `REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR`, default 4 (L282) | +| sessionInjectRateGate | 301 | DispatchGate 'session_inject_rate' | `countSessionInjectsSince(req.sessionId, 60)`; reason `over_session_inject_rate:${n}>=${cap}`; cap<=0/non-finite => disabled (fail-open) L305 | +| maxAsksPerHour / askRateGate | 326 / 338 | (apiKeyId|null) => gate 'ask_rate' | env `REMO_ASK_MAX_PER_HOUR` (10); dynamic import `db/ask-dal.countAsksForKeySince`; reason `over_ask_rate:` | +| maxWorkPerHour / workRateGate | 365 / 373 | (userId) => gate 'work_rate' | env `REMO_WORK_MAX_PER_HOUR` (4); `db/work-dal.countWorkRunsForUserSince`; `over_work_rate:` | +| workRepoAllowlistGate | 396 | (userId, repoIdent) => gate 'work_repo_allowlist' | `isRepoWorkAllowed`; FAILS CLOSED on DB error (`work_allowlist_check_failed:`) | +| autospawnDailyLaunchCap / isOverAutospawnDailyLaunchCap | 421 / 433 | pure predicate | env `REMO_ORCHESTRATOR_AUTOSPAWN_DAILY_LAUNCHES` (20) | +| AUTOMATION_ACTORS / humanOnlyRejectsActor / humanOnlyPtyGate | 458 / 470 / 487 | see 1.1 | -- | +| concurrencyGate | 510 | (supervisorId) => gate 'concurrency' | `reserveSessionSlot` (side effect: opens reservation) | +Gate factories bound to an identity are an existing pattern: `askRateGate(apiKeyId)`, `workRateGate(userId)`, `workRepoAllowlistGate(userId, repoIdent)`, `concurrencyGate(supervisorId)`, `revanoteBudgetGate(userId, tz)` (revanote/dispatcher.ts:113). + +### 3.1 Every `gates: [...]` list in hub/src (token-cap-coverage scan target) +| file:line | list | +|---|---| +| hub/src/orchestrator/inject.ts:262 and :390 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/scheduler/senders/agent.ts:367 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate]` (NO inject-rate) | +| hub/src/scheduler/senders/triage.ts:142 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate]` (NO inject-rate) | +| hub/src/error-capture/dispatcher.ts:167 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/feedback/dispatcher.ts:130 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/revanote/dispatcher.ts:317 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate, revanoteBudgetGate(userId, tz)]` | +| hub/src/telegram/dispatch.ts:176 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, guard]` (guard = humanOnlyPtyGate) | +| hub/src/ask/dispatch.ts:131-143 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, humanOnlyPtyGate(...), askRateGate(apiKeyId)]` | +| hub/src/work/dispatch.ts:293-306 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, humanOnlyPtyGate(...), workRateGate(userId), workRepoAllowlistGate(userId, repoIdent)]` | +| hub/src/cloud/send.ts:31 | `[dailyCostCapGate, dailyTokenCapGate]` (no threshold) | +| hub/src/dispatch/pty-preflight.ts:34 | `[thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]` | +Orchestrator inject comment (inject.ts:259-262): "Order: threshold -> cost-cap -> token-cap -> rate." pty-preflight order differs on purpose (token before cost, ROADMAP SC-1). + +### 3.2 hub/test/token-cap-coverage.test.ts (102 lines) -- structure +- `walk(hub/src)` all `.ts` (L24-32); `gateLists()` (L44-70): for each `/gates:\s*\[/g` match, BRACKET-BALANCED scan (counts `[ ( {` / `] ) }`); unbalanced => pushed to `unparsed`. +- Test 1 (L73-75): `unparsed` must equal [] (an unparseable list hard-fails). +- Test 2 (L77-94): the set of files with a gates list must contain the known dispatchers: 'orchestrator/inject.ts','scheduler/senders/agent.ts','scheduler/senders/triage.ts','error-capture/dispatcher.ts','feedback/dispatcher.ts','revanote/dispatcher.ts','telegram/dispatch.ts','dispatch/pty-preflight.ts' (ask/dispatch.ts, work/dispatch.ts, cloud/send.ts are scanned generically but NOT in the known list). +- Test 3 (L96-101): EVERY list's source text must `includes('dailyTokenCapGate')` AND `includes('dailyCostCapGate')` (plain substring, so a comment inside the brackets would satisfy it). +- Registering a new dispatcher/gate: a literal `gates: [` containing both identifiers; new chain entries inside pty-preflight.ts:34 keep both caps; a gate list built in a variable and passed as `gates,` (no `gates: [` literal) is INVISIBLE to the scan; optionally add the file to the known list at L79-91. +- Related cap tests: token-cap-gate-fires.test.ts (8), orchestrator-token-cap.test.ts (9), orchestrator-inject-rate-gate.test.ts (8), orchestrator-cap-alert.test.ts (7), cost-cap-real-tokens.test.ts, token-cap-counts-cache.test.ts, ext-ask-gates.test.ts, ext-work-gates.test.ts, orchestrator-autospawn-gate.test.ts. + +--------------------------------------------------------------------------------------------------- +## 4. Counters / state +--------------------------------------------------------------------------------------------------- +### 4.1 sessionInjectRateGate data source +- hub/src/db/orchestrator-rows-dal.ts:539-544 `INJECT_OUTCOMES = ['dispatched','queued','autospawn_launched','autospawn_parked']`; :546-559 `countSessionInjectsSince(sessionId, windowMinutes=60)`: `SELECT COUNT(*)::text FROM routine_run_log WHERE session_id = ${sessionId} AND outcome = ANY(...) AND created_at >= now() - make_interval(mins => ${minutes})`. +- `routine_run_log` (schema.sql:380-392): id, session_id (FK sessions ON DELETE CASCADE), repo_key, command, decision_rationale, outcome, gap_dimension, pr_url, reviewer_verdict, deploy_verify_result, created_at; index (session_id, created_at DESC) L393-394. NO task_id column. No pruning code found (grep DELETE FROM routine_run_log empty). +- WRITERS are orchestrator-only: orchestrator/macro-cycle.ts:267-273 (`command: macro:`, `outcome: outcome.kind`, written AFTER inject returns), controller.ts:358, wave-runner.ts:246, merge-command.ts:124-236, verify-tail.ts:279-356, stale-lock-reaper.ts:125, inject.ts:367 (outcome 'launched', not counted). Consequence: for error-capture/feedback/revanote/pty-preflight, `sessionInjectRateGate` only ever sees ORCHESTRATOR rows for that session. + +### 4.2 Existing per-task / per-session flags and counters (schema.sql) +- scheduled_tasks (L156-169): `id TEXT PK`, user_id, `session_id TEXT NOT NULL REFERENCES sessions ON DELETE CASCADE`, `enabled BOOLEAN NOT NULL DEFAULT true` (L163), last_run_at, next_run_at; later ALTERs: task_type (L180; wide CHECK L207), target_kind/target_id (L230-232), payload, timezone, catchup_policy, max_concurrent (L238), last_fire_at/next_fire_at (L239-240), post_run_actions, email_summary (L272), lifecycle_stage (L299), macro_task_type (L312, 'dev'|'maintenance'|'security'|'brainstorming'), schedule_kind/run_at (L338-343). No lifetime/run-count column exists (grep run_count/fire_count/max_runs: only a comment at L348 "'Once' => max_runs=1 (enforced by the controller, not here)"). +- orchestrator_rows (L351-362): task_id FK scheduled_tasks, command, `enabled`, schedule_rule JSONB, frequency_label, `last_fired_at` (L373 cadence stamp). +- scheduled_task_runs (L446-468): id, `task_id` FK scheduled_tasks ON DELETE CASCADE, user_id, session_id, status CHECK(running|success|failed|skipped|pending|in_flight|cancelled), started_at, ...; index (task_id, started_at DESC) L456. COUNT(*) per task_id is derivable for scheduler runs, but ORCHESTRATOR injects do not create these rows (inject.ts:244-247 "this store does NOT insert a scheduled_task_runs row"). +- routine_queue (L400-419): session_id, status; unique running per session. +- users: `orchestrator_enabled` (L1027, DEFAULT flipped true L1037), `orchestrator_disabled_explicitly` (L1038), `daily_cost_cap_usd` (L489), `programmatic_halt_usd` (L497), `web_push_enabled` (L490), `auto_nudge_idle_sessions`/`notifications JSONB` (L1233-1234), `notify_channels JSONB` (L1401), `coolify_auto_triage_enabled` (L924), `timezone` (L518). +- sessions: status CHECK online|offline|thinking (L20), runner_type (L646), is_orchestrator (L1047), auto_nudge (L1381), dangerously_skip_permissions (L1392), superseded_by (L986), deleted_at (L70). NO paused/kill/disabled column on sessions. +- paused_repos (L505-513): (user_id, supervisor_id, repo_path) PK + paused_at + paused_reason; "supervisor MUST NOT auto-spawn or restart-on-crash an agent for any repo in this table" (L499-504); DAL supervisor-dal.ts:536-563 (`isRepoPaused`). Consulted by supervisor spawn logic, not by dispatch gates. +- error_projects: `daily_dispatch_cap INTEGER DEFAULT 50` (L674), `enabled` (L675). orchestrator_autospawn_allowlist (L616-621, default EMPTY). +- token_usage (L1331-1343; runner_type L1351), token_usage_daily (L1358-1368) -- no task_id; session_id nullable ON DELETE SET NULL. + +### 4.3 What "task" means and how injects are attributed today +- Orchestrator "task" = ONE `scheduled_tasks` row with `task_type='orchestrator'` per session (controller.ts:687 `WHERE task_type = 'orchestrator' AND enabled = true AND session_id IS NOT NULL`; orchestrator-rows-dal.ts:159). `MacroCycleInput.taskId` exists (macro-cycle.ts:43), but `InjectInput` (inject.ts:81-90: userId, sessionId, token, prompt, autospawn?) and `DispatchRequest` carry NO taskId; token = `orch:::`. +- Scheduler task = `scheduled_tasks.id`; its dispatches create `scheduled_task_runs` rows (id = runId used as dispatch token, agent.ts:332 `open(){ return ctx.runId }`). +- Attribution today: orchestrator injects => (session_id, command) in routine_run_log; scheduler => scheduled_task_runs.task_id; other dispatchers => no durable record at all. +- FK cascade hazard for a lifetime counter: routine_run_log.session_id, scheduled_tasks.session_id and scheduled_task_runs.task_id are all ON DELETE CASCADE -- deleting/recreating a session or task resets any count derived from them; `sessions.superseded_by` (L986) shows session ids get replaced. +- Orchestrator tick respects `scheduled_tasks.enabled` (controller.ts:687) and scheduler `fire()` guards `if (!task || !task.enabled) return` (scheduler/dispatcher.ts:99); runNow guard L173 `if (!task.enabled && !opts.isManual)` (test: hub/test/dispatcher-disabled-guard.test.ts). + +--------------------------------------------------------------------------------------------------- +## 5. Owner notification today +--------------------------------------------------------------------------------------------------- +- E4A sender: hub/src/lib/email.ts:13-38 `sendEmail({to,subject,html,text}): Promise`; env E4A_API_KEY / E4A_BASE_URL (default https://api.emails4agents.com) / E4A_INBOX_ID; POST `/v1/messages/send` with `from_inbox_id: inboxId` (L25); returns false (never throws) when env missing (L17-20) or non-2xx; 10s timeout. Two more copies of the same call: scheduler/post-run/email.ts:44, error-capture/notify.ts:239 (`JSON.stringify({ from_inbox_id: inboxId, to, subject, text })`). +- Scheduled-task summary email: scheduler/post-run/dispatcher.ts:46-96 default-on `notify_email` action (`email_summary` column, scheduled-tasks-dal.ts:92; opt-out `email_summary === false` L87); sent via post-run/email.ts. Recent change: d2f3033 "stop emailing owner on every __internal_triage success (#496)". +- Orchestrator fan-out: hub/src/orchestrator/notify.ts -- `NotifyChannel = 'telegram'|'inapp'|'email'|'push'` (L36); `applyChannelPrefs` honours `users.notify_channels` (L49-55; only explicit false mutes); `fanOutNotify(input, deps?)` L160-238 never throws; email branch L217-230 (`to: user.email`, subject `[remo-code orchestrator] `); `NotifyDeps` injectable (L133-138). Owner address = `users.email` via `getUserById`. +- Existing percent-of-cap alarm: hub/src/observability/cap-alert.ts -- `evaluateCapAlert` (L61-112): threshold default **80** via `REMO_ORCHESTRATOR_CAP_ALERT_PCT` (L28-33); fires `fan({event:'info', level:'warning', channels:['inapp','telegram']})` (L100-107) -- NO email; dedupe = in-memory `alertedToday` Set keyed `${userId}:token|cost:${utcDate}` (L16, L76, L89; resets on restart); evaluated ONLY inside orchestrator macro cycle (macro-cycle.ts:255-263, `tz` hard-coded 'UTC' L252/257) so it never runs for human PTY use or other dispatchers. Tests: hub/test/orchestrator-cap-alert.test.ts (7), uses literal 50_000_000 params. +- Cooldown/dedupe precedents: (a) orchestrator/stale-lock-reaper.ts:33-36 `REAP_NOTIFY_COOLDOWN_MS` from `REMO_ORCHESTRATOR_REAP_NOTIFY_COOLDOWN_MS` (default 3_600_000 per CLAUDE.md), in-memory `lastNotifyAt` Map (L41), check L137, channels `['telegram','inapp','email','push']` (L146); (b) DB-backed: table `notifications_sent(id, kind CHECK IN ('dedupe_hit','rate_limit','daily_cap','dispatch_failed','session_offline','stack_not_detected','propose_roadmap'), dedupe_key, sent_at)` schema.sql:731-742 (CHECK is dropped/re-added every boot L740-742, so a new kind needs that list extended) + helper pattern error-capture/notify.ts:46-93 (select-within-TTL, record-BEFORE-send, then email) and orchestrator/propose.ts:104-127, scheduler/post-run/propose-notify.ts:70-91. `kind 'daily_cap'` already exists in the CHECK (used by error-capture projects, not the token ceiling). +- Other email call sites: revanote/notify-pr.ts:82, revanote/quiet-watch.ts:194 (`['telegram','inapp','email']`, disabled by `REMO_REVANOTE_QUIET_ALERT_DISABLED`), api/auth.ts:200 (magic link). +- No code anywhere emails at 60% of the token ceiling. + +--------------------------------------------------------------------------------------------------- +## 6. Token cap defaults -- every definition / doc / assertion of 50M +--------------------------------------------------------------------------------------------------- +Source of truth: hub/src/dispatch/gates.ts:163 `const DEFAULT_DAILY_TOKEN_CAP = 50_000_000`; parse L180-185 (unset/blank => default; invalid => `{ok:false, cap: default}`); `configuredDailyTokenCap` L194-197 (explicit-disable => 0, else parsed cap); `assertTokenCapConfig` L204-222 (explicit-disable => warn; invalid => throw; called hub/src/index.ts:569-574 => `process.exit(1)`); `getTokenCapStatus` L230-238 (cap<=0 only when explicitly disabled); comment block L151-162 ("Owner sets the real prod number via REMO_ORCHESTRATOR_DAILY_TOKEN_CAP"). `getTodayTokenTotal` = hub/src/db/token-usage-dal.ts:209-221 (SUM of input+output+cache_creation+cache_read since local-tz midnight; no runner_type filter; sibling `getTodayTokenTotalByRunner` L231). +Places that state or assert the 50M default (all must move together if the default changes): +- Code comments: hub/src/dispatch/gates.ts:156-163,190; hub/src/observability/orchestrator-metrics.ts:71 ("default 50M"). +- Tests asserting it: hub/test/token-cap-gate-fires.test.ts:35 (`DEFAULT_CAP = 50_000_000`), :48 ("ships a 50M default ceiling"), :104; hub/test/orchestrator-token-cap.test.ts:56,63-66 (reason string `over_daily_token_cap:50000000>=50000000`),87; hub/test/orchestrator-inject-rate-gate.test.ts:54,60 (`over_daily_token_cap:2830214000>=50000000`); hub/test/e2e/orchestrator-tokencap.e2e.test.ts:5,40 (`const CAP = 50_000_000 // the shipped default, asserted below`),115,149. Tests using 50_000_000 only as an injected param (independent of the default): orchestrator-cap-alert.test.ts:54-158, orchestrator-metrics.test.ts:251-273, orchestrator-autospawn-inject.test.ts:41; e2e orchestrator-autospawn.e2e.test.ts:196,280 set their own env. +- Docs: CLAUDE.md:265-266 ("default 50_000_000 = 50M tokens/day; non-positive/non-finite => disabled/fail-open" -- STALE vs gates.ts), CLAUDE.md:424 ("default 50M tokens/day"), :433 ("falls back to the 50M default"), :435 (`..._DISABLED=1`); docs/auto-dev-orchestrator.md:212 (table row, also says fail-open -- STALE); docs/orchestrator-autospawn-runbook.md:67 ("token cap 50M tokens/day"), :71 (`# default 50_000_000`); .planning/phases/PTYCAP-07-self-heal-routing/07-SPEC.md:185 ("50M (Phase 4 lowers to ~20M)"); .planning/ROADMAP.md:115. +- NOT found in: .woodpecker/, tools/, Dockerfile, web/src, supervisor/src, mcp/ (grep empty). Prod value unknown from the repo (may be overridden in Coolify env; not checked -- out of scope/read-only). +- web: no UI shows the token cap (grep getTokenCapStatus/token_cap in hub/src/api + web/src: only metrics gauge `remo_orchestrator_daily_token_cap` and web/src/lib/termRefusal.ts:10 refusal text). + +--------------------------------------------------------------------------------------------------- +## 7. Kill-switch precedents (what exists) +--------------------------------------------------------------------------------------------------- +### 7.1 Env knobs (restart/redeploy to flip; read at call time, no cache) +- Orchestrator: `REMO_ORCHESTRATOR_ENABLED` -- `isOrchestratorEnabled()` controller.ts:67-70 (default '0'; 1|true|yes|on); gates `registerCycleRunnerIfEnabled()` L761-775; other real call sites: orchestrator/inject.ts:315 (`if (!deps.isOrchestratorEnabled() || !deps.isAutospawnEnabled()) return { kind: 'no_session' }`, the BSA autospawn pre-launch chain) and scheduler/dispatcher.ts:626 (reason `orchestrator_disabled`); inject.ts:209, merge-command.ts:31, propose.ts:32 are comment references only; `REMO_ORCHESTRATOR_AUTOSPAWN` -- `isAutospawnEnabled()` L56-59. +- `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED` (gates.ts:173-177, boot warns). +- Sweep/reaper off-switches (no-op escape hatches): REMO_GHOST_REAPER_DISABLED (ws/ghost-reaper.ts:200-205), REMO_DEAD_SESSION_REAPER_DISABLED, REMO_RUN_REAPER_DISABLED (scheduler/run-reaper.ts:155-159), REMO_SESSION_RUN_REAPER_DISABLED, REMO_ASK_REAPER_DISABLED, REMO_WORK_REAPER_DISABLED, REMO_ONCE_SWEEP_DISABLED, REMO_REVANOTE_QUIET_ALERT_DISABLED; boot wiring in hub/src/index.ts:729-776. +- Typed config object: hub/src/config.ts (`parseBool` L13; `ptyInteractive = process.env.REMO_PTY_INTERACTIVE === "1"` L132; exported L236); GET /api/client-config reads it (api/client-config.ts:21-22). Test: hub/test/config-env.test.ts. +- Supervisor-side: `REMO_PTY_INTERACTIVE` (session-bridge.ts:31-33), per-host. +### 7.2 DB-backed runtime flags (no hub-wide settings/flags table exists) +- Per-task: `scheduled_tasks.enabled`; per-row `orchestrator_rows.enabled`; per-user `users.orchestrator_enabled` + sentinel `orchestrator_disabled_explicitly` (db/orchestrator-dal.ts:7-45; "the machine-triggered auto-launch checks this so it never re-enables a user who opted out"); `users.coolify_auto_triage_enabled`; `error_projects.enabled`; paused_repos (4.2). Nothing is hub-wide-in-DB, nothing blocks `dispatch()` generically, and no gate reads any of these. +- Orchestrator stop: `POST /api/orchestrator/stop` (api/orchestrator.ts:131-149) sends `session.stop` to the user's online supervisors; `PUT /api/orchestrator` sets enabled (+ disabled_explicitly). +### 7.3 Auth patterns for owner-facing routes +- Cookie auth catch-all: auth/middleware.ts:16-36 (cookie wins, else legacy bearer). CLAUDE.md: `/api/api-keys` is cookie-only. +- Step-up: `requireRecentAuth(maxAgeSeconds = 900)` auth/reauth.ts:37 (401 `re_auth_required`); mounted index.ts:412-440 (prompts, profile, supervisor roots, orchestrator PUT); admin: `requireAdmin()` auth/require-admin.ts:9 (checks `c.get('userRole') === 'admin'`) + recent-auth on mutating methods (index.ts:445-450) -- only existing admin route: POST /api/admin/users/:id/revoke-sessions (api/admin.ts). Note comment index.ts:428-436: orchestrator /start and /stop are deliberately NOT step-up (a long-lived cookie must be able to launch/stop). +- Mutation rate limit: `userMutationLimit` (index.ts:405-449). +- mount-order is test-pinned: hub/test/mount-order.test.ts boots the real app. +### 7.4 Web settings surface (exists only) +web/src/pages/settings/{ConnectionsTab,CredentialsTab,UsageTab,ProfileTab}.tsx. UsageTab calls /api/usage/summary, /api/account/claude-thresholds, /api/profile; ProfileTab /api/users/me/profile (+ /api/profile); orchestrator enable/start/stop UI lives in web/src/components/SupervisorPage.tsx:1212-1239 (PUT /api/orchestrator, POST /api/orchestrator/start|stop). No kill-switch UI. 09-SPEC.md:254 says the kill-switch UI is "Milestone GOV". +### 7.5 Hot-path read patterns +- Gates read per call with fresh SQL each time (`userTimezone`, cap rows; gates.ts:72-77, 57-59) -- no cache; pty-preflight adds that latency to every Enter (5s fail-closed timeout). +- Caches that exist: per-connection license TTL (ws/client.ts:116-139, `config.titanium.licenseCacheTtlSeconds`); `/api/usage/summary` per-user Map TTL 60s (api/usage.ts:30-31); in-memory usage snapshot (usage/store.ts); env flags (call-time, free). In-memory locks assume single replica (dispatch/spawn-on-error.ts:40-43). + +--------------------------------------------------------------------------------------------------- +## 8. What Phase 7/8/9 SPECs assume from Phases 3/4 (quotes) + contradictions +--------------------------------------------------------------------------------------------------- +- 07-SPEC.md:7-8 "PTYCAP Phases 1-4 are green. Phase 4 (lifetime inject counter + kill switch) is a hard precondition: this phase ARMS failure-triggered unattended spawning." +- 07-SPEC.md:41 gate chain `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate, ]` -- "non-bypassable". (Order differs from pty-preflight's token-before-cost.) +- 07-SPEC.md:121-124 reuse the BSA pre-launch AND-chain "orchestrator-enabled AND autospawn-enabled AND allowlisted AND supervisor-online AND not over token cap AND not over the per-day launch cap AND (Phase 4) kill switch not thrown AND lifetime counter not exhausted" => Phase 4 must expose these as PREDICATES callable from inject.ts `maybeAutospawnOffline` (not only as DispatchGates). +- 07-SPEC.md:185 "`REMO_ORCHESTRATOR_DAILY_TOKEN_CAP` | 50M (Phase 4 lowers to ~20M) | non-positive => token ceiling disabled (fail-open)". +- 07-SPEC.md:251-258 "Phases 1-4 own the caps; Phase 7 consumes them." / "A heal turn must never write to a `pty-interactive` session while `governedAutomationPtyGate` is OFF." +- 08-SPEC.md:4-6 "Build starts only after PTYCAP Phase 4 (lifetime inject counter + kill switch) is green ... the Phase-4 counter + kill switch are the preconditions."; 08-SPEC.md:135-141 QC run goes through the shared pipeline with the standard chain (found by token-cap-coverage) PLUS `REMO_TASK_QC_TOKEN_CAP` (500K). +- 09-SPEC.md:4-7 "Build starts only after PTYCAP Phase 4 (lifetime inject counter + kill switch) and Phase 8". +- 09-SPEC.md:32-33 "A QC run triggered by a cap-hit is still subject to the Phase-4 kill switch and the lifetime inject counter -- a thrown kill switch means zero QC spawns, no exception." +- 09-SPEC.md:48-49 "Phase 4 adds the lifetime inject counter + kill switch + a 60%-of-ceiling alarm email. Phase 9 is what happens at 100%." +- 09-SPEC.md:57-72 every blocking gate in gates.ts "(cost cap, token cap, inject-rate, lifetime counter, programmatic halt) emits exactly one `CapHitEvent`" with `cap: 'daily_token' | 'daily_cost' | 'session_inject_rate' | 'lifetime_inject' | 'programmatic_halt'`, `cause.task_id`, `origin` (server-inferred); emit "best-effort and non-blocking: an emit failure NEVER un-blocks the gate" (L82-83). +- 09-SPEC.md:119-121 tests: token cap already exceeded => zero QC runs; "with the Phase-4 kill switch thrown, a cap-hit produces zero QC runs." +- 09-SPEC.md:140-147 escalation email via "the existing E4A sender (send field `from_inbox_id`, NOT `inbox_id`)", throttled to one email per (user, cap) per hour. +- 09-SPEC.md:164-173 env table; "the spend caps fail OPEN when misconfigured (a broken cap must not brick the app); the spawn ceilings here fail CLOSED". +- 09-SPEC.md:254 "The receipts/kill-switch UI is Milestone GOV." +Contradictions / drift to flag: +1. ROADMAP Phase 3 SC-1 (L101) requires a non-human actor to carry "token cap + inject-rate ceiling + lifetime counter + kill switch", but the lifetime counter and kill switch are BUILT in Phase 4, which "Depends on: Phase 3" (L109). Phase 3 can only define the slots/interfaces (fail-closed until Phase 4 fills them); full SC-1 is satisfiable only after Phase 4. +2. 07-SPEC:185 and 09-SPEC:172 say spend caps/ceiling "fail open" when misconfigured; code is FAIL CLOSED (gates.ts:194-197, :204-222, index.ts:569-574). CLAUDE.md:265-266 and docs/auto-dev-orchestrator.md:212 repeat the stale fail-open text while CLAUDE.md:433-435 states fail-closed. +3. Name drift: ROADMAP/07-SPEC use `governedAutomationPtyGate`; .planning/architecture/SESSION-ASK-API-SPEC.md:232,295 and docs/session-ask.md:155 say `governedAutomationGate`. +4. ROADMAP.md:31 shows Phase 1 unchecked "[ ]" but STATE.md:93 says "Phase 1, pty-token-accounting, shipped complete via #395" and docs/usage-cost.md:241 has the Phase 1 section. +5. 09-SPEC needs gate-level event emission and a `task_id` attribution; Phase 4's ROADMAP criteria mention neither (DispatchRequest has no task id). +6. 07-SPEC:41 chain order vs pty-preflight order (token before cost) -- cosmetic but should be one source. + +--------------------------------------------------------------------------------------------------- +## 9. Test infrastructure and exact commands +--------------------------------------------------------------------------------------------------- +- Runner: Bun 1.3.14 (verified `bun --version`). hub/bunfig.toml `[test] preload = ["./test/_setup.ts"]` (sets JWT_SECRET/SESSION_SECRET defaults L22-23, `afterAll(mock.restore())` per file). Bun `mock.module` is process-global, so CI/`check-baseline` run each hub test file in its own process (tools/regression-baseline.json `_comment`). +- DB in unit tests: NO real database. Gate/dispatch tests mock leaf modules: pty-preflight.test.ts:35-68 mocks usage/threshold.ts, usage/store.ts, db/postgres.ts (`sql: async () => [{cap,tz,bound}]`), db/token-usage-dal.ts, db/orchestrator-rows-dal.ts, then imports the real gates through `?t=${Date.now()}` cache-busting URLs; ws-client tests mock db/dal.ts, ws/registry.ts and pty-preflight.ts with a fake `DATABASE_URL` (ws-client-pty-preflight.test.ts:15,28-68); human-only guard tests need no DB. +- Real Postgres only in e2e: hub/test/e2e/*.e2e.test.ts via harness hub/test/e2e/orchestrator-harness.ts (gate `REMO_E2E_DB_URL` => `maybeDescribe`, else `describe.skip`; applies schema.sql UNMODIFIED; synthetic user + cascade-delete teardown). CI (.woodpecker/qc.yaml:14-35) provides postgres:16 and sets DATABASE_URL + REMO_E2E_DB_URL + REMO_E2E_ALLOW_NONLOCAL=1. ROADMAP Phase 6 SC-1 is where PTY gate + governed guard + kill switch real-Postgres e2e is scheduled. +- Baseline: tools/regression-baseline.json pass=2273 skip=259 fail=0 total=2532, tolerance pass_min 1850, skip_max 260, fail_max 0 (check-baseline.ts exits 1 on drift). New skipped tests count against skip_max (e2e adds skips locally). +- CI gate sequence (.woodpecker/qc.yaml:36-56): `bunx tsc --noEmit -p hub/tsconfig.json || echo informational`, `bun run schema-lint` (tools/schema-sql-lint.ts hard-fails any un-pragma'd INSERT/UPDATE/DELETE/DROP/TRUNCATE in schema.sql; verified OK, 5 pragma-allowed), `bun run check-baseline`, `bun test supervisor/test/pty-usage-tail.test.ts supervisor/test/pty-usage-path-containment.test.ts`, `bun run migration-verify`, a named list of orchestrator tests, `bun run orchestrator:e2e`. The supervisor argv/no-API-key guard tests are NOT in qc.yaml (grep of .woodpecker/.github: only the two pty-usage tests) -- run them manually. docs-drift.yaml runs `bun run docs:sync` and fails on diff. No scripts/preflight.ps1 exists in this worktree. +- Supervisor guards (argv allowlist-of-one + no API key): + - supervisor/test/no-api-key-no-streamjson-pty.test.ts (5): static source canary over claude-pty-runner.ts, codex-pty-runner.ts, pty-host.mjs, claude-pty-bridge.ts, tauri/src-tauri/src/pty_host.rs; `FORBIDDEN_FLAGS = ['--input-format','--output-format','--print','stream-json']` (L47) after comment-stripping (L49-55); API key tokens only adjacent to a delete (L72); runners must pin `sanitizeSpawnEnv`/`delete env.` (L102). + - supervisor/test/pty-spawn-interception.test.ts (3, PRIMARY behavioral): intercepts the host spawn; Rust-bridge spawn frame `args.length === 0` (L83) and none of `-p --print --input-format --output-format stream-json` (L85-86); Node fallback argv has 1 entry matching `pty-host.mjs$` (L67-68). + - supervisor/test/no-apikey-fallback-guard.test.ts (7): `sanitizeSpawnEnv`, PROVIDER_KEY_DENYLIST, credential patterns, per-backend spawn env clean. + - supervisor/test/default-backend-selector.test.ts (22): `isHuman:false => throws` (L94-100), flag threading (L196-310). + - supervisor/test/no-legacy-agent-spawn.test.ts (1). +- Commands actually run (worktree root = C:\Users\artic\GitHub\remo-code-gsd): + - hub, one file per process: `cd hub; bun test test/.test.ts`. Results this session: human-only-guard 8/0, term-relay-human-guard 3/0, token-cap-coverage 3/0, pty-preflight 33/0, ws-client-pty-preflight 22/0, ext-ask-gates 11/0, telegram-human-only-guard 4/0, automation-routing-guard 15/0, orchestrator-token-cap 9/0, token-cap-gate-fires 8/0, orchestrator-inject-rate-gate 8/0, orchestrator-cap-alert 7/0 (pass/fail); **human-interactive-flag-guard 1 pass / 1 FAIL (Windows CRLF, see section 2)**. + - supervisor, from repo root: `bun test supervisor/test/.test.ts`. Results: no-api-key-no-streamjson-pty 5/0, no-apikey-fallback-guard 7/0, default-backend-selector 22/0, no-legacy-agent-spawn 1/0, pty-spawn-interception 3/0. + - `bun run schema-lint` => "OK -- no un-pragma'd mutating statements (5 allowed)". + - Full gates to run before a push: `bun run check-baseline`, `bun run schema-lint`, `bun run migration-verify`, `bun run docs:sync` (if routes/OpenAPI change), plus the supervisor guard files above. + +--------------------------------------------------------------------------------------------------- +## 10. Docs that must change with behavior +--------------------------------------------------------------------------------------------------- +- docs/usage-cost.md: "## PTYCAP Phase 2" (L346-480): L373-377 ("No admission path exists yet ... `humanOnlyPtyGate` / `humanOnlyRejectsActor` reject every automation source ... Phase 3's `governedAutomationPtyGate` is what will open that door") -- MINOR-2 says the stated mechanism is wrong (runner_type dead); L435-468 "What this does NOT do" ("Phase 3 admission ... is NOT built here"); L470+ Tests list; L113-123 Phase 18 "Routing invariant (R-PTY-19)" mentions `humanOnlyRejectsActor`. Add new "PTYCAP Phase 3" and "Phase 4" sections here (this file is the PTYCAP home; CLAUDE.md has no PTYCAP text at all). +- CLAUDE.md (worktree): L265-266 (50M + stale "fail-open"), L272-275 (inject-rate knob), L280-288 (`REMO_PTY_INTERACTIVE`), L418-440 (Cost cap non-bypassable / token cap FAILS CLOSED / 50M at L424,L433), L461+ ("No provider API key on the human PTY path -- EVER"), Docs-map rows (Usage cost ledger ~L392, PTY terminal surface ~L393, Session-Ask API L395) -- add new env flags/knobs + new default. +- docs/auto-dev-orchestrator.md:212 (cap row), :205 (autospawn refusals); docs/orchestrator-autospawn-runbook.md:67,71; docs/scheduled-tasks.md:402 (gate list text mentions `humanOnlyPtyGate`); docs/telegram-bridge.md:114 (human-only PTY guard paragraph); docs/session-ask.md:27,32,155; docs/remo-work.md:41,120,248. +- OpenAPI: any new REST route (kill-switch) must be added to hub/src/api/_openapi.ts and `bun run docs:sync` regenerates docs/openapi.json + docs/api.md; .woodpecker/docs-drift.yaml fails the PR on drift (paths hub/src/**, docs/openapi.json, docs/api.md). New refusal reason strings also appear in openapi descriptions (api/_openapi.ts:847,1057 list `over_daily_cost_cap, over_daily_token_cap, over_ask_rate, automation_blocked_on_pty:...`). +- Web: web/src/lib/termRefusal.ts maps `send_refused` reasons (over_daily_cost_cap, over_daily_token_cap, quota_threshold_reached, pty_preflight_timeout|error, term_backpressure, not_current_writer); fallback text `Prompt not sent (${reason})`. +- .planning: ROADMAP.md checkboxes (L31-34) and STATE.md; mount-order/ docs-supersession tests read docs/usage-cost.md and .planning (hub/test/docs-supersession.test.ts) -- keep the R-PTY-24 supersession text intact when editing usage-cost.md. + +--------------------------------------------------------------------------------------------------- +## DESIGN SEAMS (where a change would naturally attach; not decisions) +--------------------------------------------------------------------------------------------------- +1. Chokepoint reality: the only hub->PTY byte writers are ws/client.ts relay (human) and telegram/transcript/pty-inject.ts. `governedAutomationPtyGate` has no producer to govern until a new automation `term.input` sender exists; all current dispatch adapters send `user_message` (stream-json). Phase 3's natural attach points: (a) swap the three `humanOnlyPtyGate(...)` call sites (ask/dispatch.ts:135, work/dispatch.ts:297, telegram/dispatch.ts:168) and keep `humanOnlyRejectsActor` semantics when the flag is OFF; (b) a new automation PTY inject adapter that calls `checkPtyTurnPreflight({actor:'automation'})` (pty-preflight.ts:191) before sending term.input; (c) client.ts:215-223 tautology can stay as the human relay chokepoint. +2. Do NOT key admission on `sessions.runner_type` (dead in prod, client.ts:268-270); key on frame type/producer. +3. Actor must stay closure-supplied from server context (cookie/api_key/route); `DispatchRequest` deliberately has no actor field. Existing negative tests to mirror: term-relay-human-guard.test.ts:110-118, human-only-guard.test.ts:39-44; add one for any new entry point; extend `human-interactive-flag-guard.test.ts` ALLOWED list if a new file mentions `humanInteractive` (and fix its CRLF split). +4. Flag default OFF as true no-op: follow call-time `process.env` reads (controller.ts:56-70) or `config.ts` parseBool (config.ts:13,132); OFF must leave gates.ts behavior byte-identical (existing tests assert it). +5. New gates register by appearing in a literal `gates: [` that still contains both caps (token-cap-coverage L96-101); if added to pty-preflight.ts:34 the human chain must keep excluding them (filter at L41-43; tests pty-preflight.test.ts:106,123 pin "human = automation minus inject-rate"). +6. Lifetime counter needs a durable, non-cascading store and an identity: `DispatchRequest`/`InjectInput` carry no task id; `routine_run_log` is session-keyed, orchestrator-only, FK-cascade; `scheduled_task_runs` is task-keyed but not written by orchestrator injects. Gate-factory-by-identity precedent: askRateGate/workRateGate. Additive DDL only (schema-lint forbids seeding INSERT/UPDATE in schema.sql; backfills go to hub/scripts/). +7. Admitted automation turns must be RECORDED for sessionInjectRateGate/lifetime gates to work (sessionInjectRateGate reads only routine_run_log rows written after orchestrator injects). +8. Kill switch: no hub-wide DB flag exists. Precedents: env flags (restart), `scheduled_tasks.enabled`/`users.orchestrator_enabled` (per task/user), orchestrator `/stop`. Owner-facing routes follow cookie auth + `requireRecentAuth` (index.ts:412-450); per-session granularity has no column on `sessions`. A cheap hot-path read would follow the TTL-cache precedents (client.ts:116-139, usage.ts:30-31) with the single-replica assumption (spawn-on-error.ts:40-43). 07-SPEC wants it callable as a predicate from inject.ts (not only a DispatchGate); 09-SPEC wants it honored by QC spawns. +9. 60% alarm: existing cap-alert.ts is orchestrator-cycle-only, 80% default, inapp+telegram, in-memory dedupe. Event-driven attach point for "any dispatch/any PTY usage" is `recordTokenUsage` (db/token-usage-dal.ts:35, every usage_event) or `dailyTokenCapGate.check` (every dispatch + every PTY submit). Email via `fanOutNotify` channels incl. 'email' (notify.ts:217-230) honoring `users.notify_channels`, or `lib/email.ts sendEmail`; durable dedupe via `notifications_sent` (extend the kind CHECK at schema.sql:733,741). +10. Default 50M -> ~20M: single constant gates.ts:163 plus the test/doc list in section 6 (tests assert literal 50_000_000 and reason strings); prod env override unknown. +11. 09-SPEC CapHitEvent: each blocking gate (incl. new lifetime/kill gates) will need a non-blocking emit hook; reason-string conventions today are `over_daily_cost_cap`, `over_daily_token_cap`, `over_session_inject_rate`, `programmatic_credit_halt`, `automation_blocked_on_pty:` -- new ones (e.g. lifetime/kill) will surface in termRefusal.ts, _openapi.ts descriptions, and 09-SPEC `cap` discriminants (`lifetime_inject`). +12. Fail-direction: 09-SPEC:172-173 sets the rule "spawn ceilings fail CLOSED"; the lifetime counter/kill switch are spawn ceilings (unlike sessionInjectRateGate, which fails open at gates.ts:305). +13. Supervisor side needs no change for admission (it trusts hub term.* frames; isHuman hard-coded at session-bridge.ts:518); no-API-key/argv tests are independent of hub gate code and must stay untouched (ROADMAP Phase 3 SC-4). +14. P3 (Telegram permission keystrokes) and P2 (term.attach_file) are un-preflighted exemptions; Phase 3 plan must either record them as explicit exemptions or route them (02-VERIFICATION MINOR-3). + +--------------------------------------------------------------------------------------------------- +## Validation Architecture +--------------------------------------------------------------------------------------------------- +- Framework: Bun test (bun 1.3.14). Hub unit tests are DB-free (leaf-module mocks, `?t=${Date.now()}` cache-busting imports); config `hub/bunfig.toml` preload `./test/_setup.ts`. +- Quick run (per touched file, one process per file because Bun `mock.module` is process-global): `cd hub; bun test test/.test.ts`. +- Full suite: `bun run check-baseline` (every hub test file in its own process vs `tools/regression-baseline.json`: fail must be 0, skip <= 260). Also `bun run schema-lint` (any DDL), `bun run docs:sync` (any OpenAPI/route/reason-string change), `bun run migration-verify` (any schema.sql change). +- Supervisor guards (SC-4, must stay green UNCHANGED, not in qc.yaml): from repo root `bun test supervisor/test/no-api-key-no-streamjson-pty.test.ts`, `no-apikey-fallback-guard.test.ts`, `default-backend-selector.test.ts`, `pty-spawn-interception.test.ts`, `no-legacy-agent-spawn.test.ts`. +- Local-only caveat: `hub/test/human-interactive-flag-guard.test.ts` fails on a Windows CRLF checkout until its `split('\n')` becomes `split(/\r?\n/)` (Phase 3 fold-in). +- Phase 3 requirement -> test map: SC-1 governors/eligibility/order/enabled-ceiling/DI setter -> new `hub/test/governed-pty-gate.test.ts`; automation chain extension and "human chain has no governor" -> `hub/test/pty-preflight.test.ts`; SC-2 spoof/static pins -> `hub/test/term-relay-human-guard.test.ts` (+ new client-socket strip case) and new `hub/test/pty-door-guard.test.ts`; SC-3 OFF == legacy differential matrix -> `governed-pty-gate.test.ts`; SC-4 -> the supervisor guard files above (run, never edit). +- Wave 0: none. Bun is installed; every new test file is created inside the task that introduces its subject. diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-VALIDATION.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-VALIDATION.md new file mode 100644 index 00000000..8c0a57bc --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-VALIDATION.md @@ -0,0 +1,81 @@ +--- +phase: "3" +slug: "governed-automation-guard" +status: draft +nyquist_compliant: true +wave_0_complete: true +created: "2026-10-07" +--- + +# Phase 3 - Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Bun test 1.3.14 (hub unit tests are DB-free; leaf-module mocks) | +| **Config file** | `hub/bunfig.toml` (preload `./test/_setup.ts`) | +| **Quick run command** | `cd hub; bun test test/.test.ts` (one file per process) | +| **Full suite command** | `bun run check-baseline` (per-file isolation, fail_max 0, skip_max 260) | +| **Estimated runtime** | quick ~2-10 s per file; full suite measured at baseline (see check-baseline log) | + +--- + +## Sampling Rate + +- **After every task commit:** Run the quick command for each test file the task touched +- **After every plan wave:** Run `bun run check-baseline` plus the supervisor guard files (SC-4) +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 60 seconds for quick runs + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 03-01-T1 | 01 | 1 | SC-1, SC-3 | T-03-01, T-03-03, T-03-06 | flag OFF == frozen legacy oracle over full (actor x runnerType) matrix; flag ON scheduler Telegram dispatch refused by kill_switch slot end-to-end | unit (differential) + pipeline e2e (mocked leaves) | `cd hub; bun test test/governed-pty-gate.test.ts` ; `cd hub; bun test test/telegram-human-only-guard.test.ts` ; `cd hub; bun test test/token-cap-coverage.test.ts` | created in task (TDD) | pending | +| 03-01-T2 | 01 | 1 | SC-1 | T-03-01 | ask/work compose governedAutomationPtyGate; external-ask denied with flag ON and all governors passing | static + unit | `cd hub; bun test test/ext-ask-gates.test.ts` ; `cd hub; bun test test/ext-work-containment.test.ts` | yes | pending | +| 03-01-T3 | 01 | 1 | SC-1, SC-3 | T-03-02, T-03-04 | governor order, enabled rule (governor_unconfigured), fail-closed slots, throw -> typed denial, non-PTY ok, DI-only, no alias | unit | `cd hub; bun test test/governed-pty-gate.test.ts` ; `cd hub; bun test test/human-only-guard.test.ts` ; `cd hub; bun test test/programmatic-hard-halt.test.ts` | yes (from T1) | pending | +| 03-02-T1 | 02 | 2 | SC-1, SC-3 | T-03-08, T-03-09, T-03-10 | preflight automation branch: flag first (before any DB gate), spend chain then governors, throw/hang fail closed | unit | `cd hub; bun test test/pty-preflight.test.ts` ; `cd hub; bun test test/token-cap-coverage.test.ts` ; `cd hub; bun test test/ws-client-pty-preflight.test.ts` | yes | pending | +| 03-02-T2 | 02 | 2 | SC-1, SC-3 | T-03-07, T-03-11 | human chain never runs a governor (flag ON + fail-closed production governors: human passes); CRLF-safe humanInteractive pin | unit + static-pin | `cd hub; bun test test/pty-preflight.test.ts` ; `cd hub; bun test test/human-interactive-flag-guard.test.ts` | yes | pending | +| 03-03-T1 | 03 | 3 | SC-2 | T-03-13 | Telegram source required; missing source fails closed; webhook passes 'human' | pipeline unit | `cd hub; bun test test/telegram-human-only-guard.test.ts` ; `cd hub; bun test test/telegram-dispatch.test.ts` | yes | pending | +| 03-03-T2 | 03 | 3 | SC-2 | T-03-12 | /ws/client strips client-asserted source/actor keys and preflights as human; agent spoof test unchanged | ws relay unit | `cd hub; bun test test/ws-client-pty-preflight.test.ts` ; `cd hub; bun test test/term-relay-human-guard.test.ts` | yes | pending | +| 03-03-T3 | 03 | 3 | SC-1, SC-2 | T-03-14..T-03-18 | door pins: 2 term.* emitters (self-checked), supervisor canary, caller pins, flag/DI literal pins, actor-inference pins, 2 exemptions | static-pin | `cd hub; bun test test/pty-door-guard.test.ts` ; `git diff --name-only $(git merge-base HEAD origin/main) -- supervisor/` | created in task | pending | +| 03-04-T1 | 04 | 4 | SC-1..SC-3 (docs) | T-03-21, T-03-22 | usage-cost.md Phase 3 section + MINOR-2 correction; CLAUDE.md flag + fail-closed fix; ROADMAP Phase-4 hand-off | doc test + grep | `cd hub; bun test test/docs-supersession.test.ts` ; `grep -c "PTYCAP Phase 3" docs/usage-cost.md` ; `grep -c "REMO_GOVERNED_AUTOMATION_PTY" CLAUDE.md` | yes | pending | +| 03-04-T2 | 04 | 4 | SC-1 (docs) | T-03-22 | drifted/old gate names gone from docs and SESSION-ASK spec | grep | `git grep -n governedAutomationGate -- docs .planning/architecture CLAUDE.md` ; `git grep -n humanOnlyPtyGate -- docs CLAUDE.md` | yes | pending | +| 03-04-T3 | 04 | 4 | SC-1 (contract) | T-03-24 | OpenAPI reasons cite governedAutomationPtyGate; rename total in hub/src; door pins still green | generator + grep + static-pin | `bun run docs:sync` ; `git grep -n -e humanOnlyPtyGate -e human_only_pty -- hub/src` ; `cd hub; bun test test/pty-door-guard.test.ts` | yes | pending | +| 03-04-T4 | 04 | 4 | SC-4 (+ all) | T-03-20, T-03-23 | full suite fail 0 / skip <= 260; five supervisor guards pass UNCHANGED; schema-lint OK; no docs drift | full suite + guards | `bun run check-baseline` ; `bun test supervisor/test/no-api-key-no-streamjson-pty.test.ts` ; `bun test supervisor/test/no-apikey-fallback-guard.test.ts` ; `bun test supervisor/test/default-backend-selector.test.ts` ; `bun test supervisor/test/pty-spawn-interception.test.ts` ; `bun test supervisor/test/no-legacy-agent-spawn.test.ts` ; `bun run schema-lint` | yes | pending | + +*Status: pending / green / red / flaky* + +--- + +## Wave 0 Requirements + +Existing infrastructure covers all phase requirements. + +--- + +## Manual-Only Verifications + +All phase behaviors have automated verification. (Setting `REMO_GOVERNED_AUTOMATION_PTY` in prod is an owner-only cutover and is explicitly NOT part of this phase.) + +--- + +## Validation Sign-Off + +- [x] All tasks have `` verify or Wave 0 dependencies (12/12 tasks, each command followed by a ``) +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references (none: new test files are created inside the task that introduces their subject) +- [x] No watch-mode flags +- [x] Feedback latency < 60s for per-file quick runs (the full `bun run check-baseline` runs only at wave/phase end, 03-04-T4) +- [x] `nyquist_compliant: true` set in frontmatter + +Waves: one plan per wave (01 -> 02 -> 03 -> 04); all executors share one working tree and git index. + +**Approval:** planner-complete (pending execution) diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-VERIFICATION.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-VERIFICATION.md new file mode 100644 index 00000000..6d95d8db --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-VERIFICATION.md @@ -0,0 +1,49 @@ +--- +phase: PTYCAP-03-governed-automation-guard +verified: 2026-10-13 +status: passed +score: 4/4 success criteria, 4/4 plans' must_have truths verified +verdict: SHIP-WITH-NOTES +--- + +# Phase 3 (PTYCAP): Governed-Automation Guard - Verification + +Branch `phase-ptycap-03-governed-automation-guard` (PR #505, not pushed/merged by verifier). +Independent goal-backward check; SUMMARY claims were not trusted. Every result below is from a +test run or grep executed in this session in `C:\Users\artic\GitHub\remo-code-gsd`. + +## Success criteria + +| SC | Verdict | Evidence | +|----|---------|----------| +| SC-1 `humanOnlyPtyGate` -> `governedAutomationPtyGate`; non-human admitted only with token cap + inject-rate ceiling + lifetime counter + kill switch | PASS (structurally) | `hub/src/dispatch/governed-pty.ts`: `governedAutomationPtyGate` (name `governed_automation_pty`), `GOVERNOR_ORDER = kill_switch, token_cap, inject_rate, lifetime_counter`, first block wins, thrown governor -> `governor_error:` denial. Only `AUTOMATION_ACTORS` members eligible. kill_switch + lifetime_counter are fail-closed `governor_not_implemented:*` slots, so nothing is admitted in Phase 3 production (matches D-02 and the Phase-4 hand-off in ROADMAP). `grep humanOnlyPtyGate\|governedAutomationGate\|human_only_pty hub/src` = 0 hits (hard rename, no alias). Composed at `ask/dispatch.ts:137`, `work/dispatch.ts:298`, `telegram/dispatch.ts:172`. Preflight: `pty-preflight.ts:178-198` flag-first, then spend chain, then `activeGovernors()`. `governed-pty-gate.test.ts` 28 pass, `pty-preflight.test.ts` 46 pass. | +| SC-2 Actor server-inferred; spoofed `source` cannot impersonate human (negative test) | PASS | `ws-client-pty-preflight.test.ts` (24 pass) includes `term.input carrying source:scheduler + actor:orchestrator-background is preflighted as human and forwarded without the extra keys`. `term-relay-human-guard.test.ts` 3 pass (agent-socket spoof, unchanged). `telegram/dispatch.ts:82` `source` is required (compile time) and a missing source at runtime is refused on a PTY session (`telegram-dispatch.test.ts` 6 pass, `telegram-human-only-guard.test.ts` 6 pass); sole production caller passes literal `"human"` at `api/telegram-webhook.ts:399`. `pty-door-guard.test.ts` (22 pass) pins `DispatchRequest` has no source/actor member and nothing under `hub/src/dispatch` reads one. | +| SC-3 Flag-gated, default OFF, true no-op when off | PASS | `REMO_GOVERNED_AUTOMATION_PTY` read at call time, only `1\|true\|yes\|on` enables; literal appears only in `governed-pty.ts` (grep + door pin). `governed-pty-gate.test.ts` OFF==legacy differential matrix (decision + reason, resolver called once, zero governors). `pty-preflight.test.ts`: flag OFF returns `automation_blocked_on_pty:automation` before any DB gate (throwing threshold gate does not change the result). Non-PTY runner short-circuits before the flag. | +| SC-4 No-API-key + argv-allowlist guard tests pass unchanged | PASS | `git diff --name-only origin/main...HEAD -- supervisor/` = empty (0 files). Ran individually: `no-api-key-no-streamjson-pty` 5 pass, `no-apikey-fallback-guard` 7 pass, `default-backend-selector` 22 pass, `codex-fallback-no-apikey` 3 pass, `no-legacy-agent-spawn` 1 pass; all 0 fail. | + +Hard invariants: no API key on PTY path (SC-4, intact); actor server-inferred (SC-2); no spend outside gate chain +(`token-cap-coverage.test.ts` 3 pass, `token-cap-gate-fires.test.ts` 8 pass; no `gates: [` literal in `governed-pty.ts`; +human chain contains no governor, tested in `pty-preflight.test.ts`). + +## Plan must_have truths + +| Plan | Verdict | Evidence | +|------|---------|----------| +| 03-01 (8 truths: OFF==legacy, ineligible actors denied even ON, production list blocks at kill_switch, fixed order, unconfigured rules, non-PTY ok / throw -> deny, old name gone, single-param + test-only DI) | PASS | Code read of `governed-pty.ts` matches each truth line by line (single `resolve` param; DI only via `_setGovernorsForTests`/`_resetGovernorsForTests`; `token_cap` uses `isDailyTokenCapEnforced`, `inject_rate` blocks non-finite/<=0). `governed-pty-gate.test.ts` 28/0. `human-only-guard`, `ext-ask-gates`, `ext-work-containment` 8/12/18 pass. Prohibitions (alias, public param, `gates: [`) all hold by grep. | +| 03-02 (7 truths: OFF block before DB gates, ON runs spend chain then governors, production list -> kill_switch block, throw/timeout typed errors, human chain governor-free, scanned literal unchanged, CRLF-safe flag guard) | PASS | `pty-preflight.ts:35,178-198`; `humanInteractive` line at 186 pinned by `human-interactive-flag-guard.test.ts` 2 pass; `pty-preflight.test.ts` 46/0; `token-cap-coverage` green. | +| 03-03 (8 truths: Telegram source required, ws/client strips client keys, agent spoof test unchanged, two-emitter allowlist with self-check, supervisor canary, caller pins, flag/DI pins, two exemptions pinned) | PASS | `pty-door-guard.test.ts` 22/0 (tests: emitter allowlist + non-vacuous self-check, `user_message`/`permission_response` branches reference no PTY write, actor/preflight/inject/dispatchToSession caller pins, flag/DI pins). `ws-client-pty-preflight` 24/0. No file under `supervisor/` edited. | +| 03-04 (9 truths: docs section, Phase-2 claim corrected, supersession test, CLAUDE.md flag, old name gone from docs, OpenAPI, ROADMAP Phase-4 hand-off, baseline, supervisor guards) | PASS | `docs/usage-cost.md:498` "PTYCAP Phase 3" section; `CLAUDE.md:280,401,462` flag documented default OFF, "formerly" note only. `docs-supersession.test.ts` 4 pass. `bun run docs:sync` -> no git diff (no drift). ROADMAP diff: only the Phase-4 "Hand-off from Phase 3" line added; SC text of Phases 3/4 unchanged. `bun run check-baseline` (repo root): `pass=2379 skip=259 fail=0`, "OK - within tolerance" (skip <= 260). No production value of the flag set/documented. | + +## Other checks + +- `mount-order.test.ts` 21 pass (unchanged invariant). +- Branch contains no `supervisor/` change (no supervisor MSI required). + +## Notes (non-blocking) + +1. Bookkeeping stale: `.planning/ROADMAP.md` line 33 still shows Phase 3 as `[ ]` and the progress table says `In Progress (2/4 plans)`, although all 4 plans are executed. Update at phase close-out (the plan prohibited editing SC text only, not status). +2. `.planning/architecture/SESSION-ASK-API-SPEC.md` lines 224 and 321 still name `humanOnlyPtyGate` as present-tense history (lines 232/295 are rename arrows). Outside the plan's docs/CLAUDE.md scope; cosmetic. +3. SC-1 is met structurally by design: with the flag ON, Phase 3 production admits nothing because `kill_switch` and `lifetime_counter` are fail-closed placeholders. Real admission is Phase 4 (hand-off recorded in ROADMAP). Verified that this is explicit and tested, not hidden. +4. Honest scope (documented in `governed-pty.ts` header and usage-cost.md): the ask/work/telegram gate call sites are not the PTY door (they send `user_message` to the stream-json runner); the door is the preflight plus the structural pins. Verified consistent with the pins. + +## Ship verdict: SHIP-WITH-NOTES diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/COVERAGE.md b/.planning/phases/PTYCAP-03-governed-automation-guard/COVERAGE.md new file mode 100644 index 00000000..a2f9e19c --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/COVERAGE.md @@ -0,0 +1 @@ +No external API integration: internal hub gate primitives, structural tests and docs only; "API" matched the repo's own Session-Ask/OpenAPI doc references. diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md b/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md new file mode 100644 index 00000000..e4407f1e --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md @@ -0,0 +1,106 @@ +# design-fork gate 1 brief -- PTYCAP Phase 3 (Governed-Automation Guard) + +You are a READ-ONLY evaluator seat on a design-fork gate panel (refute-first). Do NOT edit, write, +commit, push, install or change any state. Read, judge, return a verdict. ASCII punctuation only. +All paths below are relative to the repo root you were given (the remo-code checkout). + +## 0. Ground first (mandatory; say in your answer which of these you actually read) +- ~/.claude/architecture-preferences.md and ~/.claude/design-preferences.md (skim; UI is N/A here) +- ~/.claude/CLAUDE.md rules 7a (no workarounds), 7c (no fallbacks), 11 (smallest diff), 12, 22c (no LLM API keys) +- .planning/ROADMAP.md lines 14-28 (hard invariants) and 95-115 (Phase 3 + Phase 4) +- .planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md sections 1, 2, 3, 4, 8 and DESIGN SEAMS +- .planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md (MINOR-1/2/3) +- Code you must read yourself before claiming anything: hub/src/dispatch/gates.ts (humanOnlyRejectsActor, + humanOnlyPtyGate, sessionInjectRateGate, dailyTokenCapGate), hub/src/dispatch/pty-preflight.ts, + hub/src/dispatch/pipeline.ts (DispatchRequest, DispatchGate), hub/src/ws/client.ts (term.input relay), + hub/src/telegram/transcript/pty-inject.ts, hub/src/{ask,work,telegram}/dispatch.ts, + supervisor/src/runners/session-bridge.ts lines 380-470. +A seat that only restates this brief without reading the code is LOW-CONFIDENCE and will not outvote an evidenced seat. + +## 1. The decision +Phase 3 goal: "Automation may drive the PTY, but only under governance." Success criteria (ROADMAP): + SC-1 humanOnlyPtyGate becomes governedAutomationPtyGate: a non-human actor is admitted ONLY when carrying + token cap + inject-rate ceiling + lifetime counter + kill switch. + SC-2 Actor remains server-inferred; a spoofed `source` cannot impersonate a human (negative test). + SC-3 Flag-gated, default OFF; a TRUE no-op when off. + SC-4 The no-API-key and argv-allowlist guard tests still pass unchanged. +Hard invariants: no provider API key on the PTY path; actor server-inferred never client-asserted; every +dispatch path inside the gate chain (hub/test/token-cap-coverage.test.ts); caps in tokens; additive idempotent +DDL only; new flags DEFAULT OFF and a true no-op when off. Phases are strictly serial: the lifetime inject +counter and the kill switch are BUILT IN PHASE 4, which depends on Phase 3. + +## 2. Verified facts (already checked by two independent agents; re-verify what you rely on) +1. Today NO automation producer emits term.input / term.attach_file to a PTY. The supervisor routes hub + `user_message` frames to the stream-json runner and only `term.*` frames to the PTY + (supervisor/src/runners/session-bridge.ts ~398-465). The only hub->agent term.input writers are the web + relay (cookie human, preflighted on prompt submit) and Telegram permission-answer keystrokes + (injectPtyKeystroke, no preflight, human-initiated, documented exempt). +2. humanOnlyPtyGate (gates.ts ~487) sits in 3 gate lists (ask/dispatch.ts, work/dispatch.ts, telegram/dispatch.ts) + and is keyed on sessions.runner_type == 'pty-interactive', which is DEAD as a key in prod (client.ts ~268-270; + every prod session is runner_type 'stream-json'; PTY is a supervisor runtime fact, not a DB fact). +3. Phase 2 built checkPtyTurnPreflight({userId, sessionId, actor:'automation'|'human'}) with chain + [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]; human = same minus inject-rate. + actor 'automation' has NO production caller (tests only). +4. DispatchRequest has no actor field; the actor reaches a gate only through a resolver closure the caller builds + from server context (cookie => human; /api/ext route => external-ask/external-work). +5. sessionInjectRateGate counts routine_run_log rows (orchestrator-only writers) and FAILS OPEN when its cap env + is <= 0; the token cap can be explicitly disabled (REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED=1). +6. Specs for Phases 7/8/9 treat spawn ceilings as FAIL CLOSED and consume Phase 4's lifetime counter and kill + switch as PREDICATES callable from the autospawn pre-launch chain and as gates. +7. telegram/dispatch.ts ~161 defaults `source = input.source ?? "human"`; its only production caller omits source. + +## 3. Forks. For each: give VERDICT (pick an option or propose a strictly better one), 2-4 lines of reasoning, +## and EVIDENCE (file:line you personally read). My recommendation is marked (R); try to REFUTE it first. + +F1 Door scope. + A (R) Governance primitives only: the flag, governedAutomationPtyGate replacing humanOnlyPtyGate at the 3 + call sites, the PTY automation chain in pty-preflight extended with the flag precondition and the two + governor slots, plus a static "door guard" test pinning the ONLY modules allowed to emit hub->agent + term.* frames (web relay, telegram pty-inject) so a future producer cannot bypass governance. + NO new byte-producer (typing a prompt into a TUI is Phase 5's first-unattended-run proof). + B A plus build the automation term.input producer (bracketed paste + submit) and wire the orchestrator now. + C Rename the gate only; no seam, no guard test. +F2 SC-1 vs the Phase 4 dependency (lifetime counter + kill switch do not exist yet). + A (R) Four named governors in a fixed ordered chain (token cap, inject-rate, lifetime counter, kill switch) in a + new module. The lifetime and kill-switch slots are real exported DispatchGates whose Phase-3 bodies FAIL + CLOSED (`governor_not_implemented:`); Phase 4 replaces the bodies. Tests use dependency injection to + pass governors and prove the admit path, and prove the production slots block. + B Implement a minimal functional lifetime counter + env kill switch inside Phase 3. + C Amend ROADMAP SC-1 to defer the two governors (weakens a locked success criterion). +F3 Ceilings must be ENABLED to count as "carried". + A (R) The governed chain treats a disabled ceiling (inject-rate cap <= 0, token cap explicitly disabled) as NOT + carried and blocks `governor_unconfigured:` even though the underlying gates fail open. + B Reuse the existing gates as-is (they fail open when disabled). +F4 Flag form. + A (R) env var REMO_GOVERNED_AUTOMATION_PTY (truthy 1|true|yes|on), read at CALL time, default OFF. OFF => no + automation turn is admitted to a PTY and the 3 existing call sites behave byte-identically (same reason + string `automation_blocked_on_pty:`). Flipping it in prod is an owner-only cutover; this phase does + not touch prod env. + B typed config.ts value read once at boot. C DB per-user setting. +F5 Naming / compatibility. + A (R) Hard rename to governedAutomationPtyGate (gate name 'governed_automation_pty'); update the 3 call sites and + tests; NO alias; keep the pure predicates humanOnlyRejectsActor / AUTOMATION_ACTORS unchanged. + B Keep humanOnlyPtyGate as a deprecated alias. +F6 Actor inference + spoof tests. + A (R) Actor stays closure-resolved from server context and DispatchRequest stays actor-free. New negative + tests: (i) an ext ask/work request carrying `source:'human'`/actor-like fields still gates as the external + actor; (ii) the governed gate ignores any `source` property on the request object; (iii) the existing + term.input spoof test stays green. List any attack surface I missed (esp. telegram/dispatch.ts ~161). +F7 Explicit exemptions. + A (R) Record Telegram permission-answer keystrokes and term.attach_file as explicit, test-pinned + human-initiated exemptions (door-guard allowlist with a justification each) instead of routing them + through preflight (routing a permission answer through a quota check could wedge a session). + B Route the Telegram keystrokes through the human preflight chain. +F8 Fold-ins from the Phase 2 verification MINORs. + A (R) In this phase's PR: make hub/test/human-interactive-flag-guard.test.ts CRLF-safe (split on /\r?\n/) and + correct docs/usage-cost.md (it credits humanOnlyPtyGate for the wrong mechanism). + B Separate PR. + +## 4. Required output (<= 120 lines) +For each fork F1..F8: `Fn VERDICT: