@miller-tech/uap 2.16.7 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/config/capacity-policy.json +33 -0
  2. package/config/model-profiles/qwen38.json +32 -33
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/bin/cli.js +57 -0
  5. package/dist/bin/cli.js.map +1 -1
  6. package/dist/capacity/policy.d.ts +32 -1
  7. package/dist/capacity/policy.d.ts.map +1 -1
  8. package/dist/capacity/policy.js +123 -4
  9. package/dist/capacity/policy.js.map +1 -1
  10. package/dist/capacity/probe.d.ts +26 -0
  11. package/dist/capacity/probe.d.ts.map +1 -1
  12. package/dist/capacity/probe.js +84 -3
  13. package/dist/capacity/probe.js.map +1 -1
  14. package/dist/cli/inference.d.ts +4 -0
  15. package/dist/cli/inference.d.ts.map +1 -1
  16. package/dist/cli/inference.js +74 -8
  17. package/dist/cli/inference.js.map +1 -1
  18. package/dist/cli/merge-gate.d.ts +59 -0
  19. package/dist/cli/merge-gate.d.ts.map +1 -0
  20. package/dist/cli/merge-gate.js +238 -0
  21. package/dist/cli/merge-gate.js.map +1 -0
  22. package/dist/cli/merge-queue.d.ts +13 -0
  23. package/dist/cli/merge-queue.d.ts.map +1 -1
  24. package/dist/cli/merge-queue.js +48 -2
  25. package/dist/cli/merge-queue.js.map +1 -1
  26. package/dist/cli/mission.d.ts +25 -0
  27. package/dist/cli/mission.d.ts.map +1 -0
  28. package/dist/cli/mission.js +225 -0
  29. package/dist/cli/mission.js.map +1 -0
  30. package/dist/cli/orch.d.ts +27 -0
  31. package/dist/cli/orch.d.ts.map +1 -0
  32. package/dist/cli/orch.js +70 -0
  33. package/dist/cli/orch.js.map +1 -0
  34. package/dist/cli/systemd-services.d.ts.map +1 -1
  35. package/dist/cli/systemd-services.js +44 -0
  36. package/dist/cli/systemd-services.js.map +1 -1
  37. package/dist/delivery/merge-gate.d.ts +168 -0
  38. package/dist/delivery/merge-gate.d.ts.map +1 -0
  39. package/dist/delivery/merge-gate.js +309 -0
  40. package/dist/delivery/merge-gate.js.map +1 -0
  41. package/dist/inference/analysis.d.ts +69 -3
  42. package/dist/inference/analysis.d.ts.map +1 -1
  43. package/dist/inference/analysis.js +123 -7
  44. package/dist/inference/analysis.js.map +1 -1
  45. package/dist/inference/probe.d.ts +38 -1
  46. package/dist/inference/probe.d.ts.map +1 -1
  47. package/dist/inference/probe.js +303 -30
  48. package/dist/inference/probe.js.map +1 -1
  49. package/dist/inference/strata.d.ts +95 -0
  50. package/dist/inference/strata.d.ts.map +1 -0
  51. package/dist/inference/strata.js +167 -0
  52. package/dist/inference/strata.js.map +1 -0
  53. package/dist/mission/database.d.ts +29 -0
  54. package/dist/mission/database.d.ts.map +1 -0
  55. package/dist/mission/database.js +131 -0
  56. package/dist/mission/database.js.map +1 -0
  57. package/dist/mission/ledger.d.ts +109 -0
  58. package/dist/mission/ledger.d.ts.map +1 -0
  59. package/dist/mission/ledger.js +294 -0
  60. package/dist/mission/ledger.js.map +1 -0
  61. package/dist/mission/poll-cycle.d.ts +73 -0
  62. package/dist/mission/poll-cycle.d.ts.map +1 -0
  63. package/dist/mission/poll-cycle.js +100 -0
  64. package/dist/mission/poll-cycle.js.map +1 -0
  65. package/dist/mission/poll-executor.d.ts +91 -0
  66. package/dist/mission/poll-executor.d.ts.map +1 -0
  67. package/dist/mission/poll-executor.js +328 -0
  68. package/dist/mission/poll-executor.js.map +1 -0
  69. package/dist/models/types.d.ts.map +1 -1
  70. package/dist/models/types.js +33 -27
  71. package/dist/models/types.js.map +1 -1
  72. package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
  73. package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
  74. package/dist/self-tuning/profiles/qwen38.js +17 -18
  75. package/dist/self-tuning/profiles/qwen38.js.map +1 -1
  76. package/dist/types/mission.d.ts +80 -0
  77. package/dist/types/mission.d.ts.map +1 -0
  78. package/dist/types/mission.js +12 -0
  79. package/dist/types/mission.js.map +1 -0
  80. package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
  81. package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
  82. package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
  83. package/docs/guides/CAPACITY_POLICY.md +49 -0
  84. package/docs/guides/INFERENCE_HEALTH.md +65 -8
  85. package/docs/guides/MISSIONS.md +170 -0
  86. package/docs/guides/PROXY.md +50 -0
  87. package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
  88. package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
  89. package/docs/visual/merge-gate.html +404 -0
  90. package/docs/visual/mission-orchestrator.html +472 -0
  91. package/package.json +2 -2
  92. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  93. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  94. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  95. package/src/policies/enforcers/plane_admin_protect.py +133 -0
  96. package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
  97. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  98. package/templates/hooks/uap-policy-gate.sh +33 -0
  99. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  100. package/tools/agents/scripts/anthropic_proxy.py +1271 -89
  101. package/tools/agents/scripts/requirements-proxy.txt +1 -1
  102. package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
  103. package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
  104. package/tools/agents/tests/test_ollama_compat.py +320 -0
  105. package/tools/agents/tests/test_plane_admin_protect.py +124 -0
  106. package/tools/agents/tests/test_stream_heartbeat.py +77 -0
