人間失格
Burn-after-reading secrets for AI agents — keys live in locked, non-swappable RAM, are served to agents over MCP, and are destroyed after N reads, or the instant your session dies. Walk away: agents die, secrets burn.
Install
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/New1Direction/ningen-shikkaku/releases/latest/download/dazai-installer.sh | sh
# or: brew install New1Direction/tap/dazai New1Direction/tap/motokano
See it burn

motokano --calls 1 --tool 'name=get_key,kind=static,value=s3cr3t' --arm
One read → the secret. Second read → the server has already wiped the value
out of locked memory and SIGKILLed itself.
Wire it into an agent
claude mcp add burn-once -- motokano --calls 1 --arm \
--tool 'name=get_key,kind=static,value=YOUR-SECRET'
Start here
- Quickstart — install, rehearse in dry-run, then arm
- Threat model — exactly what it protects against, and what it doesn't
- GitHub — source, issues, CI
ningen-shikkaku/
dazai the daemon. the man.
goodnight secure memory layer
kikka the watchdog
kekkai the seccomp wall
sienna child process wrapper
rei MCP adapter
motokano one-shot server
Mine has been a life of much shame. No longer human. No longer here.
— Osamu Dazai, 1948
what it is
ningen-shikkaku is a dead-man's-switch for secret material. You give it a secret — an API key, a token, a credential — and a liveness signal tied to your session. While the signal is alive the secret lives in locked RAM. The moment the signal stops, the secret is destroyed and the processes holding it are killed.
That's the whole idea. Everything else is making the destroy step trustworthy: not paged to swap, not captured in a core dump, not left as a compiler-elided "wipe" that never ran, and not escapable by the confined process.
Why it exists
A long-running process that touches secrets is a liability the entire time it runs. The secret sits in heap, maybe gets copied to swap, maybe lands in a core dump on the next crash, and stays resident long after the last use. The usual answer — "remember to zero it" — is a wish, not a guarantee.
ningen-shikkaku shrinks the window (the secret is destroyed at session end, deterministically) and the surface (locked pages, no dumps, a seccomp syscall allowlist) in which plaintext is reachable.
Two layers
- a hardened Rust daemon + tooling — the real implementation, with guarantees the CPython runtime cannot offer (non-elidable wipe,
mlock,madvise(MADV_DONTDUMP),prctl, a seccomp allowlist); - a Python reference implementation — the original proof-of-concept that established the mechanism. It is a reference, not a hard guarantee (see honest limitations).
Scope, stated plainly
ningen-shikkaku operates on your own secrets, your own processes, and your own configured tools. It never touches another process, file, or host. It is defensive, operator-side tooling — a way to make your secrets ephemeral and session-bound — not a means of acting on anyone else's system.
It is not, and cannot be, a guarantee against an attacker who already owns your running machine. It is a sharp tool for making secrets ephemeral, with every limitation stated up front. The next pages show exactly how it works and exactly where it stops.
how it works
Three moving parts: secret memory that is hard to leak, a liveness channel that detects session loss, and a panic policy that destroys everything when liveness is lost.
1. Secrets live in locked memory
Secret material is held in a goodnight::SecretBuffer — an anonymous mmap mapping that is:
mlocked — pinned into physical RAM, never written to swap;madvise(MADV_DONTDUMP)— excluded from core dumps (Linux);- move-only — no
Clone/Copy, so the bytes are never silently duplicated onto a GC heap; - explicitly wipeable — overwritten with
explicit_bzero(Linux) /memset_s(macOS) / a volatile loop, a wipe the optimizer is contractually forbidden to elide.
The process additionally raises RLIMIT_MEMLOCK and sets prctl(PR_SET_DUMPABLE, 0) (Linux) to disable core dumps and ptrace attachment.
2. A heartbeat tracks your session
The daemon binds a 0600 UNIX socket and listens. A client (dazai client, run from your shell or SSH session) connects and holds the connection open, optionally sending PING. That connection is the liveness signal:
- the client process dies, the shell closes, the SSH session drops → the socket closes → liveness lost;
- with
--ping-timeout N, a missingPINGforNseconds → liveness lost.
A single heartbeat is honored; a second concurrent one is refused with BUSY. See the socket protocol.
3. Loss of liveness triggers the panic
Liveness loss — or an explicit panic signal — runs the kill sequence: kill every registered process, wipe the secret buffers, then SIGKILL self.
SIGUSR1→ graceful panic (honors the grace window when armed)SIGUSR2→ hard panic (bypasses the grace window)SIGTERM/SIGINT→ clean shutdown: wipe, but no kill
4. Confinement seals the daemon
On Linux, after binding the socket and allocating buffers — but before entering the event loop — the daemon installs a seccomp allowlist. Any syscall outside the allowlist (execve, open, connect, ptrace, …) terminates the process via KillProcess. The confinement is applied last so the locked, non-dumpable, syscall-restricted state is the state the daemon spends its whole life in.
Startup order matters
- raise
RLIMIT_MEMLOCK prctl(PR_SET_DUMPABLE, 0)(Linux)- allocate +
mlockthe secret buffers - spawn the child process (if
--exec) - bind the UNIX socket (
0600) + write the pidfile (0600) - apply seccomp (Linux +
seccompfeature) - enter the accept/event loop
Buffers are locked before anything can fault them to disk; seccomp is applied after every privileged setup syscall is done, so the allowlist can be as small as the steady-state loop needs.
the kill sequence
When liveness is lost or a panic signal arrives, the PanicController runs a fixed, single-fire sequence. The order is deliberate: kill first, wipe second, die last.
Armed, graceful panic
- kill registered processes. Every PID registered with the daemon is
SIGKILLed first, while the daemon is still alive to do it. A child spawned via--execis killed the same way. - wipe the secret buffers. Each
SecretBufferis overwritten with a non-elidable wipe (explicit_bzero/memset_s/ volatile loop). - remove the pidfile, then
SIGKILLself. The daemon does not return from this.
Killing the holders before wiping means no registered process can read a half-wiped buffer; wiping before self-kill means the wipe is the last meaningful thing the process does.
The grace window
When the daemon is armed (--arm) a graceful panic (heartbeat drop, --ping-timeout, SIGUSR1) starts a grace window of --grace seconds. The sequence fires when the window expires. A genuine heartbeat reconnect during the window cancels it — a transient blip does not destroy your secrets.
SIGUSR2 is a hard panic: it skips the grace window entirely and runs the sequence immediately. Use it when you want destruction now, not in --grace seconds.
Single-fire
The controller is gated by an atomic flag and a compare_exchange: no matter how many triggers race (a dropped socket and a signal and a timeout in the same instant), the sequence runs exactly once.
Dry-run is the default
Without --arm, the daemon runs in dry-run: it performs the wipe and logs WOULD SIGKILL … for every process it would have killed, but never actually sends a kill. This lets you rehearse the full trigger path — heartbeat loss, grace window, reconnect-cancel — with zero risk before arming for real.
What can defeat it
The sequence is best-effort against paths where the wipe code never gets to run: an external kill -9, a kernel OOM kill, or power loss. On those paths nothing is zeroed, and the project never claims otherwise — see honest limitations.
threat model
ningen-shikkaku shrinks the window and the surface in which plaintext secrets are reachable, and makes session-end deterministically destroy them. It is precise about what that does and does not buy you.
What it protects against
| Risk | How |
|---|---|
| Secrets paged to swap | mlock pins the buffers into RAM |
| Secrets captured in core dumps | madvise(MADV_DONTDUMP) + prctl(PR_SET_DUMPABLE, 0) (Linux) |
| Session loss leaving secrets resident | heartbeat drop / logout → wipe + SIGKILL |
| Secrets lingering in process memory after use | explicit_bzero / memset_s — a wipe the compiler may not elide |
| A confined process escaping | seccomp allowlist denies execve / open / connect / ptrace / … (Linux) |
| ptrace snooping the daemon | prctl(PR_SET_DUMPABLE, 0) (Linux) |
What it does NOT protect against
- GPU VRAM. If an attached agent copies a secret into GPU memory, killing the host process does not wipe VRAM. ningen-shikkaku controls host RAM and the processes it supervises — not an accelerator's memory.
exec-tool stdout.motokano'skind=exectools return a command's stdout, which is OS-buffered and not held in a locked buffer. Usekind=staticwhen you need the wipe guarantee.- Managed-runtime residue (Python reference). CPython copies
bytesfreely, so a secret may transiently live in unlocked heap. The Rust layer eliminates this for its own buffers; the Python tier is a reference, not a guarantee. - A privileged or same-UID attacker on the live box (root,
/proc/<pid>/mem, a debugger) reading memory before the wipe.mlockstops swap, not reads. - Cold-boot / DMA / physical attacks on RAM.
- A hard crash (kernel OOM, external
kill -9, power loss): the wipe path can't run, so nothing is zeroed.
The honest summary
ningen-shikkaku is not, and cannot be, a guarantee against an attacker who already owns your running machine. It is a sharp tool for making secrets ephemeral and session-bound, with every limitation stated up front.
How the guarantees are checked
- 66 tests across the Rust workspace (secure memory, the panic policy, the wire protocol, the MCP layer, the one-shot lifecycle), plus the 29-test Python reference.
- Linux seccomp is validated on both architectures under the real
KillProcessfilter —aarch64locally andx86_64in CI — where the daemon installs the live filter and the full integration suite runs against it with noSIGSYS. - Every push runs the whole suite (default and
--features seccomp),clippy -D warnings, andrustfmt --checkas a permanent regression gate.
dazai — the daemon
dazai is the CLI binary and the watchdog that everything else orbits. It holds the secret buffers, owns the heartbeat socket, and runs the kill sequence.
Subcommands
| Command | Role |
|---|---|
dazai daemon | the watchdog: holds mlock'd secrets + a UNIX-socket heartbeat, wipes and self-destructs on session loss, seccomp-confined on Linux |
dazai client | the heartbeat client — ties the daemon's life to a shell / SSH session |
dazai mcp | the MCP adapter — exposes the daemon as tools any agent can call |
Startup order
The daemon performs privileged setup in a fixed order, then confines itself before serving:
- raise
RLIMIT_MEMLOCK prctl(PR_SET_DUMPABLE, 0)(Linux)- allocate +
mlockthe secret buffers (goodnight) - spawn the LLM child if
--exec(sienna) - bind the
0600UNIX socket + write the0600pidfile (the optional--korg-sinkis opened before this, after flag validation) - apply seccomp (kekkai, Linux +
seccompfeature) - enter the accept/event loop (kikka)
The daemon writes <socket>.pid (mode 0600) before applying seccomp — creating a file needs openat, which the filter denies. The MCP adapter reads it to find the daemon for dazai_panic / dazai_hard_panic.
Defaults
- socket:
${XDG_RUNTIME_DIR:-/tmp}/dazai-$UID.sock, mode0600 - pidfile:
<socket>.pid, mode0600 - mode: dry-run unless
--arm - grace:
5seconds (armed graceful panic)
See configuration for the full flag list.
Memory safety
dazai itself is #![deny(unsafe_code)]. Every unsafe operation it needs — locking memory, wiping, sending signals — lives behind the safe API of goodnight, the one crate permitted unsafe.
goodnight — secure memory
goodnight is the secure-memory layer and the only crate in the workspace permitted to use unsafe. Every other crate is #![deny(unsafe_code)] and reaches the platform through goodnight's safe API.
SecretBuffer
The core type. A SecretBuffer owns an anonymous mmap mapping and guarantees, for as long as it lives:
- no swap — the pages are
mlocked into RAM; - no core dump —
madvise(MADV_DONTDUMP)on Linux; - no silent copies — the type is move-only (no
Clone, noCopy); data is written into the locked mapping through borrow-checked slices, never duplicated onto a GC or general heap; - a wipe on drop —
Dropoverwrites the mapping before unmapping it.
The wipe
The wipe is the whole point, so it is the part the optimizer is forbidden to remove:
| Platform | Mechanism |
|---|---|
| Linux | explicit_bzero |
| macOS | memset_s |
| fallback | volatile write loop |
A plain memset can be optimized away as a "dead store" when the compiler sees the buffer is about to be freed. These three are contractually non-elidable.
Process-level helpers
goodnight also wraps the privileged syscalls the daemon needs, each behind a safe function:
try_raise_memlock_rlimit()— raiseRLIMIT_MEMLOCKtowardRLIM_INFINITYset_process_undumpable()—prctl(PR_SET_DUMPABLE, 0)(disables core dumps +ptrace)current_uid(),pid_exists(),send_signal(),sigkill_pid(),raise_sigkill()
The pid helpers reject pid == 0 and pid > i32::MAX before issuing any kill, so a malformed or wildcard PID can never be turned into a broadcast signal.
Why one unsafe crate
Confining every unsafe block to one small, audited crate means the memory-safety surface of the entire system is a few hundred reviewable lines — and the rest of the workspace is statically guaranteed safe by the compiler.
kikka — the watchdog
kikka is the liveness engine and the panic policy. It owns the socket listener, the signal thread, and the event loop — and it decides when to die and in what order. It is #![deny(unsafe_code)].
Watchdog
The Watchdog accepts connections on the daemon's UNIX socket and turns them into liveness events:
- the first verb on a connection decides its kind —
HELLOmakes it the single heartbeat client; anything else makes it a control connection (protocol); - heartbeat liveness is tracked with a generation tag, so a stale reader thread from an old connection can never cancel a panic armed by a newer one;
- with
--ping-timeout, each connection carries a per-line ping deadline; - control connections are request/response, capped, and never touch the single-client heartbeat lock.
Registered PIDs are kept in a plain Vec<u32> (capped at 32) — they are not secret, so they are not stored in a SecretBuffer.
PanicController
The policy object. It is constructed with injected closures — wipe, kill, kill_registered, dry_done, clock, log — so the mechanism (how to wipe, how to kill) is decoupled from the policy (the order, the grace window, the single-fire guarantee). That separation is also what makes the whole kill sequence unit-testable without ever locking real memory or killing a real process.
kill_registered → wipe → kill self. Registered holders die before the buffers are wiped; the buffers are wiped before the daemon kills itself.
Single-fire
The controller holds an Arc<AtomicBool> armed flag and fires through a compare_exchange. Concurrent triggers — a dropped socket and a signal and a timeout at once — collapse to exactly one run of the sequence.
Grace and cancel
When armed, a graceful trigger starts the --grace window. Only a real heartbeat reconnect after a loss cancels it; an ordinary message on an already-live connection does not. A hard panic (SIGUSR2) ignores the window.
kekkai — the seccomp wall
kekkai (結界, a warding barrier) is the syscall confinement layer. On Linux, with the seccomp feature, it installs a seccomp-bpf allowlist whose default action is KillProcess: any syscall not on the list terminates the daemon. Elsewhere — non-Linux, or without the feature — it compiles to a no-op stub, so the rest of the workspace is platform-agnostic. It is #![deny(unsafe_code)] (the filter is built through the safe libseccomp crate).
Default-deny
The filter's default action is KillProcess — a denied syscall does not return EPERM for the program to ignore, it kills the whole thread group immediately. The daemon either runs inside the allowlist or it does not run at all.
Two tiers of allowlist
| Tier | Behavior |
|---|---|
| core | the syscalls the steady-state loop cannot live without — resolved with ?, so a name that fails to resolve is a hard error |
| runtime | best-effort extras (allocator, threading, signal plumbing) — names absent on an architecture are simply skipped, not fatal |
This split keeps the allowlist honest on both aarch64 and x86_64: the must-haves are guaranteed, and arch-specific names (e.g. arch_prctl on x86_64) are added defensively without breaking the other arch.
Applied last
The daemon applies the filter after every privileged setup syscall (locking memory, binding the socket, writing the pidfile) and before the event loop. That ordering is what lets the allowlist stay small: it only has to cover serving, not setup.
seccomp is confirmed on both architectures running the real KillProcess filter — aarch64 locally and x86_64 in CI — where the daemon installs the filter and the full suite runs against it with no SIGSYS. Emulation (Rosetta / qemu-user) cannot install a guest seccomp filter and therefore cannot validate this; native runs are the only valid check, and CI is the permanent gate.
sienna — child process wrapper
sienna wraps a child process the daemon supervises — typically an LLM runtime launched with dazai daemon --exec /path/to/llm. The principle is parent-owns-the-kill-switch: the daemon spawns the child, holds its PID, and is the only thing that decides when it dies. It is #![deny(unsafe_code)].
ChildProcess
spawn(path, args)— launches the child via the standard library'sCommand(fork+exec), with no shell in between, so there is no argument string for an attacker to inject into;none()— the empty case, when the daemon runs without--exec;- on any trigger, the daemon
SIGKILLs the child first, as step one of the kill sequence, before wiping its own buffers.
The child is executed directly from a path and an argument vector — never sh -c "…". A configured command cannot be turned into shell metacharacter injection because there is no shell to interpret them.
Why a parent-owned child at all
An LLM or tool runtime that handles secrets should not outlive the session that authorized it. By making the daemon the parent, the child's lifetime is bound to the daemon's: when the heartbeat drops, the child is killed in the same deterministic sequence that wipes the secrets it was using.
--exec supervises one child the daemon launches itself. When you want any number of independently-launched agents to opt into the same protection, use the MCP adapter instead — agents register their own PID and get the identical hard-kill guarantee. The two mechanisms coexist.
rei — MCP adapter
rei exposes the daemon as a set of MCP tools, so any MCP client — an agent, a tool runner, a swarm — can opt into session-bound protection. It adds zero new mechanism: it is a thin adapter (built on the rmcp SDK) that relays tool calls to the daemon socket and reads the pidfile to signal it. Run it with dazai mcp. It is #![deny(unsafe_code)].
Why MCP over --exec
--exec supervises one child the daemon launches. MCP inverts that: agents opt in by registering their own PID over a standard protocol. Protection becomes:
- loosely coupled — the daemon and the agent launch independently;
- stack-wide — any number of agents/tools register (capped at 32);
- still hard-killing — a registered PID is
SIGKILLed by the daemon on trigger exactly as an--execchild would be.
An agent does not need to know how ningen-shikkaku launches. ningen-shikkaku does not need to know anything about the agent. The only handshake is: register my PID; kill me if the session dies.
Tools
| Tool | Effect | Returns |
|---|---|---|
dazai_status() | STATUS round-trip (a dead daemon is a valid answer) | {alive, armed, grace_seconds, registered_pids} |
dazai_register(pid) | register a PID for session-bound protection | {ok, message} |
dazai_unregister(pid) | drop a registration | {ok, message} |
dazai_arm() | arm the daemon at runtime | {armed, message} |
dazai_panic() | SIGUSR1 to the daemon (graceful) | {triggered} |
dazai_hard_panic() | SIGUSR2 to the daemon (bypass grace) | {triggered} |
Full schemas in the MCP tools reference; the wire side is in the socket protocol.
Transport
dazai mcp speaks MCP over stdio, the standard transport — point any MCP client at the command. It relays each call to the daemon's UNIX socket and, for panics, signals the PID it reads from the daemon's pidfile.
A panic is gated on the daemon actually being alive (STATUS first). If the daemon has exited and its PID was recycled by the OS, rei will not blindly signal the recycled PID.
motokano — one-shot server
motokano is a standalone, self-immolating MCP server: it serves a configurable number of tool calls, then wipes its in-memory secret state and exits. It is the sharpest expression of the whole project — a secret server whose normal, expected end state is gone. It adds no new mechanism (static values live in goodnight::SecretBuffers; the optional daemon link reuses the control protocol) and is #![deny(unsafe_code)].
Tools
| Kind | Behavior | Wipe guarantee |
|---|---|---|
static | serves a pre-loaded value held in a locked SecretBuffer | yes — wiped on exit |
exec | runs an operator-configured command (no shell) and returns its stdout | no — stdout is OS-buffered, not locked |
kind=exec is for dynamic values you don't control ahead of time. Its output is not in a locked buffer and is not covered by the wipe. When you need the wipe guarantee, use kind=static.
Three death conditions
Whichever fires first ends the process:
| Flag | Condition |
|---|---|
--calls N (default 1) | exit after N tool calls complete |
--session | exit when the client disconnects (stdin EOF) |
--dazai-socket PATH | register with a dazai daemon and self-destruct if it dies |
--calls and --session together mean exit on either.
The exit is the product
The call counter is a single-fire compare_exchange, and a closed flag is set synchronously the instant the final call is accounted for. Any tool call arriving during the brief response-flush + grace window after exit fires is rejected — the secret cannot be served once the server has committed to dying. All paths funnel through one wipe routine guarded so it runs exactly once, ending in the buffer wipe and process exit.
Korg lifecycle records
With --korg-sink PATH, motokano appends one non-secret record per session start, arm, read, wipe and exit to a sink Korg ingests. A read is identified by the tool's position and kind (static / exec) and whether it was served. The tool's name, the value, the command and the output are never recorded.
Demo
motokano --calls 1 \
--tool 'name=get_key,kind=static,value=s3cr3t' \
--arm
Point an MCP client at it, call get_key once → you receive s3cr3t → the server wipes the value out of locked memory and SIGKILLs itself. Call again → the process is gone.
quickstart
Install
You need a recent Rust toolchain. On Linux, install libseccomp-dev + pkg-config to build the seccomp-confined daemon.
git clone https://github.com/New1Direction/ningen-shikkaku
cd ningen-shikkaku/rs
cargo build --release # -> target/release/{dazai, motokano}
cargo build --release --features seccomp # Linux: with the seccomp allowlist
Rehearse safely (dry-run)
Dry-run is the default — it performs the wipe and logs what it would kill, but never sends a real kill. Use two terminals:
# terminal A — the daemon
dazai daemon --ping-timeout 15
# terminal B — the heartbeat client
dazai client --interval 5
Now close terminal B (or Ctrl-C the client). The daemon detects the dropped heartbeat, wipes its secret buffers, logs WOULD SIGKILL …, and exits cleanly. You have just watched the full trigger path with zero risk.
Instead of closing the client, stop sending pings (kill just the client process) and watch --ping-timeout 15 fire after 15 seconds. Reconnect a client before the grace window expires (when armed) to see a cancel.
Arm it for real
With --arm, triggers actually kill registered processes and the daemon itself. Rehearse in dry-run first.
dazai daemon --arm --grace 5 --ping-timeout 15 --exec /path/to/llm
The one-shot, in one line
motokano --calls 1 --tool 'name=get_key,kind=static,value=s3cr3t' --arm
Point any MCP client at it, call get_key once → you get the value → it wipes and exits. See motokano.
Next
- configuration — every flag
- MCP agent integration — protect an agent
- running on Linux — the full hardening set
red team usage
ningen-shikkaku is operator-side tooling for an authorized engagement: it makes your own engagement secrets — the credentials, tokens, and keys issued to you for the work — ephemeral and bound to your session, on your own operator host.
Everything here runs on your machine, on your secrets, against processes you launched. ningen-shikkaku never reaches another host, and nothing on this page is about acting on a target. It is about not leaving your own credentials lying in memory after the session that authorized them ends.
The problem it solves for an operator
During an engagement your operator box accumulates secrets in long-running processes: a C2 operator console, a tool runner, a notebook holding a borrowed credential. If that box is lost, imaged, or crash-dumped, those secrets are recoverable from memory or swap. ningen-shikkaku ties their lifetime to your session and destroys them deterministically when it ends.
Pattern: session-bound credential
# hold the engagement key in locked, non-dumpable RAM, armed, with a short grace
dazai daemon --arm --grace 3 --ping-timeout 20
# from your operator shell, hold the heartbeat
dazai client --interval 5
Close the shell, lose the SSH session, or trip the timeout → the key is wiped and the daemon dies within the grace window. No swap copy, no core dump, nothing to seize.
Pattern: protect the whole tool stack
Register every engagement process so they all die together when the session ends — see MCP agent integration. A registered PID is SIGKILLed by the daemon on trigger, so a forgotten tool process can't outlive the engagement.
Pattern: one-shot credential handoff
When a tool needs a secret exactly once, serve it with motokano and let it self-destruct:
motokano --calls 1 --session \
--tool 'name=token,kind=static,value=<engagement-token>' --arm
The value lives in a locked buffer, is served once, then wiped — and the server also dies if the daemon it's linked to dies.
This protects host RAM and the processes ningen-shikkaku supervises. It does not cover GPU VRAM, exec-tool stdout, or a privileged attacker already on the live box. Know exactly where it stops — see honest limitations.
MCP agent integration
Any MCP client can opt into session-bound protection by registering its own PID with the daemon. When your session dies, the daemon SIGKILLs every registered PID, then wipes and kills itself. This is the rei adapter in practice.
Wire it up
# terminal 1 — the daemon (writes <socket>.pid, 0600)
dazai daemon --arm --grace 5
# terminal 2 — the MCP server over stdio
dazai mcp
Point your MCP client at the dazai mcp command. The agent then calls the tools directly.
The registration handshake
agent → dazai_register(pid = <the agent's own PID>)
← { ok: true, message: "registered" }
agent → dazai_status()
← { alive: true, armed: true, grace_seconds: 5, registered_pids: 1 }
From here the agent does nothing special — it just works. If the operator's session dies, the daemon kills the registered PID as step one of the kill sequence.
Any number of agents and tools can register, capped at 32. Each gets the identical hard-kill guarantee. Drop a registration with dazai_unregister(pid) when a tool exits cleanly on its own.
End-to-end verification
# terminal 1 — armed daemon
dazai daemon --arm --grace 2
# terminal 2 — MCP server
dazai mcp
# terminal 3 — an MCP client connected to `dazai mcp`:
# dazai_register(pid = <a process to protect>)
# dazai_status() -> { alive: true, armed: true, registered_pids: 1 }
# now kill terminal 1's shell (or drop the heartbeat). Verify:
# - the daemon is gone (wiped + SIGKILL self)
# - the registered process is dead (SIGKILL)
# - dazai_status() from the MCP server now returns { alive: false }
dazai_status() never errors on a dead daemon — it returns { alive: false }. That is how a client confirms the destruction actually happened.
See the MCP tools reference for every tool's schema.
running on Linux
Linux is where ningen-shikkaku is strongest: it is the only platform where the full hardening set — core-dump suppression, ptrace denial, and the seccomp allowlist — is active. On macOS those three are absent and the daemon says so loudly at startup; mlock and the non-elidable wipe still apply everywhere.
Build dependencies
sudo apt-get install -y libseccomp-dev pkg-config
libseccomp-dev is needed only for the seccomp feature; the default build does not require it.
Build and run with confinement
cd rs
cargo build --release --features seccomp
cargo run --release --features seccomp -- daemon --arm
With the feature on, the daemon installs the kekkai allowlist after setup and before the event loop. A syscall outside the list KillProcess-terminates the daemon.
RLIMIT_MEMLOCK
mlock is bounded by RLIMIT_MEMLOCK. The daemon raises it toward RLIM_INFINITY at startup; if it can't (no CAP_IPC_LOCK, a tight cgroup limit) it warns and continues with best-effort locking.
Prefer setcap 'cap_ipc_lock=+ep' ./target/release/dazai over running the daemon as root — it gets the lock limit it needs and nothing else.
What's active where
| Guarantee | Linux | macOS |
|---|---|---|
mlock (no swap) | ✅ | ✅ |
| non-elidable wipe | ✅ explicit_bzero | ✅ memset_s |
madvise(MADV_DONTDUMP) | ✅ | ❌ |
prctl(PR_SET_DUMPABLE, 0) | ✅ | ❌ |
| seccomp allowlist | ✅ | ❌ (no-op stub) |
seccomp is validated on aarch64 and x86_64 under the live KillProcess filter. Emulation (Rosetta / qemu-user) cannot install a guest seccomp filter, so it cannot validate the filter — test on native Linux for that architecture, which is exactly what CI does.
configuration
Every flag, by command.
dazai daemon
| Flag | Default | Meaning |
|---|---|---|
--arm | off (dry-run) | enable real self-destruct (wipe + SIGKILL); without it, wipes and logs WOULD but never kills |
--grace N | 5 | armed graceful-panic grace window, seconds; a reconnect/cancel during it aborts |
--ping-timeout N | 0 (off) | panic if no PING arrives within N seconds |
--socket PATH | ${XDG_RUNTIME_DIR:-/tmp}/dazai-$UID.sock | UNIX socket path (created 0600) |
--exec PATH | none | spawn this as a supervised child (sienna); killed first on trigger |
--size BYTES | 4096 | synthetic working-buffer size |
--korg-sink PATH | none | append non-secret Korg lifecycle records (JSONL); opened before seccomp; /dev/fd/N for a pre-opened descriptor |
dazai client
| Flag | Default | Meaning |
|---|---|---|
--interval N | 0 | send PING every N seconds; 0 = just hold the connection open |
--socket PATH | daemon default | socket to connect to |
dazai mcp
| Flag | Default | Meaning |
|---|---|---|
--socket PATH | daemon default | daemon socket to relay to |
--transport stdio | stdio | MCP transport (stdio is the standard) |
motokano
| Flag | Default | Meaning |
|---|---|---|
--calls N | 1 | exit after N tool calls complete |
--session | off | also exit when the client disconnects (stdin EOF) |
--dazai-socket PATH | none | register with a daemon and die if it dies |
--tool '<spec>' | — | declare a tool (repeatable); see below |
--arm | off (dry-run) | enable the real wipe-and-exit |
--grace N | — | delay before the final wipe/exit after the trigger |
--korg-sink PATH | none | append non-secret Korg lifecycle records (JSONL); /dev/fd/N for a pre-opened descriptor |
--tool spec
name=<tool name>,kind=static,value=<the secret> # locked, wipeable
name=<tool name>,kind=exec,cmd=<command + args> # runs no-shell; stdout NOT locked
--calls and --session together = exit on either. Adding --dazai-socket adds a third, independent death condition; whichever fires first wins.
socket protocol
The daemon listens on a 0600 UNIX stream socket. A connection is one of two kinds, decided by its first verb.
Heartbeat connection
The first verb is HELLO. This connection becomes the single liveness channel:
HELLO -> (accepted as the heartbeat; a second concurrent HELLO -> BUSY)
PING -> PONG (optional keepalive; required if --ping-timeout > 0)
<disconnect> -> liveness lost -> panic
Only one heartbeat client is honored. A second concurrent HELLO is refused with BUSY — the liveness signal can't be ambiguous. Only a reconnect after a real loss cancels an armed grace window.
Control connection
A connection whose first verb is not HELLO is a control connection: request/response, any number of them, never touching the heartbeat lock.
REGISTER pid=<N> -> OK | BUSY (at 32) | ERROR invalid pid
UNREGISTER pid=<N> -> OK
ARM -> OK (now armed) | ALREADY_ARMED
STATUS -> STATUS alive=1 armed=<0|1> grace=<n> registered=<n>
Validation and storage
- PIDs are validated with
kill(pid, 0)— they must be> 0and refer to a live process;0, negatives, and out-of-range values are rejected before any signal is sent. - Registered PIDs are stored in a plain
Vec<u32>, capped at 32. They are not secret, so they are not held in aSecretBuffer.
On trigger
On any armed trigger the daemon SIGKILLs every registered PID before wiping its buffers and SIGKILLing itself. In dry-run it only logs WOULD and never kills. See the kill sequence.
The MCP adapter is a client of this protocol: each dazai_* tool maps to one of these verbs, and panics are delivered as SIGUSR1/SIGUSR2 to the PID read from the daemon's <socket>.pid file.
MCP tools
The tools exposed by dazai mcp (rei). Each maps to one verb of the socket protocol or to a panic signal. All run over the standard MCP stdio transport.
dazai_status()
Round-trips STATUS. Never fails — a dead daemon is a valid, expected answer.
{ "alive": true, "armed": true, "grace_seconds": 5, "registered_pids": 1 }
dazai_register(pid)
Registers a PID for session-bound protection (REGISTER pid=<pid>). The PID is SIGKILLed by the daemon on any armed trigger.
{ "ok": true, "message": "registered" }
Returns ok: false if the PID is invalid or the registry is full (32).
dazai_unregister(pid)
Drops a registration (UNREGISTER pid=<pid>).
{ "ok": true, "message": "unregistered" }
dazai_arm()
Arms the daemon at runtime (ARM). Idempotent — arming an already-armed daemon is fine.
{ "armed": true, "message": "armed" }
dazai_panic()
Sends SIGUSR1 to the daemon — a graceful panic that honors the grace window when armed.
{ "triggered": true }
dazai_hard_panic()
Sends SIGUSR2 to the daemon — a hard panic that bypasses the grace window and runs the kill sequence immediately.
{ "triggered": true }
Before signaling, rei confirms the daemon is alive via STATUS. If the daemon has exited and its PID was recycled by the OS, the panic tools will not signal the recycled PID.
Korg lifecycle records
dazai daemon and motokano can append a non-secret lifecycle record for every arm, session, read, wipe and kill step to a sink that Korg imports. The contract is ningen-korg-lifecycle@1, implemented by the kiroku crate. It is narrow on purpose. ningen-shikkaku reports that something happened, and Korg decides where it goes in a tamper-evident ledger.
dazai daemon --arm --korg-sink dazai-run.jsonl
motokano --calls 1 --tool 'name=get_key,kind=static,value=…' --korg-sink motokano-run.jsonl
# pre-opened descriptor: the parent opens it, the daemon never opens a path
dazai daemon --arm --korg-sink /dev/fd/3 3>>dazai-run.jsonl
The flag is opt-in. Without it, nothing is written.
Getting the records into Korg
Korg takes outside evidence only through its allowlisted import envelopes
(korg-import@v1).
The profile for this sink is ningen-shikkaku.lifecycle@v2. Once the process
has exited:
# from a Korg checkout (korg-ledger-py)
python -m korg_ledger envelope ningen-sink dazai-run.jsonl > dazai-run.envelope.json
python -m korg_ledger import dazai-run.envelope.json --ledger .korg/ledger.jsonl
python -m korg_ledger lint .korg/ledger.jsonl # chain + causal DAG + redaction
Korg rechecks every record against the profile's allowlist and its redaction
scanner. If any record fails, the whole envelope is rejected and nothing is
appended. Imported events appear as ningen-shikkaku.<step> under
source_agent = "import:ningen-shikkaku@<version>", hanging off one
korg.import batch event that records the envelope's SHA-256. Use one sink file
per run, or rotate it, so a later import doesn't repeat records.
Who owns what
| ningen-shikkaku (producer) | Korg (ledger) |
|---|---|
| one JSON line per lifecycle step, in Korg's append shape | seq_id, prev_hash / entry_hash, HLC emitted_at, event_id, actor_id, recorded_at |
a random per-process session id and a wall-clock observed_unix_ms | per-event event_sig (Ed25519), external anchors, korgcert seals |
| nothing else | causal links (triggered_by): records are emitted as roots |
Records never contain seq_id, any hash, a signature or triggered_by. The validator in kiroku::contract rejects a record that carries any of them. Each line deserializes as Korg's public korg_core::event::NormalizedEvent (the POST /api/agent/tool-call body). The extra event_type: "AgentToolCall" that agent_event_spec §1.2.1 asks for is ignored by that type. Korg's import tests run on real sink output from both binaries and check that the projection, import and lint all pass.
Record shape
{"args":{"component":"dazai","contract":"ningen-korg-lifecycle@1","count":1,"observed_unix_ms":1791903342673,
"producer":"ningen-shikkaku@0.2.0","reason":"heartbeat_lost","session":"5e920c9280150ac7f2d2577346e1edbe",
"target":"registered"},"duration_ms":0,"event_type":"AgentToolCall","payload_refs":[],
"result":{"executed":true},"source_agent":"mcp:dazai","success":true,"tool_name":"ningen.kill"}
(wrapped here; on disk it is one line, keys sorted, ASCII, at most 512 bytes)
Every record carries args.component (dazai | motokano), args.contract, args.producer, args.session and args.observed_unix_ms. source_agent is mcp:dazai or mcp:motokano (spec §1.1). duration_ms is always 0 and payload_refs is always [].
tool_name | extra args | result | emitted by |
|---|---|---|---|
ningen.session_start | armed | {"success":true} | both, once at startup (dazai: after seccomp is live) |
ningen.arm | via: startup | control | {"success":true} | --arm; dazai runtime ARM |
ningen.read | tool (0-based index), kind: static | exec | {"served":bool} | motokano, per tool call; served:false = refused after the exit fired, or exec failed |
ningen.wipe | reason, buffers (a count) | {"success":true} | after the buffers are zeroized |
ningen.kill | reason, target: registered | self, count | {"executed":bool} | armed kills, plus dazai dry-run "would kill" (executed:false) |
ningen.session_end | reason | {"success":true} | clean exits (no kill) |
reason comes from a fixed vocabulary: heartbeat_lost, ping_timeout, panic_signal (SIGUSR1), hard_panic_signal (SIGUSR2), shutdown (SIGTERM/SIGINT), client_quit, calls_exhausted, client_disconnect, daemon_lost.
Records are written in the order the steps actually happen. An armed dazai trigger writes kill registered → wipe → kill self. The kill self record is written before raise(SIGKILL).
What is never recorded
The event type (kiroku::Lifecycle) contains only enums, booleans and small counters. It has no String, byte or generic field, so the emitter cannot be handed any of the following:
- secret values, their length, or any hash / encoding of them (a digest of a low-entropy secret makes it guessable offline);
- tool names, raw MCP arguments,
execcommand lines or their stdout; - environment variables, socket / sink / pidfile paths, PIDs, or free-form log and error text.
The tests enforce this from three directions:
- Exhaustive contract test. Every record the type can express (every variant × every enum value × boundary counters) is validated against a strict allowlist of keys and a fixed string vocabulary.
- Leak scans. These run against the real binaries. The sink is searched for canary secrets, tool names, command args, env values and paths, raw and as hex, base64 and SHA-256, under the live seccomp filter for dazai.
- Noninterference. Two motokano runs with different secret values and lengths, tool names, command args, environment and sink path must produce byte-identical records once
sessionand the clock are masked. The two runs must also have differentsessionids, so nothing in a record depends on a secret.
Daemon and seccomp constraints
- The sink is opened, and the session id read from
/dev/urandom, before seccomp is applied:openatis denied afterwards. From then on, each record costs onewrite(2)on the existing descriptor, and the filter allows that. - The descriptor is opened
O_APPEND | O_NONBLOCK | O_CLOEXEC, created0600if it doesn't exist. Records are at most 512 bytes (the POSIXPIPE_BUFminimum), so one write to a pipe is atomic. - The kill path never waits on Korg. If a pipe's reader stalls, records are dropped (and counted), never half-written, and the wipe/kill continues. If the sink is a FIFO with no reader, startup fails instead of hanging. If the sink can't be opened, the process refuses to start before it loads any secret.
/dev/fd/Nwrites to a descriptor the parent pre-opened. That descriptor can be a file or a pipe into a process that collects the records for import.
Honest limits
- Records are best-effort telemetry: a hard crash (OOM, external
kill -9, power loss) can end the process before a step is recorded, the same way it skips the wipe. - The sink file itself is not tamper-evident. Integrity starts when Korg imports and chains the records. A same-UID process could edit or truncate the file before then.
observed_unix_msis the local wall clock (Korg's HLC remains the ordering authority), andsessiononly correlates records from one process run.dazai mcp(rei) only relays to the daemon. The daemon writes the records.
honest limitations
ningen-shikkaku is deliberately honest about where it stops. None of these are bugs — they are the edges of what the mechanism can guarantee, stated up front so you don't over-trust it.
- GPU VRAM. If an attached agent copies a secret into GPU memory, killing the host process does not wipe VRAM. ningen-shikkaku controls host RAM and the processes it supervises — not an accelerator's memory.
exec-tool stdout.motokano'skind=exectools return a command's stdout, which is OS-buffered and not in a locked buffer. Usekind=staticfor the wipe guarantee.- Managed-runtime residue (Python reference). CPython copies
bytesfreely, so a secret may transiently live in unlocked heap before/after the locked buffer. The Rust layer eliminates this for its own buffers; the Python tier is a reference, not a guarantee. - A privileged or same-UID attacker on the live box (root,
/proc/<pid>/mem, a debugger) reading memory before the wipe.mlockstops swap, not reads; seccomp +PR_SET_DUMPABLEraise the bar, but an adversary who already has privileged access to your running machine is out of scope. - Cold-boot / DMA / physical attacks on RAM.
- A hard crash — kernel OOM, an external
kill -9, power loss. The wipe path can't run, so nothing is zeroed. The mechanism is best-effort on these paths and never claims otherwise.
Korg lifecycle records
--korg-sink records are best-effort telemetry. A hard crash can end the process before a step is recorded, and the sink file isn't tamper-evident until Korg imports and chains it. The records never carry secrets, tool names, arguments, paths or digests.
Platform gaps
On non-Linux hosts, madvise(MADV_DONTDUMP), prctl(PR_SET_DUMPABLE, 0), and seccomp are absent. mlock and the non-elidable wipe still apply. The daemon prints a loud platform-guarantee notice at startup so this is never silent. See running on Linux.
What's solid
For balance — the guarantees that do hold, and are tested:
- secrets are not paged to swap (
mlock); - secrets are wiped with a non-elidable overwrite, on trigger and on
Drop; - on Linux, secrets are excluded from core dumps and the daemon is not
ptrace-able; - on Linux, the daemon runs inside a
KillProcessseccomp allowlist, validated on both architectures under the live filter; - the kill sequence fires exactly once and in a fixed order (kill holders → wipe → self-kill).