Skip to content

Agentwall

Agentwall

A runtime firewall for AI agents.

It observes what agents actually do on the host, attributes each action to the process that took it,
and keeps a record that cannot be quietly rewritten.

CI License: Apache-2.0 TypeScript Node 22.12+ Platform: Linux


An agent with credentials, a shell, and network access is asked to behave. Prompts are not a security boundary: an agent that has been argued into exfiltrating a key still holds the key. Agentwall works one layer down, where an action becomes real, and treats the agent as the untrusted party rather than a collaborator.

It is for people running agents on machines they care about: an individual whose workstation holds a dozen API keys, a small team sharing a build box, anyone operating a fleet of autonomous jobs. It runs locally, needs no account, has no paid tier, and the operator console is part of the tool.

The Agentwall operator console: live decisions, per-agent evidence, and the audit chain

Read this before the feature list

Two properties matter more than anything below, and both are limits.

It ships observing, not blocking. Egress through the proxy is recorded and allowed. The shipped entrypoint hard-codes the allow decision (src/index.ts:29), so monitor mode is not a default you might drift off, it is the only behaviour the proxy has today. Blocking is a posture you move to deliberately, once your own ledger shows what your agents legitimately reach. A firewall that starts by breaking your tooling gets switched off, and a switched-off firewall protects nothing.

Capture is cooperative, not enforced. The proxy is found through standard proxy environment variables. A process that ignores them egresses without being seen. This is measurable, not theoretical: on Node 20+, fetch bypasses https_proxy unless NODE_USE_ENV_PROXY=1 is set, and a bypassing request produces zero ledger rows. Nothing in this repository installs iptables or nftables redirection. Agentwall raises the cost of unobserved egress; it does not make it impossible.

The rest of the limits are in Limits. They are not footnotes.

Quick start

Linux, Node.js 22.12 or newer. Verified on Node 24.14.1.

npm install -g @reesebuilt/agentwall

agentwall init --mode monitor
agentwall doctor

The npm package named agentwall, without a scope, is a different and unrelated project. This one is @reesebuilt/agentwall; the command it installs is agentwall.

From a checkout instead:

git clone https://github.com/reesebuilt/agentwall.git
cd agentwall
npm install
npm run build

node dist/cli.js init --mode monitor
node dist/cli.js doctor

init writes agentwall.config.yaml and policy.yaml into the current directory. Both are gitignored, so a fresh clone has neither and init will not overwrite work you already have. doctor checks Node, the build output, and those two files.

Start it. Every value here is required for the thing it enables, so none of them are optional noise:

export AGENTWALL_OPERATOR_TOKEN="$(openssl rand -hex 32)"   # without this, every route 401s
export AGENTWALL_AUDIT_FILE="$PWD/audit.jsonl"              # without this, the chain is stdout-only
export AGENTWALL_PROXY_PORT=8899                            # without this, the proxy does not start

node dist/cli.js start

Run commands through it, from a second shell in the same directory:

https_proxy=http://127.0.0.1:8899 curl -s -o /dev/null https://example.com/
https_proxy=http://127.0.0.1:8899 python3 -c "import urllib.request; urllib.request.urlopen('https://example.com/')"
NODE_USE_ENV_PROXY=1 https_proxy=http://127.0.0.1:8899 node -e "fetch('https://example.com/')"

tail -1 audit.jsonl

Each request appends a chained record naming the process that made it:

{"agentId":"curl","plane":"network","action":"egress:https","decision":"allow",
 "reasons":["monitor-first: observed, not gated"],
 "metadata":{"host":"example.com","port":"443","pid":"1101858","comm":"curl",
             "durationMs":"378","bytesUp":"797","bytesDown":"5344"},
 "integrity":{"chainIndex":1,"hash":"0e86f943...","previousHash":"4678da51...",
              "algorithm":"sha256","status":"chained-local","canon":"cu1"}}

Ask for a policy decision. The token is mandatory; without it this returns 401:

