feat(hooks): v2 sandbox — env-allowlist + Linux Landlock + SIGTERM grace + buffer cap#109
Open
Fullstop000 wants to merge 6 commits into
Open
feat(hooks): v2 sandbox — env-allowlist + Linux Landlock + SIGTERM grace + buffer cap#109Fullstop000 wants to merge 6 commits into
Fullstop000 wants to merge 6 commits into
Conversation
Fullstop000
added a commit
that referenced
this pull request
Jun 2, 2026
Implements the four-layer v2 sandbox from docs/superpowers/specs/2026-06-03-hook-sandbox-v2-design.md: 1. Env-var allowlist on every platform. The child sees only `PATH HOME USER LANG LC_ALL TZ` from ignis's env, plus the names the hook declared in `env: [...]`. Closes the v1 credential-exfil gap. 2. Linux Landlock filesystem sandbox installed via `pre_exec` in the forked child. Per-hook `sandbox: bool` (default `true`). Allows reads from the hook folder + standard lib paths + TLS roots + $TMPDIR + /dev/urandom; writes from $TMPDIR and /dev/null only. Non-Linux builds compile to a no-op stub returning `PlatformUnsupported`; an unsupported kernel emits a one-time `hook.sandbox` warning per hook per session, cleared by `/hooks reload`. 3. SIGTERM → 1 s grace → SIGKILL on timeout. tokio's `Child::start_kill` maps to SIGKILL on Unix, so we deliver SIGTERM directly via `libc::kill(pid, SIGTERM)`. 4. 1 MiB-per-stream buffer cap. Surplus bytes are drained and discarded so the child can finish writing without blocking on a full pipe; a `hook.buffer` Warning event lands when truncation happens. Schema additions on `HookSpec`: `env: Vec<String>` and `sandbox: bool` (default `true` from JSON via Option-unwrap; default `false` in-memory via `bool::default()` for test fixtures using `..HookSpec::default()`). Tests: - config: defaults, env list ordering, sandbox false opt-out - dispatch: env_filter, sigterm_grace, buffer_cap - sandbox unit: default read/write lists, status labels - integration (tests/hook_sandbox.rs, Linux only): sandboxed write to non-/tmp path blocked; unsandboxed write succeeds
Updates `docs/usage/hooks.md`: - Replace the prominent "Hooks run unsandboxed in v1" callout with a spec of the four v2 layers (env allowlist, Landlock, SIGTERM grace, buffer cap), defaults, and platform behaviour. - Document `env: [...]` and `sandbox: bool` config fields. - Update timeout-behaviour paragraph from "SIGKILL via kill_on_drop" to "SIGTERM → 1 s grace → SIGKILL". - Note that `/hooks reload` resets the per-session degradation warning suppression. Updates `examples/hooks/translate-en/README.md`: - Replace the v1 unsandboxed warning with a one-line v2 description. - Sample `hooks.json` now declares `env` (the three vars the translator actually needs) and `sandbox: true`. Adds a one-line `[Unreleased]` CHANGELOG entry. PR # is TBD until the PR opens.
- pre_exec is now allocation-free: parent pre-builds the Landlock read/write path lists and the closure only does syscalls; errors return raw OS codes rather than the boxing `io::Error::other`. Closes the async-signal-safety hazard both reviewers flagged. - Bare hook programs (`python3 hook.py`, no parent dir) no longer fall back to `hook_folder = /`, which silently disabled read confinement. The hook folder is now `Option<PathBuf>` and omitted from reads when absent. - TMPDIR no longer trusted from env: the sandbox hardcodes `/tmp` + `/var/tmp` as the scratch directories. Launching ignis with `TMPDIR=$HOME` no longer exposes the home directory to every sandboxed hook. - HookSpec now has a manual Default with `sandbox: true` (was `false` via derived Default), so tests written with `..HookSpec::default()` exercise the secure-by-default code path. /dev/zero added to the default read list so shell-script hooks that read it (common idiom) still work. - `SandboxStatus::PlatformUnsupported` now triggers the same once-per-session warning as `NotEnforced`, matching the docs. - Stale strings cleaned up: `/hooks reload` no longer says "run unsandboxed", and the `hooks` module doc-comment now describes v2 behaviour.
d8bf2cf to
917cc21
Compare
The hook protocol shipped Landlock confinement living under
`ignis/src/hooks/sandbox.rs`, but the same mechanism applies to any
ignis subprocess caller (bash-tool next). Splits along the
mechanism/policy line:
* `ignis/src/sandbox/mod.rs` (new) — generic: `SandboxStatus`,
`apply_with_paths(reads, writes)`, the Linux Landlock syscalls.
Policy-free; takes pre-built path slices.
* `ignis/src/hooks/sandbox.rs` — hook-specific policy: the default
read/write path lists (TLS roots, scratch dirs, interpreter
paths) and a convenience `apply(hook_folder)` wrapper that calls
into `crate::sandbox::apply_with_paths`.
No behaviour change; tests preserved in their owning module. Future
bash-tool sandbox lives next to its caller and reuses the same
primitive via `use ignis::sandbox::apply_with_paths;`.
Adds a `SandboxPolicy` two-step API (parent builds, child applies) so the macOS branch can serialise a Seatbelt Scheme profile into a `CString` ahead of fork — keeping the `pre_exec` closure allocation-free on both targets. Linux Landlock behaviour is unchanged (refactored from `apply_with_paths` to the same policy type, free function preserved for back-compat). New ignis/src/sandbox/macos.rs declares `sandbox_init` / `sandbox_free_error` via libc-only `extern "C"` bindings (no new dependency), translates the same hook default path list into Seatbelt allow rules, and mirrors `/tmp` → `/private/tmp` and `/var` → `/private/var` so post-symlink-resolution opens match. The macOS branch compiles under cfg gating but is unverified on macOS hardware from this Linux WSL2 host. The runtime behaviour of sandbox_init against this profile shape needs Mac dogfood before merge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
v2 of the hook protocol's security model — closes the unsandboxed gap v1 (#102) deliberately shipped. Four layers:
PATH HOME USER LANG LC_ALL TZ. Per-hookenv: ["KEY", …]adds to it.Command::env_clear()+ explicitcmd.env(k, v)for each. Stops accidental credential exfil.landlock = "0.4"). Per-hooksandbox: bool(defaulttrue). Pre-exec hook applies a ruleset that lets the child read its hook folder,/etc/ssl/certs,/usr/lib,/lib,/lib64,/bin,/usr/bin,/sbin,/usr/sbin,/etc/resolv.conf,/dev/urandom,$TMPDIR; write$TMPDIRand/dev/nullonly. Non-Linux: empty stub, startup warning.kill_on_drop-only path. On timeout:libc::kill(pid, SIGTERM)→ 1s sleep →child.kill()(SIGKILL). tokio'sstart_killactually sends SIGKILL, so the implementation uses libc directly (commented).Warning { source: "hook.buffer", … }.Architecture decision
The original plan (memory
[[mini-sandbox-bypass-hardening]]) called forFullstop000/mini-sandbox. That crate is unpublished on crates.io and ~50 LOC of Landlock wrapper. We uselandlockdirectly for crates.io provenance and to thread a typedSandboxStatusinto the tracing span. Functionally equivalent; better supply-chain hygiene.Sandbox status telemetry
pre_execcan't backchannel from child to parent, so the parent probes the kernel's Landlock ABI once at startup (landlock_create_ruleset(NULL, 0, VERSION)) and reports the expected enforcement level in theignis.hook.dispatchtracing span. On hosts without Landlock: per-hook warning emitted once per session (cleared by/hooks reload).Test plan
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warnings— 0 warningscargo test --workspace— 544 passed / 0 failed / 1 ignoredcargo build --release$TMPDIRexits non-zero with[warn]; a sandbox-clean hook works as in v1.tests/hook_sandbox.rsverifies a hook attempting to write$HOME/leak-from-hook.txtis blocked when sandbox is on, succeeds when off.Known intermittent flake
hooks::dispatch::tests::exit_zero_empty_stdout_is_passthroughflakes ~3/10 times under full parallelcargo test --workspacedue to concurrent fork/exec on shared tempdir scripts (ETXTBSY). Passes deterministically in isolation. Not introduced by this PR — same pattern in v1's hook tests. Worth aserial_testgating PR later.Documentation
docs/usage/hooks.md— replaces the v1 "unsandboxed" warning block with the actual v2 model (threat scoped to network egress; sandbox default-on, opt-out per hook).examples/hooks/translate-en/README.md— examplehooks.jsonnow declaresenv: ["ANTHROPIC_API_KEY", …]andsandbox: true.docs/superpowers/specs/2026-06-03-hook-sandbox-v2-design.md(local-only).What's still deferred (v3+)