akua / concepts / security-model

Security model

Wasmtime sandbox, capability-model preopens, replace-rejection in production, and the cosign + SLSA chain.

akua is a sandboxed-by-default render substrate. Every render runs inside a wasmtime WASI sandbox with memory / CPU / wall-clock caps and capability-model filesystem preopens. The invariant lives in CLAUDE.md; this document records what that actually means, what's guaranteed, and what's not.


Threat model

Who is the adversary? The Package itself — author of the KCL program + the charts, overlays, policies it depends on.

Why? akua is designed to run in shared multi-tenant environments (hosted build services, CI pipelines accepting PR-submitted Packages, in-browser dev loops loading third-party examples). In all three, the Package is untrusted by definition.

What must we prevent?

  1. Reading files outside the Package directory and its explicit dep scope
  2. Writing files outside the designated output directory
  3. Making network requests
  4. Spawning subprocesses
  5. Exhausting host memory
  6. Exhausting host CPU (runaway loops, pathological schema evaluation)
  7. Exceeding a wall-clock deadline
  8. Escaping the sandbox to compromise the host process

What are we NOT trying to prevent?


Execution model

The render path runs in a wasmtime WASI sandbox. Concretely:

One Engine, many Stores — with a plugin bridge

akua follows wasmtime's documented pattern: one process-global Engine, one Linker of host imports, many per-invocation Stores. The render worker and every engine plugin (helm, kustomize, future kro/CEL) share the same Engine so that:

When a Package calls helm.template(...) or kustomize.build(...) from inside the render worker:

akua-cli (native Rust)
  └ Engine (shared)
      ├ Store A — render worker, KCL evaluator paused in host import
      │     ⇣ kcl_plugin_invoke_json_wasm (wasm import)
      │     ⇡ host Rust dispatches to registered plugin handler
      │         ⇣
      └ Store B — helm.wasm, runs the Go helm engine
            ⇡ manifests bytes
      ⇡ host writes response into Store A's guest memory, returns ptr
      ⇡ KCL continues in Store A

Both Stores live on the same Engine and the same OS thread. Wasmtime's TLS tracks which Store is currently active; the paused Store resumes correctly when the plugin callout returns. Nested Engines were explicitly ruled out — they share process-global signal handlers in an untested way and duplicate the JIT cache. See docs/spikes/wasmtime-multi-engine.md for the research + verification.

Plugin bridge boundary

The env::kcl_plugin_invoke_json_wasm import is the one hole in the worker's sandbox — the only place untrusted KCL can call out to host code. It has exactly one job: read three JSON-string arguments from the guest's linear memory, dispatch to akua-core's plugin registry on the host, allocate response bytes in the guest via the worker's exported akua_bridge_alloc, and hand back a pointer. The host never runs arbitrary guest-supplied code; only the dispatcher and its registered handlers. Plugin handlers themselves run in their own Store, not on the host — so even a compromised helm engine can't escape to the native process.

The bridge emits a one-line trace per call under AKUA_BRIDGE_TRACE=1 (stderr), useful for debugging misrouted plugin invocations.

What's guaranteed

ThreatDefense
Read files outside scopePreopened dirs only. WASI is a capability model: WasiCtxBuilder::preopened_dir(host_path, guest_path, DirPerms::READ, FilePerms::READ) hands exactly one directory to the guest. /etc, /proc, $HOME, /var/run/secrets — all unreachable because they're not mounted. There is no ambient filesystem; nothing to escape to.
Write files outside scopeSame mechanism. Output dir preopened with DirPerms::MUTATE + FilePerms::WRITE; nothing else writable.
Networkwasip1 has no socket syscalls, period. Not "denied by default" — denied by construction. No connect(), no DNS, no TLS initiation. The guest cannot fabricate a socket.
SubprocessNo fork/exec in wasip1. Shell-out is unavailable at the host-ABI level.
MemoryStoreLimitsBuilder::memory_size(256 << 20) caps each render Store at 256 MiB (tunable). memory.grow fails beyond the cap; wasm traps. Default: 256 MiB per render.
CPU (wall-clock)Config::epoch_interruption(true) + background thread calling engine.increment_epoch() on a fixed tick. store.set_epoch_deadline(K) traps when the current epoch exceeds deadline. Cheap to check (compiled into every loop backedge). Default: 30 ticks × 100 ms/tick = 3 s wall-clock deadline per render. Engine-plugin Stores (helm, kustomize) opt out with deadline = u64::MAX — the host-Rust caller above them owns whole-call timeouts. Per-invocation fuel-based instruction counting is not enabled today; can return later without ABI impact.
Stack overflowConfig::max_wasm_stack(bytes) caps the wasm-side stack. Default 512 KiB; lower for defense-in-depth.
Instance count / table bloatStoreLimitsBuilder::instances(N), tables(N). Prevents wasm from inflating host memory via many small allocations.

