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:
contentis the model's only channel. Tool results also carry an out-of-banddetailsobject used by the TUI and the dashboard; no provider serialises it into the request. Anything the model is meant to act on must be incontent, 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/limitor 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:
| Resource | Policy |
|---|---|
| Network | All egress blocked. No allowed domains |
| Read | The target root |
| Write | The 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
| Category | Default |
|---|---|
| Read-only tools | Allow |
| Dangerous tools | Ask |
| Everything else | Allow |
"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:
- 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. - The command contains no shell metacharacter:
; & | \$ % > < newline ( ) * ? { } !, or a..` path segment. - No argument starts at a root:
~,/,\, or aC:-style drive prefix. - 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"andfind . "-delete"through, because neither matched a pattern anchored on the unquoted form. uniqis deliberately absent from the read-only list.uniq in outwrites to a positional argument, so it is not a reader.%is a metacharacter becausecmd /cexpands%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
--autowill not notice. It is a convenience layer over the OS sandbox, not a replacement for it, which is why--auto --require-sandboxis 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.
| Destination | Result |
|---|---|
127.0.0.1, localhost, ::1 | Allowed. You are scanning your own lab |
RFC1918 (10/8, 172.16/12, 192.168/16) | Allowed, same reason |
Any non-http(s) scheme | Blocked |
169.254.0.0/16 (link-local / cloud metadata) | Blocked |
fe80::/10 | Blocked |
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.254to::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.1by 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-originOriginand a per-startup CSRF token header. - Run-launch routes are off unless
--enable-runsis 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=directis 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.
--autois lexical, as described above.- DNS rebinding against the DAST guard remains possible.
--yes/--allow-alldisables 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.