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; fetch is shadowed and throws. The only host bridges are __concourse_invoke (the gated invoker) and __concourse_log; both are deleted from globalThis before 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=plaintext is 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-pat is mode 600 and never printed; envelopes carry digests, never tokens or key material.

Evidence integrity

  • packages/evidence implements 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 carries keyId = 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 DEFAULT parameter 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-emscripten ships as an external installed by the Apps runtime from the deploy package.json (proven on the free plan). No native transitives are referenced.
  • The app binds 0.0.0.0:$DATABRICKS_APP_PORT behind 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

  1. External security review before launch (Brickbuilder gate).
  2. Hardened sandbox isolation (per-run worker) once compose leaves the spike.
  3. Approval path (Tower) replaces the M2 blanket rejection for mutating tools; until then the rejection is the safe state.
  4. Trademark/name sweep (Concourse CI) before public GTM.