Skip to main content

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)​

PieceState
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
Code-complete — deploy-gated

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​

  • gcloud authenticated against the target project (dev: ailang-multivac-dev).
  • The 1Password op CLI available to the executor (service-account token OP_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 varWherePurpose
AILANG_APPROVAL_SIGNING_KEYdashboardHMAC key enabling signed single-use token auth on approve/reject (mints the phone-button tokens)
AILANG_APPROVAL_BASE_URLdashboardthe dashboard's own public URL — used to build the Approve/Deny action links
AILANG_NTFY_SERVER_URLcoordinatorthe ntfy service URL (terraform output ntfy_url)
AILANG_NTFY_TOPICcoordinatorthe ntfy topic your phone subscribes to (secret-approvals)
AILANG_NTFY_AUTH_TOKENcoordinatoromit in dev (anonymous ntfy); set only with a real ntfy auth file in prod
AILANG_STORAGE=gcpexecutorselects cloud mode
AILANG_APPROVAL_URLexecutorthe dashboard base URL the approver POSTs to (falls back to AILANG_COORDINATOR_URL)
AILANG_AGENT_ID / AILANG_TASK_IDexecutorlabel the approval request (optional)
AILANG_APPROVAL_ALLOWED_CALLERSdashboardcomma-separated service-account emails allowed to create/poll secret approvals with a Google ID token (the executor lanes' SAs)
AILANG_APPROVAL_AUDIENCEdashboardoptional: comma-separated ID-token audiences to accept; defaults to AILANG_APPROVAL_BASE_URL
AILANG_APPROVAL_INTAKE_AUTHdashboardenforce (default) or off (anonymous create/poll — local dashboards and rollback only)
AILANG_APPROVAL_TOKENexecutor and dashboardshared-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, else AILANG_APPROVAL_BASE_URL) and that the verified email is on AILANG_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 for ailang run outside 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​

  1. Install the ntfy app (links above).
  2. 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.
  3. 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; the ailang-dev-ntfy-auth-token secret is reserved for it.
  4. For instant iOS delivery, the server's NTFY_UPSTREAM_BASE_URL (default https://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​

SymptomCause / fix
secret() blocks then E_SECRET_DENIED after ~5 minNo 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 capabilityRun with --caps Secret.
E_SECRET_UNAVAILABLEop missing or auth failed — there is no fallback. Check OP_SERVICE_ACCOUNT_TOKEN.
Phone never subscribesAdd the ailang-dev-ntfy-auth-token value in the ntfy app (server runs deny-all).
No notification but approval is approved in the dashboardntfy delivery issue — check the ntfy service logs and NTFY_UPSTREAM_BASE_URL.

See also​