@@ -0,0 +1,170 @@
1
+ # Missions & the Orchestrator Poll Daemon
2
+
3
+ > **🏭 Where this fits:** BUILD — the durable layer *above* `uap deliver`.
4
+ > **What it delivers:** a prompt — small or large — becomes a mission the
5
+ > system owns until its acceptance criteria are verified met. It survives
6
+ > reboots, crashes, session teardown, and context exhaustion. No babysitting.
7
+
8
+ **Visual explainer:** [docs/visual/mission-orchestrator.html](../visual/mission-orchestrator.html) —
9
+ the architecture, poll cycle, decision table, backoff, and salvage flow as a
10
+ self-contained page (open in a browser; no build, no dependencies).
11
+
12
+ **Architecture record:** [ADR-0003](../architecture/adr/0003-mission-orchestrator.md)
13
+ — why the goal layer lives above deliver, the frozen-hash policy,
14
+ provenance-checked linkage, and the claim/backoff semantics.
15
+
16
+ [The task orchestrator](./ORCHESTRATOR.md) makes a single *run* converge, and
17
+ the epic controller loops epics *within a run*. What neither owns is the goal
18
+ itself across process lifetimes: a detached deliver run survives its tool
19
+ call, but nothing resumes it after a reboot, relaunches it after a crash, or
20
+ notices it quietly died. That is the mission layer's job — the same insight
21
+ as Choir (Harvard/Weber's multi-agent autoformalization protocol): **the loop
22
+ clock and the state must be durable and external to any agent process.**
23
+
24
+ ## The model
25
+
26
+ ```
27
+ uap mission create ──▶ missions.db (frozen acceptance, attempt ledger)
28
+ │ ▲
29
+ │ --launch (or wait) │ discovery (mission:#N marker)
30
+ ▼ │
31
+ uap deliver run ──run state──▶ uap mission poll (every 300s, systemd)
32
+ │ decide per mission:
33
+ │ live run → one supervisor cycle
34
+ │ pid dead → record interrupted, salvage, relaunch
35
+ │ interrupted → salvage, resume (backoff after repeats)
36
+ │ failed → salvage, relaunch (backoff)
37
+ │ delivered → close the mission
38
+ └ board notes on delivery + struggle
39
+ ```
40
+
41
+ ## Creating a mission
42
+
43
+ ```bash
44
+ uap mission create "Harden the policy gate for admin-protected routes" \
45
+ --acceptance "npm test and tsc --noEmit pass; no new quality-metric violations" \
46
+ --launch
47
+ ```
48
+
49
+ - The acceptance criteria are **frozen** (sha256) at creation — the
50
+ statement-immutability analog. Changing them is an explicit replan that
51
+ records the prior text, hash, and reason:
52
+ `uap mission replan <id> --acceptance "<new>" --reason "<why>"`.
53
+ - `--launch` starts the first run immediately; without it the daemon launches
54
+ it on the next poll. The criteria travel inside the run instruction and
55
+ `--acceptance` turns on the behavioral acceptance judge.
56
+ - `uap mission list|show|pause|resume` manage the ledger. Paused missions are
57
+ invisible to the daemon.
58
+
59
+ ## The poll daemon
60
+
61
+ `uap mission poll` is the loop clock (Choir's `orch poll`; hidden alias
62
+ `uap orch poll` — NOT the same as `uap orchestrator on|off`, which toggles
63
+ the in-deliver blackboard orchestrator). One cycle:
64
+
65
+ 1. **Discover** — link a fresh launch's run to its mission. Linkage is
66
+ provenance-checked: the run's instruction must carry the mission's
67
+ `[mission:#N:<hash8>]` marker (the hash slice makes it replan-scoped, so
68
+ an old-criteria run can never be relinked) AND its pid must match the pid
69
+ the orchestrator itself spawned. A planted run-state file cannot know a
70
+ future child pid, so it can never steer the daemon. Any status links: a
71
+ short mission can deliver inside one interval and must still close.
72
+ 2. **Sync** — terminal run states become attempt rows; a `running` state whose
73
+ pid is gone is recorded as interrupted (a crash that never marked itself).
74
+ Rows dedupe per (run, outcome, observedAt): identical re-observations
75
+ collapse, genuinely new failures of the same run accumulate.
76
+ 3. **Decide** — the pure decision table in `src/mission/poll-cycle.ts`:
77
+ live → supervise (one `runSupervisor` cycle: it watches and acts on
78
+ reviewed policy — STOP/RETRY/ESCALATE — never signals); terminal →
79
+ salvage + relaunch; delivered → close.
80
+ 4. **Act + backoff** — launches and relaunches go through an ATOMIC CLAIM
81
+ (a 60-second window), so the daemon poll and `uap mission create --launch`
82
+ can never both spawn the same mission. The first relaunch is immediate (a
83
+ clean interruption is the healthy case, not a crash loop); each further
84
+ relaunch doubles the wait (15, 30, 60 min … capped at a day).
85
+
86
+ A cycle never throws — a broken ledger produces a report note, not a dead
87
+ daemon — and systemd `Restart=always` covers the rest.
88
+
89
+ ### The service
90
+
91
+ `uap-orchestrator.service` (installed by `installSystemdUserServices`) runs
92
+ `uap mission poll --loop --interval 300`. Node and the CLI entrypoint are
93
+ captured absolute at install time — a systemd user unit's PATH has no nvm.
94
+ `uap doctor` probes it against its declared RSS budget in
95
+ `config/capacity-policy.json` (512 MiB; restart budget 3).
96
+
97
+ ```bash
98
+ systemctl --user enable --now uap-orchestrator.service
99
+ journalctl --user -u uap-orchestrator.service -f
100
+ ```
101
+
102
+ ## Struggle & salvage (approval-gated)
103
+
104
+ Every failed/interrupted attempt seeds a **salvage proposal**: what happened,
105
+ what partial work exists, and — once a task has struggled past 3 failures — a
106
+ recommendation to escalate the preset or decompose before the next relaunch.
107
+ Proposals never auto-schedule:
108
+
109
+ ```bash
110
+ uap mission salvage list
111
+ uap mission salvage approve <id> # or: reject <id>
112
+ ```
113
+
114
+ Struggling missions also post a board note (`uap coord board`), so an
115
+ operator — or an overseer agent — sees the escalation ask without watching
116
+ the daemon. This is the Choir split: the orchestrator proposes; the overseer
117
+ rules.
118
+
119
+ ## Merge gate (evidence, not testimony)
120
+
121
+ A delivered run's DONE report is a claim. The merge gate is the last check
122
+ between that claim and master — it recomputes the verdict from recorded
123
+ evidence, deterministically, before work becomes irreversible:
124
+
125
+ | Check | What it verifies |
126
+ | --- | --- |
127
+ | **provenance** | The PR's `[mission:#N:<hash8>]` marker matches the mission's CURRENT frozen acceptance (a replan after the PR was cut supersedes its criteria) — and the ledger itself still hashes intact |
128
+ | **green-recompute** | The gate-evidence artifact for the exact sha being landed (all gates exited 0, no env hatches) — or, failing that, a fresh run-state read showing `delivered` with a passing final recorded iteration — and the run pid matches the launch; no salvage proposal awaits a ruling |
129
+ | **sorry-delta** | No stub markers (`todo!()`, `NotImplementedError`, TODO/FIXME) in ADDED lines of production files, no apology phrasing in the run's summaries |
130
+ | **axiom-honesty** | The diff does not touch gate infrastructure (hooks, CI, enforcers, baselines, this gate itself) without acknowledgment |
131
+
132
+ ```bash
133
+ uap merge gate 7 # run the gate for mission #7 (exit 1 on fail)
134
+ uap merge gate 7 --json # machine-readable findings
135
+ uap merge gate 7 --allow-gate-infra # only when the PR deliberately IS gate work
136
+ ```
137
+
138
+ The merge queue runs this automatically before landing any PR whose title or
139
+ body carries the mission marker; a gate failure skips the PR and lists every
140
+ failed check. `--force` skips stale CI checks but never the gate, and there is
141
+ no environment escape hatch. Deliberate gate-infrastructure changes are
142
+ acknowledged with a `gate-infra` PR label, in plain sight of reviewers.
143
+
144
+ Details and rationale: [ADR-0004](../architecture/adr/0004-deterministic-merge-gate.md).
145
+
146
+ ## Where state lives
147
+
148
+ | Store | Path |
149
+ | --- | --- |
150
+ | Mission ledger (SQLite, WAL) | `.uap/missions.db` |
151
+ | Durable run state (per run) | `.uap/deliver-runs/<runId>/state.json` |
152
+ | Supervisor state/events | `.uap/supervise/<runId>/` |
153
+ | Orch-spawned run logs | `.uap/orch-logs/orch-<stamp>.log` |
154
+ | Stop latch | `.uap/deliver-runs/STOP` (cooperative; the supervisor uses it too) |
155
+
156
+ ## Design notes
157
+
158
+ - **Frozen acceptance** is what makes "complete" checkable — the merge gate
159
+ (see above) verifies a PR's criteria against the frozen hash before any
160
+ merge.
161
+ - **Attempt dedupe** is per (mission, run, outcome, taskRef, observedAt), so
162
+ re-observing the same terminal state cannot inflate the struggle metric —
163
+ while a run that keeps failing after each resume accumulates and reaches
164
+ the escalation threshold.
165
+ - **Salvage dedupe**: one open proposal per failed attempt — a mission
166
+ waiting out a backoff window never duplicates proposals or board notes.
167
+ - The daemon never *drives* a live run — it supervises. Launching and
168
+ resuming are the only writes it makes to a mission's fate, and both go
169
+ through deliver's own detached-run discipline (own session, stdio to a
170
+ file, checkpointed state).
@@ -41,6 +41,54 @@ the "keep a small model from wedging" logic lives.
41
41
  | `GET /health` | Liveness (probes the upstream; stays unauthenticated) |