curl -s http://127.0.0.1:3000/evaluate \
  -H "authorization: Bearer $AGENTWALL_OPERATOR_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"agentId":"demo","plane":"network","action":"http_get",
       "payload":{"url":"http://169.254.169.254/latest/meta-data/"},
       "flow":{"direction":"egress"}}'
{"decision":"deny","riskLevel":"critical",
 "matchedRules":["net:block-ssrf-private","net:block-metadata-endpoint"],
 "reasons":["Request targets a private or local network address",
            "Request targets a cloud metadata endpoint"],
 "detections":[{"id":"det.net.ssrf.private","mitreAttack":{"techniqueId":"T1190"}},
               {"id":"det.net.metadata.access","mitreAttack":{"techniqueId":"T1552.005"}}]}

The operator console is at http://127.0.0.1:3000/dashboard. A browser cannot send a bearer header, so for local use start with AGENTWALL_ALLOW_LOOPBACK_DEV=1, which accepts loopback callers as a loopback-dev principal. Do not set it on a host reachable by anyone else.

Checking the record yourself

An audit file is worth only as much as your ability to check it without trusting us. Two verifiers ship in this repository, and the second one carries the argument.

The bundled TypeScript verifier recomputes each record's hash by calling chainAuditEvent, the same function in src/audit/chain.ts that wrote that hash (src/audit/anchor-service.ts:309-315). That makes it a useful tamper check and no evidence at all about the format: a mistake in that function is made identically when writing and when checking, and the comparison still passes. A verifier written by the same people in the same language as the writer proves the code agrees with itself.

verifier/ is a second program, in Go. It shares no code with the writer, parses JSON with a different parser, verifies Ed25519 with a different cryptography stack, and implements docs/audit-format.md rather than importing anything from src/. That document is the only thing the two programs have in common, so when both accept a file, the agreement is evidence about the FORMAT. Where they disagree one of them is wrong, and those cases are listed in Where the two verifiers disagree today rather than buried.

Every command below runs against evidence committed to this repository, so it reproduces from a bare checkout. Point --audit at your own file to check your own records; both CLI commands also accept AGENTWALL_AUDIT_FILE in place of --audit.

The bundled verifier

npm ci && npm run build
node dist/cli.js verify --audit verifier/testdata/corpus/g4-anchored-pending/audit.jsonl
PASS  chained   24 records across 4 segment(s)
            records link within each segment, so an edit inside one is detectable
PASS  linked    3 segment(s) linked, head 8759f6167246d827
            segments link and match their files, so removing or replacing one is detectable
PASS  anchored  0 confirmed, 1 pending a Bitcoin block
            a fingerprint exists off-box and still matches what is here, so a local rewrite shows

1 anchor(s) pending a Bitcoin block. Pending is not proof;
re-run verify once a block confirms.

verify reports three layers separately, because they fail independently and one combined verdict would hide which guarantee you actually have. Exit status is 0 only when all three pass, and --json gives the machine-readable form. Run the same command against corpus case b1-decision-flipped, where one decision is flipped from deny to allow and the record's own hash is left untouched: the chained layer then fails with line 2: hash mismatch, record altered after write, names the file by absolute path, and the command exits 1.

anchored fails until an anchor exists. agentwall anchor seals the live segment, signs an Ed25519 checkpoint over the head, and submits its digest to OpenTimestamps, which needs network access and no account:

cp -r verifier/testdata/corpus/g3-rotated-segments /tmp/aw-anchor-demo
node dist/cli.js anchor --audit /tmp/aw-anchor-demo/audit.jsonl
Anchored
  checkpoint index  3
  checkpoint hash   d8e5eac822f4d723eadc8c9a89c96597e282c22006c1d1d70437bd6a411556c3
  covers            24 records (3 sealed segment(s) + 6 live)
  calendar          https://alice.btc.calendar.opentimestamps.org/digest
  proof             /tmp/aw-anchor-demo/proofs/0475b7690ead3a875eadc85592210df784d775d405f042c9bee10d1dfab6a8bb.ots
  status            pending

