Skip to content

GA 4/10 · harness: spawn contract, privilege drop, env isolation, confined file tools - #257

Closed
dshakes wants to merge 6 commits into
ga/03-bridgesfrom
ga/04-harness
Closed

dshakes wants to merge 6 commits into
ga/03-bridgesfrom
ga/04-harness

Conversation

@dshakes

@dshakes dshakes commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Part 4 of 10 of the GA next-gen stack (split from #254 so each AI review fits its budget). Base: ga/03-bridges. Merge in order with a merge commit (not squash) so the stack stays intact.

Full context, verification results and needs-human list: #254 and docs/roadmap/2026-10-nextgen-sandbox-and-harness.md.

Commits in this part:

  • merge: harness spawn contract, privilege drop, env isolation, confined file tools

…N_WORKLOAD_UID

Every child ran as PID 1's root, so the SO_PEERCRED secrets check always
passed (workload uid == harness uid), the iptables REDIRECT (which exempts
the harness uid) did not apply to the workload, and tls.key was readable.
Spawn via CommandExt uid/gid (gid defaults to uid; std clears supplementary
groups). The secrets and OTLP sockets are chmod 0666 so the unprivileged
workload can still connect; SO_PEERCRED stays the authorization.
The kernel cmdline splits on whitespace and strips quotes, so argv like
'python main.py' and JSON egress lists arrived mangled. spawn.json
(workload_cmd, env, egress_rules, workload_uid/gid; all optional, unknown
fields ignored) now takes precedence over boot-args; typed fields beat
free-form env. Malformed file: fatal in prod, warn + cmdline in dev.

The cert material is copied into a root-only 0700 dir and the drive is
unmounted, so tls.key and spawn.json are unreachable by the workload uid
regardless of the image's file modes.
…d file tools

http_fetch buffered the whole body before checking the 1 MiB cap and
shell_exec used unbounded .output(). Both now stream with a bounded read
and truncate with a marker; shell_exec runs as the workload uid under the
timeout. New read_file / write_file / list_dir tools are confined to
LANTERN_WORKSPACE (default /workspace): canonicalize + prefix check, leaf
O_NOFOLLOW|O_NONBLOCK, hard links rejected, writes chowned to the workload.
…start without a workload uid

Children inherited the harness's whole environment, including
LANTERN_HARNESS_EXEC_TOKEN, LANTERN_AGENT_INSTANCE_TOKEN and the TLS key
path. as_workload() now env_clear()s and passes the harness env minus the
LANTERN_* namespace (except a short non-secret allowlist: VM/run id, socket
paths, workspace, trace parent, LANTERN_WORKLOAD_*), then drops the uid.
Used by the supervisor, vm exec (tty and not) and shell_exec.

The prod 'LANTERN_WORKLOAD_UID missing' check ran in the secrets task,
racing the supervisor; parse_env now fails first. The secrets check stays.

fs tools open through openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS) on a
root fd on Linux, closing the parent-dir swap window after the path check;
pre-5.6 kernels (ENOSYS) and non-Linux keep the plain open.
…t context

rust:1.85 cannot build the current Cargo.lock, build.rs needs protoc, and
'COPY . .' from services/harness could never reach ../../packages/proto.
Build from the repo root; a per-Dockerfile .dockerignore keeps the context
to packages/proto + services/harness.
@github-actions github-actions Bot added the domain:core Core library / business logic label Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Codex cross-audit (agent:audit)

Blocking

  1. Prod Firecracker VMs will fail to boot with this harness.
    The harness now bails in prod unless LANTERN_WORKLOAD_UID is already present, but this PR does not update the Firecracker backend to inject it or write the new spawn.json. build_boot_args still emits only VM/run/TLS/manager/env/secrets, and build_cert_image still stages only tls.crt, tls.key, and manager-ca.crt.
    main.rs:73, firecracker.rs:515, firecracker.rs:666

  2. New file tools can be turned into root file access if LANTERN_WORKSPACE is user-controlled.
    read_file/write_file run in the harness as root and trust LANTERN_WORKSPACE as the confinement root. The spawn/cmdline env path still accepts arbitrary lantern.env.* keys, so an agent/request env setting LANTERN_WORKSPACE=/ would let these tools read or overwrite host-sensitive in-guest paths like /run/lantern/harness/tls.key or /etc/passwd. The workspace root should be manager-controlled, validated, and not accepted from free-form workload env; ideally file opens should also drop to workload permissions.
    tool_runner.rs:437, tool_runner.rs:626, tool_runner.rs:675, bootenv.rs:95, firecracker.rs:527

