@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.
- package/config/capacity-policy.json +33 -0
- package/config/model-profiles/qwen38.json +32 -33
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +57 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +32 -1
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +123 -4
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts +26 -0
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +84 -3
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/inference.d.ts +4 -0
- package/dist/cli/inference.d.ts.map +1 -1
- package/dist/cli/inference.js +74 -8
- package/dist/cli/inference.js.map +1 -1
- package/dist/cli/merge-gate.d.ts +59 -0
- package/dist/cli/merge-gate.d.ts.map +1 -0
- package/dist/cli/merge-gate.js +238 -0
- package/dist/cli/merge-gate.js.map +1 -0
- package/dist/cli/merge-queue.d.ts +13 -0
- package/dist/cli/merge-queue.d.ts.map +1 -1
- package/dist/cli/merge-queue.js +48 -2
- package/dist/cli/merge-queue.js.map +1 -1
- package/dist/cli/mission.d.ts +25 -0
- package/dist/cli/mission.d.ts.map +1 -0
- package/dist/cli/mission.js +225 -0
- package/dist/cli/mission.js.map +1 -0
- package/dist/cli/orch.d.ts +27 -0
- package/dist/cli/orch.d.ts.map +1 -0
- package/dist/cli/orch.js +70 -0
- package/dist/cli/orch.js.map +1 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +44 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/delivery/merge-gate.d.ts +168 -0
- package/dist/delivery/merge-gate.d.ts.map +1 -0
- package/dist/delivery/merge-gate.js +309 -0
- package/dist/delivery/merge-gate.js.map +1 -0
- package/dist/inference/analysis.d.ts +69 -3
- package/dist/inference/analysis.d.ts.map +1 -1
- package/dist/inference/analysis.js +123 -7
- package/dist/inference/analysis.js.map +1 -1
- package/dist/inference/probe.d.ts +38 -1
- package/dist/inference/probe.d.ts.map +1 -1
- package/dist/inference/probe.js +303 -30
- package/dist/inference/probe.js.map +1 -1
- package/dist/inference/strata.d.ts +95 -0
- package/dist/inference/strata.d.ts.map +1 -0
- package/dist/inference/strata.js +167 -0
- package/dist/inference/strata.js.map +1 -0
- package/dist/mission/database.d.ts +29 -0
- package/dist/mission/database.d.ts.map +1 -0
- package/dist/mission/database.js +131 -0
- package/dist/mission/database.js.map +1 -0
- package/dist/mission/ledger.d.ts +109 -0
- package/dist/mission/ledger.d.ts.map +1 -0
- package/dist/mission/ledger.js +294 -0
- package/dist/mission/ledger.js.map +1 -0
- package/dist/mission/poll-cycle.d.ts +73 -0
- package/dist/mission/poll-cycle.d.ts.map +1 -0
- package/dist/mission/poll-cycle.js +100 -0
- package/dist/mission/poll-cycle.js.map +1 -0
- package/dist/mission/poll-executor.d.ts +91 -0
- package/dist/mission/poll-executor.d.ts.map +1 -0
- package/dist/mission/poll-executor.js +328 -0
- package/dist/mission/poll-executor.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +33 -27
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +17 -18
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/types/mission.d.ts +80 -0
- package/dist/types/mission.d.ts.map +1 -0
- package/dist/types/mission.js +12 -0
- package/dist/types/mission.js.map +1 -0
- package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
- package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
- package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
- package/docs/guides/CAPACITY_POLICY.md +49 -0
- package/docs/guides/INFERENCE_HEALTH.md +65 -8
- package/docs/guides/MISSIONS.md +170 -0
- package/docs/guides/PROXY.md +50 -0
- package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
- package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
- package/docs/visual/merge-gate.html +404 -0
- package/docs/visual/mission-orchestrator.html +472 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/plane_admin_protect.py +133 -0
- package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +33 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1271 -89
- package/tools/agents/scripts/requirements-proxy.txt +1 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
- package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
- package/tools/agents/tests/test_ollama_compat.py +320 -0
- package/tools/agents/tests/test_plane_admin_protect.py +124 -0
- 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).
|
package/docs/guides/PROXY.md
CHANGED
|
@@ -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.
|