OpenTimestamps anchors into a Bitcoin block, so this stays pending for roughly
one to six hours. It is not proof until a block confirms it.

The copy exists because anchoring writes a signing key, an anchor log, and a proof file beside the audit file, and the corpus in git stays exactly as it was generated. The proof filename in your run differs from the one above: the signing key is created on first use, so your checkpoint is signed by a key that exists only on your machine.

The independent verifier, from a bare checkout

Go 1.22 or newer, and nothing else. The Go module lives in verifier/, so these commands run from that directory rather than from the repository root:

cd verifier
go build -o agentwall-verify .
./agentwall-verify --audit testdata/corpus/g4-anchored-pending/audit.jsonl
chained  PASS  24 records across 4 segment(s)
linked   PASS  3 segment(s) linked, head 8759f6167246d827...
anchored PASS  0 confirmed, 1 pending a Bitcoin block; pending at https://alice.btc.calendar.opentimestamps.org/timestamp
signatures are self-consistent; supply --pubkey to bind them to a key you expect
overall  PASS

go run . --audit <path> runs it without producing a binary, and prints one line of its own, exit status 1, when the verifier exits nonzero.

Zero dependencies is a property you check rather than a claim you accept:

go list -m all
github.com/reesebuilt/agentwall/verifier

One line, and it is this module. SHA-256, Ed25519, SPKI parsing, and JSON all come from the Go standard library, which is why the verifier is written in Go: a program whose whole job is being trustworthy should not ask you to trust a supply chain first.

Pin the key, or a checkpoint signature is decoration

This is the honest core of the anchored layer. Corpus case b8-checkpoint-foreign-key is the good case with its checkpoint re-signed by a different key, and it is internally consistent: the signature verifies against the public key the checkpoint carries. Unpinned, it passes.

./agentwall-verify --audit testdata/corpus/b8-checkpoint-foreign-key/audit.jsonl; echo "exit $?"
chained  PASS  24 records across 4 segment(s)
linked   PASS  3 segment(s) linked, head 8759f6167246d827...
anchored PASS  0 confirmed, 1 pending a Bitcoin block; pending at https://alice.btc.calendar.opentimestamps.org/timestamp
signatures are self-consistent; supply --pubkey to bind them to a key you expect
overall  PASS
exit 0

The line above the verdict is the verifier naming what it did not check. A self-signed checkpoint proves nothing until you pin the key, because anyone who can rewrite the log can sign the rewrite with a key they generated. Pin the key you expect and the same bytes fail:

./agentwall-verify --audit testdata/corpus/b8-checkpoint-foreign-key/audit.jsonl \
                   --pubkey-file testdata/corpus/b8-checkpoint-foreign-key/pubkey.txt; echo "exit $?"
chained  PASS  24 records across 4 segment(s)
linked   PASS  3 segment(s) linked, head 8759f6167246d827...
anchored FAIL  0 confirmed, 1 pending a Bitcoin block; pending at https://alice.btc.calendar.opentimestamps.org/timestamp
    - checkpoint-key-mismatch: anchor 1: checkpoint public key does not match the pinned key
overall  FAIL
exit 1

The pin discriminates rather than refusing everything. The same pin against the honest case passes, because every checkpoint in the corpus except b8's is signed by the key pubkey.txt names:

./agentwall-verify --audit testdata/corpus/g4-anchored-pending/audit.jsonl \
                   --pubkey-file testdata/corpus/b8-checkpoint-foreign-key/pubkey.txt; echo "exit $?"
chained  PASS  24 records across 4 segment(s)
linked   PASS  3 segment(s) linked, head 8759f6167246d827...
anchored PASS  0 confirmed, 1 pending a Bitcoin block; pending at https://alice.btc.calendar.opentimestamps.org/timestamp
overall  PASS
exit 0