High

  1. Malformed spawn.json is not actually fatal in normal Firecracker prod if LANTERN_ENV comes from boot args.
    bootstrap() decides whether malformed spawn.json is fatal before hydrating /proc/cmdline, so lantern.env.LANTERN_ENV=prod has not been applied yet. That means malformed contract handling can take the dev fallback path even for prod VMs.
    bootenv.rs:239, bootenv.rs:591

I did not run tests; the workspace is read-only and even rustc --version tried to write under rustup temp state.

@compass-sdlc-bot compass-sdlc-bot Bot added the agent:reviewed-clean Reviewer found no Blocking issues this round label Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🛡️ Vuln scan — ❌ vulnerable dependency found

      #41: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.Shutdown
      #42: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.Shutdown
      #43: internal/telemetry/tracer.go:92:21: telemetry.InitTracer calls trace.TracerProvider.Shutdown
      #44: internal/handlers/runtime.go:186:50: handlers.NewGRPCSchedulerClient calls otelgrpc.NewClientHandler, which calls trace.TracerProvider.Tracer
      #45: internal/telemetry/tracer.go:79:23: telemetry.InitTracer calls trace.WithBatcher
      #46: internal/telemetry/tracer.go:80:24: telemetry.InitTracer calls trace.WithResource
      #47: internal/telemetry/tracer.go:81:23: telemetry.InitTracer calls trace.WithSampler
      #48: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls trace.errUnsupportedSampler.Error
      #49: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.init
      #50: internal/telemetry/tracer.go:17:2: telemetry.init calls trace.init
      #51: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.logDropped
      #52: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls trace.logDropped
      #53: internal/handlers/llm_proxy.go:3810:27: handlers.LlmProxyHandler.OCR calls io.ReadAll, which eventually calls trace.nonRecordingSpan.AddEvent
      #54: internal/handlers/runtime.go:1556:2: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.End
      #55: internal/middleware/span.go:34:22: middleware.EnrichSpan calls trace.nonRecordingSpan.IsRecording
      #56: internal/handlers/runtime.go:1564:19: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.RecordError
      #57: internal/handlers/runtime.go:1555:20: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.SetAttributes
      #58: cmd/server/main.go:841:38: server.main calls http.Server.ListenAndServe, which eventually calls trace.nonRecordingSpan.SetName
      #59: internal/handlers/runtime.go:1565:17: handlers.RuntimeHandler.Schedule calls trace.nonRecordingSpan.SetStatus
      #60: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls noop.Tracer.Start, which calls trace.nonRecordingSpan.SpanContext
      #61: internal/handlers/llm_proxy.go:3801:24: handlers.LlmProxyHandler.OCR calls http.Client.Do, which eventually calls trace.nonRecordingSpan.TracerProvider
      #62: internal/handlers/llm_proxy.go:3810:27: handlers.LlmProxyHandler.OCR calls io.ReadAll, which eventually calls trace.recordingSpan.AddEvent
      #63: internal/handlers/runtime.go:1556:2: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.End
      #64: internal/middleware/span.go:34:22: middleware.EnrichSpan calls trace.recordingSpan.IsRecording
      #65: internal/handlers/runtime.go:1564:19: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.RecordError
      #66: internal/handlers/runtime.go:1555:20: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.SetAttributes
      #67: cmd/server/main.go:841:38: server.main calls http.Server.ListenAndServe, which eventually calls trace.recordingSpan.SetName
      #68: internal/handlers/runtime.go:1565:17: handlers.RuntimeHandler.Schedule calls trace.recordingSpan.SetStatus
      #69: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls noop.Tracer.Start, which calls trace.recordingSpan.SpanContext
      #70: internal/handlers/llm_proxy.go:3801:24: handlers.LlmProxyHandler.OCR calls http.Client.Do, which eventually calls trace.recordingSpan.TracerProvider
      #71: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls trace.samplerArgParseError.Error
      #72: internal/handlers/connector_health.go:177:15: handlers.PersistConnectorQuarantine calls errors.As, which eventually calls trace.samplerArgParseError.Unwrap
      #73: internal/handlers/runtime.go:1554:27: handlers.RuntimeHandler.Schedule calls trace.tracer.Start
      #74: internal/telemetry/tracer.go:86:27: telemetry.InitTracer calls otel.SetTextMapPropagator, which eventually calls trace.tracerProviderConfig.MarshalLog
      #75: internal/telemetry/tracer.go:79:23: telemetry.InitTracer calls trace.WithBatcher, which eventually calls x.Feature[string].Enabled
      #76: internal/telemetry/tracer.go:16:2: telemetry.init calls resource.init, which calls x.init

