Read-only observability for the sentinel / ghost agent-security stack. Consult the spirits about what your agent has been up to.
sentinel blocks. ghost roasts. séance shows you the aftermath.
séance is a small Tauri desktop app that tails the two JSONL logs the stack already writes, joins them into a single correlated timeline, and renders it in an occult/punk séance you can actually read — deny vs pass vs shadow-bypass vs loose events, each with its own visual grammar, the roasts displayed with the respect they deserve.
It is read-only by construction. It cannot modify the enforcement layer — not the policy, not the hooks, not the logs — even if its own UI were fully compromised. See Security.
| tool | job | writes |
|---|---|---|
| sentinel | the security authority. A PreToolUse/PostToolUse hook that blocks dangerous agent tool calls. | ~/.sentinel/audit.jsonl — one AuditEvent per evaluation |
| ghost | offense bolted onto defense. Wraps sentinel, roasts every block in a punk/occult voice, and shadow-probes denials for policy bypasses. | ~/.ghost/events.jsonl — one CallRecord per bridged call |
| séance | the observer. Tails + joins both logs and shows the aftermath. | nothing. ever. |
séance stitches the two logs back together using the correlation ids the stack stamps on every line:
ghost line ──(call_id)──▶ sentinel PRE line ──(tool_use_id)──▶ sentinel POST line(s)
- ghost ↔ sentinel-pre join on
call_id— the UUID ghost mints per bridged call and hands sentinel via theSENTINEL_CALL_IDenv var. - sentinel-pre ↔ sentinel-post join on
tool_use_id— the id Claude Code puts in both hook phases' payloads for one tool call.
A governing call is therefore one ghost line + one sentinel pre line + zero-or-more post lines.
Two facts séance renders deliberately, so they read as signal not noise:
- A denied call produces NO post line. Claude Code does not fire PostToolUse
when PreToolUse denies (observed on Claude Code 2.1.207; undocumented
upstream). séance shows this as the expected state —
⊘ no post-hook line — call was denied— never as missing data. - Older lines predate the id fields. Every id is optional in both emitters.
A line with no id to join on is surfaced as a loose / unjoined event,
visually distinct (dashed sigil bar,
◌ UNJOINED) — never dropped.
| file | shape | rotation |
|---|---|---|
~/.sentinel/audit.jsonl |
timestamp, tool_name, action, reason?, matched_rule?, mode, call_id?, tool_use_id?, hook_phase? |
none — grows forever, tailed incrementally |
~/.ghost/events.jsonl (+ .jsonl.1) |
ts_ms, tool, command, decision, category?, roast?, roast_id?, shadow?, call_id?, tool_use_id? |
rotates to .jsonl.1 at 8 MiB; séance drains the old file's tail across the boundary |
crates/seance-core/ pure Rust. tail · rotation · parse · join · offset persistence.
No Tauri, no watcher — the whole correctness core is unit-tested
on any platform (77 tests).
src-tauri/ the Tauri 2.x desktop shell. Owns the notify file-watcher (+ poll
fallback) and a wall-clock timer; reads the logs with std::fs and
streams normalized, already-joined events to the webview.
src/ vanilla TS + Vite frontend. The correlated timeline, the living
"presence", filters/search. Never touches the filesystem.
The backend does the join once, in Rust, and the frontend only ever receives a
normalized discriminated union (GoverningCall | LooseEvent). Malformed lines
are counted and skipped, never fatal — the same tolerance both emitters have.
séance is designed so it cannot modify the enforcement layer, by construction rather than by policy:
- The webview has no filesystem API at all. No
tauri-plugin-fs, nowithGlobalTauri; its capability grants only event-listen. A compromised or prompt-injected webview has noreadFile/writeFileprimitive to call — it can only receive the events the backend chose to emit. - The backend never opens anything for writing on the log path. Log access
goes through
seance-core, which only ever callsFile::open(read) + stat. There is nowrite/create/truncate/remove/renameanywhere in the log-reading modules — and CI greps to keep it that way (the "structural read-only guard"). séance's own small offset-state file is the only thing it writes, and only into its own OS app-data dir. - No network, no shell, no updater. None are dependencies; the CSP's
connect-srcisself ipc:only, so the webview cannot fetch or exfiltrate. - Untrusted payloads are text, always. Commands, tool output, and roasts are
agent-authored. The frontend builds DOM with
createElement+textContentexclusively — neverinnerHTML— and a strict CSP (script-src 'self', nounsafe-inline) is the backstop.
Prerequisites: a recent Rust toolchain, Node 20+, and the Tauri prerequisites for your OS (macOS: Xcode command-line tools). Primary target is macOS (Apple Silicon).
npm install
npm run tauri dev # or: cargo tauri dev — live-reload dev build
npm run tauri build # or: cargo tauri build — a signed-ready .app / .dmgPoint it at a live stack: install sentinel + ghost (see their repos), let your agent make a few hooked tool calls, and séance shows the joined timeline within ~1s of each call landing.
cargo test -p seance-core # the tail/rotation/join correctness suite
cargo clippy -p seance-core --all-targets -- -D warningsnpm run dev # vite dev server
# open http://localhost:1420/?demo to preview with synthetic data (no backend)- Offset persistence: séance remembers where it left off per file (keyed on
inode). sentinel's log resumes from the saved offset. ghost's log resumes
verbatim, so if a rotation happened while séance was closed it drains the
unconsumed tail of
events.jsonl.1on the next poll instead of losing it. Deletetail_state.jsonin the app-data dir to replay full history. - The display face is the system-serif fallback stack (no bundled/remote font — the strict CSP forbids font CDNs and we ship zero binary font assets).
Dual-licensed under either of Apache-2.0 or MIT at your option, matching sentinel.