--pubkey <base64-spki> takes the key inline, --pubkey-file <path> reads base64 or PEM. The pin is worth what its source is worth: a key you recorded elsewhere when it was created is worth more than one read out of the directory that holds the evidence. The bundled verifier has no pin flag. It compares each checkpoint against the public half of the signing key file beside the audit log (src/audit/anchor-service.ts:421-433), which is the writer's own key rather than one you chose.

The conformance corpus

verifier/testdata/corpus/ holds one directory per case, each with an expected.json naming the exit code and the three layer verdicts the format requires. Good cases are written by the production writers in src/audit, so the corpus cannot drift into a private idea of the format. Forgeries are byte edits applied on top of a case that passed, and they are internally consistent on purpose, so catching one takes a property the forger cannot recompute:

  • b2-record-removed-tail-relinked removes a record, then relinks and rehashes the entire tail. Every previousHash in the file is correct. The chain index sequence is what exposes it.
  • b15-sealed-segment-rewritten rewrites a sealed segment end to end and relinks it internally. The manifest entry bound to that segment's bytes is what exposes it.
  • b16-live-tail-rewritten-after-checkpoint rewrites the last live record after the checkpoint was signed. The live tail that checkpoint committed to is what exposes it, and re-committing to the rewrite needs the signing key.
  • b8-checkpoint-foreign-key re-signs the checkpoint consistently under another key. Only a pin supplied from outside the evidence exposes it.
  • b17-sealed-segment-missing deletes a sealed segment file and leaves the manifest naming it. Both verifiers report segment-missing on the linked layer, which is the layer that made the claim.
  • b4-index-reuse-concurrent-writers appends a second writer's records under indexes already used. Both verifiers fail the chained layer with an index gap and a broken link, and the message names a gap, restart, or reused index, because that shape is what two processes appending to one chain look like rather than one altered record. The single-writer lock exists to prevent it.

Two limit cases pin what the format does not bind, so neither limit can be quietly forgotten. l1-confirmed-with-pending-proof passes: an anchor claiming confirmed whose proof carries only a pending attestation is accepted, because nothing compares the status claim against the attestations in the proof. l2-legacy-canon-unmarked fails with hash-mismatch-or-legacy-canon, because records hashed under the pre-marker key order cannot be recomputed by a verifier that does not carry ICU collation tables, and reporting them as unverifiable is a different statement from reporting them as tampered.

Run both verifiers over every case:

npm run build
cd verifier && go build -o agentwall-verify . && cd ..
node scripts/conformance.js

The run prints one line per case. Its tail:

DIVERGENCE b9-anchor-digest-altered
             format expects exit=1 chained=true linked=true anchored=false
             typescript returns exit=0 chained=true linked=true anchored=true
             it reports the digest the record claims was submitted and never recomputes one from the checkpoint the record embeds
DIVERGENCE b10-proof-truncated
             format expects exit=1 chained=true linked=true anchored=false
             typescript returns exit=0 chained=true linked=true anchored=true
             it never opens a proof file, so a proof that cannot be parsed still counts as an anchor
DIVERGENCE b11-torn-tail
             format expects exit=1 chained=true linked=true anchored=false
             typescript returns exit=1 chained=false linked=true anchored=false
             it reports a partial final line as a broken chain rather than as the torn tail a hard kill leaves behind
ok         b12-duplicate-key-shadowed  exit=1 chained=false linked=true anchored=false
DIVERGENCE b13-confirmed-without-proof
             format expects exit=1 chained=true linked=true anchored=false
             typescript returns exit=0 chained=true linked=true anchored=true
             it counts the status field, so an anchor claiming confirmation passes with no proof bytes behind it
ok         b14-submission-never-reached-calendar  exit=1 chained=true linked=true anchored=false
ok         b15-sealed-segment-rewritten  exit=1 chained=true linked=false anchored=true
ok         b16-live-tail-rewritten-after-checkpoint  exit=1 chained=true linked=true anchored=false
ok         b17-sealed-segment-missing  exit=1 chained=true linked=false anchored=true
ok         l1-confirmed-with-pending-proof  exit=0 chained=true linked=true anchored=true
ok         l2-legacy-canon-unmarked  exit=1 chained=false linked=true anchored=false

