POST /registry/register accepted any URL string and persisted it as
the workspace's A2A endpoint — an attacker could register a workspace
with url=http://169.254.169.254/latest/meta-data/ and cause the platform
to proxy requests to the cloud metadata service when proxying A2A traffic.
Fix: validateAgentURL() helper rejects:
- empty URL
- non-http/https schemes (file://, ftp://, etc.)
- 169.254.0.0/16 link-local IPs (AWS/GCP/Azure IMDS endpoints)
Allows RFC-1918 private ranges (Docker networking uses 172.16-31.x.x).
Adds 12 unit tests covering valid Docker-internal URLs and all SSRF vectors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /registry/register accepted any URL string and persisted it as
the workspace's A2A endpoint — an attacker could register a workspace
with url=http://169.254.169.254/latest/meta-data/ and cause the platform
to proxy requests to the cloud metadata service when proxying A2A traffic.
Fix: validateAgentURL() helper rejects:
- empty URL
- non-http/https schemes (file://, ftp://, etc.)
- 169.254.0.0/16 link-local IPs (AWS/GCP/Azure IMDS endpoints)
Allows RFC-1918 private ranges (Docker networking uses 172.16-31.x.x).
Adds 12 unit tests covering valid Docker-internal URLs and all SSRF vectors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three unauthenticated routes allowed arbitrary read/write/delete of all
global platform secrets (API keys, provider credentials) with zero auth:
- GET/PUT/POST /settings/secrets
- DELETE /settings/secrets/:key
- GET/POST/DELETE /admin/secrets (legacy aliases)
Fix: new AdminAuth middleware with same lazy-bootstrap contract as
WorkspaceAuth — fail-open when no tokens exist (fresh install / pre-Phase-30
upgrade), enforce once any workspace has a live token. Any valid workspace
bearer token grants access (platform-wide scope, no workspace binding needed).
Changes:
wsauth/tokens.go — HasAnyLiveTokenGlobal + ValidateAnyToken functions
wsauth/tokens_test.go — 5 new tests covering both new functions
middleware/wsauth_middleware.go — AdminAuth middleware
router/router.go — global secrets routes now registered under adminAuth group
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three unauthenticated routes allowed arbitrary read/write/delete of all
global platform secrets (API keys, provider credentials) with zero auth:
- GET/PUT/POST /settings/secrets
- DELETE /settings/secrets/:key
- GET/POST/DELETE /admin/secrets (legacy aliases)
Fix: new AdminAuth middleware with same lazy-bootstrap contract as
WorkspaceAuth — fail-open when no tokens exist (fresh install / pre-Phase-30
upgrade), enforce once any workspace has a live token. Any valid workspace
bearer token grants access (platform-wide scope, no workspace binding needed).
Changes:
wsauth/tokens.go — HasAnyLiveTokenGlobal + ValidateAnyToken functions
wsauth/tokens_test.go — 5 new tests covering both new functions
middleware/wsauth_middleware.go — AdminAuth middleware
router/router.go — global secrets routes now registered under adminAuth group
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap CanvasInner return in React Fragment to host skip-nav link as sibling of <main>
- Add <a href="#canvas-main"> skip link (sr-only, revealed on focus) before <main>
- Add id="canvas-main" to <main> element
- Add aria-label="Molecule AI workspace canvas" to ReactFlow wrapper
- Add Canvas.a11y.test.tsx: 4 jsdom tests covering all three a11y landmarks
369/369 tests pass; next build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap CanvasInner return in React Fragment to host skip-nav link as sibling of <main>
- Add <a href="#canvas-main"> skip link (sr-only, revealed on focus) before <main>
- Add id="canvas-main" to <main> element
- Add aria-label="Molecule AI workspace canvas" to ReactFlow wrapper
- Add Canvas.a11y.test.tsx: 4 jsdom tests covering all three a11y landmarks
369/369 tests pass; next build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The WorkspaceAuth middleware (PR #31) now requires bearer tokens on all
/workspaces/:id/* sub-routes. The E2E test_api.sh already captured ECHO_TOKEN
and SUM_TOKEN from /registry/register but was not passing them to the ten
/activity curl calls, causing 10 FAIL assertions in CI.
Add -H "Authorization: Bearer $ECHO_TOKEN" (or $SUM_TOKEN) to every
GET and POST /workspaces/:id/activity call in the Activity Log Tests section.
PATCH /workspaces/:id and DELETE /workspaces/:id remain unauthenticated (they
are on the root router, not the wsAuth group).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The WorkspaceAuth middleware (PR #31) now requires bearer tokens on all
/workspaces/:id/* sub-routes. The E2E test_api.sh already captured ECHO_TOKEN
and SUM_TOKEN from /registry/register but was not passing them to the ten
/activity curl calls, causing 10 FAIL assertions in CI.
Add -H "Authorization: Bearer $ECHO_TOKEN" (or $SUM_TOKEN) to every
GET and POST /workspaces/:id/activity call in the Activity Log Tests section.
PATCH /workspaces/:id and DELETE /workspaces/:id remain unauthenticated (they
are on the root router, not the wsAuth group).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UX Audit Run 6 critical finding: Legend panel and workspace node cards used 8px and 9px
text (6–7pt), which is physically unreadable and fails WCAG minimum guidelines.
- Legend.tsx: raise all text-[8px]/[9px]/[10px] → text-[11px] across every sub-component
(StatusItem labels, TierItem badge+label, CommItem icon+label, section headers)
- WorkspaceNode.tsx: raise text-[8px]/[9px] → text-[10px] for all readable labels in
the main card (status text, skill badges, task/error banners, tier badge, sub count,
Team Members header) and TeamMemberChip primary name/role text
Compact 7px elements inside TeamMemberChip (tier/sub badges, status micropills) retained
to preserve dense canvas layout — only human-readable labels were upgraded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UX Audit Run 6 critical finding: Legend panel and workspace node cards used 8px and 9px
text (6–7pt), which is physically unreadable and fails WCAG minimum guidelines.
- Legend.tsx: raise all text-[8px]/[9px]/[10px] → text-[11px] across every sub-component
(StatusItem labels, TierItem badge+label, CommItem icon+label, section headers)
- WorkspaceNode.tsx: raise text-[8px]/[9px] → text-[10px] for all readable labels in
the main card (status text, skill badges, task/error banners, tier badge, sub count,
Team Members header) and TeamMemberChip primary name/role text
Compact 7px elements inside TeamMemberChip (tier/sub badges, status micropills) retained
to preserve dense canvas layout — only human-readable labels were upgraded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up to root-cause analysis in #17 (see 2026-04-14 02:14 UTC comment).
The Security Auditor's hourly DAST was creating test workspaces, secrets,
and plugins to probe auth/validation logic — but only secrets and plugins
had teardown in the prompt. Workspace-create probes leaked rows into
`workspaces` with sequential IDs aaaaaaaa- bbbbbbbb- cccccccc- dddddddd-,
each trapped in a restart loop on missing config.yaml. Four hourly runs,
four leaked workspaces.
Adds explicit step 4a: DAST TEARDOWN. Maintains three lists (workspaces,
secrets, plugins) populated as probes run, and iterates them at the end
with DELETE calls. Uses `|| true` so partial teardown failures don't
break the audit, but every created artifact gets a cleanup attempt.
Doesn't remove the cleanup the cron was already doing for secrets/plugins
— just formalises the pattern so workspace-create (and any future probe
surface) is covered by the same contract.
Related:
- #17 — rogue workspace restart loop (root cause was this)
- #26 — audit cron routing (this PR sits alongside that structure)
Follow-up to root-cause analysis in #17 (see 2026-04-14 02:14 UTC comment).
The Security Auditor's hourly DAST was creating test workspaces, secrets,
and plugins to probe auth/validation logic — but only secrets and plugins
had teardown in the prompt. Workspace-create probes leaked rows into
`workspaces` with sequential IDs aaaaaaaa- bbbbbbbb- cccccccc- dddddddd-,
each trapped in a restart loop on missing config.yaml. Four hourly runs,
four leaked workspaces.
Adds explicit step 4a: DAST TEARDOWN. Maintains three lists (workspaces,
secrets, plugins) populated as probes run, and iterates them at the end
with DELETE calls. Uses `|| true` so partial teardown failures don't
break the audit, but every created artifact gets a cleanup attempt.
Doesn't remove the cleanup the cron was already doing for secrets/plugins
— just formalises the pattern so workspace-create (and any future probe
surface) is covered by the same contract.
Related:
- #17 — rogue workspace restart loop (root cause was this)
- #26 — audit cron routing (this PR sits alongside that structure)
Fix A — platform/internal/middleware/wsauth_middleware.go (NEW):
WorkspaceAuth() gin middleware enforces per-workspace bearer-token auth on
ALL /workspaces/:id/* sub-routes. Same lazy-bootstrap contract as
secrets.Values: workspaces with no live token are grandfathered through.
Blocks C2, C3, C4, C5, C7, C8, C9, C12, C13 simultaneously.
Fix A — platform/internal/router/router.go:
Reorganised route registration: bare CRUD (/workspaces, /workspaces/:id)
and /a2a remain on root router; all other /workspaces/:id/* sub-routes
moved into wsAuth = r.Group("/workspaces/:id", middleware.WorkspaceAuth(db.DB)).
CORS AllowHeaders updated to include Authorization so browser/agent callers
can send the bearer token cross-origin.
Fix B — workspace-template/heartbeat.py:
_check_delegations(): validate source_id == self.workspace_id before
accepting a delegation result. Attacker-crafted records with a foreign
source_id are silently skipped with a WARNING log (injection attempt).
trigger_msg no longer embeds raw response_preview text; references
delegation_id + status only — removes the prompt-injection vector.
Fix C — workspace-template/skill_loader/loader.py:
load_skill_tools(): before exec_module(), verify script is within
scripts_dir (path traversal guard) and temporarily scrub sensitive env
vars (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY,
WORKSPACE_AUTH_TOKEN, GITHUB_TOKEN, GH_TOKEN) from os.environ; restore
in finally block. Defence-in-depth even if /plugins auth gate is bypassed.
Fix D — platform/internal/handlers/socket.go:
HandleConnect(): agent connections (X-Workspace-ID present) validated via
wsauth.HasAnyLiveToken + wsauth.ValidateToken before WebSocket upgrade.
Canvas clients (no X-Workspace-ID) remain unauthenticated.
Fix D — workspace-template/events.py:
PlatformEventSubscriber._connect(): include platform_auth bearer token in
WebSocket upgrade headers alongside X-Workspace-ID.
Fix E — workspace-template/executor_helpers.py:
recall_memories() and commit_memory() now pass platform_auth bearer token
in Authorization header so WorkspaceAuth middleware allows access.
Fix F — workspace-template/a2a_client.py:
send_a2a_message(): timeout=None → httpx.Timeout(connect=30, read=300,
write=30, pool=30). Resolves H2 flagged across 5 consecutive audits.
Tests: 149/149 Python tests pass (test_heartbeat + test_events updated to
assert new source_id validation behaviour and allow Authorization header).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix A — platform/internal/middleware/wsauth_middleware.go (NEW):
WorkspaceAuth() gin middleware enforces per-workspace bearer-token auth on
ALL /workspaces/:id/* sub-routes. Same lazy-bootstrap contract as
secrets.Values: workspaces with no live token are grandfathered through.
Blocks C2, C3, C4, C5, C7, C8, C9, C12, C13 simultaneously.
Fix A — platform/internal/router/router.go:
Reorganised route registration: bare CRUD (/workspaces, /workspaces/:id)
and /a2a remain on root router; all other /workspaces/:id/* sub-routes
moved into wsAuth = r.Group("/workspaces/:id", middleware.WorkspaceAuth(db.DB)).
CORS AllowHeaders updated to include Authorization so browser/agent callers
can send the bearer token cross-origin.
Fix B — workspace-template/heartbeat.py:
_check_delegations(): validate source_id == self.workspace_id before
accepting a delegation result. Attacker-crafted records with a foreign
source_id are silently skipped with a WARNING log (injection attempt).
trigger_msg no longer embeds raw response_preview text; references
delegation_id + status only — removes the prompt-injection vector.
Fix C — workspace-template/skill_loader/loader.py:
load_skill_tools(): before exec_module(), verify script is within
scripts_dir (path traversal guard) and temporarily scrub sensitive env
vars (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY,
WORKSPACE_AUTH_TOKEN, GITHUB_TOKEN, GH_TOKEN) from os.environ; restore
in finally block. Defence-in-depth even if /plugins auth gate is bypassed.
Fix D — platform/internal/handlers/socket.go:
HandleConnect(): agent connections (X-Workspace-ID present) validated via
wsauth.HasAnyLiveToken + wsauth.ValidateToken before WebSocket upgrade.
Canvas clients (no X-Workspace-ID) remain unauthenticated.
Fix D — workspace-template/events.py:
PlatformEventSubscriber._connect(): include platform_auth bearer token in
WebSocket upgrade headers alongside X-Workspace-ID.
Fix E — workspace-template/executor_helpers.py:
recall_memories() and commit_memory() now pass platform_auth bearer token
in Authorization header so WorkspaceAuth middleware allows access.
Fix F — workspace-template/a2a_client.py:
send_a2a_message(): timeout=None → httpx.Timeout(connect=30, read=300,
write=30, pool=30). Resolves H2 flagged across 5 consecutive audits.
Tests: 149/149 Python tests pass (test_heartbeat + test_events updated to
assert new source_id validation behaviour and allow Authorization header).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Currently no workspace in the molecule-dev template installs any of the
four available plugins (browser-automation, ecc, molecule-dev, superpowers).
Agents run without coding guardrails, codebase conventions, or debugging
discipline unless a plugin is installed per-workspace via the runtime
POST /workspaces/:id/plugins endpoint — which isn't happening.
Changes:
1. defaults.plugins: [ecc, molecule-dev, superpowers]
- ecc: "Everything Claude Code" — coding standards, API design,
deep research, security review, TDD workflow, node guardrails
- molecule-dev: project-specific conventions, past bugs, review-loop skill
- superpowers: systematic debugging, TDD, plan writing/execution,
verification-before-completion
All three target runtime claude_code (matches our default).
2. plugins override on Research Lead + its 3 children + UIUX Designer:
[ecc, molecule-dev, superpowers, browser-automation]
- Research agents need live web access for scraping/trending/docs,
which is core to their role.
- UIUX Designer gets Puppeteer via CDP; this may work around the
libglib/X11 gap that breaks Playwright today (#23 — the image-level
fix remains the right long-term solution, but browser-automation
uses puppeteer-core + a Chrome CDP proxy and may bypass the deps
issue entirely).
Note: platform/internal/handlers/org.go:345 treats per-workspace
`plugins:` as a REPLACEMENT of defaults (not a union), which is why
each opt-in workspace re-lists the full set. Documented inline in the
template so future editors don't accidentally drop defaults.
No other roles take browser-automation — Dev Lead, BE, FE, DevOps,
Security, QA, PM all get the default set only. If they need web access
they can install ad-hoc via the runtime plugin API.
Currently no workspace in the molecule-dev template installs any of the
four available plugins (browser-automation, ecc, molecule-dev, superpowers).
Agents run without coding guardrails, codebase conventions, or debugging
discipline unless a plugin is installed per-workspace via the runtime
POST /workspaces/:id/plugins endpoint — which isn't happening.
Changes:
1. defaults.plugins: [ecc, molecule-dev, superpowers]
- ecc: "Everything Claude Code" — coding standards, API design,
deep research, security review, TDD workflow, node guardrails
- molecule-dev: project-specific conventions, past bugs, review-loop skill
- superpowers: systematic debugging, TDD, plan writing/execution,
verification-before-completion
All three target runtime claude_code (matches our default).
2. plugins override on Research Lead + its 3 children + UIUX Designer:
[ecc, molecule-dev, superpowers, browser-automation]
- Research agents need live web access for scraping/trending/docs,
which is core to their role.
- UIUX Designer gets Puppeteer via CDP; this may work around the
libglib/X11 gap that breaks Playwright today (#23 — the image-level
fix remains the right long-term solution, but browser-automation
uses puppeteer-core + a Chrome CDP proxy and may bypass the deps
issue entirely).
Note: platform/internal/handlers/org.go:345 treats per-workspace
`plugins:` as a REPLACEMENT of defaults (not a union), which is why
each opt-in workspace re-lists the full set. Documented inline in the
template so future editors don't accidentally drop defaults.
No other roles take browser-automation — Dev Lead, BE, FE, DevOps,
Security, QA, PM all get the default set only. If they need web access
they can install ad-hoc via the runtime plugin API.
Addresses the gap surfaced by CEO 2026-04-13: audit agents (Security
Auditor, QA Engineer, UIUX Designer) were running their crons successfully
but findings stayed in agent memory and didn't consistently flow to
GitHub issues or to developers with build ability. BE noticed Security
findings once via a manual escalation; subsequent hourly audits
accumulated 13 criticals (including an unauthenticated-plugin-install
RCE) with no durable tracking.
Changes:
1. Security Auditor schedule: replace 12h (7 6,18 * * *) with hourly
(17 * * * *) to match what's actually running in the platform DB.
Rewrite the prompt with the full body of the runtime cron — git diff
scoping, gosec/bandit, manual checklist, live API DAST, secrets scan,
open-PR review.
2. QA Engineer schedule: keep 12h cadence, tighten post-audit routing.
3. UIUX Designer: add a schedule (was previously runtime-only — see #24).
Uses hourly cadence to match runtime. Accepts Playwright may be
unavailable (see #23) and falls back to HTML analysis with the
limitation noted in the deliverable.
All three audit crons now end with an identical FINAL STEP — DELIVERABLE
ROUTING block that makes the post-audit flow MANDATORY:
a. File a GitHub issue for each CRITICAL / HIGH finding (dedupe first)
b. delegate_task to PM with a structured summary listing issue numbers;
PM decides which dev agent picks up which issue
c. Even on clean cycles, send PM a one-line "clean on SHA X" so audits
are observable
d. Memory write becomes a secondary record, not the primary deliverable
Rationale: findings need to flow into the issue tracker (durable, visible
to CEO, part of the PR/issue review feedback loop already in place) and
through PM (who owns cross-team orchestration). Memory-only output is
invisible to everyone except the auditor itself.
Related:
- #23 — UIUX Designer container missing libglib/X11 for Playwright.
This PR accepts the current limitation; #23 tracks the image fix.
- #24 — template-vs-runtime schedule drift. This PR backfills the template;
#24 tracks the platform-layer fix for preventing future drift.
- 13 open criticals in Security Auditor memory are out of scope for this
PR (that's team work once the routing is in place).
Addresses the gap surfaced by CEO 2026-04-13: audit agents (Security
Auditor, QA Engineer, UIUX Designer) were running their crons successfully
but findings stayed in agent memory and didn't consistently flow to
GitHub issues or to developers with build ability. BE noticed Security
findings once via a manual escalation; subsequent hourly audits
accumulated 13 criticals (including an unauthenticated-plugin-install
RCE) with no durable tracking.
Changes:
1. Security Auditor schedule: replace 12h (7 6,18 * * *) with hourly
(17 * * * *) to match what's actually running in the platform DB.
Rewrite the prompt with the full body of the runtime cron — git diff
scoping, gosec/bandit, manual checklist, live API DAST, secrets scan,
open-PR review.
2. QA Engineer schedule: keep 12h cadence, tighten post-audit routing.
3. UIUX Designer: add a schedule (was previously runtime-only — see #24).
Uses hourly cadence to match runtime. Accepts Playwright may be
unavailable (see #23) and falls back to HTML analysis with the
limitation noted in the deliverable.
All three audit crons now end with an identical FINAL STEP — DELIVERABLE
ROUTING block that makes the post-audit flow MANDATORY:
a. File a GitHub issue for each CRITICAL / HIGH finding (dedupe first)
b. delegate_task to PM with a structured summary listing issue numbers;
PM decides which dev agent picks up which issue
c. Even on clean cycles, send PM a one-line "clean on SHA X" so audits
are observable
d. Memory write becomes a secondary record, not the primary deliverable
Rationale: findings need to flow into the issue tracker (durable, visible
to CEO, part of the PR/issue review feedback loop already in place) and
through PM (who owns cross-team orchestration). Memory-only output is
invisible to everyone except the auditor itself.
Related:
- #23 — UIUX Designer container missing libglib/X11 for Playwright.
This PR accepts the current limitation; #23 tracks the image fix.
- #24 — template-vs-runtime schedule drift. This PR backfills the template;
#24 tracks the platform-layer fix for preventing future drift.
- 13 open criticals in Security Auditor memory are out of scope for this
PR (that's team work once the routing is in place).
- computeAutoLayout() BFS tree layout seeds from anchored nodes; assigns
distinct x/y to workspaces returned at 0,0 by the API and persists via PATCH
- buildNodesAndEdges() accepts layoutOverrides map so hydration uses computed
positions instead of raw 0,0 coordinates
- canvas-events WORKSPACE_PROVISIONING grid layout replaces offset===offset
assignment that caused position:{x:t,y:t} in the minified bundle
- 8 new vitest tests cover computeAutoLayout and override behaviour (365 pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- computeAutoLayout() BFS tree layout seeds from anchored nodes; assigns
distinct x/y to workspaces returned at 0,0 by the API and persists via PATCH
- buildNodesAndEdges() accepts layoutOverrides map so hydration uses computed
positions instead of raw 0,0 coordinates
- canvas-events WORKSPACE_PROVISIONING grid layout replaces offset===offset
assignment that caused position:{x:t,y:t} in the minified bundle
- 8 new vitest tests cover computeAutoLayout and override behaviour (365 pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses FLAG 1 and FLAG 2 from the 7-Gate review on PR #20.
FLAG 1 (token persisted on disk):
Previous: `git clone https://x-access-token:${GITHUB_TOKEN}@github.com/...` wrote
the full tokenized URL into /workspace/repo/.git/config as `[remote "origin"] url = …`.
Token survived container restarts on any bind-mounted workspace_dir.
Fix: after clone, `git remote set-url origin https://github.com/${GITHUB_REPO}.git`
scrubs the token from the remote URL. Token is only in the clone command's argv
(transient) and not persisted on disk. Falls back to anonymous for public repos.
FLAG 2 (docs not updated):
Added GITHUB_REPO and GITHUB_TOKEN entries under a new 'GitHub' section in
.env.example with notes about (a) what they're read for, (b) that GITHUB_TOKEN
should be registered as a global secret via POST /admin/secrets, (c) how it's
handled to avoid on-disk persistence.
FLAG 3 (per-workspace gating) is deferred to a separate issue — it's a platform
design question about secret scope/ACLs, not a template fix.
Addresses FLAG 1 and FLAG 2 from the 7-Gate review on PR #20.
FLAG 1 (token persisted on disk):
Previous: `git clone https://x-access-token:${GITHUB_TOKEN}@github.com/...` wrote
the full tokenized URL into /workspace/repo/.git/config as `[remote "origin"] url = …`.
Token survived container restarts on any bind-mounted workspace_dir.
Fix: after clone, `git remote set-url origin https://github.com/${GITHUB_REPO}.git`
scrubs the token from the remote URL. Token is only in the clone command's argv
(transient) and not persisted on disk. Falls back to anonymous for public repos.
FLAG 2 (docs not updated):
Added GITHUB_REPO and GITHUB_TOKEN entries under a new 'GitHub' section in
.env.example with notes about (a) what they're read for, (b) that GITHUB_TOKEN
should be registered as a global secret via POST /admin/secrets, (c) how it's
handled to avoid on-disk persistence.
FLAG 3 (per-workspace gating) is deferred to a separate issue — it's a platform
design question about secret scope/ACLs, not a template fix.