Vulnerability #15: GO-2026-6443
    Server panic via missing authority or Host headers in google.golang.org/grpc
  More info: https://pkg.go.dev/vuln/GO-2026-6443
  Module: google.golang.org/grpc
    Found in: google.golang.org/grpc@v1.82.1
    Fixed in: google.golang.org/grpc@v1.82.2
    Example traces found:
      #1: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.http2Server.HandleStreams

Vulnerability #16: GO-2026-6348
    Heap Memory Exhaustion (OOM) via HTTP/2 DATA Frame Fragmentation in
    google.golang.org/grpc
  More info: https://pkg.go.dev/vuln/GO-2026-6348
  Module: google.golang.org/grpc
    Found in: google.golang.org/grpc@v1.82.1
    Fixed in: google.golang.org/grpc@v1.83.1
    Example traces found:
      #1: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.BufferSlice.MaterializeToBuffer
      #2: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.IsBelowBufferPoolingThreshold
      #3: internal/handlers/runs.go:447:25: handlers.RunService.StreamRunEvents calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.StreamRunEventsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.StreamEvent].Send, which eventually calls mem.NewBuffer
      #4: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls mem.ReadAll
      #5: internal/handlers/llm_proxy.go:3935:104: handlers.LlmProxyHandler.Transcribe calls multierr.multiError.Error, which eventually calls mem.writer.Write
      #6: internal/handlers/runtime.go:1984:27: handlers.RuntimeHandler.StreamLogs calls grpc.GenericClientStream[github.com/dshakes/lantern/gen/go/lantern/v1.LogsRequest, github.com/dshakes/lantern/gen/go/lantern/v1.RuntimeLogLine].Recv, which eventually calls transport.ClientStream.Close
      #7: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.ClientStream.Header
      #8: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ClientStream.Read
      #9: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ClientStream.RecvCompress
      #10: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.ClientStream.TrailersOnly
      #11: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.NewHTTP2Client
      #12: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.NewServerTransport
      #13: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.ServerStream.Read
      #14: internal/handlers/dataplane.go:519:30: handlers.DataPlaneService.RunStream calls grpc.GenericServerStream[github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamClientMsg, github.com/dshakes/lantern/gen/go/lantern/v1.DpRunStreamServerMsg].Recv, which eventually calls transport.Stream.ReadMessageHeader
      #15: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.http2Client.Close
      #16: internal/handlers/schedules.go:237:33: handlers.RESTHandler.UpdateSchedule calls time.LoadLocation, which eventually calls transport.http2Client.GracefulClose
      #17: internal/handlers/runtime.go:354:28: handlers.pumpExec calls grpc.clientStream.CloseSend, which eventually calls transport.http2Client.NewStream
      #18: cmd/server/main.go:906:25: server.main calls grpc.Server.GracefulStop, which eventually calls transport.http2Server.Close
      #19: cmd/server/main.go:263:32: server.main calls grpc.Server.Serve, which eventually calls transport.http2Server.HandleStreams

Your code is affected by 16 vulnerabilities from 3 modules and the Go standard library.
This scan also found 3 vulnerabilities in packages you import and 19
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
make: *** [Makefile:270: audit] Error 3

@github-actions

Copy link
Copy Markdown
Contributor

🔷 Gemini cross-audit (agent:audit-gemini)