26 cases, typescript and go: 22 agreed, 4 declared divergence(s), 0 failure(s)

Each case is copied to a temp directory before it runs, so a verifier cannot alter what it checks. Regeneration is deterministic, which is what makes an unexpected diff mean something:

npm run gen:corpus && git status --porcelain verifier/testdata

That prints nothing, because the regenerated tree is byte identical to the committed one.

Where the two verifiers disagree today

Four corpus cases get different verdicts from the two verifiers. In three of them the bundled TypeScript verifier accepts evidence the format rejects, which makes the Go verifier the stricter of the two today. In the fourth both reject the file, and the bundled verifier blames the chain instead of naming the torn tail:

Case The edit Bundled TypeScript verifier Go verifier
b9-anchor-digest-altered the anchor record's digest field altered anchored PASS, exit 0. It reports the digest the record claims was submitted and never recomputes one from the checkpoint the record embeds digest-mismatch, anchored FAIL, exit 1
b10-proof-truncated the OTS proof truncated inside a length prefix anchored PASS, exit 0. It never opens a proof file, so a proof that cannot be parsed still counts as an anchor proof-parse-error, anchored FAIL, exit 1
b11-torn-tail a partial final line, as a hard kill leaves behind chained FAIL, exit 1. It condemns the whole chain over one partial write torn-tail reported distinctly, chained PASS, exit 1 because nothing is anchored
b13-confirmed-without-proof an anchor claiming confirmed with its proof file deleted anchored PASS, exit 0. It counts the status field, so a claim of confirmation passes with no proof bytes behind it proof-missing, anchored FAIL, exit 1

The three acceptance gaps are limits of the bundled verifier as it ships today. The harness prints every entry in this list on each run and fails if one of them starts agreeing (scripts/conformance.js:40-66), so the list cannot rot into a set of excuses, and it is why the summary line above reports four declared divergences instead of agreement on every case.

What verification does not prove

  • Completeness. An anchor shows that what was written was not altered afterwards. It cannot show that everything which should have been written was. A decision that was never recorded leaves nothing to detect, and no verifier finds it.
  • A correct specification. Two readers of the same wrong document agree with each other and are both wrong, so independence catches implementation bugs and shared runtime assumptions, not a format mistake. The document is docs/audit-format.md, which is why it is written at the byte level and kept short enough to read in one sitting.
  • Inclusion in a Bitcoin block, while an anchor is pending. Pending means a calendar accepted a submission. Both verifiers report pending as pending, and the Go verifier reports a Bitcoin attestation as a block height plus the derived value for you to compare against that block's merkle root, which it does not fetch.

To run the tests behind the claims in this file:

npx jest tests/audit-chain.test.ts tests/audit-signing.test.ts tests/audit-anchor.test.ts \
         tests/operator-auth.test.ts tests/route-auth.test.ts tests/ssrf.test.ts tests/policy.test.ts
npm run lint     # tsc --noEmit
npm test         # full suite
cd verifier && go test ./...

What it does

Sees egress, and knows which process caused it

A CONNECT-aware forward proxy (src/proxy/forward-proxy.ts) captures egress from any client honouring proxy environment variables. Verified with curl, python3, bun, and node (the last needs NODE_USE_ENV_PROXY=1).

Identity is observed, not self-reported. Agentwall maps the client socket back to its owning process through /proc/net/tcp inode matching and /proc/<pid>/fd, so a record carries the real pid and comm even when the agent framework cooperates in no way at all and even if it lies about who it is.

The cost scales with how many processes and descriptors the host has, so treat these as shape rather than a constant. Resolving a socket for a process not seen recently walks all of /proc; a recently-seen process is checked directly from a 16-entry cache. Measured on a 430-process host: 19.7 ms cold, 0.48 ms warm (medians). On a busier machine the same walk measured roughly 44 ms cold and 1.6 ms warm, recorded at src/proxy/forward-proxy.ts:60-71. For HTTPS the walk happens after the tunnel is established, off the connection's critical path. Attribution failure degrades to pid: null and never blocks egress.

