Evidren docs
Reference for Evidren's engine and command line. The output blocks are captures of runs
against packages/cli/dist/bin.js at product commit e6b53e2.
To install Evidren, use the downloads. This page is for developers who build Evidren from its source tree. What Evidren does not guarantee: How it works.
Build from source.
Node 22 or newer is required. There is no native build step: the record's index uses the
node:sqlite inside Node.
Without the alias, substitute node <evidren>/packages/cli/dist/bin.js for
evidren in every command on this page.
EVIDREN_HOME must be on a local disk, not NFS or SMB: the append lock relies on
local filesystem atomicity to serialize the hook and the gateway.
# 1. Build. One pnpm workspace. cd signat pnpm install pnpm run build # 2. Make `evidren` runnable from anywhere. alias evidren="node $(pwd)/packages/cli/dist/bin.js" # 3. From your project directory. cd /path/to/your/project evidren init evidren status
Fig. 1: the whole install, before publication. Evidren itself comes from no registry; pnpm install fetches its dependencies from npm.
What evidren init does.
Everything it does is local. Nothing is sent anywhere.
The home. $EVIDREN_HOME (default ~/.evidren, or
~/.signat for an install made under the previous name) gets
redaction.key, 32 random bytes at mode 0600,
telemetry.json with both streams off, and a genesis chain head, so erasing the
record and its seal on an initialized home fails loudly.
The wiring. Every assistant whose binary is on PATH is wired. For
Claude Code, hooks are merged into <project>/.claude/settings.json. If
that file is not valid JSON, init leaves it alone and warns; if it sets
allowManagedHooksOnly, init warns that enterprise policy may block project
hooks.
The manifest. What was wired is stamped into the chain as a coverage manifest, which
status and verify read back. A home never stamped reports
coverage: unknown.
The probe. Init drives the installed hook through a real shell with an
rm -rf / payload and requires a deny. That proves the hook denies, not that
the assistant calls it, and init says so: check evidren log after a real
agent session.
The consent text. The exact JSON each telemetry stream would send is printed. Both
stay off until evidren telemetry on.
Evidren decides only on the tool calls it is registered for. Everything else runs unmonitored and leaves no line in the record. What it sees, call by call.
CLI reference.
--home <dir> overrides the Evidren home for any command.
--json takes no value; every other --flag consumes the next
token.
Exit code 1: verify on a failed chain or pack, evidence without
--out, and an unknown command.
| Command | Does |
|---|---|
evidren init [--project <dir>] |
Create the home, wire every assistant on PATH, stamp the coverage
manifest, fire the probe, print the consent text
|
evidren status [--project <dir>] | Home, key, hooks, chain, coverage, telemetry |
evidren log [--severity <s>] [--type <t>] [--session <s>]
[--action <a>] [--limit <n>] [--json] | Recent lines, from the SQLite index. Default limit 50 |
evidren policy | The effective policy here, plus any rejected repo overrides |
evidren verify [--pack <dir>] | Check the chain and the tail seal, or an exported pack, and print what that does not prove |
evidren shadow [--profile <f>] [--onunmatched <a>] [--json] | Replay the record against a candidate policy. Reports only |
evidren card | A shareable incident card: the rule and the time, no command, path or argument |
evidren evidence --out <dir> [--json] | Export an Evidence Pack |
evidren gateway config <path> [--allowlist <a,b>] | Route an MCP client config's servers through the gateway |
evidren gateway run | Run the gateway. Reads EVIDREN_GATEWAY_SPEC |
evidren telemetry status | on <stream> | off [stream] | rotate-id | Inspect or toggle the two streams |
evidren help | The above |
Fig. 2: the command column is what evidren help prints at commit
e6b53e2.
$ evidren status
home: /tmp/evidren-cap-K6Koal/.evidren
redaction key: present
hooks: not installed
audit: 6 lines, chain OK
telemetry: usage: off, threat-intel: off, pending events: 0
coverage (stamped 2026-09-21T09:23:40.458Z):
claude-code
before execution: Bash|Write|Edit
results checked: Bash|Write|Edit|Read
MCP tool calls: NOT routed — run `evidren gateway config`
Any tool not listed above executes without a verdict and leaves no line. MCP tool calls are covered only after `evidren gateway config` repoints the client. A missing line is not detectable from the chain.
$ evidren policy
onUnmatched: allow
no-remote-pipe-shell deny when: type=shell matches=curl.*\|\s*(bash|sh)
force-push-needs-approval require_approval when: type=shell matches=git push.*(--force(?!-with-lease|-if-includes)|-f)\b
block-nonexistent-packages deny when: type=package_install detector=deps finding=nonexistent
redact-secrets-outbound redact when: type=mcp_tool_call detector=secrets
$ evidren log
#1 2026-09-20T09:21:00.640Z allow shell claude-code sev:- session:site
#2 2026-09-20T09:21:00.727Z deny shell claude-code sev:critical session:site
#3 2026-09-20T09:21:00.799Z require_approval shell claude-code sev:- session:site
#4 2026-09-20T09:21:00.866Z redact file_write claude-code sev:high session:site
#5 2026-09-20T09:21:00.932Z allow shell claude-code sev:- session:site
Fig. 3: real output from a throwaway setup, not a real install. The log lines
were recorded for this page on 20 September 2026.
The record.
One JSONL line per decision, appended and never rewritten, at
$EVIDREN_HOME/audit/audit.jsonl. The index.db beside it is a
rebuildable node:sqlite index for evidren log; the JSONL is the
source of truth.
Each line carries the sha256 of the line before it, and
audit/chain-head.json seals the tail. A recognised secret is stored as a keyed
reference, an unrecognised one as written. Formats:
Record format.
Retention: none. The file grows. Evidren does not rotate, trim or forward it.
One chain, two processes
The hook and the gateway append to one chain through an exclusive lockfile with an ownership token; a crashed holder's lock is stolen only behind a double stat guard. If the lock cannot be taken, the append throws rather than forking the chain, and the verdict stands: a deny survives an unwritable audit home.
What evidren verify prints
Success prints its own boundary every time: unaltered and untruncated, yes; complete, unknowable from the file alone. What a verified chain proves.
Failure names the first link that no longer matches, the line after the edit:
audit chain FAILED: prev-hash-mismatch at line 3. On a home the reason is one
of ten: prev-hash-mismatch, unparseable-line,
chain-head-missing, chain-head-missing-on-initialized-home,
chain-head-unparseable, chain-head-mismatch,
chain-head-hmac-invalid, genesis-chain-head-hmac-invalid,
redaction-key-missing, chain-head-present-but-log-empty.
The Evidence Pack.
evidren evidence --out <dir> writes seven files and prints a sha256 for
each of the five content files, plus one for the manifest. The manifest sha256 is the
single value to record out of band, somewhere the exporting machine cannot reach; it
fixes the pack contents at export time.
evidren verify --pack <dir> performs the same chain check from the
pack's own bytes. It does not check the tail seal, and it says so. What an outsider can
check without Evidren: the verifier.
| File | Contents |
|---|---|
audit.jsonl | The recorded chain, byte for byte, with the payloads masked at decision time |
chain-head.json | The tail seal: {seq, headHash, hmac} |
policy.json | The policy as resolved at export time |
summary.json | Counts only, by verdict, action type and severity; redaction references; first and last timestamp; chain status at export |
shadow-report.json | The counterfactual under the proposed stricter profile, labelled in the file as not in force |
manifest.json |
Format, Evidren version, line count, chain status, a sha256 per content file, and
externallyVerifiable: which checks an outside party can and cannot run
|
VERIFY.md | A runnable recipe for rechecking the chain without Evidren, and the pack's own statement of what it does not prove |
Fig. 4: a broken chain is exported too. summary.json and
manifest.json then carry the failure reason, and the CLI says so.
evidren shadow measures the quiet posture.
The shipped default is onUnmatched: allow. shadow replays the
record against a candidate policy and reports what would have been decided differently.
It enforces nothing and changes no config.
The bundled policies/shadow-strict.yaml adds nine rules proposed on July 31,
2026 to the 4 shipped ones. Two have since shipped as detector rules,
staged-remote-exec and evidren-self-tamper; the other seven are
drafted, not in force and not signed off.
--profile <file> replays your own YAML instead.
On the July 31, 2026 corpus the seven would have stopped or queried 16 of the then
remaining 25 silent allows; the corpus has since grown to 81 cases
with 24 silent allows, and that replay has not been rerun.
Across 40 ordinary developer commands the profile interrupts 4, all
require_approval; the shipped default interrupts none.
WHAT THIS DOES NOT SHOW (do not read absence as safety): - actions never routed through Evidren are not in the log - only policy rules replay; a new DETECTOR’s findings were never recorded - payloads are the redacted ones the audit stored (D19)
Fig. 5: every run ends by printing its own blind spots.
The MCP gateway.
A second, separate step. The Claude Code hook does not cover MCP tool calls; to cover them, you route them.
evidren gateway config path/to/mcp-config.json
Every entry under mcpServers is rewritten to launch through the gateway; the
original command and args travel in EVIDREN_GATEWAY_SPEC. The file is first
copied to <path>.bak. If that exists, even as a planted dangling
symlink, the next free .bak.N is used, and the copy refuses to write through
it.
Once routed, the gateway evaluates the server's tool list on connect (poisoning, hidden
unicode, definition drift between sessions), each outbound tool call (secrets in
arguments, destination allowlist) and each inbound result (indirect prompt injection,
secrets). A deny fails the tool list. A require_approval or
redact finding there is recorded and the list still passes, so a drift alarm
is a line in the record, not a block.
The upstream process gets the SDK's safe defaults, the server's own declared
env and whatever you name in --allowlist, never your full
process.env. Six loader variables are removed even if env or
--allowlist names them: NODE_OPTIONS, LD_PRELOAD,
LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES,
DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH.
Redacted results are rebuilt from scratch, masked text and isError only, so
structuredContent and any extra fields cannot pass through unmasked.
Three layers merge, weakest first.
- Built in defaults.
policies/default.yaml, compiled in. -
$EVIDREN_HOME/config.yaml, trusted. May do anything: setdefaults.onUnmatched, redefine rules, disable rules. -
<repo-root>/.evidren.yaml(or.signat.yamlfrom before the rename), untrusted. Tighten only. Found by walking up from the working directory.
What the repo layer may not do
The repo layer is the one a hostile repository controls. It may add a rule with a new id
whose action is not allow, and it may neutralize a
require_approval rule. It may not set
defaults.onUnmatched, redefine an existing rule, add an allow
rule, or allowRules or disableRules a deny or
redact rule.
An unparseable config file is treated as absent rather than crashing the pipeline.
TRUST VIOLATION: repo config attempted to redefine existing rule "no-remote-pipe-shell"; ignored
Fig. 6: every rejected attempt is dropped, recorded in the chain and printed.
A committed .evidren.yaml cannot weaken a deny. An agent that can write
$EVIDREN_HOME/config.yaml, .claude/settings.json or the audit
directory can disarm Evidren: the limits.
The policy file.
action is one of four verdicts. Every when key present must
match; an empty when matches everything. A rule missing a valid
id or action is dropped, not fatal.
version: 1
defaults:
onUnmatched: allow
rules:
- id: no-remote-pipe-shell
when: { type: shell, matches: 'curl.*\|\s*(bash|sh)' }
action: deny
severity: critical
overrides:
allowRules: [] # downgrade a rule to allow
disableRules: [] # remove a rule entirely
Fig. 7: the shipped default is 4 rules and
onUnmatched: allow. A test asserts it stays that way, so a proposed rule
cannot drift into enforcement unnoticed.
when key | Matched against |
|---|---|
type | the normalized action type |
matches |
for type: shell, the command string; otherwise
JSON.stringify(payload). Unanchored, case sensitive
RegExp. A pattern that does not compile never matches
|
detector | a finding from that detector is present |
finding | a finding with that code is present |
Fig. 8: the four match keys, and nothing else.
Evaluation order, strictest first
- an explicit
denyrule - a
criticalfinding suggestingdeny - an explicit
require_approvalrule - a finding suggesting
require_approval defaults.onUnmatched, only when nothing matched
Then the redaction modifier. If the gate is allow and any finding produced
concrete redaction targets, the verdict becomes redact. A
redact gate with no targets escalates to require_approval and
attaches a redaction-incomplete finding, because a redaction the record
cannot execute would store the payload in clear.
redact-secrets-outbound is scoped type: mcp_tool_call, which
only the MCP gateway emits. On actions the hook covers, masking comes from the redaction
modifier, not from that rule.
Telemetry. Two streams, both off.
No endpoint is configured, so even an enabled stream sends nothing.
evidren telemetry off disables both streams at once.
usage
A random install UUID, never tied to machine or identity and rotatable with
evidren telemetry rotate-id; Evidren version; OS and arch; Node version;
surface; decision counts. Nothing else. Batched at most once a day, state in
telemetry-usage.json.
threat-intel
Only on a deny in a known bad class: deps findings coded
nonexistent or security-hold, and critical command-risk findings coded remote-pipe-*.
known-malicious is reserved in the filter, but no detector emits it today.
The token is the package name, or for a command a sha256 of the normalized signature,
never the command line. Queued in telemetry-queue.jsonl.
threat-intel event the whole payload {"t":"threat-intel","installId":"ae7b9b4e-72ed-49e0-963e-f62b5b2cca2a","code":"nonexistent","severity":"high","ecosystem":"npm","token":"left-pad-utils"}
Fig. 9: the example printed by the consent text at evidren init, byte for
byte.
Never sent: allow, ask and redact events; file contents; paths; repo names; environment variables; secrets; tool arguments; raw command lines.
Three filters run before anything is queued. The event is dropped if the record's masking
pass would change a single byte of it, compared as is and again case folded. Its shape is
validated field by field. A package name token of 16 characters or more with a Shannon
entropy above 3.5 bits per character is dropped, so a mistyped
npm i AKIA… cannot be shared as a package name.
Build, test and gates.
The gate scripts drive the built artifacts in dist/, not source, so they
catch what a source level suite cannot see: entry guards, code splitting, hook quoting.
gate-m4.mjs runs 19 checks, among them that a deny survives an unwritable
audit home and that evidren verify fails on a truncated tail.
pnpm install pnpm run build # before typecheck: @evidren/cli typechecks against the others' built .d.ts pnpm run typecheck pnpm run lint pnpm run test node scripts/gate-m1.mjs node packages/mcp-gateway/gate-m3.mjs node scripts/gate-m4.mjs
Fig. 10: gate-m2.mjs stays out of CI on purpose: it queries a live package
registry, so in CI it would test the network, not Evidren. It runs locally at release
time.
CI
CI builds and tests on macOS, Windows and Linux, on Node 22 and 24, and runs the offline gates. The Windows installer and the Mac disk image on the download page are built there.
12 packages
@evidren/core (policy engine, config, audit chain, telemetry),
@evidren/detectors, @evidren/claude-code (hook and installer),
@evidren/copilot-cli (the GitHub Copilot CLI hook), @evidren/mcp-gateway, @evidren/integrations (one entry
per agent tool, with what is verified), @evidren/product (lifecycle, status,
record and views), @evidren/cli, and two front ends:
evidren-vscode (a thin editor binding) and desktop (the macOS
app, which ships its own interpreter). All 12 are
private: true: none is on a package registry.