42
42
  | `GET /v1/context` | Per-session token-usage / utilization / guardrail telemetry (dashboards & debug) |
43
43
 
44
+ ### Ollama-compatible surface (`PROXY_OLLAMA_PORT`, default 11434)
45
+
46
+ The same proxy also serves the Ollama wire protocol on a **second port**
47
+ (default `11434`, Ollama's well-known port — the one Ollama-native tooling
48
+ auto-discovers and cannot be re-pointed). Set `PROXY_OLLAMA_PORT=0` to disable
49
+ it (do that on a box running a real Ollama, which owns 11434); if the port is
50
+ busy at startup the surface disables itself and the Anthropic/OpenAI surface
51
+ carries on unaffected.
52
+
53
+ | Route | Purpose |
54
+ |---|---|
55
+ | `GET /api/version` | Ollama discovery handshake |
56
+ | `GET /api/tags` | Model list — backend-served ids only (the Anthropic-protocol Claude ids are not leaked here) |
57
+ | `POST /api/show` | Model card (404s unknown ids like real Ollama) |
58
+ | `GET /api/ps` | Running-model list (the backend stays loaded; capacity policy owns that) |
59
+ | `POST /api/chat` | Chat — messages, options, tools (OpenAI tool schema) |
60
+ | `POST /api/generate` | Text generation — prompt + optional system |
61
+
62
+ Every request runs the **full guardrail path** (admission control, rail
63
+ budgeting, loop detection, tool narrowing, malformed-tool retry, context
64
+ pruning) — an Ollama client gets the same treatment an Anthropic client does.
65
+ The tradeoff matches the OpenAI-compatible surface: streaming is buffered
66
+ through the guardrails, then emitted as Ollama NDJSON (one content line plus a
67
+ final `done: true` line), not token-by-token.
68
+
69
+ Options map only where they survive both translation hops — `temperature`,
70
+ `top_p`, `top_k`, `stop`, and `num_predict` → `max_tokens`. Everything else is
71
+ dropped honestly rather than accepted and silently ignored: Ollama's
72
+ `format` (structured output) is not carried by the underlying pipeline yet
73
+ (wiring `response_format` through is a tracked contract change), `num_ctx`
74
+ would fight the rail budget, and `min_p`/`repeat_penalty`/`seed` are not
75
+ forwarded by the second hop. `keep_alive`/`raw`/`template` are lifecycle
76
+ features with no local equivalent.
77
+
78
+ Two auth behaviors to know about when `PROXY_AUTH_TOKEN` is set: the
79
+ discovery routes above stay unauthenticated (Ollama clients probe them before
80
+ any credential could be presented), but `POST /api/chat`, `POST /api/generate`
81
+ and `POST /api/show` gate exactly like `/v1/messages` — and since most
82
+ Ollama-native tooling has no way to configure a bearer token, auth-on
83
+ realistically means Ollama clients stop at a 401. On the default loopback
84
+ deployment (no token) everything works.
85
+
86
+ If the port is busy at startup (typically a real Ollama), the surface
87
+ disables itself and the Anthropic/OpenAI surface carries on unaffected — the
88
+ error lands in this proxy's journal. The reverse ordering has the opposite
89
+ signature: if this proxy binds 11434 first, a later-started real Ollama is the
90
+ one that fails to bind, and its error shows up in Ollama's logs, not ours.
91
+
44
92
  ---
