Self-serve secret scoping: prod + throwaway-dev scopes via a control-plane Variable (not name-munging) #15

Closed
opened 2026-06-29 19:45:21 +00:00 by plat · 2 comments
Owner

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}, :435 base.password = platToken, :292 MCP Bearer ${platToken}); PLAT_TOKEN is the Forgejo admin PAT (dispatcher.ts:54); preview.yml runs on: pull_request and consumes secrets.* (same-repo → secrets populated); ENV_RE already accepts dev (server.ts:1890); nsFor = ${owner}--${name}--${env}; the chart hook is extraEnvFrom. Here is the final proposal.


Secret scoping — round 2

Answering the critique first

A suffix is enforced by nothing. KEY__PREVIEW is 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.

  • Values stay in Forgejo Secrets (write-only; GET→405; unchanged).
  • Scope lives in one Forgejo Actions Variable, 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 no actions:variables:write and git push ≠ Variables write, so the agent cannot author or alter scope.
  • The namespace is the only thing that varies. sync-env resolves each secret to a scope and seals it into a per-scope namespace, holding the object name invariant:
    • prod (default) → <o>--<a>--prod / Secret <app>-user-env (today's path, unchanged)
    • dev (in PLAT_DEV) → <o>--<a>--dev / Secret <app>-user-env
  • Resolver: case-normalize names (Forgejo Variable names are case-insensitive), parse PLAT_DEV as 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"

  1. Repo → Settings → Actions → Secrets → New: STRIPE_SECRET_KEY = sk_live_… → done, it's prod.
  2. New Secret: OPENAI_API_KEY = sk-throwaway….
  3. Settings → Actions → Variables → New: PLAT_DEV = OPENAI_API_KEY.
  4. Push to main (or hit Run on 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-env seals dev-marked keys into <o>--<a>--dev's <app>-user-env.
  • The dispatcher (trusted, in-cluster, k8s read) — when it spawns an agent for (o,a) — reads <o>--<a>--dev's <app>-user-env and injects only those keys into that one run's env (the refactored spawnEnv, below).
  • One delivery path only (not the judge's RoleBinding and injection — injection): the agent gets the dev value as a normal env var in its process, exactly as if it were running the app locally.

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 one sync-env run 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:

  • Cross-tenant (A's dev token must not reach B's agent): dev values seal into A--…--dev; the dispatcher injects by reading only the run's own (o,a) namespace; the spawnEnv allowlist means no ambient cross-app env. Test: app B's run never sees app A's injected env.
  • No dev→prod promotion: dev values land only in the dev namespace, never the prod <app>-user-env; and the agent cannot edit PLAT_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}, copying PLAT_TOKEN into every --dangerously-skip-permissions subprocess;
  • runner.ts:435 — the clone remote is https://plat:<PLAT_TOKEN>@host/o/r.git, persisted into .git/config (recoverable via git remote -v);
  • runner.ts:292 — .mcp.json carries Authorization: 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 PUT PLAT_DEV directly, write/overwrite secrets, and — most damaging — author a pull_request workflow that emits ${{ toJSON(secrets) }} to exfiltrate every prod value (confirmed: preview.yml runs on: pull_request and already reads secrets.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:

  1. Strip the admin PAT from the subprocess entirely. The dispatcher keeps PLAT_TOKEN for its own work (label swaps, branch protection, create_app); the agent gets nothing admin by any channel.
  2. Per-(owner,app) git credential, push-only. Recommend a per-repo deploy key (true per-repo, zero API/admin/secrets surface) in the remote URL — never the admin PAT.
  3. Per-app MCP bearer. The .mcp.json bearer becomes a non-admin scoped token, and the MCP server authorizes every call against the run's (o,a) rather than acting as plat admin for any caller (today it collapses to PLAT_AUTH_HEADER). The agent legitimately needs MCP; it must not be able to target another app or an admin endpoint through it.
  4. spawnEnv refactor. 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.
  5. Branch protection on .forgejo/workflows/** (Forgejo "Protected file patterns"), plus the trusted toJSON(secrets) seal running on push to protected main with the workflow taken from base. This is what closes the toJSON(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)

  • Cross-tenant: prevented — namespace isolation + per-run env allowlist + per-app push/MCP credential. The agent process holds only its app's scoped credential and its app's injected dev env.
  • No dev→prod promotion: structurally prevented — dev seals only into the dev namespace; the agent cannot edit PLAT_DEV; scope lives in the owner's control plane.
  • Prod secrets unreachable by the agent: no prod user-env in the agent's process; the agent can't author/merge a secret-reading workflow (protection on workflows/** + non-admin); prod values live in a namespace it has no k8s access to.
  • Not claimed (by design): we do not protect the dev token's secrecy — it's throwaway. We accept that values sit in write-only Forgejo Secrets as the source (humans can't 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-env run (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) a PLAT_DEV line 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-env seals everything (minus reserved keys) into prod's <app>-user-env; previews get nothing; ENV_RE already allows dev. No PLAT_DEV = exactly today's behavior, so every phase is additive and backward-compatible.

Phasing (smallest-first, each shippable + QA-able)

  • Phase 0 — credential boundary (the foundation; ship first). Steps 1–5 above. Zero secret-behavior change — pure hardening, and the highest-value security fix on its own. QA: agent can still clone/push/use MCP; git remote -v and .mcp.json reveal no admin; a PR adding echo ${{ toJSON(secrets) }} to a workflow is rejected by branch protection; an MCP call targeting another app is denied.
  • Phase 1 — dev scope. PLAT_DEV parsing in sync-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.
  • Phase 2 — loud seam. Referential hard-fail + reconciliation summary; lint flagging high-entropy values placed in Variables. QA: typo a PLAT_DEV line → sync fails loudly with the offending name.
  • Phase 3+ — graduation, only if wanted. Preview scope via a stable <o>--<a>--preview Secret sealed at trusted time and copied into each pr-<N> namespace by the trusted MCP (never per-PR sealing in the untrusted PR job); the general PLAT_SCOPES enum 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)

  1. Default for an unmarked secret. Recommend default-prod + loud reconciliation. Dev is opt-in, so the only silent default is a real value going to prod — the expected direction — and every new key's destination is printed each run. (Lever if you want zero silent defaults: make scope mandatory and hard-fail any unscoped key. I think that's friction the throwaway use-case doesn't want.)
  2. Scope representation now. Recommend the boolean PLAT_DEV list. 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.
  3. Per-app git credential mechanism. Recommend a per-repo deploy key for git push + a non-admin scoped token for the MCP bearer (MCP authorizing per-(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).

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}`, `:435` `base.password = platToken`, `:292` MCP `Bearer ${platToken}`); `PLAT_TOKEN` is the Forgejo admin PAT (`dispatcher.ts:54`); `preview.yml` runs `on: pull_request` and consumes `secrets.*` (same-repo → secrets populated); `ENV_RE` already accepts `dev` (`server.ts:1890`); `nsFor = ${owner}--${name}--${env}`; the chart hook is `extraEnvFrom`. Here is the final proposal. --- # Secret scoping — round 2 ## Answering the critique first A suffix is enforced by *nothing*. `KEY__PREVIEW` is 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. - **Values** stay in Forgejo **Secrets** (write-only; `GET`→405; unchanged). - **Scope** lives in one Forgejo **Actions Variable, `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 no `actions:variables:write` and `git push ≠ Variables write`, so the agent cannot author or alter scope. - **The namespace is the only thing that varies.** `sync-env` resolves each secret to a scope and seals it into a per-scope namespace, holding the object name invariant: - prod (default) → `<o>--<a>--prod` / Secret `<app>-user-env` (today's path, unchanged) - dev (in `PLAT_DEV`) → `<o>--<a>--dev` / Secret `<app>-user-env` - Resolver: case-normalize names (Forgejo Variable names are case-insensitive), parse `PLAT_DEV` as 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" 1. Repo → Settings → Actions → **Secrets** → New: `STRIPE_SECRET_KEY = sk_live_…` → done, it's prod. 2. New Secret: `OPENAI_API_KEY = sk-throwaway…`. 3. Settings → Actions → **Variables** → New: `PLAT_DEV = OPENAI_API_KEY`. 4. Push to main (or hit *Run* on `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-env` seals dev-marked keys into `<o>--<a>--dev`'s `<app>-user-env`. - The **dispatcher** (trusted, in-cluster, k8s read) — when it spawns an agent for `(o,a)` — reads `<o>--<a>--dev`'s `<app>-user-env` and **injects only those keys into that one run's env** (the refactored `spawnEnv`, below). - **One delivery path only** (not the judge's RoleBinding *and* injection — injection): the agent gets the dev value as a normal env var in its process, exactly as if it were running the app locally. 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 **one** `sync-env` run 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: - **Cross-tenant (A's dev token must not reach B's agent):** dev values seal into `A--…--dev`; the dispatcher injects by reading *only* the run's own `(o,a)` namespace; the `spawnEnv` allowlist means no ambient cross-app env. Test: app B's run never sees app A's injected env. - **No dev→prod promotion:** dev values land *only* in the dev namespace, never the prod `<app>-user-env`; and the agent cannot edit `PLAT_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}`, copying `PLAT_TOKEN` into every `--dangerously-skip-permissions` subprocess; - `runner.ts:435` — the clone remote is `https://plat:<PLAT_TOKEN>@host/o/r.git`, persisted into `.git/config` (recoverable via `git remote -v`); - `runner.ts:292` — `.mcp.json` carries `Authorization: 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 `PUT` `PLAT_DEV` directly, write/overwrite secrets, and — most damaging — author a `pull_request` workflow that emits `${{ toJSON(secrets) }}` to exfiltrate every prod value (confirmed: `preview.yml` runs `on: pull_request` and already reads `secrets.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: 1. **Strip the admin PAT from the subprocess entirely.** The dispatcher keeps `PLAT_TOKEN` for its own work (label swaps, branch protection, `create_app`); the *agent* gets nothing admin by any channel. 2. **Per-(owner,app) git credential, push-only.** Recommend a **per-repo deploy key** (true per-repo, zero API/admin/secrets surface) in the remote URL — never the admin PAT. 3. **Per-app MCP bearer.** The `.mcp.json` bearer becomes a **non-admin scoped token**, and the **MCP server authorizes every call against the run's `(o,a)`** rather than acting as `plat` admin for any caller (today it collapses to `PLAT_AUTH_HEADER`). The agent legitimately needs MCP; it must not be able to target another app or an admin endpoint through it. 4. **`spawnEnv` refactor.** 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. 5. **Branch protection on `.forgejo/workflows/**`** (Forgejo "Protected file patterns"), plus the trusted `toJSON(secrets)` seal running on **push to protected main** with the workflow taken from base. This is what closes the `toJSON(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) - **Cross-tenant:** prevented — namespace isolation + per-run env allowlist + per-app push/MCP credential. The agent process holds only its app's scoped credential and its app's injected dev env. - **No dev→prod promotion:** structurally prevented — dev seals only into the dev namespace; the agent cannot edit `PLAT_DEV`; scope lives in the owner's control plane. - **Prod secrets unreachable by the agent:** no prod user-env in the agent's process; the agent can't author/merge a secret-reading workflow (protection on `workflows/**` + non-admin); prod values live in a namespace it has no k8s access to. - **Not claimed (by design):** we do *not* protect the dev token's secrecy — it's throwaway. We accept that values sit in write-only Forgejo Secrets as the source (humans can't `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-env` run (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) a `PLAT_DEV` line 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-env` seals everything (minus reserved keys) into prod's `<app>-user-env`; previews get nothing; `ENV_RE` already allows `dev`. No `PLAT_DEV` = exactly today's behavior, so every phase is additive and backward-compatible. ## Phasing (smallest-first, each shippable + QA-able) - **Phase 0 — credential boundary (the foundation; ship first).** Steps 1–5 above. *Zero* secret-behavior change — pure hardening, and the highest-value security fix on its own. QA: agent can still clone/push/use MCP; `git remote -v` and `.mcp.json` reveal no admin; a PR adding `echo ${{ toJSON(secrets) }}` to a workflow is rejected by branch protection; an MCP call targeting another app is denied. - **Phase 1 — dev scope.** `PLAT_DEV` parsing in `sync-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. - **Phase 2 — loud seam.** Referential hard-fail + reconciliation summary; lint flagging high-entropy values placed in Variables. QA: typo a `PLAT_DEV` line → sync fails loudly with the offending name. - **Phase 3+ — graduation, only if wanted.** Preview scope via a **stable** `<o>--<a>--preview` Secret sealed at trusted time and **copied** into each `pr-<N>` namespace by the trusted MCP (never per-PR sealing in the untrusted PR job); the general `PLAT_SCOPES` enum 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) 1. **Default for an unmarked secret.** *Recommend default-prod + loud reconciliation.* Dev is opt-in, so the only silent default is a real value going to prod — the expected direction — and every new key's destination is printed each run. (Lever if you want zero silent defaults: make scope mandatory and hard-fail any unscoped key. I think that's friction the throwaway use-case doesn't want.) 2. **Scope representation now.** *Recommend the boolean `PLAT_DEV` list.* 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. 3. **Per-app git credential mechanism.** *Recommend a per-repo deploy key for git push + a non-admin scoped token for the MCP bearer (MCP authorizing per-`(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).
Author
Owner

IMPLEMENTED + merged + red-team-validated (this session). The chosen model: scope lives OUTSIDE the secret name — in a readable Forgejo Variable PLAT_DEV the 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.

IMPLEMENTED + merged + red-team-validated (this session). The chosen model: scope lives OUTSIDE the secret name — in a readable Forgejo **Variable `PLAT_DEV`** the 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.
Author
Owner

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.

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.
plat closed this issue 2026-07-02 02:35:41 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-platform/mitosis#15
No description provided.