One quirk worth knowing: a process name comes from /proc/<pid>/comm, which is the thread name, not the binary. Node reports MainThread, so Node egress is attributed to MainThread rather than node.

Decides, with precedence you can predict

The policy engine (src/policy/engine.ts) scores an action across six planes (network, tool, content, browser, identity, governance) and returns one of allow, redact, approve, deny. Every matching rule contributes; the most restrictive wins, ordered deny > approve > redact > allow (src/policy/engine.ts:12-16). Results carry matched rule IDs, plain reasons, a risk level, and detections mapped to MITRE ATT&CK technique IDs, so an operator and an audit record agree on why something happened.

The engine's built-in default is deny (src/config.ts:150), as is egress default-deny (src/config.ts:158). init --mode guarded and --mode strict both write that. init --mode monitor deliberately writes allow instead, because the point of monitor mode is to learn your real traffic without breaking it. Check which you have before assuming you are protected:

grep -A1 defaultDecision agentwall.config.yaml

Policy is a built-in rule pack plus hot-reloadable YAML. A file that fails to parse is rejected whole and the previous ruleset stays in force (src/policy/runtime.ts:66-77), so a typo cannot leave you running with half a policy or none.

Also present: DLP detectors with inline redaction (AWS keys, GitHub PATs and OAuth tokens, OpenAI keys, Slack tokens, private keys, JWTs, SSNs, credit cards, emails, phone numbers); SSRF and egress inspection with scheme, port, and host allowlists that block private, loopback, and link-local ranges plus cloud metadata endpoints; shell command preflight; a persistent approval queue with auto/always/never modes; per-session and per-actor rate limits, pending-approval caps, and cost budgets; manifest drift detection against approved fingerprints; and session pause, resume, and terminate enforced on /evaluate.

Keeps a record that resists rewriting

Audit events are SHA-256 hash-chained, each record naming its predecessor (src/audit/chain.ts). Edit one and every later link breaks. The integrity status a record carries is chained-local, deliberately not verified, because linking at write time is not evidence that anything checked it.

Three properties make the file survive real operation rather than only a demo:

  • Single-writer lock. An O_EXCL lock file holds the writer's pid. A second writer starts only if the first is provably unable to append, and "I could not tell" is not proof: an unverifiable live owner refuses the takeover with an explanation (src/audit/file-sink.ts:121-174). Two processes appending would interleave two chains into one file and destroy the property the log exists for.
  • Torn-tail recovery. A crash mid-append leaves a partial record. On restart Agentwall resumes from the last intact one and reports what it dropped (resumed from the last intact record; discarded 1 torn record(s) at the tail).
  • Restart-safe resume. A restart continues the existing chain rather than starting a new one, logging audit chain resumed from prior run. A genuine discontinuity is reported as one instead of being silently absorbed.

agentwall anchor seals the segment, signs an Ed25519 checkpoint over the head, and submits the digest to OpenTimestamps, which batches it into a Merkle tree whose root lands in a Bitcoin transaction. No account and no API key. The calendar's response is the proof and is persisted, since discarding it would reduce the anchor to a claim that an HTTP request once happened.

Why bother, given the chain and the signature already exist: neither survives an adversary who owns the host. Anyone who can write the file can recompute the chain, and anyone who can read the signing key can re-sign it. Every purely local control has that ceiling. An anchor breaks it by putting a fingerprint somewhere this machine cannot reach back into, so rewriting history requires altering a record held by someone the operator does not control. The reasoning is kept next to the code in src/audit/signing.ts:12-37 and src/audit/anchor.ts:6-46.

Refuses unauthenticated callers

Operator auth (src/auth/operator.ts) is a bearer token compared in constant time, registered as a global onRequest hook on an allowlist model: everything is protected unless explicitly public, so a route added next year is guarded by default rather than open until someone remembers. Only /health and /api/health are public.