What's enforced in Package code itself (belt and suspenders)

Even inside the sandbox, akua applies additional invariants on the Package's own code:


What's NOT shipped yet

This is the current-state gap vs the target. See docs/roadmap.md phases for timing.

GuaranteeState todayPhase
Path-traversal rejection in plugin handlersShipped — resolve_in_package + allowed_roots✅ Phase 0
helm.template / kustomize.build via WASM enginesShipped — no shell-out, wasmtime-hosted✅ Phases 1 + 3
Typed charts.* imports + lockfile digestsShipped — path + OCI, replace override✅ Phase 2a, 2b A+B
akua render --strict rejects raw chart pathsShipped — E_STRICT_UNTYPED_CHART✅ Phase 2b C
akua verify path-dep digest drift detectionShipped — PathDigestDrift / PathMissing✅ Phase 2b C
Render worker wrapped in wasmtimeShipped — every render runs inside a Store with memory/epoch caps + capability-model preopens✅ Phase 4
akua serve per-tenant isolationVerb doesn't existPhase 5
cosign keyed verification on OCI depsShipped — [signing] cosign_public_key, ECDSA P-256✅ Phase 6 A
akua publish with cosign sign-by-defaultShipped — P-256 PKCS#8 PEM private keys✅ Phase 7 A
akua pull with manifest digest verifyShipped✅ Phase 7 A
cosign keyless (fulcio + rekor) verificationNot implementedPhase 6 B
SLSA v1 attestation generation on publishShipped — DSSE envelope, in-toto v1 statement✅ Phase 7 B
akua verify attestation chain walkShipped — pulls .att sidecars + DSSE verify + subject-digest check for every OCI dep✅ Phase 7 C
Recursive attestation walk over transitive depsNot implemented — needs published Package to attest its own depsPhase 7 C (follow-up)
Encrypted cosign private keys — PKCS#8 PBES2Shipped — $AKUA_COSIGN_PASSPHRASE env var✅ Phase 7 C
OCI-vendored deps → network-free akua render after pullShipped — .akua/vendor/<name>/ convention✅ Phase 7 C
HSM / cosign-native key formatNot implementedPhase 7 D
Git dep checkout via gixShipped — pure Rust, no shell-out✅ Phase 2b C
Private-repo OCI auth (docker config / akua auth.toml)Shipped — Basic + bearer PAT✅ Phase 2b C
Docker credential helpersNot implemented — would require shell-outWon't ship

Why no shell-out, ever

A prior design considered keeping helm.template as shell-out for convenience, with a feature flag and clear "trusted input only" warnings. That design is rejected. Reasons:

  1. Opt-in security is not security. If the flag defaults to safe but can be flipped by a single command, every hosted service will eventually flip it for a one-off, forget, and get hit. "Secure by default" means the unsafe path doesn't exist, not that it's one flag away.
  2. Shell-out inherits host privileges. helm runs as the akua process's user with full PATH, env, cwd, network. Sandboxing individual subprocess invocations (seccomp, unshared namespaces) is possible but fragile, platform-specific, and hard to verify.
  3. WASM engines are the viable alternative. Benchmarks at docs/performance.md show KCL under wasmtime/WASI runs at ~2× native — comfortably inside the sub-100ms render budget. helm-engine-wasm prior work hit 20 MB WASM + 2.3s cold render. These are fine numbers.
  4. Removing shell-out forces the right engineering. As long as shell-out is "available as an escape hatch," investment flows there instead of toward the WASM engines. Cutting it is what unblocks Phases 1 + 3.

The alternative — keep shell-out with lots of warnings — would ship a sandbox that has a hole in it. That's worse than shipping no sandbox; users would assume protection that doesn't exist.