Phase 6 gave us interactive permission requests with real-time approval workflows. But the permission system itself had five major security loopholes identified in docs/security-improvements-research.md:
Problem 1: Path bypass attacks. Guardian Shell captures the raw filename from the sys_enter_openat tracepoint — whatever path the userspace process passes to openat() is matched against policy. An agent can bypass deny rules with trivial string tricks:
/proc/self/root/etc/shadow → does NOT match deny rule "/etc/shadow"
../../../etc/shadow → does NOT match deny rule "/etc/shadow"
/proc/1234/root/etc/shadow → does NOT match deny rule "/etc/shadow"
Problem 2: Missing syscall coverage. The openat2 syscall (Linux 5.6+, syscall 437) bypasses the sys_enter_openat tracepoint entirely. Rust's standard library and security-conscious tools increasingly use openat2. An agent using openat2 instead of openat operates completely unmonitored.
Problem 3: Approval fatigue. Research shows 43% of SOC analysts turn off alerts when overwhelmed, and 40% ignore incoming alerts entirely. An AI agent can exploit this by:
- Flooding the human with benign-looking requests until they rubber-stamp everything
- Crafting persuasive justifications that blend into routine approval workflows
- Re-requesting denied resources repeatedly until the human gives in
- Requesting critical resources during high-volume periods when attention is divided
Problem 4: No risk differentiation. A request to read /tmp/cache.txt and a request to read /etc/shadow look identical in the UI — same banner, same approve button, same one-click approval. There's nothing forcing the human to slow down for high-risk decisions.
Problem 5: No persistent audit trail. The resolved permission history was in-memory only (last 100 entries in a VecDeque). On daemon restart, all audit history was lost. No way to detect patterns like "this agent was denied 5 times then approved" or "approval rate is 98% — someone is rubber-stamping."
Phase 7 addresses all five through a combination of userspace path normalization, eBPF syscall coverage expansion, multi-layered approval hardening, risk-based UI friction, and persistent SQLite audit trails.
| File | Lines | Purpose |
|---|---|---|
guardian/src/permissions.rs |
460 | Permission hardening module: rate limiter, risk classifier, auto-deny/auto-approve, justification analyzer |
| File | What Changed |
|---|---|
guardian/src/config.rs |
Added PermissionsConfig and AutoApproveRule structs with serde defaults; added normalize_path() function; modified check_file_policy() and check_exec_policy() to normalize paths before matching; made path_matches() public; added 5 new tests for normalization and bypass prevention |
guardian/src/ipc.rs |
Added rate_limits and event_db fields to IpcState; imported permissions module; added risk fields to PendingPermission, ResolvedPermission, PermissionEvent; completely rewrote handle_request_permission() with hardening pipeline; added SQLite persistence in resolve_permission() and all auto-deny/approve/timeout paths |
guardian/src/main.rs |
Added mod permissions; added normalize_path import and call in process_file_event(); added openat2 tracepoint load/attach with graceful fallback; added event_db to IpcState initialization |
guardian-ebpf/src/main.rs |
Added guardian_file_openat2 tracepoint handler for sys_enter_openat2 (82 lines) with open_how struct flags reading |
guardian/src/dashboard/db.rs |
Added permission_audit table schema with 3 indexes; added StoredPermissionAudit struct; added insert_permission_audit() and query_permission_audit() methods |
guardian/src/dashboard/routes/api.rs |
Added risk fields to list_pending_permissions JSON response; added query_permission_audit endpoint and AuditQuery struct |
guardian/src/dashboard/routes/pages.rs |
Added risk_level, risk_flags, wait_seconds, requires_type_confirm, justification_warnings to PendingRequestInfo; added risk_level to ResolvedRequestInfo; updated requests() handler to populate new fields |
guardian/src/dashboard/mod.rs |
Added /api/permissions/audit route |
guardian/templates/base.html |
Rewrote permission banner with risk-colored borders, risk badge, justification warnings, risk flags, mandatory wait timer countdown, type-to-confirm input for CRITICAL; updated Alpine.js store to handle risk fields from SSE and fetch |
guardian/templates/requests.html |
Added Risk column to both pending and resolved tables; added wait timer on approve button with Alpine.js countdown; added type-to-confirm for CRITICAL risk rows; expanded colspan from 7 to 8 |
guardian/static/app.css |
Added risk badge styles (.badge-risk-low/medium/high/critical); added risk-colored banner variants (.perm-banner.risk-*); added justification warning tag styles; added type-to-confirm input styles |
guardian-common/src/lib.rs |
Added NetworkEvent struct (52 bytes), new map name constants for exec enforcement and network monitoring |
guardian-ebpf/src/main.rs |
Added 9 new BPF maps (7 exec enforcement + 2 network), evaluate_exec_policy(), guardian_enforce_exec LSM hook, guardian_file_open_legacy tracepoint, guardian_net_connect tracepoint |
guardian/src/alerting/mod.rs |
Added NetworkConnect variant to EventType enum |
guardian/src/config.rs |
Added NetworkPolicy struct with port-based allow/deny rules, check_network_policy() function, network_policy field to AgentConfig |
CLAUDE.md |
Updated current state to Phase 7; updated project structure; updated known limitations; added Phase 7 design decisions; updated test counts |
Agent calls openat("/proc/self/root/etc/shadow")
│
▼
eBPF tracepoint captures raw path: "/proc/self/root/etc/shadow"
│
▼
Perf event → Userspace daemon event loop
│
▼
normalize_path("/proc/self/root/etc/shadow")
│
├── Strip /proc/self/root/ prefix → "/etc/shadow"
├── Strip /proc/<pid>/root/ prefix → "/etc/shadow"
└── Resolve ".." components → "/etc/shadow"
│
▼
check_file_policy("/etc/shadow") → BLOCKED (deny rule matches)
The normalize_path() function in config.rs handles three categories of bypass:
pub fn normalize_path(raw: &str) -> String {
// 1. Strip /proc/self/root/ prefix (filesystem namespace escape)
// /proc/self/root/etc/shadow → /etc/shadow
// 2. Strip /proc/<pid>/root/ prefix (other process namespace)
// /proc/1234/root/etc/shadow → /etc/shadow
// 3. Resolve ".." components (directory traversal)
// /tmp/../etc/shadow → /etc/shadow
// /home/user/../../etc/shadow → /etc/shadow
}Applied in two locations:
check_file_policy()/check_exec_policy()inconfig.rs— normalizes before policy matchingprocess_file_event()inmain.rs— normalizes before logging and alerting
Limitations: Does not resolve symlinks (e.g., /tmp/link-to-shadow → /etc/shadow). Full symlink resolution requires the kernel-side bpf_d_path() approach described in the research doc (Linux 5.11+, not yet implemented).
sys_enter_openat ← hooked since Phase 1
sys_enter_openat2 ← NEW in Phase 7
sys_enter_execve ← hooked since Phase 2
The openat2 syscall (Linux 5.6+) provides the same file-open functionality as openat but with a struct open_how argument that adds RESOLVE_* flags. Without hooking openat2, an agent could bypass all monitoring by using the newer syscall.
eBPF implementation in guardian-ebpf/src/main.rs:
#[tracepoint]
pub fn guardian_file_openat2(ctx: TracePointContext) -> u32 {
// x86_64 tracepoint layout:
// offset 16: int dfd
// offset 24: const char __user *filename
// offset 32: struct open_how __user *how
// Read filename at offset 24 (same position as openat)
// Read flags from struct open_how (first u64 field)
// Evaluate policy → set PENDING_DENY if blocked
// Send event to userspace via EVENTS perf buffer
}Graceful fallback in daemon (main.rs):
// openat2 may not exist on kernels < 5.6
let has_openat2 = load_tracepoint(&mut bpf, "guardian_file_openat2").is_ok();
// Only attach if load succeeded
if has_openat2 {
match attach_tracepoint(&mut bpf, "guardian_file_openat2", "syscalls", "sys_enter_openat2") {
Ok(()) => info!("Attached: syscalls/sys_enter_openat2 (openat2 monitoring)"),
Err(e) => warn!("openat2 tracepoint not available (kernel < 5.6?): {}", e),
}
}The daemon continues to function on older kernels — openat2 support is additive, not required.
The handle_request_permission() function in ipc.rs was rewritten as a multi-stage hardening pipeline. Each stage can short-circuit the request before a human ever sees it:
Agent sends RequestPermission via Unix socket
│
▼
┌─────────────────────────────────────────────┐
│ Stage 1: MAX PENDING CHECK │
│ Agent already has ≥2 pending requests? │
│ → Immediate DENY: "Too many pending" │
└─────────────────────┬───────────────────────┘
│ pass
▼
┌─────────────────────────────────────────────┐
│ Stage 2: AUTO-DENY │
│ Resource on never-approve list? │
│ (/etc/shadow, /etc/sudoers, ~/.ssh/*) │
│ → Immediate DENY + audit trail │
└─────────────────────┬───────────────────────┘
│ pass
▼
┌─────────────────────────────────────────────┐
│ Stage 3: AUTO-APPROVE │
│ Resource on safe-list with max duration? │
│ (/tmp/**, /proc/self/**) │
│ → Immediate APPROVE + grant + audit trail │
└─────────────────────┬───────────────────────┘
│ pass
▼
┌─────────────────────────────────────────────┐
│ Stage 4: RATE LIMITING │
│ Per-minute limit exceeded? (3/min) │
│ Per-hour limit exceeded? (15/hr) │
│ Exponential backoff cooldown active? │
│ Same resource denied within 5 minutes? │
│ → Immediate DENY + audit trail │
└─────────────────────┬───────────────────────┘
│ pass
▼
┌─────────────────────────────────────────────┐
│ Stage 5: RISK CLASSIFICATION │
│ Score resource: path patterns + exec type │
│ + post-denial multiplier + rate multiplier │
│ → RiskLevel: Low / Medium / High / Critical│
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Stage 6: JUSTIFICATION ANALYSIS │
│ Scan for suspicious patterns: │
│ "urgent", "bypass", "trust me", "admin"... │
│ → Bump risk level if patterns found │
│ → Add warning flags to UI │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Stage 7: CREATE REQUEST & WAIT │
│ Create oneshot channel │
│ Broadcast to dashboard via SSE │
│ Agent blocks for up to 120 seconds │
└─────────────────────┬───────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
Human approves 120s timeout
via dashboard (auto-deny)
│ │
▼ ▼
Grant created Denial recorded
+ audit trail + audit trail
│ │
└─────────┬─────────┘
▼
Agent unblocks with decision
Each agent has an AgentRateLimit struct tracking request patterns:
pub struct AgentRateLimit {
pub requests_this_minute: u32, // Resets every 60s
pub requests_this_hour: u32, // Resets every 3600s
pub minute_reset: Instant,
pub hour_reset: Instant,
pub consecutive_denials: u32, // For exponential backoff
pub last_denial_at: Option<Instant>,
pub recently_denied_resources: HashMap<String, Instant>, // 5-min cooldown
}Thresholds and escalation:
| Parameter | Value | Purpose |
|---|---|---|
| Max requests/minute | 3 | Prevents approval flood attacks |
| Max requests/hour | 15 | Prevents sustained harassment |
| Cooldown after denial | 30s × 2^(n-1), max 600s | Exponential backoff discourages persistence |
| Max pending/agent | 2 | Forces serialization of requests |
| Same-resource cooldown | 5 minutes | Prevents re-request spam for denied resources |
Exponential backoff formula:
cooldown = min(deny_cooldown_secs × 2^(consecutive_denials - 1), 600)
After 1 denial: 30s cooldown
After 2 denials: 60s cooldown
After 3 denials: 120s cooldown
After 4 denials: 240s cooldown
After 5 denials: 480s cooldown
After 6+ denials: 600s cooldown (10 minute cap)
Consecutive denials reset to 0 on any approval, so legitimate agents recover quickly.
Every permission request is assigned a risk score that maps to a 4-tier risk level:
Score Range → Risk Level → UI Treatment
0 - 25 → LOW → Standard banner, no wait
26 - 50 → MEDIUM → Yellow banner, 3s wait before approve
51 - 75 → HIGH → Orange banner, 5s wait before approve
76 - 100 → CRITICAL → Red banner, 10s wait, type "CONFIRM" to approve
Scoring algorithm:
Base score: 25 (MEDIUM)
Path pattern adjustments:
Critical paths (/etc/shadow, /etc/sudoers, /root/**): score = 90
High paths (/etc/passwd, /var/log/**): score = 60
Low paths (/tmp/**, /proc/self/status, /proc/meminfo): score = 10
Multipliers (applied sequentially):
Exec type (resource_type == "exec"): score × 1.5
Post-denial (agent has consecutive denials > 0): score × 2.0
High request rate (agent has > 5 requests/hour): score × 1.3
Justification risk bump:
If suspicious patterns detected in justification text: score bumped up one tier
Critical path patterns:
const CRITICAL_PATTERNS: &[&str] = &[
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d/**",
"/root/**",
"/root/.bash_history",
];High-risk executable patterns:
const HIGH_RISK_EXECS: &[&str] = &[
"/usr/bin/curl", "/usr/bin/wget", "/usr/bin/nc",
"/usr/bin/ncat", "/usr/bin/ssh", "/usr/bin/scp",
"/usr/bin/rsync", "/usr/bin/rm", "/usr/bin/dd",
"/usr/sbin/mkfs", "/usr/bin/shred",
];Example risk calculations:
| Request | Base | Adjustments | Final Score | Level |
|---|---|---|---|---|
file /tmp/cache.txt |
10 | low_risk_path | 10 | LOW |
file /etc/passwd |
60 | sensitive_path | 60 | HIGH |
file /etc/shadow |
90 | critical_path | 90 | CRITICAL |
exec /usr/bin/curl |
25 | exec_type(×1.5)=37, risky_exec=max(37,60)=60 | 60 | HIGH |
file /tmp/x after 1 denial |
10 | low_risk_path, post_denial(×2.0)=20 | 20 | LOW |
file /etc/passwd after 2 denials + high rate |
60 | sensitive_path, post_denial(×2.0)=120→100 | 100 | CRITICAL |
When an agent provides a justification string (e.g., "This is urgent, trust me"), it's scanned for patterns that indicate social engineering:
const SUSPICIOUS_PATTERNS: &[(&str, &str)] = &[
// Urgency tactics
("urgent", "URGENCY"),
("immediately", "URGENCY"),
("emergency", "URGENCY"),
("asap", "URGENCY"),
// Security bypass requests
("disable security", "SECURITY_BYPASS"),
("bypass", "SECURITY_BYPASS"),
("override", "SECURITY_BYPASS"),
("skip check", "SECURITY_BYPASS"),
// Trust/reassurance manipulation
("trust me", "REASSURANCE"),
("don't worry", "REASSURANCE"),
("it's safe", "REASSURANCE"),
("it's fine", "REASSURANCE"),
("it's harmless", "REASSURANCE"),
// Authority claims
("admin told", "AUTHORITY_CLAIM"),
("supervisor", "AUTHORITY_CLAIM"),
("authorized by", "AUTHORITY_CLAIM"),
// Sensitive data mentions (contextual red flag)
("ssh key", "SENSITIVE_MENTION"),
("password", "SENSITIVE_MENTION"),
("credential", "SENSITIVE_MENTION"),
("secret", "SENSITIVE_MENTION"),
("token", "SENSITIVE_MENTION"),
("api key", "SENSITIVE_MENTION"),
];When patterns are detected:
- Warning tags appear in the permission banner and requests page
- Risk level is bumped up one tier (e.g., MEDIUM → HIGH)
- The human must acknowledge the warnings before the approve button activates
Example: Agent sends justification "This is urgent, trust me it's safe" → triggers URGENCY + REASSURANCE warnings → risk level bumped.
Configurable via the [permissions] section of config.toml:
[permissions]
auto_deny = [
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d/**",
"/root/**",
"/root/.bash_history",
]
[[permissions.auto_approve]]
pattern = "/tmp/**"
max_duration_secs = 300
[[permissions.auto_approve]]
pattern = "/proc/self/**"
max_duration_secs = 60
rate_limit_per_minute = 3
rate_limit_per_hour = 15
deny_cooldown_secs = 30
max_pending_per_agent = 2Auto-deny: Resources that should NEVER be approvable via interactive request. Agent gets immediate denial without the request ever reaching the dashboard. Applied after normalize_path() to prevent bypass.
Auto-approve: Low-risk resources that can be granted without human intervention. Reduces decision fatigue by eliminating trivial approvals. Each rule specifies a maximum grant duration.
Both are checked early in the pipeline, before rate limiting or risk classification, for efficiency.
The dashboard enforces risk-proportional friction on the approve workflow:
Permission banners change color based on risk level:
| Risk | Border Color | Icon | Banner Class |
|---|---|---|---|
| LOW | Green | ? |
.perm-banner.risk-low |
| MEDIUM | Yellow (default) | ? |
.perm-banner.risk-medium |
| HIGH | Orange | ? |
.perm-banner.risk-high |
| CRITICAL | Red | ! |
.perm-banner.risk-critical |
The approve button is disabled for a configurable period after the banner appears:
// Alpine.js component on each banner
x-data="{ dur: 600, confirmText: '', waitElapsed: 0 }"
x-init="if (req.wait_seconds > 0) {
let iv = setInterval(() => { waitElapsed++; if (waitElapsed >= req.wait_seconds) clearInterval(iv); }, 1000);
}"
// Approve button
:disabled="(req.wait_seconds > 0 && waitElapsed < req.wait_seconds)
|| (req.requires_type_confirm && confirmText !== 'CONFIRM')"During the wait period, the button text shows Wait 5s... → Wait 4s... → Wait 3s... etc.
When risk_level === 'critical', an additional input field appears:
<template x-if="req.requires_type_confirm">
<div class="perm-type-confirm">
<input type="text" x-model="confirmText" placeholder="Type CONFIRM">
<span class="hint">Type CONFIRM to enable approve</span>
</div>
</template>The approve button remains disabled until the human types exactly CONFIRM. This prevents accidental one-click approval of critical resources.
Suspicious patterns detected in the agent's justification are displayed as red warning tags:
<template x-if="req.justification_warnings && req.justification_warnings.length > 0">
<div class="perm-banner-warnings">
<template x-for="w in req.justification_warnings" :key="w">
<span class="perm-warning-tag" x-text="w"></span>
</template>
</div>
</template>These appear as inline red badges like URGENCY: "urgent" and REASSURANCE: "trust me".
Technical scoring details are shown as small gray badges below the request details:
critical_path:/etc/shadow exec_type post_denial:2 high_rate:8/hr
This gives the human operator visibility into why the risk was classified at a particular level.
CREATE TABLE IF NOT EXISTS permission_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
agent_name TEXT NOT NULL,
resource_type TEXT NOT NULL, -- "file" or "exec"
resource_path TEXT NOT NULL,
justification TEXT,
risk_level TEXT, -- "low", "medium", "high", "critical"
risk_flags TEXT, -- JSON array: ["critical_path:/etc/shadow", "exec_type"]
requested_at TEXT NOT NULL, -- RFC3339 timestamp
resolved_at TEXT NOT NULL, -- RFC3339 timestamp
approved INTEGER NOT NULL, -- 0 = denied, 1 = approved
reason TEXT NOT NULL, -- "Approved by user", "Auto-denied", "Timed out", etc.
grant_duration_secs INTEGER -- NULL if denied
);
CREATE INDEX idx_perm_audit_agent ON permission_audit(agent_name);
CREATE INDEX idx_perm_audit_resolved ON permission_audit(resolved_at DESC);
CREATE INDEX idx_perm_audit_approved ON permission_audit(approved);Every permission decision is persisted, regardless of how it was resolved:
| Resolution Path | Where Written | Reason String |
|---|---|---|
| Auto-denied (never-approve list) | handle_request_permission() |
"Auto-denied: resource is on the never-approve list" |
| Auto-approved (safe list) | handle_request_permission() |
"Auto-approved: low-risk resource" |
| Rate-limited | handle_request_permission() |
"Rate limited: {details}" |
| Timed out (120s) | handle_request_permission() |
"Timed out" |
| Human approved | resolve_permission() |
"Approved by user" |
| Human denied | resolve_permission() |
"Denied by user" |
GET /api/permissions/audit?limit=100
Returns JSON:
{
"entries": [
{
"id": 42,
"request_id": 15,
"agent_name": "code-agent",
"resource_type": "file",
"resource_path": "/etc/shadow",
"justification": "Need to check password hashes",
"risk_level": "critical",
"risk_flags": "[\"critical_path:/etc/shadow\"]",
"requested_at": "2026-03-12T10:30:00Z",
"resolved_at": "2026-03-12T10:30:00Z",
"approved": false,
"reason": "Auto-denied: resource is on the never-approve list",
"grant_duration_secs": null
}
],
"total": 42
}The persistent audit trail survives daemon restarts and enables future anomaly detection queries like:
- Approval rate > 90% in 24h → rubber-stamping warning
- Agent denied 5+ times then approved → persistence attack warning
- Agent with > 20 requests in 24h → abuse warning
┌──────────────────────┐ ┌───────────────────────────────────────┐
│ LLM Agent │ │ Guardian Daemon │
│ (cgroup sandbox) │ │ │
└──────────┬───────────┘ │ ┌─────────────────────────────────┐ │
│ │ │ handle_request_permission() │ │
guardian-ctl │ │ │ │
request-permission │ │ 1. Max pending check │ │
--name agent │ │ 2. Auto-deny check │ │
--path /etc/passwd │ │ 3. Auto-approve check │ │
--justification "..." │ │ 4. Rate limit check │ │
│ │ │ 5. Risk classification │ │
│ IPC (Unix socket) │ │ 6. Justification analysis │ │
├───────────────────>│ │ 7. Create oneshot channel │ │
│ │ │ 8. Broadcast SSE event │ │
│ (agent blocks │ └──────────────┬──────────────────┘ │
│ waiting for │ │ │
│ oneshot) │ │ SSE: permission │
│ │ │ event with risk │
│ │ │ data │
│ │ ▼ │
│ │ ┌─────────────────────────────────┐ │
│ │ │ Dashboard (all pages) │ │
│ │ │ │ │
│ │ │ ┌───────────────────────────┐ │ │
│ │ │ │ Permission Banner │ │ │
│ │ │ │ [CRITICAL] /etc/passwd │ │ │
│ │ │ │ Risk: critical_path │ │ │
│ │ │ │ Wait 5s... [Deny] │ │ │
│ │ │ │ │ │ │
│ │ │ │ (after 5s) │ │ │
│ │ │ │ [Approve] [Deny] │ │ │
│ │ │ └───────────────────────────┘ │ │
│ │ └──────────────┬──────────────────┘ │
│ │ │ │
│ │ POST /api/permissions/{id}/deny │
│ │ │ │
│ │ ┌──────────────▼──────────────────┐ │
│ │ │ resolve_permission() │ │
│ │ │ │ │
│ │ │ 1. Send via oneshot channel │ │
│ │ │ 2. Update rate limiter │ │
│ │ │ 3. Record in VecDeque │ │
│ │ │ 4. Persist to SQLite │ │
│ │ │ 5. Broadcast SSE resolution │ │
│ │ └──────────────┬──────────────────┘ │
│ │ │ │
│ oneshot resolves │ │ │
│<──────────────────────────────────── │ │
│ │ │
▼ └───────────────────────────────────────┘
Agent receives:
{ approved: false,
reason: "Denied by user" }
When a permission request is created, the following SSE event is broadcast:
{
"id": 7,
"kind": "request",
"agent_name": "code-agent",
"resource_type": "file",
"resource_path": "/etc/passwd",
"justification": "Need to read user list",
"timeout_secs": 120,
"requested_at": "2026-03-12T10:30:00Z",
"approved": null,
"reason": null,
"risk_level": "high",
"risk_flags": ["sensitive_path:/etc/passwd"],
"wait_seconds": 5,
"requires_type_confirm": false,
"justification_warnings": []
}The Alpine.js store in base.html processes these events and renders banners with the appropriate risk treatment on every page.
[permissions]
# Resources that can NEVER be approved via interactive request.
# Checked after path normalization, so bypass attempts are caught.
auto_deny = [
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d/**",
"/root/**",
"/root/.bash_history",
]
# Resources auto-approved without human intervention.
# Each rule specifies a maximum grant duration in seconds.
[[permissions.auto_approve]]
pattern = "/tmp/**"
max_duration_secs = 300 # 5 minutes
[[permissions.auto_approve]]
pattern = "/proc/self/**"
max_duration_secs = 60 # 1 minute
# Rate limiting parameters
rate_limit_per_minute = 3 # Max permission requests per agent per minute
rate_limit_per_hour = 15 # Max permission requests per agent per hour
deny_cooldown_secs = 30 # Base cooldown after denial (doubles exponentially)
max_pending_per_agent = 2 # Max simultaneous pending requests per agentIf the [permissions] section is omitted, all features still work with sensible defaults:
- Empty auto-deny/auto-approve lists (all requests go to human)
- Rate limits: 3/min, 15/hr
- Cooldown: 30s base
- Max pending: 2
| Test | What It Verifies |
|---|---|
test_auto_deny |
Auto-deny correctly blocks /etc/shadow and .ssh/** patterns |
test_auto_deny_with_bypass_attempts |
/proc/self/root/etc/shadow and /tmp/../etc/shadow are caught via normalize_path() |
test_auto_approve |
/tmp/file.txt matches auto-approve rule with correct duration |
test_risk_classification |
/etc/shadow → CRITICAL, /tmp/file.txt → LOW, exec /usr/bin/curl → ≥ HIGH |
test_justification_analysis |
"urgent, trust me it's safe" triggers URGENCY + REASSURANCE; benign text returns empty |
test_rate_limiter |
First 3 requests pass, 4th is rate-limited |
| Test | What It Verifies |
|---|---|
test_normalize_path_already_clean |
Clean paths pass through unchanged |
test_normalize_path_proc_self_root |
/proc/self/root/etc/shadow → /etc/shadow |
test_normalize_path_proc_pid_root |
/proc/1234/root/etc/shadow → /etc/shadow |
test_normalize_path_dotdot |
/tmp/../etc/shadow → /etc/shadow |
test_policy_blocks_normalized_bypass |
check_file_policy() blocks /proc/self/root/etc/shadow against deny rule /etc/shadow |
Total test count: 25 tests (15 config + 6 permissions + 4 main)
| Attack Vector | Before Phase 7 | After Phase 7 |
|---|---|---|
/proc/self/root/etc/shadow bypass |
Undetected, reads shadow | Normalized to /etc/shadow, BLOCKED |
../../../etc/shadow traversal |
Undetected, reads shadow | Normalized to /etc/shadow, BLOCKED |
/proc/<pid>/root/etc/shadow |
Undetected, reads shadow | Normalized to /etc/shadow, BLOCKED |
openat2() syscall |
Completely unmonitored | Hooked via sys_enter_openat2 tracepoint |
| Approval flood (100 requests/min) | Human overwhelmed, rubber-stamps | Rate-limited to 3/min, auto-denied |
| Re-requesting denied resource | Human fatigued, eventually approves | 5-minute same-resource cooldown + backoff |
| "Urgent! Trust me!" justification | Looks routine, approved reflexively | Warning tags displayed, risk bumped |
One-click approve of /etc/shadow |
Same UI as /tmp/file.txt |
10s wait + type "CONFIRM" required |
| Persistent re-denial attack | No escalation | Exponential backoff: 30s → 60s → 120s → ... → 10min |
| Audit trail lost on restart | In-memory only | SQLite persistence survives restarts |
| Exec bypass (log-only) | Exec events logged but never blocked | Full enforcement via LSM bprm_check_security |
Legacy open syscall |
Completely unmonitored | Hooked via sys_enter_open tracepoint |
| Network exfiltration | Not monitored at all | Outbound connections logged with port-based policy |
| Attack Vector | Status | Fix |
|---|---|---|
| Symlink to sensitive file | Open | LSM file_open with bpf_d_path() (Phase 7a #2) |
| Exec enforcement | Fixed | LSM bprm_check_security with PENDING_EXEC_DENY map |
| Network exfiltration | Partially Fixed | sys_enter_connect tracepoint with port-based policy (monitoring + userspace policy; kernel-side enforcement deferred) |
rename() policy bypass |
Open | LSM inode_rename (Phase 7a #6) |
unlink() file deletion |
Open | LSM inode_unlink (Phase 7a #7) |
| Hardlink to sensitive file | Open | LSM inode_link (Phase 7d #23) |
| Dynamic linker bypass | Open | Deny ld-linux in exec policy (Phase 7a #4) |
io_uring bypasses tracepoints |
Mitigated | LSM hooks still fire for io_uring; full fix: seccomp (Phase 7d #25) |
| Rubber-stamping detection | Open | Anomaly detection on approval patterns (Phase 7d #28) |
| Decision | Rationale |
|---|---|
| Userspace normalization (not kernel-side) | Quick win: catches 90% of bypasses with zero kernel changes. Full bpf_d_path() fix requires Linux 5.11+ and is planned for Phase 7a #2. |
Separate permissions.rs module |
Clean separation: permissions logic is independent of IPC plumbing. Testable without async runtime or BPF maps. |
| Risk scoring with multipliers | Composable: multiple risk factors compound naturally. An exec of a sensitive file after multiple denials escalates dramatically. |
| String matching (not regex) for justifications | Lightweight: no regex crate dependency in a no_std-adjacent module. Simple substring matching catches the most common social engineering patterns with near-zero false positives. |
| Exponential backoff on denials | Standard rate-limiting pattern. Allows legitimate agents to recover (resets on approval) while making persistence attacks increasingly costly. |
| Auto-deny before rate limit check | Critical resources should be rejected instantly, even if the agent hasn't been rate-limited yet. Don't waste rate limit budget on known-bad requests. |
| Auto-approve before rate limit check | Low-risk resources shouldn't consume rate limit budget. An agent accessing /tmp repeatedly shouldn't trigger rate limiting. |
SQLite audit in IpcState |
The DB reference is Option<Arc<EventDb>> — None when dashboard is disabled. Permission hardening still works without SQLite; audit is additive. |
| openat2 graceful fallback | load_tracepoint returning Err is non-fatal. Daemon continues with openat-only coverage on older kernels. |
| Type "CONFIRM" not resource path | The research doc suggested typing the resource path. Changed to "CONFIRM" for UX — resource paths can be long and error-prone to type. Still forces deliberate action. |
| Wait timer per-banner (not global) | Each banner has its own countdown via Alpine.js x-data. If two CRITICAL requests arrive, each has its own 10s timer. |
| Risk flags as gray badges | Shows the human why the risk level was set (transparency) without cluttering the primary UI. Advanced users can see scoring details. |
| Permission ID increment in auto-deny path | Even auto-denied requests consume a permission ID for consistent audit trail numbering. |
Complete permission hardening module:
- Lines 15-128:
AgentRateLimit— per-agent rate limiting state with minute/hour counters, exponential backoff, same-resource cooldown - Lines 135-168:
RiskLevelenum — Low/Medium/High/Critical withwait_seconds()andrequires_type_confirm()methods - Lines 177-293:
classify_risk()— scores resource path/type against pattern lists with multipliers - Lines 300-320:
check_auto_deny()/check_auto_approve()— pattern matching against config - Lines 327-370:
analyze_justification()— suspicious pattern detection in justification text - Lines 376-459: Tests (6 tests)
- Added
PermissionsConfigstruct with serde defaults - Added
AutoApproveRulestruct - Added
permissions: Option<PermissionsConfig>toConfig - Added
pub fn normalize_path(raw: &str) -> String(30 lines) - Modified
check_file_policy()to callnormalize_path()before matching - Modified
check_exec_policy()to callnormalize_path()before matching - Made
path_matches()public (pub fn) - Added 5 new tests for normalization and bypass prevention
- Added
guardian_file_openat2tracepoint handler (82 lines) - Reads
filenameat offset 24,struct open_how *at offset 32 - Extracts flags from first field of
open_howstruct - Same policy evaluation and PENDING_DENY logic as
guardian_file_open
- Added
rate_limits: HashMap<String, AgentRateLimit>toIpcState - Added
event_db: Option<Arc<EventDb>>toIpcState - Added risk fields to
PendingPermission:risk_level,risk_flags,justification_flags - Added risk fields to
ResolvedPermission:risk_level,risk_flags - Added risk fields to
PermissionEvent:risk_level,risk_flags,wait_seconds,requires_type_confirm,justification_warnings - Rewrote
handle_request_permission()with 7-stage hardening pipeline - Added SQLite persistence in
resolve_permission()and all auto-resolve paths (auto-deny, auto-approve, rate-limited, timeout)
- Added
mod permissions; - Added
normalize_pathcall inprocess_file_event()before policy check - Added openat2 tracepoint load:
let has_openat2 = load_tracepoint(...).is_ok() - Added openat2 tracepoint attach with fallback warning
- Added
rate_limits: HashMap::new()andevent_db: Noneto IpcState init - Added
s.event_db = Some(db.clone())when dashboard starts
- Added
permission_audittable creation with 3 indexes - Added
StoredPermissionAuditstruct (14 fields) - Added
insert_permission_audit()method (serializes risk_flags to JSON) - Added
query_permission_audit()method (returns recent entries)
- Updated
list_pending_permissionsto includerisk_level,risk_flags,wait_seconds,requires_type_confirm,justification_warnings - Added
query_permission_auditendpoint - Added
AuditQuerystruct
- Added
risk_level,risk_flags,wait_seconds,requires_type_confirm,justification_warningstoPendingRequestInfo - Added
risk_leveltoResolvedRequestInfo - Updated
requests()handler to populate new fields
- Rewrote permission banner template (72 → 54 lines of template code):
- Risk-colored banner via
:class="'risk-' + risk_level" - Risk level badge
- Justification warning tags
- Risk flags as gray badges
- Wait timer countdown with
x-initinterval - Type-to-confirm input for CRITICAL
- Conditional approve button disabled state
- Risk-colored banner via
- Updated Alpine.js store to set defaults for new fields from both fetch and SSE paths
- Added Risk column to pending table with badge + warnings + flags
- Added per-row Alpine.js wait timer (
waitElapsedcounter) - Added conditional type-to-confirm input for
requires_type_confirmrows - Added Risk column to resolved history table
- Updated colspan from 7 to 8 for empty states
- Added
.badge-risk-low/medium/high/criticalbadge styles - Added
.perm-banner.risk-low/medium/high/criticalborder color variants - Added
.perm-banner-risk,.perm-banner-warningslayout styles - Added
.perm-warning-tagred warning badge style - Added
.perm-type-confirminput layout style
| Method | Path | Change |
|---|---|---|
| GET | /api/permissions/pending |
Modified: Now includes risk_level, risk_flags, wait_seconds, requires_type_confirm, justification_warnings |
| GET | /api/permissions/audit |
New: Query persistent SQLite audit trail. Params: ?limit=100 |
Phase 7 upgrades exec monitoring from log-only (Phase 2) to full kernel-side enforcement. Previously, exec events were captured by the sys_enter_execve tracepoint and logged, but the binary was never blocked from executing. Now, a new LSM hook on bprm_check_security can deny exec at the kernel level, preventing the binary from ever running.
Exec enforcement requires its own set of BPF maps, structurally mirroring the file access maps:
| Map | Type | Purpose |
|---|---|---|
EXEC_DENY_EXACT |
HashMap | Exact-match deny rules for exec paths |
EXEC_DENY_PREFIXES |
Array | Prefix-based deny rules for exec paths |
EXEC_ALLOW_EXACT |
HashMap | Exact-match allow rules for exec paths |
EXEC_ALLOW_PREFIXES |
Array | Prefix-based allow rules for exec paths |
EXEC_DEFAULT_ACTION |
Array | Global default action for exec (allow/deny) |
EXEC_CGROUP_DEFAULT_ACTION |
HashMap | Per-cgroup default action for exec |
PENDING_EXEC_DENY |
HashMap | Pending exec denials keyed by pid_tgid |
Added to guardian-ebpf/src/main.rs. Structurally identical to evaluate_policy() but reads from the exec-specific maps (EXEC_DENY_EXACT, EXEC_DENY_PREFIXES, EXEC_ALLOW_EXACT, EXEC_ALLOW_PREFIXES, EXEC_DEFAULT_ACTION, EXEC_CGROUP_DEFAULT_ACTION). This separation ensures exec policy evaluation is completely independent of file access policy evaluation.
The existing sys_enter_execve tracepoint handler now calls evaluate_exec_policy() after capturing the exec path. When the policy evaluates to deny, it sets PENDING_EXEC_DENY[pid_tgid] to signal the LSM hook. The event is still sent to userspace via the perf event array for logging.
Attached to the bprm_check_security LSM hook point. This hook fires during exec processing after the kernel has loaded the binary. It checks the PENDING_EXEC_DENY map for the current pid_tgid:
- If found: deletes the map entry and returns
-EPERM(the binary never executes) - If not found: returns
0(exec proceeds normally)
This follows the same PENDING pattern used for file enforcement (PENDING_DENY + file_open LSM).
The sys_enter_execve tracepoint fires at syscall entry, before the kernel begins processing the exec. The kernel then opens the binary file internally and calls bprm_check_security during exec processing. This guarantees the tracepoint has already evaluated policy and populated PENDING_EXEC_DENY before the LSM hook checks it:
sys_enter_execve fires --> exec tracepoint evaluates exec policy --> sets PENDING_EXEC_DENY
--> kernel opens binary internally (no sys_enter_openat fires)
--> file_open LSM fires --> checks PENDING_DENY --> empty --> allows
--> bprm_check_security fires --> checks PENDING_EXEC_DENY --> -EPERM --> exec BLOCKED
During execve, the kernel internally opens the binary file to read its contents. This internal open triggers the file_open LSM hook. If exec denials were stored in the same PENDING_DENY map used for file access enforcement, the file_open hook would consume the denial entry (delete it after reading), and the bprm_check_security hook would find nothing. The binary would be read (file open allowed) but the exec denial would be lost. Separate maps ensure clean isolation between file access enforcement and exec enforcement.
The load_lsm() and attach_lsm() helper functions were generalized to accept a program name and hook name as parameters, allowing them to load both file_open and bprm_check_security LSM programs. If bprm_check_security attachment fails (kernel does not support it, or BPF LSM is not enabled), exec falls back to monitor-only mode. The daemon logs a warning and continues operating with file enforcement intact.
populate_exec_enforcement_maps()inmain.rspopulates the 7 exec maps from the agent configuration, mirroring howpopulate_enforcement_maps()works for file access.process_exec_event()updated to log "BLOCKED" (not just "DENY") when the daemon is in enforce mode and the policy denies the exec.- Path normalization (via
normalize_path()) applied to exec events before policy evaluation, consistent with file access events.
On modern Linux (glibc 2.26+, released 2017), the open() C library function is implemented as openat(AT_FDCWD, ...), so the existing sys_enter_openat tracepoint already captures these calls. However, ancient statically-linked binaries or hand-rolled assembly may still invoke the raw open syscall (syscall number 2 on x86_64) directly, bypassing openat entirely.
As a belt-and-suspenders measure, Phase 7 adds a guardian_file_open_legacy tracepoint attached to sys_enter_open:
- Tracepoint field offsets (x86_64): filename pointer at offset 16, flags at offset 24
- Same logic as
guardian_file_open: callsevaluate_policy()to check file access rules, setsPENDING_DENY[pid_tgid]on denial, and sends the event to userspace via the perf event array - Reuses existing maps:
EVENT_BUF,EVENTS, andPENDING_DENYare shared with theopenathandler. No new BPF maps are needed. - Graceful fallback: The tracepoint is loaded and attached with an
is_ok()check. Ifsys_enter_opendoes not exist on the running kernel (some architectures have removed it), the daemon continues withopenat-only andopenat2coverage. A warning is logged but operation is not affected.
Phase 7 adds monitoring of outbound network connections, allowing administrators to detect and log when agents connect to external hosts. This is monitoring-only — no kernel-side enforcement is implemented yet.
Added to guardian-common/src/lib.rs. A 52-byte #[repr(C)] struct containing:
| Field | Type | Description |
|---|---|---|
pid |
u32 |
Process ID |
tgid |
u32 |
Thread group ID |
uid |
u32 |
User ID |
family |
u16 |
Address family (AF_INET=2 or AF_INET6=10) |
dest_port |
u16 |
Destination port (network byte order converted to host) |
dest_addr4 |
u32 |
IPv4 destination address (for AF_INET) |
dest_addr6 |
[u8; 16] |
IPv6 destination address (for AF_INET6) |
comm |
[u8; 16] |
Process command name |
| Map | Type | Purpose |
|---|---|---|
NET_EVENT_BUF |
PerCpuArray | Scratch buffer for building NetworkEvent (avoids 512-byte stack limit) |
NET_EVENTS |
PerfEventArray | Sends network events to userspace daemon |
Attached to sys_enter_connect, this tracepoint monitors outbound connection attempts:
- Reads the
sockaddr *pointer from the tracepoint args at offset 24 - Reads
sa_family(first 2 bytes of sockaddr) to determine the address family - For AF_INET (family=2): reads the 8-byte
sockaddr_instructure — port at byte offset 2, IPv4 address at byte offset 4 - For AF_INET6 (family=10): reads the 28-byte
sockaddr_in6structure — port at byte offset 2, IPv6 address at byte offset 8 - Skips non-IP connections (Unix domain sockets, netlink, etc.) by returning early for unrecognized families
- Populates the
NetworkEventinNET_EVENT_BUFand sends it to userspace viaNET_EVENTS
Added to guardian/src/config.rs:
pub struct NetworkPolicy {
pub default: String, // "allow" or "deny"
pub allow_ports: Vec<u16>,
pub deny_ports: Vec<u16>,
}Configuration example in config.toml:
[agents.network_policy]
default = "allow"
deny_ports = [22, 25, 445]
allow_ports = [80, 443, 53]check_network_policy()evaluates port-based network rules following the same deny-takes-precedence model as file access policy. If a port appears in bothallow_portsanddeny_ports, the deny rule wins.process_net_event()formats IPv4 addresses as dotted-quad notation and IPv6 addresses as colon-separated hex, then dispatches the event to the alerting system.
Network monitoring is monitoring-only in this phase. There is no LSM socket_connect hook — policy violations are logged as warnings but connections are not blocked at the kernel level. Kernel-side network enforcement is deferred to a future phase.
Added to guardian/src/alerting/mod.rs to support network events in the alerting pipeline. Network events flow through the same AlertManager dispatch path as file and exec events, supporting deduplication, severity filtering, and all configured alert outputs (JSON log, webhook, Slack, email).
The network tracepoint is loaded and attached with an is_ok() check. If sys_enter_connect attachment fails, network monitoring is silently disabled and the daemon continues with file and exec monitoring only.
Symptom: The dashboard becomes completely unresponsive after visiting 3-4 pages. No page loads, no API responses — the browser appears to hang indefinitely.
Root Cause: HTTP/1.1 browsers enforce a limit of ~6 concurrent connections per origin (per the HTTP/1.1 spec, RFC 7230). Server-Sent Events (SSE) connections are long-lived — they stay open for the lifetime of the page. The dashboard was creating multiple SSE connections per page load without ever closing them:
base.html(Alpine.js storeinit()): Creatednew EventSource('/events/stream')on every page — this runs on ALL pages since every template extendsbase.htmlindex.html(recentEvents()component): Created a SECONDEventSource('/events/stream')for the live event tableevents.html(eventFilter()component): Created a SECONDEventSource('/events/stream')for the live event stream with filtering
Each page navigation created 1-2 new SSE connections that were never closed (no beforeunload cleanup). The connection lifecycle looked like:
Visit / → 2 SSE connections (base.html + index.html) = 2 total
Navigate /events → 2 SSE connections (base.html + events.html) = 4 total
Navigate /agents → 1 SSE connection (base.html) = 5 total
Navigate /policy → 1 SSE connection (base.html) = 6 total ← LIMIT HIT
Navigate /alerts → Browser queues request, waits for a free slot ← HANGS
At 6 connections, all HTTP/1.1 connection slots are consumed by stale SSE connections from previous pages. The browser queues all new requests (page loads, API calls, htmx requests) waiting for a slot to free up. Since SSE connections never close on their own, the dashboard is permanently stuck.
Fix (3 files changed):
-
base.html— Single shared SSE connection architecture:- Moved SSE creation to a standalone IIFE that runs before Alpine initializes
- Stores the connection as
window.__guardianSSE - Relays SSE event types (
event,lag,permission) as custom DOM events (guardian:event,guardian:lag,guardian:permission) - Added
beforeunloadevent listener to close the SSE connection when navigating away from the page - Alpine.js store now listens to
guardian:permissionDOM events instead of directly to the EventSource
// Single shared SSE — runs once, before Alpine (function() { var sse = new EventSource('/events/stream'); window.__guardianSSE = sse; // Relay to DOM events so child pages don't need their own SSE sse.addEventListener('event', function(e) { document.dispatchEvent(new CustomEvent('guardian:event', { detail: e.data })); }); sse.addEventListener('permission', function(e) { document.dispatchEvent(new CustomEvent('guardian:permission', { detail: e.data })); }); // Close on navigation to free the connection slot window.addEventListener('beforeunload', function() { if (sse) { sse.close(); } }); })();
-
index.html—recentEvents()component:- Removed
sse,retryDelayfields andconnect()method entirely init()now listens toguardian:event,guardian:sse-open,guardian:sse-errorDOM events- Connection status derived from
window.__guardianSSE.readyState - Zero EventSource instances created by this page
- Removed
-
events.html—eventFilter()component:- Same treatment as
index.html: removedsse,retryDelay,connect()method - Listens to
guardian:event,guardian:lag,guardian:sse-open,guardian:sse-errorDOM events - Zero EventSource instances created by this page
- Same treatment as
After fix — connection lifecycle:
Visit / → 1 SSE connection (shared from base.html)
Navigate /events → old SSE closed (beforeunload), 1 new SSE = 1 total
Navigate /agents → old SSE closed (beforeunload), 1 new SSE = 1 total
Navigate /policy → old SSE closed (beforeunload), 1 new SSE = 1 total
... (always exactly 1 SSE connection, properly cleaned up)
Design decision: Custom DOM events (CustomEvent) were chosen over having child pages directly reference window.__guardianSSE because:
- Decoupled: child pages don't need to know about EventSource API or manage listeners
- Timing-safe: DOM events work regardless of whether the SSE was open before or after the child component initialized
- Single responsibility: base.html owns the connection lifecycle; child pages just consume events
The remaining items from docs/security-improvements-research.md:
- LSM
file_openwithbpf_d_path()for canonical path enforcement (eliminates symlink attacks) - LSM
bprm_check_securityfor exec blocking - Dynamic linker detection (
ld-linuxblocking) - LSM
inode_renameandinode_unlinkfor rename/delete enforcement
cgroup/connect4BPF program for per-agent network policy- Network event types and config model
- Domain → IP resolution at startup
- Inode-based deny map for critical files
- Content hashing via
bpf_ima_file_hash(Linux 5.18+) io_uringblocking via seccomp- Anomaly detection on approval patterns