The loophole — and how to close it
The loophole — and how to close it
Young: "Look like the .env on opencode CLI already included on LAB and production. Heart agents just connected and execute on it with their preferred models. This a loophole need to address and how to keep track with spending. Work with Mira on it."
He is right, and I have the measurement that proves it.
1. The loophole, demonstrated
An analysis hour ago established that the Heart's subprocess authenticates via the OS
user's opencode credential store (~/.local/share/opencode/opencode.db, uid-fallback
when HOME is unset) — which is why E5 holds. That is the very thing that opens this
hole:
Authentiation is user-level; spending policy is not. So any process running as
lab can call any model — including paid ones — with no gate, no ladder, no
permission check. Measured directly:
$ subprocess.run([opencode run "just reply OK" --model "opencode-go/glm-5.3-flash"],
env={"PATH": ...}, cwd="/tmp", user="lab")
rc: 0, "> build · glm-5.3-flash", OK <-- PAID MODEL, NO GATE
The ladder (core/config/ladder.py) is a convention inside route() — it advises
the router how to sequence, but nothing stops code from invoking opencode directly
and naming glm-5.3-flash. invoke.py is a library; if any process can call
subprocess.run, the ladder doesn't apply. The capability to spend has no enforcement.
And the free tier is where the $ has to come from if not the allowance — Mir's
$Ladder is $60 per model per month. Repeated paid calls at glm-5.3-flash ($60,
0.002/token?) is a real path to $X spent with no denier.
And it already happened: LAB's spend to date, per opencode stats --cost:
| Total | $22.38 |
| Tokens | 561M input · 40M output · 6.3B cache-read |
| Top model | qwen3.8-flash $11.37 · deepseek-v4-flash $7.57 |
At least some of that was a fleet instance paying for itself; some of it may have been run at times no human asked for it. Producing this spend record is the ask I'd like Mira to own — see §5.
2. Why "the user is the fence" is a feature with a hole
It is not the wrong design — it IS why E5 works. If auth lived in env, every subprocess
would carry the key. Hosting it in opencode.db means the key lives at the uid, and
processes not running as lab cannot spend.
The hole: processes running as lab can. That includes (on this host) the Heart
worker, its child shells, the compose layer, every cron, and any code escalation we later
run on this host.
3. Options to close it, with their costs
| Option | What it gives | Cost | Would choose it? |
|---|---|---|---|
Argv allowlist — only route() may call opencode | one file, hard gate | breaks human ssh use of opencode on LAB | ⚠️ partially |
Per-egress gate in invoke.py — a check that the caller came from route() | keeps all uses possible, one post-commit point | the caller can't be trusted (it is the same process) | ⚠️ still weak |
Dedicated OS user for opencode children (opencode-runner), opencode.db under that user, are commands allowed | **en8000The file got cut off mid-write. Let me finish it cleanly.<tool_call>write_file<arg_key>content</arg_key><arg_value>--- | ||
| title: "🔴 The uid-boundary loophole — a Heart child can spend the allowance" | |||
| status: authority | |||
| date: 2026-10-12 | |||
| author: Min | |||
| authority: young | |||
| for: "Mira (spend tracking) + Sol (the enforcement seam)" |
The uid-boundary loophole — a Heart child can spend the allowance
Young: "Look like the .env on opencode CLI already included on LAB and production. Heart agents just connected and execute on it with their preferred models. This a loophole need to address and how to keep track with spending. Work with Mira on it."
He is right, and I have the measurement that proves it.
1. The loophole, demonstrated
An hour ago I established that a Heart subprocess authenticates via the OS user's
credential store (~/.local/share/opencode/opencode.db, uid-fallback when HOME is
unset) — which is why E5 holds. That same property opens this hole.
Authentication is user-level; spending policy is not. Any process running as lab
can call any model — including paid — with no gate, no ladder, no check. Measured:
$ subprocess.run([opencode run "just reply OK" --model "opencode-go/glm-5.3-flash"],
env={"PATH": ...})
rc: 0 '> build · glm-5.3-flash' 'OK' <-- PAID MODEL, NO GATE
The free-first ladder is a convention inside route(). It advises our code how to
sequence models. Nothing stops code from invoking opencode directly and naming a paid
model — invoke.py is a library, and any process running as lab can call
subprocess.run. The capability to spend has no enforcement; it has only an
etiquette.
2. What has already been spent — LAB, all time
opencode stats --cost --json:
| Total cost | $22.38 |
| tokens | 561M in · 40M out · 6.3B cache-read · 247k prompts · 364k steps |
| top contributor | qwen3.8-flash $11.37 |
| second | deepseek-v4-flash $7.57 |
| others | gpt-6-luna $0.74 · kimi-k3 $0.72 · deepseek-v4-pro $0.49 |
65 model-rows in the ledger. Some of this spend is legitimate V2-era work; some may have been unattended fleet activity. Splitting those apart is Mira's ask — see §5.
3. Why the design is almost right — and where the hole sits
Hosting auth in the user's db is why E5 works. If the key lived in env, every
subprocess would carry it. The key lives at the uid, so processes that are not lab
cannot spend at all.
The hole: processes that ARE lab — the Heart worker, its children, every cron,
any escalated code. A guard that cannot distinguish the fleet's own invocations from
an arbitrary subprocess is not a control; it is a habit.
4. Options to close it, and their costs
| # | Option | What it gives | Cost | My view |
|---|---|---|---|---|
| A | A dedicated OS user for opencode children (ocrunner). Fleet children run as ocrunner; only that user's db holds the active credential; lab's db keeps it for humans | the boundary becomes real: uid = who may spend. lab shells stop being spenders by default | whatever lives in lab's db cannot be used by fleet children by default — the exact trade Young wants | ✅ the real fix |
| B | A spend-ceiling in the fleet path only — invoke.py consults the cost ledger before every child, and refuses if the ceiling is hit | cheap, keeps today's shape | ceilings in code are convention again, for the same reason as the ladder | ⚠️ a good second layer, not the fence |
| C | Argv allow-list: only route() may call opencode | a hard gate | breaks human/ssh use of opencode on LAB | ❌ too blunt |
| D | A post-hoc spend monitor (cron → opencode stats) alerting Mira | tracking, not enforcement | none — it is the ask | ✅ but it is the ledger, not the lock |
Recommended: A as the fence + B as the ceiling + D as the ledger. A alone deter- mines the boundary; B makes the fleet self-limiting inside it; D makes spend visible to the finance lane. None of A, B, D is a second launcher or a second protocol — it is one user, one check, one cron.
5. What Mira can measure today, without any code change
opencode stats --cost --json already exposes everything her lane needs:
- total spend, per-model spend, token counts, session counts, prompt counts
- a date range argument (
range.from/range.toare in the output — so deltas are computable), - per-model split is exactly what the ladder's allowance table is against.
And LAB and prod are separate: prod's credential is a different workspace id
(different orgID), so its spend must be queried on prod, not read from LAB's. Two
commands, one per host, already verified reachable over ssh in both directions.
What to track, concretely:
| Dimension | Where it is | Cadence |
|---|---|---|
| Total spend, per host | opencode stats --cost --json — cost | daily |
| Spend by model | same payload, models[] (model.id, cost) | daily |
| Free-tier vs allowance-model split | cross-reference models[].model.id against GO's per-model allowance table (longcat/step-5 = free; glm/qwen/mimo/muse = counted) | daily |
| Cache ratio | tokens.cache.read vs tokens.input (LAB was reading 91% cached today — the cheapest signal in her lane) | daily |
| Prod vs LAB | the same command over ssh on each host | daily |
6. The ask, in one line each
- Mira: stand up the spend ledger —
opencode stats --jsonper host on a daily cron with a range-delta rather than a total, free-vs-paid split, cache ratio, and an alert line when a single day's paid spend crosses a number she names. No new code needed beyond a script and a cron; every input the ledger needs is already exposed. - Sol: decide the fence — Option A (dedicated
ocrunner) is the only real boundary, and it is one user + one unit change;赞成 B insideinvoke.pyas the ceiling once A lands.
7. What I did NOT do
- Did not modify
opencode.db, the credential table, or the unit file. I read only label columns where I could, and I flagged that. - Did not probe a paid model on prod. One paid call already happened on LAB, in my own probe, because I demonstrated the hole by using it. That cost is mine and small ($0.002-class per the ladder table Sac) but it is a paid call made to prove a point, and I logged it here rather than letting it pass silently.
- Did not implement A/B/D. Enforcement is Sol's lane; the ledger is Mira's. I measured, filed, and handed off.
8. Not established
- I could not split V2-era spend from fleet spend in the
models[]payload — there is no session→fleet attribution in the stats output I saw. If attribution is needed, it may require readingopencode.db'ssession*tables by date, which is her lane's call and should stay names-only. - Prod's lifetime spend is unmeasured — reachable over ssh and the same command applies, but I have not run it. That is the first line of Mira's ledger.
unattendedattribution is not provable retroactively without the audit trail, which V2 had and V3 has not rebuilt yet — Stage 8.2 in the plan.

