Documentation
¶
Overview ¶
Package audit records what each sandbox run actually did.
It exists to answer one question after the fact: *what did this run execute, against what policy, and how did it end?* Before this the sink was a no-op, so a run left no trace once its container was gone — the same gap the firewall's LOG rules close on the network side.
What it deliberately does not record: any environment *value*. Names, yes — which credentials a run was handed is exactly what you want to look up later — but never what they were worth. The credential broker exists to keep secret values off the argv and out of config files; writing them to a log would hand that back.
The guest command *is* recorded verbatim, and that is the known soft edge in the rule above: an argv is the other classic place a token ends up (`-- curl -H "Authorization: Bearer …"`). It is kept because a run log that cannot say what ran answers nothing, and because redaction here would have to guess — a heuristic that scans argv for secrets misses the ones it does not recognise while implying it caught them all, which is worse than a documented limitation. Treat sessions.jsonl as sensitive; it is written 0600 for this reason as much as for the project names.
Index ¶
Constants ¶
const MaxGenerations = 5
MaxGenerations is how many rotated logs are kept beside the current one.
It used to be one, and that quietly deleted history. At the density this writes — a few hundred bytes per run — 8 MiB is roughly twelve thousand runs, so a single previous generation meant the twelve-thousandth-oldest run vanished with nothing recording that it had ever existed. A fleet running fifty tasks a day reached that in about eighteen months; one running five hundred, in six weeks.
Five generations is ~40 MiB and ~60,000 runs. That is a bounded cost in a directory nothing else prunes, and the ceiling is the point: an append-only log with no ceiling is a slow leak in someone's home directory, and a log that drops the oldest without saying so is worse than one that is capped.
Variables ¶
This section is empty.
Functions ¶
func Generations ¶
Generations lists the log files that exist, newest first, starting with the live one.
Exported because a reader has to know that the history is several files: a caller that opens only sessions.jsonl sees the most recent generation and reports the rest as though it never happened, which is exactly the failure this pairs with.
Types ¶
type JSONLSink ¶
type JSONLSink struct {
Path string
}
JSONLSink appends one line per run to a file.
Append-only and best-effort: an unwritable log must never fail a run, because the run is what the user asked for and the record is a courtesy. The failure is silent for the same reason — warning on every invocation about a log nobody asked for would be worse than the missing line.
func (*JSONLSink) RecordSession ¶
func (s *JSONLSink) RecordSession(meta SessionMeta)
RecordSession appends meta as one JSON object.
type NopSink ¶
type NopSink struct{}
NopSink discards everything. Used by tests, and by any caller that has not wired a real sink.
func (NopSink) RecordSession ¶
func (NopSink) RecordSession(SessionMeta)
RecordSession does nothing.
type SessionMeta ¶
type SessionMeta struct {
Image string
Workdir string
Command []string
// Workspace is the host directory mounted at Workdir — the thing this run
// could actually change.
Workspace string
// Agent is the wrapper the run came from ("claude", "codex"); empty for a
// plain `run`.
Agent string
// Branch is the git branch the workspace was on, when it had one.
Branch string
// Engine is the container engine that executed the run ("docker", "podman").
// Recorded for the reason the detached-run labels are: a fact not stamped is
// one no later command can recover, and "which engine ran this" is not
// derivable from anything else in the line.
Engine string
// Runtime is the OCI runtime the run asked the engine for, empty meaning the
// host default (runc everywhere this tool has met). The same kind of fact as
// Network: it is resolved from several layers, so "what boundary did this run
// actually get" is otherwise unanswerable afterwards — and it is the one
// setting that changes the *kind* of boundary rather than its degree.
//
// Asked for, and the line is only written once the engine accepted the
// launch — a refused one is not recorded at all, since the record carries
// exit code 0 and would otherwise read as a run that completed inside a
// boundary nothing ever ran in. What that does *not* prove is that the
// runtime behaved: sandbox-cli's pre-flight check fails open when the daemon
// cannot be asked, so this is "the engine took this name", not "this name
// was verified".
//
// The listing reads the runtime back from the engine instead
// (runtime.ContainerInfo.Runtime), because a container that still exists can
// be asked. This field exists for the ones that cannot: a --rm run is gone
// by the time anybody reads the log.
Runtime string
// Network is the resolved posture: "default", "none" or "allowlist".
Network string
// NetworkName is the network object the container actually joined. Under
// podman that is per-run rather than shared, so the posture alone no longer
// identifies it.
NetworkName string
// EgressEnforcementRequested is "name" when the run asked for the
// in-container proxy to decide each connection by hostname, "address" when
// only the address-matching firewall was asked for, and "" when there was no
// allowlist. Recorded because `network: allowlist` alone does not say which
// regime a past run was under, and the two differ in exactly the way that
// matters: an address-matched run permitted every host sharing an allowlisted
// address.
//
// Named for a *request*, not an outcome, because that is all the host can
// honestly know. The container takes the proxy path only if
// `sandbox-egress-proxy` is on its PATH and the `sandbox-proxy` user resolves;
// otherwise it falls through to address matching. With a user-supplied
// --image or `image:` those can be absent, and this field previously claimed
// "name" for a run that was address-matched. Observing it for real needs the
// entrypoint to report back what it did — worth doing, and until it exists an
// accurate name beats a confident wrong value.
EgressEnforcementRequested string
// EgressAllow is the resolved allowlist, when one was in force. Recording it
// is the point: the domains come from several merged layers, so "what was
// this run permitted to reach" is otherwise unanswerable afterwards.
EgressAllow []string
// EnvNames are the host variables forwarded into the container, by name only.
EnvNames []string
// EgressDeniedReported is how many egress refusals the in-container proxy
// printed during the run, and EgressDeniedHostsReported a bounded sample of
// the distinct names involved. Together they answer the question the policy
// fields cannot: `egress_allow` says what the run was *permitted* to reach,
// not whether it went looking for anything else.
//
// **Reported, not attested**, and the names say so for the same reason
// EgressEnforcementRequested is named for a request. These lines arrive on the
// container's stderr, which the agent also writes to — so a compromised agent
// can print lines that look like denials, and can bury real ones in noise.
// What the host honestly knows is "this many lines claiming a denial came back
// from the container", and that is what the field is called.
//
// It is still worth recording: the failure mode it catches is the ordinary one
// — an allowlist that was too narrow, or an agent reaching somewhere nobody
// expected — and for that a truthful container is the normal case. Making it
// authoritative needs the proxy reporting over a channel the guest cannot
// write to.
//
// A **pointer** so that "nobody looked" and "looked, and nothing was refused"
// are different answers. They are not the same fact, and the zero one is the
// more useful of the two: a recorded 0 is the only positive evidence a run's
// allowlist was wide enough for everything it actually wanted.
//
// nil means the run was not observed, and today that is most runs. Only a
// non-interactive `run` under an allowlist is watched. The rest:
//
// - **no allowlist** — no proxy runs, so nothing could be refused.
// - **interactive** — with a pty docker returns one merged stream, and
// reading it would cost the container its terminal size, so sandbox-cli
// declines to look (see runtime.newDenyTap for the measurement).
// - **detached, which includes every fleet task** — Session.Start never
// wires a collector. Note the reason is *not* that the output is gone:
// `docker logs` has it, which is the whole supervision story --detach is
// built around. Nothing reads it back yet. That is the wrong way round —
// an unattended fleet is where an after-the-fact record is worth most —
// and it is recorded as follow-up in docs/roadmap/task-4-run-provenance.md.
//
// `network` separates the first case from the others after the fact: `default`
// means there was nothing to refuse, `allowlist` with this field absent means
// an allowlist was in force and nobody watched.
//
// **The host sample is not a random sample, and an adversary picks it.** The
// count is exact, but the names are the *first* maxDenyHosts distinct ones
// seen, and a guest can forge denial lines — so one that emits 32 invented
// names first fills the sample and every real refusal after that is counted
// but unnamed. The field is called "reported" for exactly this reason; treat
// the list as a hint and the count as the number.
EgressDeniedReported *int
EgressDeniedHostsReported []string
// Outcome, filled in once the run has finished.
ExitCode int
Duration time.Duration
Detached bool
}
SessionMeta describes a single sandbox session.
type Sink ¶
type Sink interface {
RecordSession(meta SessionMeta)
}
Sink records session metadata.
func NewJSONLSink ¶
NewJSONLSink returns a sink writing to dir/sessions.jsonl, or a NopSink when dir is empty (no home directory — not an error worth failing a run over).