peperspray is a Linux-first credential access guard for developer
workstations.
The current Linux implementation is a root-owned daemon that uses Linux
fanotify permission events to detect protected credential-file reads and block
them before they complete unless an explicit policy rule allows the access.
This repository is currently suitable for personal production / dogfooding on Ubuntu 24.04 and Fedora/RHEL-family developer workstations. It includes host-level read enforcement, packaging, service management, logs, health checks, policy review/apply tooling, and desktop notifications, while keeping remaining hardening gaps explicit:
- TOML configuration parsing and logical validation
- protected users and protected credential path groups
- allow-rule based policy decisions
- learn/enforce decision behavior
- path normalization and
~expansion for protected paths - optional operation and parent-process constraints on allow rules
- Linux
/procprocess inspection - JSONL decision logs with timestamps and event IDs
- log inspection, filtering, following, JSON output, and event lookup
- learned-access review and suggested allow-rule generation
- starter and interactive config generation through
setup - human and JSON status output
- root-owned Linux daemon with
fanotifyread enforcement .deband RPM packaging, systemd service, log rotation, and denied-access desktop notifications
The target product is described in docs/SPEC.md. In short:
peperspraydruns as a root-ownedsystemdservice on Linux systems with fanotify permission-event support.peperspraywill manage setup, status, modes, logs, testing, and policy review.- Default posture is zero-trust in enforce mode: protected credential reads are denied unless an allow rule matches.
- Learn mode records accesses that would have been denied without blocking them.
- Platform-neutral policy, event, process, and logging types are kept separate from Linux-specific event collection where practical.
Implemented commands:
setupstatuslearnenforceservice statusservice startservice stopservice restartpolicy-validatetest-accessinspect-processlogswhypolicy-reviewassistant doctor
Implemented policy and CLI capabilities:
- Config loading from TOML
- Config validation beyond syntax, including:
- unknown protected groups
- duplicate protected group names
- duplicate allow-rule names
- duplicate allow-rule behavior
- Learn/enforce policy decisions
- Protected path matching using component-aware
Path::starts_with, plus inode/device identity matching for protected file aliases - Path normalization for config and access events
~expansion for protected group paths- Optional
operationfield on allow rules - Optional
parent_exefield on allow rules - Optional
exe_sha256executable hash pin on allow rules - Process inspection from
/proc/<pid> - Parent-process chain collection
- JSONL decision logs
- Log filtering by decision, count, and timestamp
- Log following
- Single-event lookup by event ID or latest matching event
- Policy-review suggestions as human text, JSON, TOML, or suggestion file, with minimum-event filtering
- Optional local Ollama assistant support for
why --assistandpolicy-review --assist - Starter and interactive config generation with detected local tools
- Safe mode changes with config backups
- Protected preset discovery with the
dev-browser-walletdefault profile - Expanded protected presets for developer credentials, browser credential stores, wallets, and password managers
- Config v2
protected_groups.patternsfor browser/profile path globs - Split CLI/library layout with
pepersprayandpeperspraydbinaries pepersprayddaemon with config validation, lifecycle JSONL logs, fanotify marks, and policy enforcement- Linux
fanotifypermission-event loop, event conversion, decision logging, and FAN_ALLOW/FAN_DENY responses - Privileged fanotify integration tests proving learn/enforce read behavior on Ubuntu 24.04
- Privileged path-identity regression tests proving hard-link, bind-mount, and mount-namespace aliases are blocked
- Service management wrappers around
systemctl - Installed-layout scaffolding under
packaging/ - Local
.debpackage build script and Debian maintainer scripts - Local RPM package build script and RPM spec for Fedora/RHEL-family systems
- Containerized RPM build wrapper for non-RPM hosts
- Remove/purge behavior for the installed service, config, and runtime log
- Installed logrotate policy for runtime log retention
- Best-effort desktop notifications for denied reads with in-memory throttling
doctorhealth checks for backend availability, config validity, missing optional protected paths, and unsafe installed path permissions- daemon startup refusal for unsafe installed config, log, or binary permissions
policy-applyfor merging reviewed allow-rule suggestions with validation and config backups- QEMU package lifecycle test for install, service startup, log permissions, upgrade permission repair, remove, and purge on Ubuntu 24.04
- QEMU RPM package lifecycle runner for Fedora/RHEL-family systems
- QEMU privileged integration runner for fanotify enforcement and path-identity regression tests on Ubuntu 24.04
- Documented path semantics and inode/device alias hardening
- Periodic daemon rescan for protected paths created after startup
- Multi-architecture release builds for Linux x86_64 and ARM64 packages
peperspray can optionally use a local Ollama-compatible model to explain
events and policy-review candidates. Assistant support is disabled unless you
run an assistant subcommand or pass --assist; it is never used by the daemon
or the policy enforcement path.
Recommended local models favor fast, instruction-following output over deep
reasoning. The assistant asks Ollama to disable thinking/reasoning traces and
return compact JSON because peperspray only needs concise advisory text.
gemma4:12bfor 16GB VRAM systems such as Radeon RX 6800 XT workstations.qwen3:14bas the next recommended alternative.qwen3.5:latest,qwen3:8b,llama3.1:8b, ormistral:7bas faster smaller-model options.
The CLI never pulls models automatically:
ollama pull gemma4:12b
peperspray assistant doctor --assistant-model gemma4:12b
peperspray why last --assist --assistant-model gemma4:12b
peperspray policy-review --assist --assistant-model gemma4:12bWhile the local model is processing, the CLI prints progress to stderr so JSON and normal stdout output remain parseable.
Assistant preferences may be stored in
~/.config/peperspray/assistant.toml; CLI flags always override that file. See
docs/ASSISTANT.md for the privacy model and redaction
modes.
- Rust toolchain with Cargo
- Linux is the intended platform
- The CLI can run normal simulated policy tests without root privileges.
- Host enforcement requires a root-owned daemon with Linux fanotify support.
cargo build
cargo test
cargo fmt
cargo clippy --all-targets -- -D warningsThe current test suite covers config validation, policy decisions, path handling, process metadata parsing, log filtering/follow helpers, event lookup, setup generation, status JSON, policy-review candidate grouping/output, and CLI integration behavior.
The installed CLI and daemon default to the system config path:
/etc/peperspray/config.toml
For local development examples, pass an explicit config path:
cargo run -- status --config examples/config.tomlA typical config looks like this:
mode = "learn"
[[users]]
uid = 1000
groups = ["aws", "ssh", "github", "gcloud", "docker", "npm", "ansible", "git", "dotenv"]
[[protected_groups]]
name = "aws"
paths = ["~/.aws"]
[[protected_groups]]
name = "ssh"
paths = ["~/.ssh"]
[[protected_groups]]
name = "github"
paths = ["~/.config/gh"]
[[protected_groups]]
name = "gcloud"
paths = ["~/.config/gcloud"]
[[protected_groups]]
name = "docker"
paths = ["~/.docker"]
[[protected_groups]]
name = "npm"
paths = ["~/.npmrc"]
[[protected_groups]]
name = "ansible"
paths = ["~/.ansible", "~/.ansible/vault_password"]
[[protected_groups]]
name = "git"
paths = ["~/.git-credentials", "~/.netrc"]
[[protected_groups]]
name = "dotenv"
paths = [".env", ".env.local", ".env.development", ".env.production"]
[[allow_rules]]
name = "Allow AWS CLI"
uid = 1000
exe = "/usr/bin/aws"
# exe_sha256 = "64-character sha256 digest"
path_group = "aws"
operation = "open_read"
[[allow_rules]]
name = "Allow SSH client"
uid = 1000
exe = "/usr/bin/ssh"
path_group = "ssh"
operation = "open_read"
[[allow_rules]]
name = "Allow Docker CLI"
uid = 1000
exe = "/usr/bin/docker"
path_group = "docker"
operation = "open_read"Policy fields:
mode:learnorenforceusers: protected user IDs and the credential groups that apply to themprotected_groups: named sets of protected paths and optional glob patternsallow_rules: explicit access rules
Allow-rule fields:
name: human-readable rule nameuid: protected user IDexe: executable path that is allowedexe_sha256: optional SHA-256 pin for the resolved executablepath_group: protected group the executable may accessoperation: optional operation constraint; currentlyopen_readparent_exe: optional parent executable constraint
If exe_sha256 is set, the rule only matches when the resolved executable file
hash equals that digest. If it is omitted, the rule keeps the current path-only
executable matching behavior. policy-review --toml includes exe_sha256 when
the logged executable is still readable.
If operation is omitted, the rule currently applies to any known operation.
Since the current policy model only has open_read, generated rules include
operation = "open_read" for clarity.
If parent_exe is set, at least one parent process in the logged parent chain
must match that executable.
In learn mode, a protected access without a matching allow rule is allowed and
logged with:
"would_deny": trueIn enforce mode, the same access is denied.
If the daemon cannot inspect a short-lived process while handling a fanotify event, learn mode allows the event and writes a daemon error record; enforce mode keeps the conservative behavior and denies the event.
Run commands through Cargo during development.
cargo run -- setup --output ./generated-config.tomlOverwrite an existing generated config:
cargo run -- setup --output ./generated-config.toml --forceEmit a JSON setup report:
cargo run -- setup --output ./generated-config.toml --force --jsonRun interactive setup:
cargo run -- setup --output ./generated-config.toml --interactivesetup detects common tools in PATH, such as aws, ssh, gh, gcloud,
docker, npm, ansible-vault, git, browsers, and password-manager helpers,
and only generates allow rules for tools that are present. Some tools also add
known helper rules when the helper binary exists; for example Git adds
/usr/lib/git-core/git.
cargo run -- presets
cargo run -- presets --jsonThe default profile is dev-browser-wallet, covering developer credentials,
browser credential stores, wallet data, and password-manager data.
cargo run -- learn --config ./generated-config.toml
cargo run -- enforce --config ./generated-config.tomlMode changes validate the config first and write a .bak copy before replacing
the config.
Validate the daemon config without starting the fanotify loop:
cargo run --bin pepersprayd -- \
--config ./generated-config.toml \
--log-file ./events.jsonl \
--checkInstalled-mode defaults are /etc/peperspray/config.toml and
/var/log/peperspray/events.jsonl. The systemd service starts the daemon as
root, marks absolute protected paths from the config, and periodically rescans
for protected paths created after startup. Protected paths such as ~/.aws are
expanded against the configured protected user's home directory, not root's home
directory.
The experimental fanotify probe initializes a permission-event mark without starting the full loop:
cargo run --bin pepersprayd -- \
--config ./generated-config.toml \
--log-file ./events.jsonl \
--check \
--fanotify-probe /path/to/protectThe fanotify loop reads FAN_OPEN_PERM events, converts each event
into an AccessEvent, evaluates policy, appends a JSONL decision log, and writes
FAN_ALLOW or FAN_DENY back to the kernel:
sudo target/debug/pepersprayd \
--config ./generated-config.toml \
--log-file ./events.jsonl \
--fanotify-path /path/to/protectWhen --fanotify-path is omitted, the daemon uses existing absolute paths from
protected_groups. Missing paths and relative filename presets such as .env
are skipped by the fanotify marker.
In enforce mode, denied reads trigger best-effort desktop notifications via
notify-send when the user's session bus is available. Notifications are
throttled per user, executable, protected group, and operation for five minutes
to avoid repeated popups from noisy tools. Denies are still blocked and logged
if desktop notifications are unavailable.
The notification path runs runuser to deliver the popup into the target
user's session bus. The installed pepersprayd.service grants the
capabilities (CAP_SETUID, CAP_SETGID, CAP_SETPCAP, CAP_DAC_OVERRIDE,
CAP_AUDIT_WRITE), the @setuid syscall group, and writable+executable memory
(MemoryDenyWriteExecute=no) that runuser and its PAM modules require.
Granting these widens the attack surface of a root daemon, so the unit is the
single place this tradeoff is made. If you prefer a stricter no-notification
profile, revert those directives as documented in
packaging/systemd/pepersprayd.service; denies remain enforced and logged with
the popups disabled.
To run the ignored privileged fanotify tests on a Linux host:
cargo test --test privileged_fanotify --no-run
sudo "$(find target/debug/deps -maxdepth 1 -type f -executable -name 'privileged_fanotify-*' | head -n1)" --ignored --nocaptureTo run the ignored privileged path-identity regression tests:
cargo test --test privileged_path_identity --no-run
sudo "$(find target/debug/deps -maxdepth 1 -type f -executable -name 'privileged_path_identity-*' | head -n1)" --ignored --nocaptureThese tests assert that hard-link, bind-mount, and mount-namespace aliases for
protected files are blocked. If path-identity behavior changes, update the
expectations and docs/PATH_SEMANTICS.md together.
To run both ignored privileged test suites in an isolated Ubuntu 24.04 QEMU VM:
packaging/qemu-test-privileged.sh --image ./noble-server-cloudimg-amd64.imgThe QEMU runner builds the test artifacts on the host, copies them into the VM,
and runs them as root with PEPERSPRAYD_BIN pointing at the copied daemon. The
runner uses KVM when /dev/kvm is available and falls back to TCG otherwise;
set QEMU_ACCEL=kvm or QEMU_ACCEL=tcg to force one mode.
These commands wrap systemctl for the installed pepersprayd service:
cargo run -- service status
sudo cargo run -- service start
sudo cargo run -- service stop
sudo cargo run -- service restartThe service is intended to run as root. Installed layout notes live in
packaging/INSTALL_LAYOUT.md.
Installed packages include /etc/logrotate.d/peperspray for
/var/log/peperspray/events.jsonl. The default policy rotates daily, rotates
early at 10 MiB, keeps 14 rotations, and compresses older logs.
packaging/build-deb.sh
cp ./target/debian/peperspray_0.1.4_amd64.deb /tmp/
sudo apt install /tmp/peperspray_0.1.4_amd64.deb
sudo apt remove peperspray
sudo apt purge peperspraypackaging/build-deb.sh builds for the host architecture by default. Set
PEPERSPRAY_ARCH=arm64 when building on a Linux ARM64 host; release builds
publish both amd64 and arm64 Debian artifacts.
Validate the package lifecycle in a QEMU Ubuntu 24.04 VM:
packaging/qemu-test-deb.sh --image ./noble-server-cloudimg-amd64.imgSee docs/QEMU_PACKAGE_TESTING.md for prerequisites, accelerator options, and
exact checks.
Build directly on a Fedora/RHEL-family host with rpmbuild:
packaging/build-rpm.shpackaging/build-rpm.sh builds for the host architecture by default. Release
builds publish both x86_64 and aarch64 RPM artifacts.
Or build from an Ubuntu/Debian host through a Fedora container:
packaging/build-rpm-container.shValidate the RPM lifecycle in a QEMU Fedora VM:
packaging/qemu-test-rpm.sh --image ./Fedora-Cloud-Base.qcow2cargo run -- status
cargo run -- status --json
cargo run -- status --config ./generated-config.tomlcargo run -- policy-validate
cargo run -- policy-validate --config examples/config.tomlcargo run -- doctor
cargo run -- doctor --jsondoctor checks backend availability, config parsing, installed config/log/binary
ownership and modes, and missing configured protected paths. Missing optional
preset paths are warnings; unsafe ownership or group/world-writable installed
paths are errors.
Manual executable/UID input:
cargo run -- test-access ~/.aws/credentials \
--exe /usr/bin/python3 \
--uid 1000PID-derived process metadata:
cargo run -- test-access ~/.aws/credentials --pid $$Emit JSON for a decision:
cargo run -- test-access ~/.aws/credentials \
--exe /usr/bin/python3 \
--uid 1000 \
--jsonAppend a decision to a JSONL log:
cargo run -- test-access ~/.aws/credentials \
--exe /usr/bin/python3 \
--uid 1000 \
--log-file ./events.jsonlcargo run -- inspect-process $$This reads process metadata from /proc, including UID, executable, CWD,
command line, parent PID, and parent chain.
cargo run -- logs --log-file ./events.jsonl
cargo run -- logs --log-file ./events.jsonl --last 5
cargo run -- logs --log-file ./events.jsonl --decision allow
cargo run -- logs --log-file ./events.jsonl --decision deny
cargo run -- logs --log-file ./events.jsonl --since 2026-01-01T00:00:00Z
cargo run -- logs --log-file ./events.jsonl --follow
cargo run -- logs --log-file ./events.jsonl --jsoncargo run -- why <event-id> --log-file ./events.jsonl
cargo run -- why last --log-file ./events.jsonl
cargo run -- why last --decision deny --log-file ./events.jsonl
cargo run -- why <event-id> --log-file ./events.jsonl --jsonHuman-readable review:
cargo run -- policy-review --log-file ./events.jsonlJSON review output:
cargo run -- policy-review --log-file ./events.jsonl --jsonTOML snippets only:
cargo run -- policy-review --log-file ./events.jsonl --toml
cargo run -- policy-review --log-file ./events.jsonl --min-events 3 --tomlWrite TOML suggestions to a separate file:
cargo run -- policy-review \
--log-file ./events.jsonl \
--write-suggestions ./suggested-rules.tomlOverwrite an existing suggestion file:
cargo run -- policy-review \
--log-file ./events.jsonl \
--write-suggestions ./suggested-rules.toml \
--forcepolicy-review never modifies the active config. It only suggests allow rules
based on learn-mode would_deny events.
Apply a reviewed suggestion file:
cargo run -- policy-apply \
--suggestions ./suggested-rules.toml \
--config ./generated-config.toml \
--dry-run
cargo run -- policy-apply \
--suggestions ./suggested-rules.toml \
--config ./generated-config.toml \
--forcepolicy-apply validates the merged config before replacing it and writes a
backup beside the active config.
Logs are newline-delimited JSON. The daemon writes access decision records and
daemon lifecycle records to the same file. Commands such as logs, why, and
policy-review read decision records and skip daemon lifecycle records.
Each decision event can include:
event_idtimestamppiduidexecwdcmdlineparent_chaintarget_pathtarget_file_identityoperationdecisionreasonmatched_path_groupwould_deny
Example shape:
{
"event_id": "7bcb0f0c-2d1f-4726-bf88-60d14db0e847c",
"timestamp": "2026-05-24T20:30:12.123456789Z",
"pid": 12345,
"uid": 1000,
"exe": "/usr/bin/python3",
"cwd": "/home/alice/project",
"cmdline": ["python3", "script.py"],
"parent_chain": [
{
"pid": 12300,
"ppid": 12000,
"uid": 1000,
"exe": "/usr/bin/zsh",
"cmdline": ["zsh"]
}
],
"target_path": "/home/alice/.aws/credentials",
"target_file_identity": {
"dev": 2049,
"ino": 123456
},
"operation": "open_read",
"decision": "allow",
"reason": "learn mode: would deny access to protected group 'aws'",
"matched_path_group": "aws",
"would_deny": true
}The current code is organized around the planned backend split:
src/config.rs: config types, loading, normalization, and validationsrc/daemon.rs: daemon config validation, lifecycle logging, and event handlersrc/event.rs: access-event model and operation typesrc/fanotify.rs: Linuxfanotifypermission-event helperssrc/identity.rs: executable identity helpers such as SHA-256 hashingsrc/policy.rs: policy decision enginesrc/logging.rs: decision log serialization and JSONL helperssrc/paths.rs: path normalization and~expansion helperssrc/process.rs: Linux/procprocess inspectionsrc/cli.rs: clap command definitions and CLI defaultssrc/main.rs: top-level command dispatch and CLI-specific access-event helperssrc/setup.rs: starter config generation and local tool detectionsrc/status.rs: status output formattingsrc/review.rs: learned-access grouping and suggested allow-rule generationsrc/service.rs: systemd service management wrapperssrc/commands/logs.rs: log filtering, lookup, and renderingsrc/bin/pepersprayd.rs: daemon entrypointpackaging/INSTALL_LAYOUT.md: intended installed filesystem layoutpackaging/build-deb.sh: local.debpackage builderpackaging/build-rpm.sh: local RPM package builderpackaging/build-rpm-container.sh: containerized RPM package builderpackaging/qemu-test-deb.sh: QEMU package lifecycle smoke testpackaging/qemu-test-rpm.sh: QEMU RPM lifecycle smoke testpackaging/qemu-test-privileged.sh: QEMU privileged fanotify/path-identity test runnerpackaging/deb/: Debian metadata and maintainer scriptspackaging/rpm/: RPM spec and RPM-specific logrotate policypackaging/systemd/pepersprayd.service: installed systemd unitdocs/QEMU_PACKAGE_TESTING.md: QEMU package validation guidedocs/FAILURE_BEHAVIOR.md: intended failure behavior and hardening notesdocs/PATH_SEMANTICS.md: current path behavior and remaining caveats
src/main.rs is intentionally kept as a thin dispatcher so the portable policy,
logging, setup, status, and review behavior remains easier to test in isolation.
Official releases are published by the GitHub Actions release workflow.
To publish from a tag:
git tag v0.1.4
git push origin v0.1.4The release version must match Cargo.toml without the leading v. The
workflow runs formatting, tests, clippy, builds Linux x86_64 and ARM64 binaries,
builds Debian amd64 and arm64 packages, builds Fedora/RHEL-family x86_64
and aarch64 RPM packages, and publishes those files as GitHub Release assets.
The workflow can also be run manually from GitHub Actions with an explicit version and prerelease flag.
Suggested next milestones:
- Improve policy-review ergonomics so learned accesses are easier to review,
group, filter, and apply safely. Target improvements include
--sincefiltering, clearer parent/helper process context, duplicate suppression, risky-rule warnings for broad tools such as shells/editors/package hooks, and better dry-run diffs before applying suggestions. - Add ARM64 QEMU package lifecycle coverage after release package builds prove stable.
The current Linux release is useful for personal production / dogfooding on
Ubuntu 24.04 and Fedora/RHEL-family systems, and has a Linux fanotify loop
validated by privileged integration tests. It still has documented limits around
daemon self-protection, root compromise, and rename-heavy protected path
workflows.
It can answer:
Would this access be allowed or denied by the policy?
The daemon path can enforce:
Block this real process before it reads the file.
That boundary is suitable for personal dogfooding and developer workstation hardening, but it should not be treated as high-assurance protection against root compromise, daemon tampering, or periods where the daemon is not running.
peperspray is available under either the MIT License or the Apache License
2.0, at your option. This permissive dual-license model is intended to be
friendly to both personal and professional use, including enterprise adoption.
See LICENSE-MIT and LICENSE-APACHE.