It fails closed. With no token configured and loopback-dev off, every other route returns 401 and the server logs why, loudly, because a service that silently refuses everything is worse than one that explains itself. A wrong token is an explicit failure that does not fall through to the loopback path.

Limits

Stated plainly, because a security tool that oversells itself is worse than no tool.

Limit What it means
Monitor-first, no blocking The proxy records and allows. decide is hard-coded to allow at src/index.ts:29. Enforcement is a posture you build toward, not something you get by installing this.
Cooperative capture Proxy environment variables are honoured voluntarily. A process that ignores them egresses unobserved. No iptables or nftables redirection is installed.
Anchoring is pending, not instant An OpenTimestamps anchor stays pending until a Bitcoin block confirms, roughly one to six hours. verify reports pending as pending. Pending is not proof.
Anchoring proves no alteration, not completeness An anchor shows that what was written was not altered afterwards. It cannot show that everything which should have been written was. Silent omission at write time is a different, unsolved problem.
A signature is necessary, not sufficient It proves a key holder vouched. On a host where the audited principal can read the key, an agent with root can sign anything the operator can. Off-box anchoring is what closes that gap.
The bundled verifier is the less strict of the two Four conformance cases (b9, b10, b11, b13) get different verdicts from the two verifiers. In b9, b10, and b13 the bundled TypeScript verifier accepts evidence the format rejects; in b11 both reject the file and the bundled one blames the chain rather than naming the torn tail. They are listed in Where the two verifiers disagree today, and the conformance harness fails if any of them starts agreeing.
No TLS interception CONNECT traffic is visible at hostname and port level only. Request paths, headers, and bodies stay opaque. This is deliberate: MITM would need a CA in every runtime trust store, which breaks the framework-agnostic property the proxy exists for.
Attribution is Linux-only It reads /proc/net/tcp and /proc/<pid>/fd. There is no macOS or Windows equivalent here. The rest of the server is portable; process attribution is not.
Channel containment is Telegram only Slack and Discord appear in the platform schema (src/integrations/communication-channel/control.ts:5) with no route implementation behind them.
The watchdog does not auto-deny It evaluates heartbeat age and exposes a kill-switch flag, and a rule denies on the watchdog_timeout flow label (src/policy/rules.ts:394), but nothing wires staleness to that label automatically. Treat it as a signal you act on, not an automatic containment.
Telemetry is off by default The OTLP/HTTP JSON decision-trace exporter is hand-rolled over Node http/https with no OpenTelemetry SDK dependency, and is disabled unless configured (src/config.ts:136).
Bearer tokens, not identity A shared token, not OIDC or mTLS. There is no identity-provider integration.
Single host Multiple instances can be polled into one summary view. There is no clustered or highly-available control plane.

API

Every route except /health and /api/health requires Authorization: Bearer <token>.

POST /evaluate                                          # policy decision
POST /inspect/content                                   # DLP secret and PII scan, redaction
POST /inspect/network                                   # egress and SSRF inspection
POST /inspect/manifest                                  # manifest drift detection
POST /approval/request    GET /approval/pending         # approval queue
POST /approval/:requestId/respond
POST /integrations/communication-channel/guardrail      # channel containment
POST /integrations/damage-control/command-preflight     # shell command preflight
GET  /detections          GET /rules                    # detection catalog, active rules
GET  /api/dashboard/state GET /api/dashboard/events      # operator console state, SSE stream
GET  /api/org/summary                                   # multi-instance summary
GET  /health              GET /ready                    # liveness; /ready needs the token

Configuration

Config resolution order: --config <path>, $AGENTWALL_CONFIG, ./agentwall.config.yaml, ./agentwall.config.yml, ./examples/config.yaml (src/config.ts:179-187). Paths inside the config are relative to the working directory, so run Agentwall from the directory you ran init in.

