Self-serve secret scoping: prod + throwaway-dev scopes via a control-plane Variable (not name-munging) #15
Labels
No labels
bug
discussion
duplicate
enhancement
goal
help wanted
horizon:backlog
horizon:governance
horizon:mvp
invalid
operator-decision
question
roadmap
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
open-platform/mitosis#15
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Design from a research+tournament pass. Scope does NOT live in the secret name (Forgejo has no per-environment secrets — see forgejo discussions#440). It lives in a readable Forgejo Variable the agent credential cannot write, resolving at seal-time into a real namespace (the isolation boundary). v1 = two scopes: prod (default) + dev (opt-in throwaway the AI agent may use freely while coding).
I've verified the load-bearing code facts. The three credential channels are real (
runner.ts:171{...process.env},:435base.password = platToken,:292MCPBearer ${platToken});PLAT_TOKENis the Forgejo admin PAT (dispatcher.ts:54);preview.ymlrunson: pull_requestand consumessecrets.*(same-repo → secrets populated);ENV_REalready acceptsdev(server.ts:1890);nsFor = ${owner}--${name}--${env}; the chart hook isextraEnvFrom. Here is the final proposal.Secret scoping — round 2
Answering the critique first
A suffix is enforced by nothing.
KEY__PREVIEWis a string sitting in the same field as the value, joined to meaning by a convention the resolver agrees to honor — and the agent that writes your app can mint or rewrite that name at will. This model never touches the secret's name. Scope is a separate fact in the owner's control plane (a Forgejo Variable the agent's credential cannot write) that resolves at seal-time into a real Kubernetes namespace — the isolation boundary itself. The mechanism is the namespace plus a credential boundary; the name carries no meaning at all. That is the difference between a convention and an enforced property, and it's why "prefix→suffix" doesn't apply: there was no boundary behind the suffix, and there are two real ones behind this.Why scope must live outside the secret (the forced move)
Forgejo has no per-environment secrets — secrets are repo/org/user-scoped only; "Environments" is an unimplemented feature request (forgejo discussions#440). GitHub's and Vercel's clean answer — scope is a property on the value — is simply not available in our store. So every honest option that isn't name-munging has to put the scope column somewhere outside the secret. The only real question is where it lives and in whose trust domain, and the answer that wins is: where the agent can't reach it, resolving into a boundary that actually isolates.
The model
Two real scopes for v1: prod (the default — where real values go) and dev (opt-in — a throwaway the agent uses freely while coding). Previews get no user secrets in v1 (status quo; see Phasing). That makes scope a boolean, so the representation is one readable Variable, not a grid.
GET→405; unchanged).PLAT_DEV— a newline/comma list of the secret names that are dev-shared. Variables are readable config in the owner's control plane; the agent's credential has noactions:variables:writeandgit push ≠ Variables write, so the agent cannot author or alter scope.sync-envresolves each secret to a scope and seals it into a per-scope namespace, holding the object name invariant:<o>--<a>--prod/ Secret<app>-user-env(today's path, unchanged)PLAT_DEV) →<o>--<a>--dev/ Secret<app>-user-envPLAT_DEVas a flat name list, reject-on-unknown is moot for a boolean but the referential hard-fail below is not.I am deliberately not shipping the round-1-judge's full hybrid. Dropped from v1: B's config-vs-secret store split (a real secret mis-filed into readable Variables is a silent plaintext leak — that footgun isn't worth a feature nobody asked for), and the general
{prod,dev,preview}enum (premature for two scopes). Both are named graduation tiers, not v1.Phone happy-path: "prod Stripe key + a throwaway dev token the agent may use freely"
STRIPE_SECRET_KEY = sk_live_…→ done, it's prod.OPENAI_API_KEY = sk-throwaway….PLAT_DEV = OPENAI_API_KEY.sync-env). The job summary prints a reconciliation table:STRIPE_SECRET_KEY → prod,OPENAI_API_KEY → dev.Stripe is live in the prod app. The OpenAI key is now injected into every agent run for this app — the agent calls the real API while coding, no PR→preview roundtrip. Two tabs, same Actions settings page, glanceable on a phone.
The throwaway-dev-token → Claude-agent flow, done right
Simple and correctly framed — the goal is to enable the agent, not to protect a token the owner doesn't care about. No sandbox, no entropy gate, no CI scan (round-1 theater, dropped).
sync-envseals dev-marked keys into<o>--<a>--dev's<app>-user-env.(o,a)— reads<o>--<a>--dev's<app>-user-envand injects only those keys into that one run's env (the refactoredspawnEnv, below).Honest caveat, stated plainly: because Forgejo Secrets are write-only (
GET→405), the dispatcher cannot read the value from Forgejo — it reads the sealed k8s Secret. So a newly added dev secret needs onesync-envrun to land (push-to-main or manual). After that, every spawn has it with zero roundtrip. That one-time seal is irreducible given write-only secrets, and it's still categorically better than a CI→preview roundtrip per iteration.The two constraints that survive the reframe, and how they hold:
A--…--dev; the dispatcher injects by reading only the run's own(o,a)namespace; thespawnEnvallowlist means no ambient cross-app env. Test: app B's run never sees app A's injected env.<app>-user-env; and the agent cannot editPLAT_DEV(no Variables-write credential), so it cannot broaden a key's scope. Structurally prevented — once the credential boundary below holds.The foundation the round-1 judge mislabeled as "polish": the credential boundary
This is the load-bearing mechanism, and it is a v1 blocker, not a graduation tier. Both reviewers proved the same thing independently, and the code confirms it: the agent today holds the Forgejo admin PAT by three channels —
runner.ts:171—spawnEnv()does{...process.env}, copyingPLAT_TOKENinto every--dangerously-skip-permissionssubprocess;runner.ts:435— the clone remote ishttps://plat:<PLAT_TOKEN>@host/o/r.git, persisted into.git/config(recoverable viagit remote -v);runner.ts:292—.mcp.jsoncarriesAuthorization: Bearer ${PLAT_TOKEN}.While that's true, scope-in-Variables is defeated end-to-end and is not a differentiator between any scope model: with admin the agent can
PUTPLAT_DEVdirectly, write/overwrite secrets, and — most damaging — author apull_requestworkflow that emits${{ toJSON(secrets) }}to exfiltrate every prod value (confirmed:preview.ymlrunson: pull_requestand already readssecrets.REGISTRY_TOKEN; Forgejo populates secrets for same-repo, non-fork PR workflows). A spawnEnv allowlist alone closes one of three channels.The fix — one change that simultaneously makes scope real, closes the leak, and enforces cross-tenant isolation at the OS boundary:
PLAT_TOKENfor its own work (label swaps, branch protection,create_app); the agent gets nothing admin by any channel..mcp.jsonbearer becomes a non-admin scoped token, and the MCP server authorizes every call against the run's(o,a)rather than acting asplatadmin for any caller (today it collapses toPLAT_AUTH_HEADER). The agent legitimately needs MCP; it must not be able to target another app or an admin endpoint through it.spawnEnvrefactor. Replace{...process.env}with an explicit allowlist retaining only what Claude + git need —PATH,HOME,NODE_*,CLAUDE_CONFIG_DIR,ANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKEN,SSL_CERT_FILE/GIT_SSL_CAINFO(the self-signed platform CA),GIT_TERMINAL_PROMPT=0— and take a per-run env argument so the per-app dev token is injected for that one run only. Necessary but not sufficient: it ships with steps 1–3, never alone..forgejo/workflows/**(Forgejo "Protected file patterns"), plus the trustedtoJSON(secrets)seal running on push to protected main with the workflow taken from base. This is what closes thetoJSON(secrets)exfil — and it only binds once the agent is non-admin (admin bypasses protections), which is exactly why step 1 is the prerequisite for everything.The unifying insight: one change — drop the agent's admin, give it a scoped non-admin push credential — is what makes scope-in-Variables real (agent can't rewrite it), makes branch protection actually bind, and closes the process-env leak. The credential + namespace boundary is the mechanism; scope-as-data is the clean UX on top.
Real security properties (honest)
PLAT_DEV; scope lives in the owner's control plane.workflows/**+ non-admin); prod values live in a namespace it has no k8s access to.GET; only the trusted seal workflow reads them).The one residual risk reviewers must treat as load-bearing: value-in-Secret and scope-in-Variable are two un-joined objects, reconcilable only inside the trusted
sync-envrun (no referential integrity in Forgejo). The seam is structural — given no env-secrets, the only seam-free alternatives are name-munging (rejected) or a bespoke store (over-engineered). So we make it loud, not silent: (a) aPLAT_DEVline naming a secret in neither store is a hard sync error, not a warning; (b) every run prints a reconciliation table (key → scope → namespace) that surfaces newly-seen keys; (c) both run only in the agent-uninfluenceable protected sync workflow. The hard-fail is the thing that converts "two objects that can drift" from a silent mis-scope into a loud failure — not optional polish.Non-breaking migration
Today:
sync-envseals everything (minus reserved keys) into prod's<app>-user-env; previews get nothing;ENV_REalready allowsdev. NoPLAT_DEV= exactly today's behavior, so every phase is additive and backward-compatible.Phasing (smallest-first, each shippable + QA-able)
git remote -vand.mcp.jsonreveal no admin; a PR addingecho ${{ toJSON(secrets) }}to a workflow is rejected by branch protection; an MCP call targeting another app is denied.PLAT_DEVparsing insync-env; seal dev keys into<o>--<a>--dev; dispatcher reads + injects per run. QA: mark a key dev, watch the agent call the real API live; confirm app B's run can't see it.PLAT_DEVline → sync fails loudly with the offending name.<o>--<a>--previewSecret sealed at trusted time and copied into eachpr-<N>namespace by the trusted MCP (never per-PR sealing in the untrusted PR job); the generalPLAT_SCOPESenum when a third scope appears; a thin Vercel-style Targets grid rendered over the existing Variable/Secret data — never a separate database (so if the UI is down, secrets stay editable in Forgejo).Decisions left to you (with recommendations)
PLAT_DEVlist. It's the minimum for two scopes and dodges the preview seam entirely. Graduate to the{prod,dev,preview,…}enum only when a third scope is genuinely needed.(o,a)). Deploy keys give true per-repo, zero API surface; a scoped PAT is easier to mint but broader. The deploy-key path is the cleanest hard boundary.—
Files referenced (in the seed):
clone_agents/src/runner.ts(spawnEnv:169, MCP bearer :292, clone remote :435),clone_agents/src/dispatcher.ts(admin PAT :54),clone_mcp/src/server.ts(nsFor:1839,ENV_RE:1890,PLAT_AUTH_HEADER:207),clone_apptemplate/.forgejo/workflows/preview.yml(on: pull_request+secrets.*),clone_apptemplate/charts/app/templates/deployment.yaml(extraEnvFrom:66).IMPLEMENTED + merged + red-team-validated (this session). The chosen model: scope lives OUTSIDE the secret name — in a readable Forgejo Variable
PLAT_DEVthe agent's credential can't write — and resolves at seal-time into a real namespace. v1 = prod (default) + dev (throwaway token -> Claude builder). #16 (now closed) is what makes the scope ENFORCED (agent can't rewrite the Variable or exfil). Merged: mcp#3, agents#5. Deferred (Phase 3): preview-scope. See #2 for the full deploy state.Audit (team-board): implemented, merged, red-team validated — closing.
Shipped: plat/mcp#3 (5d2db986) + plat/agents#3 (bbe30613) + plat/agents#5 (6ae7681a). Scope lives OUTSIDE the secret name — the Forgejo Variable
PLAT_DEV(agent credential cannot write it) resolves at seal-time into<owner>--<app>--dev, a real namespace. Dev names seal + inject into the builder run env; a PLAT_DEV name with no secret is a hard sync failure with a reconciliation table. Enforcement comes from the #16 boundary (closed). Deferred preview-scope (Phase 3) is a new ask — file separately if wanted.Hygiene note: the delivering PRs are cross-repo (plat/mcp, plat/agents), which never auto-closes a plat/mitosis issue — hence this manual close.