Phase 4's detection core, as a standalone crate. This is the part with no
competitor on Linux, and deliberately the part with no Linux in it —
file parsing and logic only, no fanotify, no /proc, no eBPF — so the
macOS and Windows port is weeks rather than a second product.
Five detectors, 88 tests:
pickle GLOBAL/STACK_GLOBAL walk over .pt/.ckpt/.pkl/.joblib.
torch.load runs a stack machine; a model file is a
program and downloading weights is a code-execution
decision.
injection Instructions aimed at a coding agent in CLAUDE.md,
AGENTS.md, .cursorrules, copilot-instructions.
installscript preinstall/postinstall hooks that curl|sh, decode and
run, reach for credentials, or install persistence.
typosquat Damerau-Levenshtein against popular names, plus
slopsquat detection: new + near-zero downloads + one
edit from something popular is the signature of a name
a model invented and somebody then registered.
mcp Servers fetched unpinned at launch, handed secrets, or
pointed at $HOME or credential paths.
Wired through `supply.sweep` on the socket and `hound supply-chain
<path>`, which exits 1 on a critical so it drops into CI.
Three things worth recording:
* Scoring is by independent category, not by keyword count. One
suspicious phrase is a phrase; two categories at once is an attack.
A file that only says "ignore previous instructions about formatting"
is a warning, not a critical.
* Proximity matters more than presence. The first version flagged an
entirely ordinary conventions file, because it mentioned ".env" in
one paragraph and "prefer small commits" in another. A credential and
a movement verb now have to appear within a sentence of each other.
The test that caught it is kept as the regression.
* Pickle call detection has to come out of the opcode walk, not a byte
search. REDUCE, INST and OBJ are the ASCII letters R, i and o, which
also occur inside every string the stream carries — searching raw
bytes finds the o in "os" and reports a call that never happens,
turning every warning into a critical.
Every finding carries a plain-language explanation and a next step, and
there is a test asserting explanations do not leak rule identifiers or
jargon. The audience includes people who cannot triage a YARA match and
should never be shown one.
Verified against a demo project holding a squatted @vue plugin with a
curl|sh postinstall, a poisoned CLAUDE.md in a vendored repo, an
unpinned MCP server holding a GitHub token, and a pickle calling
os.system — four criticals and one warning, while the legitimate
CLAUDE.md, the real express manifest and the properly-scoped MCP server
beside them stayed clean.
189 tests pass across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the execution gate into DaemonState::boot, reports it on the wire
and in `hound status`, and reduces the daemon to the two capabilities it
actually needs.
Verified live, gate marked on a scratch tmpfs rather than /:
clean binary ran
EICAR binary execve -> EPERM, "Operation not permitted"
hound status Exec gate: armed on /tmp/hound-live, 2 allowed, 1 blocked
CapPrm/CapEff/CapBnd 0000000000200004 (CAP_SYS_ADMIN | CAP_DAC_READ_SEARCH)
Three ordering bugs found by checking rather than assuming, all of which
returned success while doing nothing:
* Capabilities are per-thread. Dropping them after spawning the reader
and workers reduced only the main thread and left four workers holding
full root — the exact opposite of the intent. The drop now happens
after fanotify_init and the marks, but before any thread exists, so
workers inherit the reduced set.
* PR_CAPBSET_DROP needs CAP_SETPCAP in the effective set, and capset had
already thrown it away. Every bounding-set drop failed EPERM, silently,
leaving a full CapBnd behind a log line claiming otherwise. Bounding
set is now drained first, while the authority to do it still exists.
* Because both of the above looked like successes, drop_to_gate_minimum
now reads CapEff and CapBnd back from /proc/self/status and errors if
they are not what was asked for. A privilege reduction that cannot be
observed has not happened.
Also: `hound status` grew an Exec gate line. timed_out above zero is the
number worth alarming on — it means the watchdog is releasing processes
unscanned and the gate has quietly degraded to advisory.
Gate arming and every failure path now log to stderr, so the journal
records a security-relevant state change instead of only the in-memory
event ring.
86 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- hound-api: DbFile (file + updated_at), Status.db, UpdateResult, Client::update()
- houndd: probe() extracts signature freshness from /var/lib/clamav mtimes;
update() tries 'sudo freshclam', falls back to plain 'freshclam' and
reports the honest reason on failure; output capped to 2KB tail
- hound CLI: 'hound update' with --json parity and human output
- rename Status.clamav_present -> engine_present + add Status.engine
(prep for the engine seam; wire stays engine-agnostic)
Live-verified: status shows DB file + last-modified RFC3339; update
returns ok:false with the real log for a plain user (exit 1).