Docs · Security
Concourse spike — security review
Status: M3 review · 2026-09-02 · covers M0–M2 artifacts on fabric-free
Scope: the spike repo (packages/{evidence,synthesis,certify,codemode, uc-registration}, apps/app, spikes/m0|m1). This is a self-review to accompany the Brickbuilder pack; an external review is still required before public launch (PLAN.md risks).
Threat model
- Agent-authored code runs in the compose sandbox. Authors are LLM agents, not authenticated principals; the sandbox's job is to make the only egress the gated invoker, and the gate's job is to keep every exit inside the governed catalog under the caller's identity.
- Customer API specs are untrusted input to synthesis. Synthesis is pure data transformation (no eval, no code execution of spec content); generated Python/TS is reviewed by the certification gate before it is registered or served.
- The workspace (fabric-free) is a shared, free-plan environment with observed platform variance (compute stops, token revocation). Treat every platform behavior as provisional; evidence envelopes record what was actually observed.
Sandbox (packages/codemode)
- QuickJS guest (quickjs-emscripten 0.32, release-sync build). Guest has
no filesystem, network, or module system;
fetchis shadowed and throws. The only host bridges are__concourse_invoke(the gated invoker) and__concourse_log; both are deleted fromglobalThisbefore the program runs so guest code cannot rebind them. - TypeScript is stripped syntactically (sucrase, no ES transforms) — no type-directed behavior, no code added.
- Limits: 64 MB memory cap, 1 MB stack cap, wall-clock deadline with an interrupt handler that pauses while a tool dispatch is in flight; async drain loop bounds pending jobs and deferred promises.
- Residual risk (accepted for the spike): QuickJS provides language isolation, not a hardened process boundary. A QuickJS engine bug could in principle escape to the App process. The App process itself holds no secrets beyond the caller's request-scoped identity and has no filesystem writes; blast radius is bounded by the gate (below) and the Databricks App container. Hardened isolation (separate worker/process per run) is a post-spike item.
Gated invoker and approvals
- Fail-closed by construction: unknown tool → reject; mutating or destructive tool → reject with "approval-gated" (no approval path exists in M2, so nothing can satisfy it); call budget (default 16) enforced; upstream non-2xx → reject.
- Every attempt — allowed or rejected — lands in the audit ledger (seq, tool, safety class, input sha256, ok, ms). Ledgers are embedded in compose results and signed verify envelopes.
- Invariant carried from the Runway authorization boundary: system actors can never satisfy approvals; re-authorization happens at execution time (the gate re-checks the descriptor on every call, never caching a decision).
Authentication posture (free plan findings)
- App ingress accepts user OAuth U2M tokens (
databricks auth token) and rejects user PATs (401{}) even when the PAT is valid at the workspace API. Unauthenticated callers are 302-redirected to interactive OAuth. Programmatic access therefore uses short-lived U2M tokens; the app itself never mints or stores credentials. - Observed revocations: a freshly minted PAT 403'd within ~40 minutes;
a U2M refresh token died mid-session. Operational rule: treat all
free-plan credentials as ephemeral; re-auth via
databricks auth login(incognito — the browser session can be pinned to the wrong Databricks account,error=not_account_user). DATABRICKS_AUTH_STORAGE=plaintextis used headless because the keyring is unreachable in this environment. Spike-only; a production control plane must use secret-managed M2M credentials (PLAN.md: OAuth M2M service principal).
Secrets hygiene
- Finding (fixed in M3): the spike Ed25519 private key was tracked
in git (
spikes/m1/evidence/keys/). Any signature made with it is only as trustworthy as the repo's read ACL. M3 rotates the keypair, untracks the private key, and re-signs current envelopes; historical envelopes remain verifiable against the retired public key only. ~/.concourse-m1-patis mode 600 and never printed; envelopes carry digests, never tokens or key material.
Evidence integrity
packages/evidenceimplements the Runway evidence-v2 contract: the digest is the SHA-256 of the exact serialized report string (no canonicalization), the Ed25519 signature covers those bytes, the storage boundary re-verifies the digest before signing or writing, and every envelope/manifest carrieskeyId = sha256(SPKI DER)[:24]as the rotation handle.- The UC Volume ledger is content-addressed: one file per report digest
plus a
.manifest.json, overwrite-in-place (re-export is a no-op), readback-verified byte-for-byte on fabric-free. - The spike signing key was rotated and untracked in M3; envelopes signed before the rotation are historical artifacts verifiable only with the retired key and the pre-M3 verifier.
Unity Catalog surface
- Generated UC Python functions are the governance/discovery surface,
not the executors: serverless UDF egress is refused (
Errno 111), re-confirmed 2026-09-02. The App remains the only egress substrate. - UC Python UDFs reject
DEFAULTparameter values; optionals must be passed explicitly (NULL skips them in the generated body). - Registration is idempotent and marker-checked; a drifted artifact is replaced, an identical one is untouched — repeated pipeline runs cannot silently change the catalog surface.
Deployment
- Single-file esbuild bundle;
quickjs-emscriptenships as an external installed by the Apps runtime from the deploypackage.json(proven on the free plan). No native transitives are referenced. - The app binds
0.0.0.0:$DATABRICKS_APP_PORTbehind the Databricks ingress; authn is enforced by the ingress, not the app. The app serves no static secrets and holds no state beyond an in-memory compose-run ring for/console.
Residual risks / follow-ups
- External security review before launch (Brickbuilder gate).
- Hardened sandbox isolation (per-run worker) once compose leaves the spike.
- Approval path (Tower) replaces the M2 blanket rejection for mutating tools; until then the rejection is the safe state.
- Trademark/name sweep (Concourse CI) before public GTM.