README
¶
NockLock™
Fence, not guardrails. Sandbox your AI agents without changing how they work.
NockLock puts a fence around your AI coding agent. It controls which secrets the agent can see, which files it can touch, and which domains it can reach. Inside the fence, the agent runs with full permissions. While the fences are active, nothing gets out beyond the access you allow.
Why NockLock
Your AI agent runs with full shell access to your environment, your filesystem, and your network. NockLock leaves the way your agent works alone and controls what it can reach.
The secret fence filters environment variables. Your agent sees PATH and HOME. It never sees AWS_SECRET_ACCESS_KEY.
The filesystem fence is built differently on each platform. Linux uses a kernel root allowlist (Landlock) with LD_PRELOAD event logging. macOS uses kernel Seatbelt (sandbox-exec) to confine writes to filesystem.root plus required runtime paths, and it denies credential and sensitive paths such as ~/.ssh, ~/.aws, ~/.config, ~/.gnupg, and ~/Library/Keychains for both reads and writes.
The default network policy is a userspace proxy with a domain allowlist. A client that ignores HTTP_PROXY can bypass it. On Linux, nocklock wrap --net-fence=netns opts into a kernel-enforced version: a network namespace with an nftables default-drop floor and a transparent HTTP(S) and DNS allowlist, so nothing outside network.allow leaves the namespace on any transport.
Quick start
brew install nocktechnologies/tap/nocklock
cd your-project
nocklock init
nocklock wrap -- claude
Four commands and your agent is fenced.
macOS and Claude Code: Claude Code keeps a claude.ai login in the macOS login keychain, and the fence denies ~/Library/Keychains (Seatbelt cannot scope to one keychain item, so NockLock does not open it). The login has to arrive through the environment, and the default secret fence blocks *_TOKEN*, so plain nocklock init strips an exported CLAUDE_CODE_OAUTH_TOKEN. Use the claude-code preset:
claude setup-token
export CLAUDE_CODE_OAUTH_TOKEN=<token from claude setup-token> # or ANTHROPIC_API_KEY
nocklock init --runtime claude-code
nocklock wrap -- claude
The claude-code preset passes both variables through the secret fence. With a custom config, remove the matching pattern from secrets.block (for example *_TOKEN*) and add the exact name to secrets.pass; block outranks pass, so adding to pass alone does not work. If wrap sees claude on macOS with neither variable reaching the child, it prints a one-line hint (stripped by the fence, or unset so run claude setup-token; never the value) and still launches. Claude Code's --bare mode does not read CLAUDE_CODE_OAUTH_TOKEN; use ANTHROPIC_API_KEY there.
On Linux, the default read-only system grants cover binaries and libraries, plus
/etc/ld.so.cache (loader cache), passwd and group (identity lookup),
nsswitch.conf, hosts, and resolv.conf (name resolution), localtime
(timezone), and public CA trust in the Debian/Alpine (/etc/ssl/certs/)
and RHEL/Fedora (/etc/pki/tls/certs/, /etc/pki/ca-trust/extracted/)
layouts. They do not grant all of
/etc/: files such as /etc/environment and TLS private keys stay outside
the default allowlist. Loader configuration (ld.so.conf*), CA maintenance
configuration, and alternatives directories are not needed by these runtime
checks; symlinks into the allowed binary/library trees already resolve. Missing
optional paths are skipped. Add distro-specific runtime files explicitly if needed.
On macOS, nocklock init writes the same default filesystem.root = ".", and nocklock wrap enforces it as a kernel Seatbelt write boundary. Writes outside the root, .nock, and essential per-user runtime paths are denied. The built-in credential and sensitive paths (~/.ssh, ~/.aws, ~/.config, ~/.gnupg, ~/Library/Keychains, plus your filesystem.deny paths) are denied for reads and writes. Reads outside the root are not confined, and filesystem.allow and filesystem.allow_rw are not enforced on macOS. filesystem.mode = "read-only" is enforced: it drops the write allow for the root. See "Filesystem platform boundary" below.
To start from a preset for a specific runtime:
nocklock init --runtime codex
nocklock init --runtime aider
nocklock init --runtime gemini-cli
nocklock init --runtime opencode
nocklock init --runtime goose
How it works
nocklock wrap does three things before it starts your agent.
It filters environment variables against the pass and block lists, which accept glob patterns. This runs on Linux and macOS.
It fences the filesystem. On Linux, Landlock applies a kernel allowlist and LD_PRELOAD records blocked-access events. On macOS, Seatbelt denies writes by default, then permits only filesystem.root, NockLock state, and essential per-user runtime paths; its sensitive-path denies also block reads. Every child process inherits the profile. Before launch, NockLock validates the generated profile, so a requested macOS fence either engages or refuses to start. It never silently degrades.
It routes network traffic through a local proxy that enforces a domain allowlist. On Linux, in proxy mode with syscall enforcement and the filesystem interposer enabled, IP socket creation is denied in the child; proxy-aware HTTP(S) clients connect to an advertised loopback proxy address that the interposer maps onto a Unix socket. Direct IP sockets, including --noproxy bypass attempts, fail closed. For HTTPS, only the hostname is inspected. There is no certificate injection and no payload decryption. If the proxy is not confirmed healthy, the agent does not start.
On Linux you can instead pass --net-fence=netns. The child then runs in its own network namespace behind a kernel default-drop floor, keeps its configured IP socket families, and reaches only network.allow hosts through a transparent HTTP(S) proxy and a fixed-answer DNS stub that answers DNS over UDP and TCP port 53 inside the namespace. All other traffic leaving the namespace is dropped in the kernel, including direct DNS to other resolvers, non-DNS UDP, QUIC (UDP/443), SCTP and raw IP. This mode needs the privileged egress helper described under "Linux network-egress helper" and fails closed without it.
What “network fence active” means
wrap prints and signs the effective egress level for each session. The proxy checks hostnames only for clients that use it; it does not decrypt HTTPS.
| Level | Boundary |
|---|---|
KERNEL |
Linux netns with a kernel default-drop floor and the configured HTTP(S)/DNS allowlist. |
CONFINED |
Linux proxy with syscall enforcement and the filesystem interposer bridge; the child can connect only to the proxy's Unix socket. |
SEATBELT |
macOS proxy with a Seatbelt kernel floor (needs filesystem.root): the profile denies every outbound network connect except to the proxy's loopback port, and allows no Unix-socket connect, so a client that ignores HTTP_PROXY is denied by the kernel. It is not CONFINED, which is defined by the Linux syscall and interposer bridge, and look-back rules (Linux CONFINED only) are refused at SEATBELT. |
ADVISORY |
Userspace proxy only. A client that ignores HTTP_PROXY can reach any host. The wrap warning names this limitation. |
OFF |
network.allow_all = true; any host is reachable. |
UNREACHABLE |
Syscall enforcement restricts proxy traffic to Unix sockets, but the filesystem interposer bridge is disabled, so the allowlist cannot be reached. Wrap refuses to start. |
Set network.require_enforced = true in .nock/config.toml or pass --require-enforced-egress to refuse ADVISORY, OFF, and UNREACHABLE levels. Linux can use --net-fence=netns; macOS meets the requirement through the SEATBELT floor.
The macOS floor is the Seatbelt profile that filesystem.root already requires: (deny network-outbound) plus a TCP-only allow (remote tcp) for localhost:<proxy port>, and a deny of the resolver's Mach services (com.apple.mDNSResponder, com.apple.mDNSResponderHelper). getaddrinfo reaches mDNSResponder over Mach, not a socket, so without that deny a fenced agent could put data into DNS queries; with it, the agent cannot resolve names itself, and proxy-aware clients send the hostname to the proxy, which resolves it outside the profile. NockLock holds the proxy's port on [::1] too (wrap refuses to start if that port is taken), because localhost in the rule admits both loopback families. The proxy runs outside the profile, so its upstream connects are unaffected. If the profile is rejected or the proxy is unhealthy, wrap refuses to start, and the signed fence-state row records the floor. The honest limits: Apple deprecates sandbox-exec; reads outside filesystem.root are not confined; with filesystem.root = "" there is no profile and egress is ADVISORY; the proxy's loopback port is the only local destination; /etc/hosts lookups stay allowed (a local file read, no query leaves the host); a tool that must resolve a public name itself (not through the proxy) fails inside the fence; and the Mach deny covers the resolver services named above, so a resolver service not on that list is not claimed closed.
Look-back rules (Linux, CONFINED only)
A look-back rule narrows egress after a denied file open. It never adds an allow:
[[network.lookback]]
name = "probe-then-silence"
on = "file_blocked" # only trigger supported today
within = "10m" # optional; default is the rest of the session
then = "deny_egress"
After the interposer reports a denied open, the proxy closes open tunnels and requests and denies new ones, each logged as a signed network_blocked row citing the rule and the trigger row. wrap refuses to start with a rule unless the egress level is CONFINED. Static binaries, raw syscalls and a cleared LD_PRELOAD bypass the interposer, so they never trip the rule. See docs/design/look-back-rules.md.
Linux blocked accesses are logged to the event log (see "Event log" for where it lives). The macOS Seatbelt path records its fence state but does not yet emit one audit event per denied file; Seatbelt returns its native permission error. Blocked domains get a 403.
Filesystem platform boundary
Linux supplies root-only filesystem isolation. macOS supplies a kernel-enforced
write boundary. Seatbelt deliberately keeps (allow default), because a
(deny default) profile aborts common processes, then applies (deny file-write*) with narrow write allows for the canonical filesystem.root,
.nock, /private/tmp, the invoking user's temp and cache paths, and required
/dev pseudo-devices. The built-in and configured sensitive paths remain
denied for both reads and writes, even when they sit under the configured root. Reads outside the
root are not confined; NockLock does not claim macOS read isolation. Every
emitted path is canonicalized, and an unresolvable path or a rejected profile
fails closed before the agent starts.
Apple deprecates sandbox-exec, but it is still present and working on
macOS 26.5. NockLock tests it in macOS CI and will track its availability. Its
per-file deny events are best-effort on macOS via the unified log: the profile
tags each file-deny rule with the session id, and a background log stream
tailer records the matching denials as file_blocked rows (each operation and path once, at most 500 per
session, then one row counting the rest). Only kernel-originated records count: a record that a process
inside the fence logs under the Sandbox sender name is ignored. The unified log can drop lines under
load, so a missing row is not proof that no denial happened, and a tailer that
cannot start becomes one warning row without affecting the wrapped command.
Every macOS wrap also records exactly one filesystem-fence state in the event
log: ENGAGED, REFUSED-TO-START, or DEGRADED.
The v0.5 filesystem.macos_allow_unfenced = true escape hatch was removed in
v0.6.0. When Seatbelt cannot be applied, wrap always records
REFUSED-TO-START and does not launch the child. A config that sets the key to
true fails to load; = false is still accepted.
Tamper-evident audit log (v1)
Forward fence decisions to Command
Add this to .nock/config.toml to send committed file, network, and secret allow/block events to Command's ops log:
[audit.forward]
enabled = true
url = "https://cc.nocktechnologies.io"
api_key_env = "NOCKLOCK_FORWARD_KEY"
Set NOCKLOCK_FORWARD_URL to the same approved Command origin and set the dedicated NOCKLOCK_FORWARD_KEY before nocklock wrap. Both values must come from the operator environment; the project config cannot select another credential name or redirect the key to another host. NockLock always strips both variables from the fenced child, records the removal in events.db, and never stores the key value in config or the audit log. Existing NOCKCC_API_KEY pass-through remains governed by the secret fence. Forwarding is off by default. Each ops-log entry includes the action, target, decision, session ID, and committed audit-chain entry hash. Blocked decisions use high severity; allowed decisions use info. Command currently accepts these as event_type = "other" with source = "nocklock" in data_blob.
Delivery uses a bounded background queue with short retries. Network failures do not change fence decisions or remove events from events.db; one teardown warning summarizes failed or dropped deliveries. The queue is not durable, so events that cannot be sent during the current session remain available only in the local audit log.
nocklock verify --audit walks the SHA-256 hash chain in the event log and reports whether it is intact. Each row carries a SHA-256 hash of its contents, linked to the previous row's hash. A verification run looks like this:
$ nocklock verify --audit
AUDIT: CONSISTENT — 247 events verified, hash chain intact (not externally anchored)
Head hash: fe48c7a11e02b9ebe9ba07eb7df01e1e3c5b18f3fcb31af8f0eb5d8b4f1e7a4c
This means the logged events have not been altered, deleted (except possibly the most recent), or reordered since they were recorded. An intentional compaction (--prune) re-anchors the chain and still verifies as consistent, but it is never silent: verify prints a NOTE: line that reports when the prune happened and how many events it removed. The chain is not externally anchored. An attacker with write access to the SQLite file can defeat this check by rewriting the chain and chain_head in the same transaction. A passing audit verification means the stored rows and their integrity metadata are internally consistent. It does not prove the history is authentic, or that NockLock itself recorded the entries.
v1 is a tamper-evident log, not an unforgeable receipt. The hash chain alone cannot resist an active file writer.
Signed audit log (v1.1: Ed25519)
v1.1 layers an Ed25519 signature over the same canonical bytes the hash chain already covers. Signing adds authenticity: it proves that NockLock, as holder of the private key, wrote each row. The unkeyed hash chain cannot prove that. Because the chain algorithm is public and keyless, an active writer with access to the event log can recompute every hash and rewrite chain_head in lockstep, and v1 verification still passes. A signature the attacker cannot produce without the key defeats exactly that writer.
The key is a NockLock-managed file at ~/.config/nocklock/signing-ed25519.key (mode 0600, generated on first use, and kept outside the database so a db-only attacker cannot sign). The key directory is resolved canonically and must end at a directory owned by the current user and not group- or world-accessible. A benign user-owned symlink in the path is accepted, since macOS temp dirs and a symlinked ~/.config are normal on every platform. A symlink whose target is owned by another user, or is group- or world-accessible, is rejected. The versioned chain_head signature binds the head, the prune boundary, the signing-adoption boundary, and the public-key fingerprint. An adopted log refuses writes unless its managed key matches that fingerprint and verifies the existing head.
Every wrap also signs a config.digest row for its resolved policy; a changed policy warns before the child starts, and verify --audit reports its history and rejects sessions missing that row.
nocklock verify --audit reports four states and never conflates them. AUTHENTIC means signed and valid. CONSISTENT means the hash chain is intact but unsigned, or signed with no key supplied to check it. FORGED means a signature does not verify, or signing artifacts remain without their adoption marker. SUSPECT means a signed check was explicitly required via --ed25519-pub but the log carries no signatures and no adoption markers, whether or not any events remain; this could be a full strip of every signing artifact, or a deletion of every row with the head reset, and it is reported non-zero, never as a clean pass.
Supplying --ed25519-pub states the external expectation that the log is signed. Without it, a genuinely unsigned or pre-adoption log reads CONSISTENT. With it, a completely erased signing record reads SUSPECT. Telling a never-signed log apart from a fully stripped one cryptographically requires anchoring the signed head off-box, which is what the v1.2 external chain-head anchor below does. Signing is adopted going forward, so rows written before adoption verify as consistent-but-unsigned, not forged. Run nocklock verify --export-pubkey to publish the public key for out-of-band checking.
Because the head is signed, deleting the newest rows and rewriting chain_head in lockstep, the attack the unkeyed chain cannot resist, is now caught as FORGED: the attacker cannot re-sign the shortened head. The remaining gap is rollback. An attacker who restores an earlier, legitimately signed snapshot of the whole database presents a shorter log that still verifies, because its head was validly signed at the time. Detecting that requires anchoring the signed head off-box, which is exactly what the external chain-head anchor adds.
External chain-head anchor (v1.2)
An anchor is a small signed record of the audit chain's head, {version, agent_id, head_hash, row_count, created_at, sig}, captured at a point in time and stored outside events.db. It closes the rollback and tail-truncation gap that the signed head alone cannot: the signed chain_head proves a log is authentic for the rows it currently holds, but its signature was valid when it was written, so a shorter, earlier, legitimately signed snapshot still verifies. An anchor pins the row count and head hash the log should have, so a later chain with fewer rows is caught.
The anchor is signed with the same NockLock-managed Ed25519 key that signs the audit rows and the head; agent_id is that key's fingerprint, the same identity recorded in chain_head. Its signed bytes lead with a 0x05 domain-separation byte (rows use 0x01, the head 0x03), so a row or head signature can never be replayed as an anchor. The exact layout is pinned by a byte-literal test.
$ nocklock anchor emit --out anchor.json # or to stdout without --out
$ nocklock verify --against-anchor anchor.json
ANCHOR: OK — local chain reproduces the anchored head at 247 rows (anchor attests 247 rows, local has 247)
verify --against-anchor first authenticates the anchor itself, with the supplied --ed25519-pub or else the local managed key. Without a key it fails closed rather than falling back to a hash-only pass. Then it checks the local chain. Fewer rows than attested is ANCHOR: TRUNCATION, which covers both tail truncation and rollback to an earlier snapshot, since both present a shorter chain. A head-hash mismatch at the attested count is ANCHOR: TAMPERED. A bad or wrong-key anchor is ANCHOR: FORGED. Every verdict other than OK exits non-zero. A chain that has legitimately grown past the anchor still passes. A nocklock wrap session emits an anchor to <db-dir>/chain-anchor.json on teardown (best-effort, but logged) and, when configured, pushes it off-box (see below).
Two limits apply. A legitimate --prune re-anchors the chain and invalidates any anchor emitted before it; verify prints a NOTE when the local chain_head records a prune after the anchor's timestamp, and a fresh anchor should be emitted after a prune (wrap re-emits on every teardown). And chain-anchor.json sits in the same trust boundary as events.db, so a file-access attacker can delete it or swap in an older valid anchor alongside a matching database rollback. Its real value is as the off-box push hook. Once the head is pinned somewhere the agent cannot reach (NockCC, for example), rollback and truncation become observable even against a full-file adversary.
Off-box anchor push
Set two environment variables to pin anchors somewhere the agent cannot reach:
NOCKLOCK_ANCHOR_URL: base URL of the anchor store. It must behttps://; plainhttp://is accepted only for loopback hosts (127.0.0.1,::1,localhost) for local development and tests.NOCKLOCK_ANCHOR_TOKEN: optional bearer token, read from the environment only (never a config key or flag) and never printed in errors or logs.
nocklock wrap strips both variables from the fenced child's environment, whatever the secret-fence config says. With the URL set, wrap pushes the anchor it writes on teardown under a hard 5-second timeout. The push is fail-open: on any failure wrap prints NockLock: warning: chain anchor NOT pushed off-box: <reason> and exits with the wrapped command's own exit code. With the URL unset, teardown behaves exactly as before. nocklock anchor push [--file <path>] pushes an anchor by hand (default: <db-dir>/chain-anchor.json).
$ nocklock verify --against-remote-anchor
ANCHOR: TRUNCATION — truncation detected: anchor attests 247 rows, local has 190
verify --against-remote-anchor fetches the latest stored anchor for the local signing identity and runs the same checks as --against-anchor (the two flags are mutually exclusive). An unset URL, an unreachable store, or no stored anchor prints ANCHOR: UNAVAILABLE (anchor_unavailable) and exits non-zero. A missing off-box pin is reported, never read as a pass.
Server contract (the NockCC endpoint ships separately):
POST {base}/api/nocklock/anchors/with{"anchor": <anchor object>, "pubkey": "<base64 Ed25519 public key>"}andAuthorization: Bearer <token>. Any 2xx means stored.GET {base}/api/nocklock/anchors/{agent_id}/latest/returns the latest anchor, or 404 when none is stored.- The server answers 409 when a pushed anchor's
row_countis lower than the latest one it holds for thatagent_id. This server-side monotonic check is what makes a local rollback observable: a rolled-back host can no longer re-pin a shorter chain, and--against-remote-anchorthen reports the gap asTRUNCATION. Redirects are not followed and response bodies are capped at 64 KiB.
Secret preflight scanning
Check selected local files before giving them to an agent:
nocklock scan src .env
nocklock scan --json src
nocklock scan --env src # also inspect the invoking environment, without filtering
No config or account is required. Paths are relative to the current directory;
omitting them scans .. Exit status is zero only when the scan completes with
no findings. JSON reports distinguish complete from findings, because a
complete scan can still find a credential. Reports show detector IDs and
locations, never matching values or source lines. Recognized credential formats
in filenames or environment names are also replaced with [redacted] in report
locations.
The initial detectors recognize AWS access-key IDs (AKIA/ASIA), GitHub token
formats (classic, OAuth, app, refresh and fine-grained), and PEM private-key
headers. These are format checks; they do not check that a credential is valid.
Unrecognized, encoded or compressed secrets can go undetected.
To require preflight before every wrap launch, add settings to your existing
[secrets] table:
[secrets]
# Keep your existing pass/block rules here.
scan_env = true
scan_paths = ["src", ".env"]
# Optional exact names intentionally available to the agent:
scan_env_allow = []
wrap checks environment values after pass/block filtering and after removing
its own anchor credentials. scan_env_allow exempts exact names from value
scanning only; it never overrides a block rule. Scan paths are relative to the
project containing .nock/config.toml, even when launched from a subdirectory.
With a profile and no project config, the root is the current directory.
Profile overlays can add paths or enable scanning, but they cannot disable a
base scan, remove its paths or add environment exceptions. An absent or false
scan_env and empty scan_paths leave existing behavior unchanged. --dry-run
displays the configured policy without scanning.
There are no implicit exclusions: hidden and binary files are inspected, and ignore files are not consulted. Use explicit paths to choose the scope. Symlinks, special files, unreadable or missing inputs, and detected concurrent changes make the scan incomplete. Bounds are 1 MiB per file or environment value, 32 MiB of content, 10,000 entries (unique traversed paths plus environment values), 10,000 selected-path arguments and 64 levels below each selected path. An explicit path is counted once; duplicate paths are not scanned again. Exceeding a bound is an incomplete scan, not a silent skip. Safe file opens are supported on Linux and macOS. Recursive traversal uses open directory handles, so replacing a directory path cannot redirect a child read. Identity, metadata and bounded content rereads detect observed changes; they do not provide an atomic snapshot or rule out every concurrent write.
A required scan that finds something, or that comes back incomplete, prevents child execution and is recorded in the local audit log. Failure to record that result also prevents launch. This is a point-in-time preflight check. It does not redact reads, monitor agent context, protect paths outside the selected scope, or prevent files from changing after the scan. Runtime secret-file protection is separate.
Configuration
nocklock init creates .nock/config.toml with safe defaults:
[project]
name = ""
root = "."
[filesystem]
root = "."
mode = "read-write"
linux_enforcement = "required"
allow = [
"~/.claude/",
"/tmp/",
"/usr/",
"/bin/",
"/lib/",
"/lib64/",
"/etc/ld.so.cache",
"/etc/passwd",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/hosts",
"/etc/resolv.conf",
"/etc/localtime",
"/etc/ssl/certs/",
"/etc/pki/tls/certs/",
"/etc/pki/ca-trust/extracted/",
]
# Linux only: these paths are explicitly read-write; allow stays read-only.
allow_rw = []
deny = [
"~/.ssh/",
"~/.aws/",
"~/.gnupg/",
"~/.nock/",
]
[network]
allow = [
"github.com",
"api.github.com",
"api.anthropic.com",
"registry.npmjs.org",
"pypi.org",
"rubygems.org",
"crates.io",
]
allow_all = false
require_enforced = false
[syscall]
enforcement = "required"
allow_namespaces = false
socket_families = [
"unix",
"inet",
"inet6",
]
[secrets]
pass = [
"HOME",
"PATH",
"SHELL",
"USER",
"LANG",
"TERM",
]
block = [
"AWS_*",
"STRIPE_*",
"DATABASE_URL",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"*_SECRET*",
"*_PASSWORD*",
"*_TOKEN*",
]
[logging]
# A relative db goes to NockLock's audit state directory outside the project;
# an absolute path is used verbatim. See "Event log".
db = "events.db"
level = "info"
[cloud]
enabled = false
api_key = ""
endpoint = "https://cc.nocktechnologies.io/api/fence/events/"
The defaults are deliberately safe. Adjust them per project.
On Linux, filesystem.allow grants read-only access. Use
filesystem.allow_rw only for paths a tool must modify, such as a runtime's
state directory. Both lists are ignored on macOS, whose Seatbelt backend uses
the configured root as its write boundary instead.
The default Linux policy includes the system binary, library, and configuration
paths needed to launch a shell. Missing read-only paths, such as ~/.claude/
in a new home, grant nothing for that session and do not prevent a wrap.
Runtime presets exist for claude-code, codex, aider, gemini-cli, opencode, and goose. Each preset keeps the network default-deny, blocks private ranges, keeps Linux filesystem and syscall enforcement required, and passes only the runtime's documented first-party provider key or keys. claude-code also passes CLAUDE_CODE_OAUTH_TOKEN (see the macOS note under Quick start). codex keeps its login in a file under ~/.codex; the built-in macOS deny list does not include ~/.codex, so reads are not blocked, but Codex's token-refresh writes outside the project root would be, and this has not been verified on a macOS runner. Use OPENAI_API_KEY if a refresh fails. gemini-cli targets the API-key path; OAuth and Vertex AI setups need explicit operator review before widening the filesystem or egress. opencode targets OpenCode Zen/Go through opencode.ai; direct third-party providers should use a reviewed custom config. goose is a multi-provider preset covering Anthropic, OpenAI, Gemini, Groq, and OpenRouter; only the provider key the user has set is live, and the rest are unset and harmless. MCP extensions that reach additional hosts need an operator overlay.
Two candidate runtimes are deliberately left without a preset:
cursor-agent: its first-party egress endpoints are not documented clearly enough to pin without guessing.continue: its provider endpoints are user-configurable and can include hosted or self-hosted providers, so no single default allowlist would be accurate.
Commands
| Command | Description |
|---|---|
nocklock init |
Create .nock/config.toml with safe defaults |
nocklock init --runtime <name> |
Create .nock/config.toml from an embedded runtime preset; fails if one exists. Add --diff to print the preset's policy changes against the existing config (no write) |
nocklock wrap -- <cmd> |
Run a command inside the fence |
nocklock wrap --net-fence=netns -- <cmd> |
Linux only: run inside the kernel-enforced netns egress fence (needs the privileged helper) |
nocklock wrap --require-enforced-egress -- <cmd> |
Refuse to start unless egress is KERNEL or CONFINED |
nocklock wrap --profile list |
List embedded runtime presets |
nocklock wrap --dry-run |
Validate config without starting fences or a command |
nocklock scan [path ...] |
Scan selected local files; --env adds environment values and --json prints structured results |
nocklock validate [config-path] |
Validate a config file and print the effective policy |
nocklock doctor |
Check whether each fence is enforced on this host; exit 0 PROTECTED, 2 GAPS FOUND, 3 PARTIAL, 4 UNPROTECTED (see below) |
nocklock verify |
Run the adversarial fence self-test (proof of block) |
nocklock verify --audit |
Verify the audit log hash chain and signatures |
nocklock verify --against-anchor <file> |
Verify the audit chain against an external chain-head anchor file |
nocklock verify --against-remote-anchor |
Verify the audit chain against the latest anchor in the off-box store |
nocklock verify --export-pubkey |
Print the Ed25519 audit-log public key for out-of-band verification |
nocklock anchor emit [--out <file>] |
Emit a signed chain-head anchor |
nocklock anchor push [--file <file>] |
Push an anchor to the off-box store at NOCKLOCK_ANCHOR_URL |
nocklock egress-probe |
Probe Linux network-egress enforcement feasibility on this host (structured --json) |
nocklock status |
Show configured fences and event log summary (does not track running sessions) |
nocklock log |
View fence event history |
nocklock log --blocked |
Show only blocked events |
nocklock log --stats |
Show aggregate statistics |
nocklock config |
Display current configuration |
nocklock version |
Show version |
Installation
Homebrew (recommended)
brew install nocktechnologies/tap/nocklock
From source
git clone https://github.com/nocktechnologies/nocklock.git
cd nocklock
make build-all
Requires Go 1.26 or newer. The binary is built to ./nocklock. On Linux, build-all also compiles the filesystem fence interposer library (libfence_fs.so). On macOS, the library build is skipped automatically, and the filesystem fence uses the built-in Seatbelt sandbox (sandbox-exec).
Verify the installation
nocklock version
Linux network-egress helper (privileged)
On Linux, the opt-in network-egress fence (nocklock wrap --net-fence=netns,
netns transparent redirect) needs a small root-owned helper plus a constrained
NOPASSWD sudoers grant. nocklock wrap
runs unprivileged and hands the composed child to that helper over passwordless
sudo. The child's argv, env, and the credential to drop to travel in a 0600
per-session request file, and only the file path rides argv, so the fixed
check / setup --request-file <path> sudoers policy is a real privilege
boundary rather than an argument-injection surface. Keeping the request off
stdin lets sudo pass the caller's real stdin (a TTY or a pipe) straight through
to the fenced child, so interactive and piped agents keep their input stream.
Without the helper, the egress fence fails closed at runtime.
Install the binary, then the helper:
make install # builds and installs the nocklock binary
sudo make install-egress-helper # installs the root-owned helper + sudoers grant
# or, to name the unprivileged wrap user explicitly:
sudo NOCKLOCK_EGRESS_USER=<user> scripts/install-egress-helper.sh
The installer writes a root-owned shim to /usr/libexec/nocklock-egress-helper
and this constrained grant to /etc/sudoers.d/nocklock-egress. The grant is
validated with visudo -cf before it is moved into place, so a broken file is
never left behind. <user> is the unprivileged user that runs nocklock wrap:
Cmnd_Alias NOCKLOCK_EGRESS = /usr/libexec/nocklock-egress-helper check, \
/usr/libexec/nocklock-egress-helper setup --request-file *
<user> ALL = (root) NOPASSWD: NOCKLOCK_EGRESS
Verify it as the wrap user:
sudo -n /usr/libexec/nocklock-egress-helper check # prints nothing and exits 0
nocklock doctor # egress-helper check reports ok
Works with
NockLock does not care which agent you run. It wraps any CLI tool that respects standard environment variables.
nocklock wrap -- claude # Claude Code
nocklock wrap -- cursor # Cursor
nocklock wrap -- codex # Codex CLI
nocklock wrap -- aider # Aider
nocklock wrap -- your-custom-agent # Anything
Event log
Every fence decision is recorded in an SQLite event log.
For a NEW project the log does not live in your project. It lives in
NockLock's audit state directory,
$XDG_STATE_HOME/nocklock/<project-key>/ (or
~/.local/state/nocklock/<project-key>/), where <project-key> is derived from
the project's path. The chain anchor sits beside it.
That is a security boundary, not a preference. The fence grants your project root to the agent so it can create and delete files there, and Landlock cannot exclude a path underneath a granted directory — an event log stored in the project would be editable by the very agent it records.
If you already have a log at .nock/events.db, NockLock keeps using it there.
It will not move an existing audit chain for you: relocating a live SQLite
database, its write-ahead-log sidecars and its chain anchor is one operation
that has to either fully succeed or not start, and a half-finished move leaves a
chain that still verifies while missing its most recent entries. The trade is
that while the log sits inside the fence root, the agent cannot create or remove
entries directly in the root — everything inside existing subdirectories still
works, and nocklock wrap says so on stderr each run. To lift the restriction,
move the audit directory (events.db, its -wal/-shm sidecars and
chain-anchor.json together) outside the fence root while no session is
running; nocklock state migrate will do that for you in a later release.
logging.db set to a relative path uses its filename in the audit state
directory, except that an existing log at the explicitly configured in-project
path is retained for compatibility. An absolute path is accepted only inside
your project or the audit state directory; anywhere else is rejected when the
config loads, because logging.db is a setting a repository can ship. Changing
the filename does not hide an existing default-name chain: if both the old
default location and the newly configured destination are candidates, NockLock
refuses to guess which chain is authoritative and names both paths. Likewise,
an existing legacy .nock/events.db remains visible after a rename.
nocklock wrap refuses to start if the resolved logging.db directory is the
project root: Landlock cannot protect the database's sidecars and chain anchor
while granting that root. Move the database, sidecars, and chain anchor
together under .nock/ with
[logging] db = ".nock/events.db", or use the NockLock state directory with
[logging] db = "events.db".
nocklock doctor warns if an absolute path lands back inside
filesystem.root, where the agent can reach it.
Query it with nocklock log:
$ nocklock log --blocked
Session a1b2c3d4 started 2026-04-09 14:23:01 ended 2026-04-09 14:47:33 (24m 32s)
secret_blocked: AWS_SECRET_ACCESS_KEY
file_blocked: /home/user/.ssh/id_rsa
network_blocked: evil.example.com:443
Total: 3 event(s) across 1 session(s), 3 blocked, 0 passed
$ nocklock log --stats
Total events: 847
Sessions: 12
Blocked: 23
Passed: 824
First event: 2026-04-07 09:00:01
Last event: 2026-04-09 14:47:33
NockLock Dashboard
The CLI is free and open source. For teams that want visibility across machines, NockLock Dashboard adds cloud monitoring, alerts, and team-wide fence event history.
Philosophy
NockLock is a fence, not guardrails.
Guardrails tell the agent what not to do. The agent can ignore them, work around them, or hallucinate past them. Guardrails are prompts.
nocklock doctor verdicts and exit codes (--json adds per-boundary boundaries posture of enforced, advisory, disabled, unavailable or enabled, and the proxy-mode egress_level):
| Exit | Verdict | Meaning |
|---|---|---|
| 0 | PROTECTED | filesystem, syscall, network and secrets are all enforced; the network is not allow_all and its egress level is CONFINED or SEATBELT |
| 2 | GAPS FOUND | a boundary is configured but its backend is missing |
| 3 | PARTIAL | some boundary is disabled or advisory; each is listed by name |
| 4 | UNPROTECTED | no boundary is enforced |
A missing or unparseable .nock/config.toml also exits 2.
Boundary postures: enforced (the fence is active), advisory (logs or warns but does not block), disabled (turned off in config), enabled (configured but its backend is missing), and unavailable (the fence cannot enforce on this host or config; it never counts toward PROTECTED). unavailable is how doctor reports the UNREACHABLE egress level: syscall enforcement restricts proxy traffic to Unix sockets while the interposer bridge is disabled, so the allowlist cannot be reached.
A fence sits between the agent and the resource. How hard the boundary is depends on the fence. The secret fence is absolute: a blocked variable is gone from the environment before the agent starts. On Linux, the filesystem fence is kernel-enforced with Landlock by default and composes with LD_PRELOAD logging; static binaries and children that clear LD_PRELOAD are still denied by the kernel. On macOS, the filesystem fence is a kernel-enforced Seatbelt write confinement. Writes outside the configured root and its essential runtime exceptions are denied, and the built-in and configured sensitive paths are denied for reads and writes. It does not claim read confinement outside the root. The profile follows descendants even when they cross into protected system binaries. The network fence stops normal and prompt-injected attempts to reach unapproved domains and logs every try. On Linux with syscall fencing enabled, proxy-mode runs allow only Unix-domain sockets in the child; the interposer rewrites only the configured loopback proxy connect to the proxy's Unix socket, and all other AF_INET/AF_INET6 connects fail closed. With --net-fence=netns the child keeps IP sockets and the boundary is composed: the nftables default-drop floor and the network namespace prevent alternate transports and proxy bypass (direct DNS to other resolvers, non-DNS UDP, QUIC, SCTP and raw IP are dropped, and nothing but the transparent proxy and the in-namespace DNS stub is reachable), while the transparent HTTP(S) proxy and the DNS stub enforce the network.allow domain policy.
NockLock does not restrict how your agent works. It restricts what your agent can reach. Inside the fence, your agent still has full permissions.
Contributing
See CONTRIBUTING.md.
License
Built by Nock Technologies™.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
nocklock
command
Package main is the entry point for the NockLock CLI.
|
Package main is the entry point for the NockLock CLI. |
|
internal
|
|
|
anchorclient
Package anchorclient pushes external chain-head anchors off-box and fetches the latest one back (N10647 Inc.3b, client half).
|
Package anchorclient pushes external chain-head anchors off-box and fetches the latest one back (N10647 Inc.3b, client half). |
|
cli
Package cli defines the cobra command tree for NockLock.
|
Package cli defines the cobra command tree for NockLock. |
|
config
Package config handles TOML configuration parsing and defaults for NockLock.
|
Package config handles TOML configuration parsing and defaults for NockLock. |
|
fence/fs
Package fs implements filesystem fence configuration processing and rule serialization for NockLock.
|
Package fs implements filesystem fence configuration processing and rule serialization for NockLock. |
|
fence/network
Package network implements a local HTTP/HTTPS proxy for NockLock's network fence.
|
Package network implements a local HTTP/HTTPS proxy for NockLock's network fence. |
|
fence/network/netns
Package netns is the home of NockLock's Linux network-namespace egress fence (Candidate B of the linux-network-egress-enforcement spec) and its acceptance tests.
|
Package netns is the home of NockLock's Linux network-namespace egress fence (Candidate B of the linux-network-egress-enforcement spec) and its acceptance tests. |
|
fence/secrets
Package secrets implements environment variable filtering for NockLock.
|
Package secrets implements environment variable filtering for NockLock. |
|
fence/syscallfence
Package syscallfence fences the SYSTEM-CALL surface of a fenced child.
|
Package syscallfence fences the SYSTEM-CALL surface of a fenced child. |
|
forward
Package forward sends committed fence decisions to Command's ops log.
|
Package forward sends committed fence decisions to Command's ops log. |
|
logging
External chain-head anchor (N10647, audit-log v1 follow-on).
|
External chain-head anchor (N10647, audit-log v1 follow-on). |
|
version
Package version provides build version information for NockLock.
|
Package version provides build version information for NockLock. |
|
pkg
|
|
|
receipt
Package receipt verifies one session's NockLock audit chain offline, read only, with nothing but the Ed25519 public key.
|
Package receipt verifies one session's NockLock audit chain offline, read only, with nothing but the Ed25519 public key. |