45
93
 
46
94
  ## Lifecycle: `uap proxy`
@@ -190,6 +238,8 @@ set is below.
190
238
  | `PROXY_CONTEXT_WINDOW` (0 = auto) | Explicit local context window; 0 derives it from the live rail |
191
239
  | `PROXY_COUNT_TOKENS_SCALE` (auto) | Scales the reported token count so the client auto-compacts before the local rail overflows |
192
240
  | `PROXY_CONTEXT_PRUNE_THRESHOLD` (0.85) / `PROXY_CONTEXT_PRUNE_TARGET_FRACTION` (0.50) | Backstop pruning: prune at 85% of the window, land the session at ~50% |
241
+ | `PROXY_PRUNE_SPIRAL_STREAK` (3) / `PROXY_PRUNE_SPIRAL_EPSILON` (0.01) | Prune circuit breaker fires only on a RISING post-prune floor (3 consecutive prunes each >1 point higher). A flat floor above the threshold is fixed system+tools overhead, not a death spiral — the breaker is held |
242
+ | `PROXY_FINAL_ANSWER_CHARS` (800; deprecated env alias `PROXY_END_TURN_FINAL_CHARS`) | Content reaching this many characters is a legitimate final answer: the unexpected-end-turn retry is skipped and the client's loop ends naturally (thinking-runaway stalls have empty content). Also exempts a `required` tool turn returning such content from the `required_tool_miss` streak, retries, and forced-tool dampener; keeps the stream-path `malformed_tool_streak` from counting it; and stops the recon-convergence no-write streak from advancing on it — a finished model's summary is a completion, not a miss. Content matching the deferral phrases ("I need more cycles…") stays retried even when long — a capitulation is not a final answer. 0 = always retry |
193
243
 