Variable Effect
AGENTWALL_OPERATOR_TOKEN Bearer token for every non-public route. Unset means everything returns 401.
AGENTWALL_ALLOW_LOOPBACK_DEV 1 accepts loopback callers without a token. Local development only.
AGENTWALL_AUDIT_FILE Path for the hash-chained audit log. No default, by design: a security product should not invent a location in $HOME. Unset means stdout only.
AGENTWALL_PROXY_PORT Forward proxy port. Unset or 0 means the proxy does not start.
AGENTWALL_PROXY_HOST Proxy bind host. Defaults to 127.0.0.1.
AGENTWALL_PROXY_LEDGER Flat JSONL view of destinations, for allowlist analysis. No default, same reason as the audit file. Unset means no flat ledger; the audit chain is still the record.
AGENTWALL_AGENT_HOME Directory the dashboard probes for an agent behaviour contract. Defaults to ~/.agentwall/agent.
AGENTWALL_TELEGRAM_TEST_BOT_TOKEN Bot token for the Telegram containment routes. Unset disables them.
AGENTWALL_TELEGRAM_TEST_WEBHOOK_SECRET Shared secret Telegram must present on the webhook.
AGENTWALL_TELEGRAM_TEST_AGENT_ID Agent id recorded for messages arriving on that webhook.
AGENTWALL_TELEGRAM_TEST_SEND_ENABLED 1 permits outbound sends. Default is receive-only.
AGENTWALL_CONFIG Explicit config path.

Architecture

Request to decision to audit, for a single agent action:

flowchart TD
    A["Agent action"] -->|"POST /evaluate"| AUTH{"Operator auth<br/>bearer token"}
    AUTH -->|401| Z0["Rejected"]
    AUTH -->|ok| C{"Rate &amp; cost limits"}
    C -->|throttled| Z["Blocked and audited"]
    C -->|ok| D{"Session paused<br/>or terminated?"}
    D -->|contained| Z
    D -->|active| E["Policy engine"]

    subgraph INPUTS["Evaluation inputs"]
      direction LR
      F["DLP scan<br/>secrets &amp; PII"]
      G["Egress / SSRF<br/>inspector"]
      H["Provenance &amp;<br/>flow labels"]
      I["Built-in &amp; YAML<br/>rules"]
    end
    INPUTS --> E

    E --> J{"Decision<br/>deny &gt; approve &gt; redact &gt; allow"}
    J -->|redact| L["Redacted content"]
    J -->|approve| M["Approval queue"]
    J -->|deny| N["Blocked"]
    J -->|allow| K["Permitted"]

    M --> O["Operator console"]
    O -->|"approve / deny"| J

    K --> P["Audit event"]
    L --> P
    N --> P
    P --> Q["SHA-256 hash chain"]
    Q --> R["Ed25519 checkpoint"]
    R --> S["OpenTimestamps anchor<br/>pending until a block"]
Loading

Egress observed by the proxy enters the same hash chain, attributed to the originating process (src/index.ts:27-83).

Built with

TypeScript 5 (strict) on Node.js 22.12+, Fastify 5, Zod, YAML policy via js-yaml, Jest. Runtime dependencies are deliberately three: fastify, js-yaml, zod. Logging is Fastify's own pino instance, which arrives as its dependency rather than ours. The audit and anchoring paths use Node's own crypto and plain HTTP with no third-party clients, because a dependency inside the component whose entire job is being trustworthy is a supply-chain risk this project declines.

Docs

Threat model - Architecture - Install - Tutorials - Changelog

Contributing

Issues and pull requests are welcome, including ones that show a claim in this file is wrong. See CONTRIBUTING.md, GOVERNANCE.md, and CODE_OF_CONDUCT.md. Security reports go through SECURITY.md, not a public issue.

License

Apache-2.0. See LICENSE and NOTICE.

About

Runtime firewall for AI agents. Attributes egress to the process that caused it, applies policy with predictable precedence, and keeps a hash-chained audit record anchored off-box. Monitor-first.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages