Skip to content

Inject shard-specific secrets into app compose templates (#138)#173

Open
ClaydeCode wants to merge 8 commits into
mainfrom
feature/clayde/app-compose-secrets
Open

Inject shard-specific secrets into app compose templates (#138)#173
ClaydeCode wants to merge 8 commits into
mainfrom
feature/clayde/app-compose-secrets

Conversation

@ClaydeCode

Copy link
Copy Markdown
Contributor

Closes #138.

What

Apps can now declare and receive shard-specific secrets in their docker-compose.yml.template:

services:
  db:
    environment:
      POSTGRES_PASSWORD: "{{ secret('db_password') }}"

On render, secret('<name>') returns an app-scoped secret — generating one (32 chars, [A-Za-z0-9], via the stdlib secrets CSPRNG) on first reference and persisting it in a new app_secrets table. Names are arbitrary and declared purely by referencing them. A secret is stable across re-renders, upgrades, and restarts (reused, never rotated), kept on uninstall (so a reinstall reuses it, matching the app's retained user_data), and not encrypted at rest — matching the decisions in the issue thread.

Templating syntax decision: the issue floated {{ secret.my-secret }}; a Jinja attribute access breaks on hyphens (parses as subtraction), so this uses the callable {{ secret('name') }} form — the alternative the issue also blessed ("identifiers cannot be mistaken for fixed keys").

The secret rides into the existing DB-snapshot backup automatically (db_snapshot.py discovers all non-yoyo tables), so it survives a restore.

How it works

render_docker_compose_template (app_installation/util.py) loads the app's existing secrets, renders with a SecretResolver that mints missing secrets synchronously during the single Jinja pass (Jinja is sync, the DB is not), persists new secrets before writing the compose file (a persistence failure aborts the render rather than leaving a compose file with secrets that were never stored), then writes the file.

Recommended reading order

  1. migrations/shard-core-0002-app-secrets.sql — the app_secrets table (composite PK (app_name, name), no FK to installed_apps so secrets survive uninstall)
  2. shard_core/database/app_secrets.pyget_all_for_app / idempotent insert
  3. shard_core/service/app_installation/app_secrets.pygenerate_secret, SecretResolver, load/persist
  4. shard_core/service/app_installation/util.py — wiring into the render path
  5. tests/test_app_secrets.py, tests/test_db_snapshot.py — coverage
  6. agents.md — documents the template context + honest limitations

Verification

  • tests/test_app_secrets.py (10 tests) and tests/test_db_snapshot.py (3) green.
  • tests/test_app_installation.py (12, real Docker) green — confirms the change is safe on the real install path.
  • ruff check clean; full suite collects (236 tests, no import breakage).

Review panel

Five reviewers ran (adversarial + test-adversary + DevEx + security + DB/migration), each given only the diff and the issue, instructed to refute. No blocking findings from any reviewer. Cheap advisories addressed:

# Reviewer Advisory Resolution
1 DevEx (×2) Service module secrets.py shadowed stdlib secrets (forced a pysecrets alias) and mismatched the DB module app_secrets.py Fixed — renamed to app_secrets.py, dropped the alias (commit 5480bed)
2 Test adversary test_distinct_names asserted rendered values but never that both secrets minted in one pass were persisted (a persist-loop bug storing only the first would slip through) Fixed — added DB assertion (commit ced8b0d)
3 Test adversary Reinstall-reuse (the headline lifecycle promise) only covered transitively Fixed — added test_secret_reused_after_reinstall (commit ced8b0d)
4 Test adversary generate_secret() never unit-tested directly (charset check was probabilistic) Fixed — added length/charset/uniqueness unit test (commit ced8b0d)
Me (self-review) agents.md claimed a "tracked separately" SandboxedEnvironment fix — no such issue is filed Fixed — softened wording, no fabricated reference (commit 277fe75)

Advisories deliberately not taken, with reasons:

  • Non-sandboxed Jinja lets a malicious app template escape and read any app's secrets (security + adversarial). This is pre-existingportal/fs already render through the same non-sandboxed jinja2.Template, and custom-app upload is app-reachable per the architecture contract. This diff does not widen it: SecretResolver is built with only the current app's rows, so secret('x') can never name another app's secret without the pre-existing escape. Honestly disclosed in agents.md; a real SandboxedEnvironment fix is a separate, broader change.
  • Secrets accumulate for permanently-removed apps (no purge path) — intended per the issue ("keep on uninstall"), symmetric with retained user_data, and rows are tiny.
  • Read-generate-persist is non-atomic (TOCTOU under concurrent same-app renders) — unreachable today: renders are serialized through the single InstallationWorker, and boot re-render runs sequentially before the worker starts.
  • Unbounded secret minting — dominated by the fact that a hostile template already has RCE; not worth a cap now.
  • Worker end-to-end secret test — the direct-render tests are a fair proxy and the passing real-Docker test_app_installation.py already exercises the render path; skipped as costly for low marginal coverage.

🤖 Generated with Claude Code

ClaydeCode and others added 8 commits July 20, 2026 20:15
App-scoped secrets are stored in a new app_secrets table keyed by
(app_name, name). No foreign key to installed_apps: the DB snapshot
restore inserts every table in one transaction in alphabetical order, so
a non-deferrable FK (app_secrets sorts before installed_apps) would fail
restore. Secrets are kept on uninstall by design, so there is no cascade
to want anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SecretResolver returns existing app-scoped secrets and mints new ones
(32 chars from [A-Za-z0-9], via the secrets CSPRNG) synchronously so a
compose template renders in a single jinja pass; new secrets are persisted
afterwards. Secrets are reused, never rotated, so they stay stable across
upgrades, uninstall/reinstall and restarts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Templates reference a secret with {{ secret('my-name') }}. The callable
form is chosen over {{ secret.my-name }} because jinja parses a dotted
name as attribute access (hyphens/dots break it) and a callable takes an
arbitrary key that cannot be mistaken for a fixed context key like fs or
portal. New secrets are persisted before the compose file is written, so a
persistence failure aborts the render rather than leaving a file with
unstored secrets. Secrets are kept on uninstall (nothing deletes them),
matching an app's retained user_data on reinstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers generation+persistence+injection, reuse across renders, distinct
names getting distinct values, app-scoping (each app's secret isolated),
retention on uninstall, secret-free templates persisting nothing,
persist-before-write abort ordering, insert idempotency, and app_secrets
riding along in the db_snapshot backup/restore round-trip. Each assertion
was verified to fail when its code path is broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Also notes the honest limitation: app-scoping is not a boundary against a
hostile app author because the compose template is rendered with a
non-sandboxed jinja environment (as portal/fs already are).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SandboxedEnvironment follow-up is not filed as an issue, so don't imply
it is tracked; also add app_secrets to the DB tables list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The service module was named secrets.py, shadowing the stdlib secrets it
uses (forcing a pysecrets alias) and mismatching the database.app_secrets
module for the same concept. Rename to app_secrets.py; drop the alias.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enerator

Add a reinstall-reuse test (the headline lifecycle promise, previously only
covered transitively), assert both secrets minted in one render pass are
persisted, and unit-test generate_secret's length/charset/randomness directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ClaydeCode
ClaydeCode requested a review from max-tet July 21, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inject shard-specific secrets into app Docker-compose templates

1 participant