194
244
  ### Connection health & backpressure
195
245
 
@@ -0,0 +1,70 @@
1
+ # Plane-admin enforcer now fails closed on shell operations
2
+
3
+ Date: 2026-10-02
4
+ Deliver run: `run-20261002T030209-1af47d` (status: delivered)
5
+
6
+ ## Problem
7
+
8
+ `uap-policy-gate.sh` maps an errored or missing enforcer to ALLOW for everything
9
+ except the two enforcers named in `must_fail_closed()` (`enforcement_self_protect`
10
+ on `SEC_SENSITIVE`, `schema_diff_gate` on `COMMIT_OP`). `plane_admin_protect` —
11
+ the guard over Plane admin credential resets — was not in that selector, so a
12
+ crashed or absent plane enforcer was a silent bypass of the admin controls on
13
+ exactly the surface it exists to protect.
14
+
15
+ ## Change
16
+
17
+ New `SHELL_OP` arm, keyed on the tool name (the enforcer's own activation test is
18
+ tool-based — a shell tool is the only one it is ever asked about):
19
+
20
+ ```bash
21
+ SHELL_OP=0
22
+ case " $TOOL " in
23
+ *" Bash "*|*" bash "*|*" BashTool "*|*" Shell "*|*" shell "*|*" Terminal "*|*" terminal "*|*" execute_command "*|*" run_command "*|*" exec "*|*" local_shell "*|*" command "*)
24
+ SHELL_OP=1
25
+ ;;
26
+ esac
27
+ [[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && SHELL_OP=0
28
+ ```
29
+
30
+ and the selector case:
31
+
32
+ ```bash
33
+ must_fail_closed() {
34
+ case "$1" in
35
+ enforcement_self_protect) [[ "$SEC_SENSITIVE" == "1" ]] ;;
36
+ schema_diff_gate) [[ "$COMMIT_OP" == "1" ]] ;;
37
+ plane_admin_protect) [[ "$SHELL_OP" == "1" ]] ;;
38
+ *) false ;;
39
+ esac
40
+ }
41
+ ```
42
+
43
+ Scoping rationale (mirrors why `schema_diff_gate` is gated on `COMMIT_OP`): the
44
+ plane-admin surface is mutable through the shell and nothing else. Arming the
45
+ fail-closed net on every edit would turn a broken plane enforcer into a blanket
46
+ block on all edits in the session. Non-shell tools keep failing open.
47
+ `UAP_SELF_PROTECT_OFF=1` clears this arm too — otherwise the override named in
48
+ the refusal is a no-op for the case that prints it.
49
+
50
+ ## Files changed
51
+
52
+ - `templates/hooks/uap-policy-gate.sh` — +33 lines (SHELL_OP arm, selector case, rationale comments)
53
+ - `.claude/hooks/uap-policy-gate.sh`, `.factory/hooks/uap-policy-gate.sh`, `.omp/hooks/uap-policy-gate.sh` — identical mirrors (drift test requirement; `uap worktree create` seeds from `templates/hooks/`)
54
+ - `tools/agents/tests/test_gate_failclosed_plane_admin.py` — new, 15 tests
55
+ - `package.json` — `test:enforcers` now registers `test_gate_failclosed_plane_admin` (additive; nothing removed)
56
+
57
+ ## Verification
58
+
59
+ - `UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_gate_failclosed_plane_admin` → 15 tests OK
60
+ - `tools.agents.tests.test_gate_failclosed_schema_diff` → 23 tests OK (drift sweep green)
61
+ - All four tracked hook copies byte-identical to `templates/hooks/`; `bash -n` clean on each
62
+ - No other fail-open behavior changed
63
+
64
+ ## Note on the pending-intent log
65
+
66
+ The earlier recorded intent `ts=1790903849` (a `_pt_for` path-resolution change to
67
+ the gate) was **not** applied. It was archived to `.uap/pending-deliver.applied.jsonl`
68
+ with an operator note recording that it was deferred deliberately: the delivered
69
+ hardening is the `must_fail_closed` arm, not the path-resolution rewrite. 38 other
70
+ intents remain pending and visible.
@@ -0,0 +1,116 @@
1
+ # Session summary — Plane Admin Protect enforcer (hardened + shipped to force)
2
+
3
+ Date: 2026-10-02. Repo: `universal-agent-protocol` (UAP). Consuming project: `pay2u`.
4
+
5
+ ## Why
6
+
7
+ The Plane admin account `admin@pay2u.com.au` (droplet `134.199.173.65`,
8
+ `/opt/plane/plane-app`) had its credentials mutated by an agent command
9
+ (`docker exec plane-app-api-1 python manage.py changepassword …`). A guard
10
+ policy — `plane-admin-protect` — existed but was weak: no expiry on its waiver,
11
+ no fail-closed behaviour, no tests, and the copy the gate actually executes had
12
+ drifted from the source.
13
+
14
+ ## What was built
15
+
16
+ ### 1. Hardened enforcer — `src/policies/enforcers/plane_admin_protect.py` (132 lines)
17
+
18
+ - Shell ops only (`bash`/`run_bash`/`shell`/`execute_command`/`terminal`); everything
19
+ else allows.
20
+ - Blocks only when a command **both** targets the Plane stack **and** carries a
21
+ credential-mutation verb:
22
+ - targets: `plane-app`, `134.199.173.65`, `/opt/plane`, `plane-db`
23
+ - verbs: `reset_password`, `set_password`, `changepassword`, `createsuperuser`, `passwd`
24
+ - Scans via `scannable_command()` so quoted payloads are visible.
25
+ - Owner waiver `policies/waivers/plane-admin-reset` must carry `expires: YYYY-MM-DD`:
26
+ - valid → allow, with `waiver valid until <date>`
27
+ - expired / missing expiry / **unreadable** → **fails CLOSED** (`allowed:false`,
28
+ exit 2, `route:"owner-waiver"`, `waiverHint`) — never a crash (exit 1), never a
29
+ silent allow.
30
+ - Documents its own limit: text scan raises cost, it is not a boundary (see below).
31
+
32
+ ### 2. Policy schema doc — `src/policies/schemas/policies/plane-admin-protect.md`
33
+
34
+ Category `safety`, level `REQUIRED`, stage `pre-exec`, default `on`, tags
35
+ `plane, credentials, admin, safety, droplet, owner-waiver`. States the rule, the
36
+ target/verb lists, and the allowed read-only cases.
37
+
38
+ ### 3. Tests — `tools/agents/tests/test_plane_admin_protect.py` (10 tests, all green)
39
+
40
+ Observed incident spelling blocked · `bash -c` wrapper blocked · non-shell op
41
+ allowed · read-only Plane command allowed · **prose** naming a verb NOT blocked
42
+ (`git commit -m "refused to passwd /opt/plane admin"`, `uap memory store …`) ·
43
+ heredoc body naming a verb NOT blocked · valid waiver allows · expired waiver
44
+ fails closed · missing expiry fails closed · unreadable waiver fails closed
45
+ without crashing.
46
+
47
+ Registered in `package.json` → `test:enforcers` (the suite-coverage guard
48
+ `test_enforcer_suite_coverage` passes, so the module is genuinely in the gate).
49
+
50
+ ## Verification
51
+
52
+ - `python3 -m unittest …test_plane_admin_protect` → 10/10 OK.
53
+ - `npm run test:enforcers` → **Ran 1348 tests, OK (skipped=1)**.
54
+ - `npm run build` (tsc) → clean.
55
+ - Indirection matrix (15 real commands, temp repo root): blocked — heredoc that
56
+ writes the mutation script, `T=…; V=…` variable indirection, `printf … | sh`,
57
+ `docker compose exec`, `kubectl exec`, `ansible plane-app`. Allowed —
58
+ `bash r.sh` (script contents invisible), base64 payload, `ssh host '…'` with no
59
+ shell-exec token, `ansible all -m shell -a …` (no target token), read-only
60
+ Plane commands, prose in commit messages / memory stores, target-only, verb-only.
61
+ Net: the guard catches the realistic accidental paths; deliberate evasion via
62
+ an indirect payload is out of scope and is stated in the enforcer docstring.
63
+ - End-to-end through the real gate in `pay2u`
64
+ (`.claude/hooks/uap-policy-gate.sh`, `tool_name`/`tool_input` payload):
65
+ mutation command → **blocked** with the owner-waiver hint; read-only Plane
66
+ commands → allowed (only the unrelated `expert-review-required` gate fires).
67
+
68
+ ## Deployed to force in pay2u
69
+
70
+ `uap policy add-tool -p 7c26d0f7-3adc-4b36-9ebc-5ea554b6a687 -t plane_admin_protect -c <src>`
71
+ run from the `pay2u` root. Result:
72
+
73
+ - `.policy-tools/7c26d0f7-…_plane_admin_protect.py` sha `63841c556f3ba71c` == source sha.
74
+ - `policies.db` `executable_tools.code` byte-identical to the materialized copy.
75
+ - `.integrity.sha256` manifest rewritten to match (the gate verifies and
76
+ self-repairs against it, so the fix is now actually in force, not just merged).
77
+
78
+ Re-verified end-to-end through the live gate in `pay2u`:
79
+
80
+ | payload | exit | verdict |
81
+ | --- | --- | --- |
82
+ | `docker exec plane-app-api-1 python manage.py createsuperuser` | 2 | blocked (owner-waiver hint) |
83
+ | `kubectl logs deploy/plane-app-worker -n plane --tail=50` | 0 | allowed |
84
+
85
+ Canonical row is now `709d51b3-…` / `plane-admin-protect` (active, matches the
86
+ built-in matrix slug, hardened code in force); `7c26d0f7-…` (`Plane Admin
87
+ Protect`) is inactive. Both materialized copies are identical to source.
88
+
89
+ ## Open items (not done)
90
+
91
+ 1. **No waiver exists**: neither `pay2u/policies/waivers/` nor the UAP root has
92
+ `plane-admin-reset`. That is correct default posture (owner approval required);
93
+ create it only with explicit owner instruction and a real `expires:` date.
94
+ 2. **UAP changes are uncommitted** (`package.json` modified; enforcer, schema doc
95
+ and test untracked). Commit + push not performed — needs an explicit go-ahead.
96
+ 3. **Gate fail-open for this enforcer**: `must_fail_closed()` in
97
+ `.claude/hooks/uap-policy-gate.sh` fail-closes only for
98
+ `enforcement_self_protect` (when sensitive) and `schema_diff_gate` (on
99
+ commit/push). A plane enforcer that errors or is missing is read as ALLOW.
100
+ The integrity self-repair covers the missing/stale-copy path; an import-time
101
+ crash of this enforcer specifically would still pass the command.
102
+ 4. **Known, documented evasion surface** (accepted, not a boundary): `bash r.sh`
103
+ where `r.sh` holds the mutation, base64 payloads, and `ssh host '<verb>'`
104
+ with no shell-exec token. Text scan is a cost-raiser, stated in the enforcer
105
+ docstring.
106
+ 5. `pay2u` gate blocks inline `UAP_NO_REVIEW=1`; a review artifact or waiver is
107
+ needed before shipping there.
108
+
109
+ ## Resolved since first draft
110
+
111
+ - Duplicate policy rows: `709d51b3-…` (`plane-admin-protect`, the built-in slug)
112
+ is the single active row; `7c26d0f7-…` is inactive. No double verdict.
113
+ - Stale `rtk_wrap` manifest entry: no longer present in
114
+ `pay2u/.policy-tools/.integrity.sha256`, no `rtk_wrap.py` in `.policy-tools/`,
115
+ and the last `restored: …rtk_wrap.py` log line is ~6 days old — the per-call
116
+ restore churn has stopped.