Blocking Issues

  1. Production Gate Bypass in bootenv::bootstrap() (Security / Invariant bridge: stop sending the model's no-reply reasoning to the contact #5)

    • Location: services/harness/src/bootenv.rs:238-239
    • Issue: resolve_spawn_contract(spawn, crate::tls::is_prod_env()) is invoked before hydrate_env_from_cmdline(contract).
    • Impact: In Firecracker microVMs, PID 1 starts with an empty environment; LANTERN_ENV=production is passed via the kernel command line (/proc/cmdline). Because /proc/cmdline has not yet been hydrated into std::env, crate::tls::is_prod_env() evaluates to false. If spawn.json is malformed, unreadable, or corrupted in production, resolve_spawn_contract warns and silently falls back to boot-args rather than refusing to start.
    • Remedy: Inspect /proc/cmdline for LANTERN_ENV (or hydrate LANTERN_ENV prior to resolving the contract).
  2. list_dir Symlink Misclassification and Information Leak (Correctness)

    • Location: services/harness/src/tool_runner.rs:1450-1456
    • Issue: The code calls entry.metadata() to determine entry type, commenting: // DirEntry metadata does not follow symlinks. In Rust's standard library, std::fs::DirEntry::metadata() calls stat(2) and explicitly follows symlinks.
    • Impact:
      • Valid symlinks pointing to files return m.is_file() == true and are misreported as "type": "file" (with the target file's size).
      • Valid symlinks pointing to directories return m.is_dir() == true and are misreported as "type": "dir".
      • Broken symlinks fail metadata() with NotFound and are categorized as "type": "other".
      • m.is_symlink() is never true for any resolvable symlink.
    • Remedy: Use entry.file_type() (which uses d_type or lstat and does not follow symlinks) and check ft.is_symlink().
  3. Privilege Escalation via c.env in spawn.json (Security)

    • Location: services/harness/src/bootenv.rs:88-100
    • Issue: contract_env_pairs() only checks if a key is already set by typed fields (pairs[..typed]). If the manager does not set c.workload_uid (e.g. None), free-form c.env is allowed to set LANTERN_WORKLOAD_UID="0".
    • Impact: A guest workload spec can supply LANTERN_WORKLOAD_UID=0 in env. This passes require_workload_identity in production, runs the workload as root (uid=0), and satisfies SO_PEERCRED checks on /run/lantern/secrets.sock. Additionally, c.env can overwrite sensitive harness-only variables (LANTERN_MANAGER_ADDR, LANTERN_VM_TLS_KEY, etc.) when hydrated into the process environment.
    • Remedy: Filter c.env to reject or strip any keys in the LANTERN_* namespace before exporting them.

High Severity & Architectural Invariants

  1. Fail-Open on Cert Drive Unmount Failure (Security / Invariant twilio: send via Messaging Service (RCS + automatic SMS fallback) #10)

    • Location: services/harness/src/bootenv.rs:485-494
    • Issue: If umount(CERT_MOUNT) fails, mount_cert_drive() logs an error and returns spawn.
    • Impact: The cert drive remains mounted at /run/lantern/certs. Because image-builder ext4 permissions are not trusted, tls.key and spawn.json (containing all workload env vars and secrets) remain accessible to the unprivileged workload UID.
    • Remedy: In production (is_prod_env()), an unmount failure must be fatal, or force a lazy unmount (MNT_DETACH) before proceeding.
  2. PTY Slave Ownership in Interactive Exec (Correctness / Regression)

    • Location: services/harness/src/exec.rs:225-248, 275-290
    • Issue: openpty() is called by the harness running as root (UID 0), creating the slave PTY device /dev/pts/X owned by root:root (mode 0600/0620). spawn_on_pty calls as_workload(&mut cmd) to drop the spawned process to workload_uid, but the slave PTY is never chowned to the workload UID/GID.
    • Impact: In lantern vm exec -t, interactive programs that open /dev/tty (shells like bash, text editors, or python) fail with EACCES (Permission denied).
    • Remedy: Call fchown(slave, workload_uid, workload_gid) before spawning the child process on the PTY.

Edge Cases & Minor Regressions

  1. WORKLOAD_VISIBLE_LANTERN_KEYS Breaks Snapshot-Restore (LANTERN_RESTORE_HINT)

    • Location: services/harness/src/supervisor.rs:100-113
    • Issue: WORKLOAD_VISIBLE_LANTERN_KEYS lists 11 allowed keys and suppresses all other LANTERN_* variables.
    • Impact: LANTERN_RESTORE_HINT (required by stateful workloads like examples/headless-agents/03-stateful-research/workload.py to resume from snapshot) is stripped from the child environment upon fork/exec.
    • Remedy: Add LANTERN_RESTORE_HINT to WORKLOAD_VISIBLE_LANTERN_KEYS.
  2. Missing Defense-in-Depth Proxy Variables in shell_exec and exec.rs

    • Location: services/harness/src/tool_runner.rs:280-295, services/harness/src/exec.rs:90-105
    • Issue: as_workload clears the environment (env_clear), stripping proxy variables. Supervisor::run explicitly re-injects proxy_env_pairs, but neither shell_exec nor exec.rs does so.
    • Impact: If iptables REDIRECT is absent or bypassed, HTTP tools executed via shell_exec or vm exec will not use the egress proxy.
  3. write_file Inability to Create Parent Directories

    • Location: services/harness/src/tool_runner.rs:1255-1265, 1395-1415
    • Issue: In resolve_in_workspace(root, path, false), parent.canonicalize() is called on the parent path. If intermediate directories do not already exist, canonicalization fails with NotFound, preventing write_file from writing to nested paths.

@dshakes

dshakes commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the rewritten stack #265–#275 (same changes; history rewritten to drop a 43 MB build binary committed by mistake; part 11 #275 fixes the confirmed Codex audit findings raised on this stack).

@dshakes dshakes closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:reviewed-clean Reviewer found no Blocking issues this round domain:core Core library / business logic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant