Documentation menu

Security model

locac reads and executes code written by someone who may want to attack the machine reading it. That is not a hypothetical for a vulnerability-research tool. It is the normal case. Everything below follows from one rule.

The rule

The target repository is untrusted input. Its source, its comments, its README, its test fixtures, and anything its code prints at runtime are all treated as attacker-authored, including as a prompt-injection channel.

Everything that steers the agent, from configuration and prompt overrides to skills, roles and key bindings, comes from LOCAC_HOME (~/.locac by default) and from nowhere else. locac never reads instructions out of the code it is auditing.

The session database lives under LOCAC_HOME too, never inside the target root, because sandboxed target code has write access to that root.

The untrusted fence

Every tool result carrying target-derived bytes is wrapped before it reaches the model:

⟦untrusted-output 3f9a1c02⟧
<the tool's output>
⟦/untrusted-output 3f9a1c02⟧

The nonce is derived from the session id, so it differs between runs and cannot be predicted from the target's contents. Output that tries to close the fence and continue as instruction text closes the wrong fence.

Piped stdin gets the same treatment; see Running an audit.

Two supporting rules make the fence worth having:

  • content is the model's only channel. Tool results also carry an out-of-band details object used by the TUI and the dashboard; no provider serialises it into the request. Anything the model is meant to act on must be in content, where the fence applies.
  • Results are budgeted. A tool result is capped (8,000 tokens by default) and truncated with a hint to narrow the request with offset/limit or a tighter glob, rather than letting a 600 KB file push the fence markers out of the context window.

Path confinement

Every filesystem tool routes through a path guard that resolves the real path, following symlinks, and checks that it is inside the target root.

For a path that does not exist yet (a file about to be written), the guard canonicalises the nearest existing ancestor and re-appends the remaining tail, so a symlinked parent directory cannot be used to place a new file outside the root.

Path escapes the target root /home/you/target: /etc/passwd. Retry with a path inside it.
Path could not be contained to the target root …

Skill directories are the one exception, and a narrow one: the bundled and user skill directories are mounted as read-only extra roots. They are visible to reads so the agent can open a skill's body, and invisible to writes and to execute.

The OS sandbox

bash and execute run the target's code. When an OS sandbox is available they run inside it:

ResourcePolicy
NetworkAll egress blocked. No allowed domains
ReadThe target root
WriteThe target root, plus scratch directories

On Windows the scratch list is empty: the sandbox backend runs commands as a different user, so that user's %TMP% is already its own, and ACL-walking the operator's temp directory blew the backend's setup budget on every run.

The verdict is measured, not assumed

locac does not ask whether a sandbox is installed. It runs a no-op command through the sandbox, against the root this run will confine, and requires exit 0 and the probe token back on stdout.

That distinction is load-bearing. Ubuntu 24.04+ ships kernel.apparmor_restrict_unprivileged_userns=1, under which the dependency check passes, the outer bubblewrap starts, the nested user namespace is denied, and every command then exits 1 with no output. A capability check reports a working sandbox; the probe reports the truth. The probe is capped at 20 seconds, so a wedged sandbox degrades the run instead of stalling its startup.

Without a working sandbox the run still starts, unconfined, and says so:

locac: sandbox=direct — NO OS confinement. Target code runs with cwd pinned to the root only.
  why: <the host's own reason>
  use --require-sandbox to refuse to start unconfined.

--require-sandbox turns that notice into a refusal.

A sandbox can also be partial. On Linux, confinement is two layers: bubblewrap for filesystem and network, and a seccomp stage that blocks AF_UNIX so confined code cannot talk its way out over a host socket. The second layer can go missing silently, and a compiled binary triggered exactly that, because the stage binary was resolved from the library's own module URL, which inside a compiled artifact is the embedded virtual filesystem. Measured on Ubuntu 26.04: the binary refused writes outside the root and had no DNS, yet a world-writable system bus socket stayed reachable from "sandboxed" code. locac now reads the wrapped argument vector itself and reports what is missing:

locac: sandbox=os — unix-socket blocking off: no apply-seccomp, AF_UNIX egress is not confined.

The verdict is host-dependent by nature, so it is printed for the operator and never reaches the model or the system prompt, which have to be byte-identical across machines.

Confined commands serialize across roots

The sandbox manager's configuration, its network bridges, its cleanup, and on Windows its filesystem ACEs are all process-global. Two audits of two different repositories in one process - which the dashboard can start, and which eval ab does by design - therefore share one fence, and the second root asking to be pinned used to move that fence while the first root's command was still running.

A process-wide execution lease now covers the whole command lifetime: pinning, wrapping, spawning, waiting, and cleanup. The fence cannot be re-pointed underneath a live child. The cost is real - confined commands from different roots run one at a time rather than in parallel - and it is the intended trade: a command that waits is slower, while a command whose confinement moved mid-flight is unconfined.

Per-OS prerequisites are in Installation; locac selftest reports what this host actually gets.

The approval gate

CategoryDefault
Read-only toolsAllow
Dangerous toolsAsk
Everything elseAllow

"Ask" with no way to ask is denied. A piped or --print run has no interactive terminal, so a dangerous call is blocked rather than waved through. The full resolution order and the exact dangerous set are in the Tool reference.

What --auto will and will not approve

--auto auto-approves a shell command only when all of the following hold:

  1. The leading token is in a fixed read-only list: ls cat head tail wc file stat grep egrep rg find fd tree pwd echo which basename dirname realpath sort cut nl strings sha256sum md5sum true.
  2. The command contains no shell metacharacter: ; & | \ $ % > < newline ( ) * ? { } !, or a ..` path segment.
  3. No argument starts at a root: ~, /, \, or a C:-style drive prefix.
  4. No write or execute flag is present: find -delete/-exec/-execdir/-ok/-okdir/-fls/-fprint/-fprintf, fd -x/-X/--exec/--exec-batch, rg --pre/--hostname-bin, sort -o/--output, tree -o/--output.

Three details are worth knowing, because each of them was a bug first:

  • Quotes are stripped before inspection. Reading the raw string let cat "/etc/passwd" and find . "-delete" through, because neither matched a pattern anchored on the unquoted form.
  • uniq is deliberately absent from the read-only list. uniq in out writes to a positional argument, so it is not a reader.
  • % is a metacharacter because cmd /c expands %USERPROFILE%.

execute is never auto-approved, whatever the command looks like. The fallback for anything unclassified is deny.

The classification is lexical. A symlink inside the repository still resolves outside it, and --auto will not notice. It is a convenience layer over the OS sandbox, not a replacement for it, which is why --auto --require-sandbox is the combination worth typing.

The DAST egress guard

The DAST tools send live HTTP requests, which makes them an SSRF primitive if left unguarded. The guard is deliberately permissive about private ranges and absolute about the rest.

DestinationResult
127.0.0.1, localhost, ::1Allowed. You are scanning your own lab
RFC1918 (10/8, 172.16/12, 192.168/16)Allowed, same reason
Any non-http(s) schemeBlocked
169.254.0.0/16 (link-local / cloud metadata)Blocked
fe80::/10Blocked
fd00:ec2::254 (IMDS over IPv6)Blocked
scheme "file" is not allowed — DAST targets must be http(s) (blocks file:// local read + data:/gopher: SSRF)
link-local / cloud-metadata host "169.254.169.254" is blocked (SSRF to instance credentials)
host "metadata.internal" resolves to blocked address 169.254.169.254 (SSRF to link-local / cloud-metadata)

Two mechanics matter:

  • IPv4-mapped IPv6 is decoded. The URL parser canonicalises ::ffff:169.254.169.254 to ::ffff:a9fe:a9fe, which defeats a string comparison. The guard recovers the embedded IPv4 address and checks that.
  • Hostnames are resolved, then checked on every egress, not once at configuration time. If any resolved address is blocked, the request is blocked.

Known residual: DNS rebinding. This is resolve-then-check, not connect-time IP pinning, so a name that resolves benignly during the check and maliciously during the connect is not stopped. Mitigating it are the facts that DAST is operator-initiated, permission-gated, and circuit-broken. If that is not enough for your environment, do not enable the DAST tools.

The dashboard

locac web can launch runs, and a run executes bash. The dashboard is therefore treated as a local RCE surface.

  • It binds 127.0.0.1 by default. Binding anything else is refused unless a dashboard password and a JWT secret are both configured.
  • The password is stored as an argon2id hash, never in plaintext.
  • Sessions are stateless HS256 JWTs verified with a constant-time comparison; the cookie is HttpOnly; SameSite=Strict; Path=/.
  • Every /api/ route, reads included, is Host-guarded against DNS rebinding, and every mutation additionally requires a same-origin Origin and a per-startup CSRF token header.
  • Run-launch routes are off unless --enable-runs is passed.
  • All untrusted content, from transcripts and tool output to finding evidence and live stream frames, is rendered as text by the client, never as HTML.

Details are in Dashboard and evaluation.

Secrets

The API key is never echoed, never persisted outside the config file you wrote it into, never logged, and never written into a finding, a transcript, or a memory entry. Anything the dashboard sends to the browser has it replaced with a redaction placeholder, and saving a form back preserves the stored key rather than writing the placeholder over it.

The one place a key can leak is your own doing: locac config show prints the effective configuration, including a literal apiKey. Do not paste that output into a bug report.

What this does not defend against

Stated plainly, because a security tool that overstates its own guarantees is worse than one that does not try:

  • sandbox=direct is not confinement. The cwd is pinned; nothing else is. If the host has no working sandbox, target code runs with your privileges.
  • Inside the sandbox, the target root is writable, and on POSIX so is the system temp directory. Do not audit a repository you are not prepared to see modified.
  • --auto is lexical, as described above.
  • DNS rebinding against the DAST guard remains possible.
  • --yes / --allow-all disables the gate entirely. That is what they are for; know what you are pointing them at.
  • A prompt-injection payload can still influence the model. The fence and the invariants make it far harder for target text to be obeyed, but the defence is a strong prior, not a proof. The evidence gates are the backstop: a finding cannot claim High or Critical without an execution artifact whose checked assertion passed for that exact location, plus a passing quorum, whatever the model was told.

Next

Context and compaction covers how old tool output is elided without losing the evidence that a finding depends on.