Secret Approvals — push-to-phone
AILANG's Secret effect resolves 1Password references (op://Vault/Item/field)
behind a declared, capability-gated, flow-controlled effect. In the cloud,
resolution can be gated on a human approval pushed to your phone: an agent
calls secret(ref), the call blocks, your phone shows an Approve/Deny
notification, and the value is resolved just-in-time only on approval.
The resolved value never leaves the executor — the approval request, Pub/Sub event, and notification carry only the reference, a purpose, and the requesting agent.
Status (what works today)
| Piece | State |
|---|---|
Static leak prevention (<secret> → {not secret} is a compile error) | ✅ shipped (v0.26.0) |
Local secret() resolution via op (no approval gate) | ✅ shipped |
| Networked approver — executor blocks, polls the coordinator | ✅ shipped (M1) |
| Coordinator intake/status/resolve + signed-token auth | ✅ shipped (M2) |
| ntfy Cloud Run service + approvals topic/subscription (Terraform) | ✅ shipped (M3, ailang-multivac) |
kind=approval publish + coordinator→ntfy bridge | ✅ shipped (M3.5) |
| This runbook | ✅ you're reading it |
The full code path is now built (executor → dashboard intake → publish →
coordinator bridge → ntfy → phone, and back). What remains is deploying it:
terraform apply, building the ntfy image, setting the secrets/env below, and
subscribing your phone. Until then a gated secret() in the cloud blocks and
times out (fail closed). Everything is testable locally without the deploy
(see the demo at the end).
Architecture
agent-executor (ailang run): secret(ref) → blocks
│ POST /api/approvals {ref,purpose,agent} (value NEVER sent)
▼
coordinator (Cloud Run): pending approval record + publish kind=approval
▼
${prefix}-approvals topic → approvals-push-sub → coordinator /pubsub/push bridge
▼
ntfy (Cloud Run): push to your iPhone — "agent X wants op://… — <purpose>" [Approve][Deny]
│ button POSTs a signed single-use token → /api/approvals/{id}/approve
▼
coordinator records the decision ──poll──▶ executor unblocks
▼
agent-executor: op read <ref> → value (labelled <secret>), used, scrubbed
Prerequisites
gcloudauthenticated against the target project (dev:ailang-multivac-dev).- The 1Password
opCLI available to the executor (service-account tokenOP_SERVICE_ACCOUNT_TOKEN, trust model A). - The ntfy app on your phone — iOS or Android.
Deploy (dev first)
The infra is Terraform in the ailang-multivac repo, gated behind
enable_secret_approvals (already true in terraform/environments/dev/).
# 1. Plan + apply the ntfy service, approvals topic/subscription, and secret.
cd ailang-multivac
make plan ENV=dev # review: ailang-dev-ntfy, ailang-dev-approvals(-push-sub), ailang-dev-ntfy-auth-token
make apply ENV=dev
# 2. Build + push the ntfy image (ailang repo cloudbuild 'build-ntfy' step,
# or build docker/Dockerfile.ntfy manually and push to the artifact registry).
# 3. Set the ntfy auth token value (never stored in Terraform state).
openssl rand -hex 32 | gcloud secrets versions add ailang-dev-ntfy-auth-token \
--project ailang-multivac-dev --data-file=-
# 4. Find the ntfy URL.
cd terraform && terraform output -raw ntfy_url
ntfy_min_instances defaults to 0 (scale-to-zero, like the other services).
Cold start is fine for the push path; set it to 1 in the tfvars if you want
guaranteed iOS fetch-back delivery (ntfy holds the message in memory).
Coordinator and executor configuration
/api/approvals is served by the dashboard service; /pubsub/push (the ntfy
bridge) by the coordinator. So the env splits across three places:
| Env var | Where | Purpose |
|---|---|---|
AILANG_APPROVAL_SIGNING_KEY | dashboard | HMAC key enabling signed single-use token auth on approve/reject (mints the phone-button tokens) |
AILANG_APPROVAL_BASE_URL | dashboard | the dashboard's own public URL — used to build the Approve/Deny action links |
AILANG_NTFY_SERVER_URL | coordinator | the ntfy service URL (terraform output ntfy_url) |
AILANG_NTFY_TOPIC | coordinator | the ntfy topic your phone subscribes to (secret-approvals) |
AILANG_NTFY_AUTH_TOKEN | coordinator | omit in dev (anonymous ntfy); set only with a real ntfy auth file in prod |
AILANG_STORAGE=gcp | executor | selects cloud mode |
AILANG_APPROVAL_URL | executor | the dashboard base URL the approver POSTs to (falls back to AILANG_COORDINATOR_URL) |
AILANG_AGENT_ID / AILANG_TASK_ID | executor | label the approval request (optional) |
AILANG_APPROVAL_ALLOWED_CALLERS | dashboard | comma-separated service-account emails allowed to create/poll secret approvals with a Google ID token (the executor lanes' SAs) |
AILANG_APPROVAL_AUDIENCE | dashboard | optional: comma-separated ID-token audiences to accept; defaults to AILANG_APPROVAL_BASE_URL |
AILANG_APPROVAL_INTAKE_AUTH | dashboard | enforce (default) or off (anonymous create/poll — local dashboards and rollback only) |
AILANG_APPROVAL_TOKEN | executor and dashboard | shared-secret bearer for callers that cannot mint an ID token (local/CLI); the dashboard accepts it only when set to the same value there |
Who may create and poll approvals
The dashboard is public, so POST /api/approvals and GET /api/approvals/{id}
authenticate the caller and fail closed (401 no/bad credential, 403 a Google
identity not on the allowlist). Two credentials are accepted:
- A Google-signed ID token — what an executor on Cloud Run sends. The
approver mints it from the metadata server with the audience set to
AILANG_APPROVAL_URL; the dashboard checks signature, issuer (accounts.google.com), audience (AILANG_APPROVAL_AUDIENCE, elseAILANG_APPROVAL_BASE_URL) and that the verified email is onAILANG_APPROVAL_ALLOWED_CALLERS. The two URLs must be the same string (a trailing slash is ignored). AILANG_APPROVAL_TOKEN— a shared secret set on both sides, compared in constant time. A laptop's user ADC cannot mint ID tokens, so this is the route forailang runoutside GCP. When it is set on the client it is sent instead of an ID token.
The verified caller is recorded on the approval (caller in its context).
Approve/Deny are unchanged: signed ntfy token or an Approver session.
Phone setup
- Install the ntfy app (links above).
- In the app → Add subscription → set the server to your ntfy URL
(from
terraform output ntfy_url, or a custom domain) and the topic name. - No token needed (dev). The dev ntfy runs
NTFY_AUTH_DEFAULT_ACCESS=read-write, so just subscribe to the topic — leave the auth fields blank. Security in dev rests on the unguessable topic + the value-free notification + the HMAC-signed Approve/Deny action (approval-signing-key), not on ntfy auth. Prod hardening (a persistent ntfy auth file + per-user tokens) is a follow-up; theailang-dev-ntfy-auth-tokensecret is reserved for it. - For instant iOS delivery, the server's
NTFY_UPSTREAM_BASE_URL(defaulthttps://ntfy.sh) relays an APNs wakeup ping — only a ping, never the payload.
Test it
Once the bridge ships and the deploy is done:
# A gated secret task running in cloud mode (point at the dashboard URL):
AILANG_STORAGE=gcp AILANG_APPROVAL_URL=<dashboard-url> \
AILANG_APPROVAL_TOKEN=<the dashboard's shared token> \
ailang run --caps Secret,IO --entry main yourtask.ail
secret("op://…") blocks → your phone buzzes → tap Approve → the run
continues and resolves the value; tap Deny (or let it time out, default
5 min) → the run fails closed with E_SECRET_DENIED.
To test the parts that work today without any of the above, see the local
demo: examples/runnable/secrets/demo.sh
(static leak rejection + local secret() resolution with a fake op).
Guarantees
- Value isolation — the resolved value exists only in the executor process, post-approval. The request, Pub/Sub event, and notification are value-free by construction.
- Fail closed — any denial, timeout, or transport error returns
E_SECRET_DENIED; there is no silent fallback to a blank credential. - Single-use tokens — Approve/Deny buttons carry HMAC single-use, short-TTL tokens; reused or expired tokens are rejected.
- Self-hosted — approval metadata stays on your GCP infra; only an APNs wakeup ping transits the ntfy.sh upstream.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
secret() blocks then E_SECRET_DENIED after ~5 min | No decision arrived. Today: the publish/consume bridge isn't wired (see status). After it ships: check the coordinator published, the subscription delivered, and ntfy reached your phone. |
effect 'Secret' requires capability | Run with --caps Secret. |
E_SECRET_UNAVAILABLE | op missing or auth failed — there is no fallback. Check OP_SERVICE_ACCOUNT_TOKEN. |
| Phone never subscribes | Add the ailang-dev-ntfy-auth-token value in the ntfy app (server runs deny-all). |
No notification but approval is approved in the dashboard | ntfy delivery issue — check the ntfy service logs and NTFY_UPSTREAM_BASE_URL. |
See also
- IFC labels — the
<secret>label + sink enforcement. - Design: m-secret-effect-remote-approval.md, m-secret-remote-approval-wiring.md.