Skip to content

Commit 5bd9f10

Browse files
RoyLinRoyLin
authored andcommitted
release: v0.4.0 — file-access intervention (fileguard), KVM-validated
a3s-observer-fileguard: fanotify FAN_OPEN_PERM guard denying open() of policy-listed files. Marks the specific files (not the mount, which gates+can-wedge all system I/O). Fixed a misaligned-read abort (read_unaligned). Validated in a non-prod KVM VM: denied file's open() -> EPERM, sibling opens fine, sshd/system unaffected. Second intervention example ('阻止某些文件读写') done. 0.3.0 -> 0.4.0.
1 parent 8edc756 commit 5bd9f10

7 files changed

Lines changed: 65 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5-
## [Unreleased]
5+
## [0.4.0] — file-access intervention
66

77
### Added
88

99
- **File-access intervention** (opt-in): `a3s-observer-fileguard` — a fanotify `FAN_OPEN_PERM`
10-
guard that denies `open()` of paths matching an external deny-prefix policy file (the other
11-
example from the intervention ask, "阻止某些文件读写"). Chosen over eBPF LSM `file_open`
12-
because `bpf` is **not** in this kernel's active `lsm=` set (would need a custom boot
13-
cmdline); fanotify is stock-kernel and userspace-policy-driven — the same external model as
14-
the egress guard. **Build-validated; runtime validation pending a non-prod VM** (file
15-
enforcement on a host is gated to a non-prod box, same as egress).
10+
guard that denies `open()` of the specific files listed in an external policy file (the other
11+
example from the intervention ask, "阻止某些文件读写"). Marks the named files (not the whole
12+
mount — a mount-wide perm guard gates *all* system I/O and can wedge services). Chosen over
13+
eBPF LSM `file_open` because `bpf` is not in this kernel's active `lsm=` set; fanotify is
14+
stock-kernel and userspace-policy-driven — the same external model as the egress guard.
15+
- **Validated end-to-end in a throwaway KVM VM**: the denied file's `open()` returns `EPERM`
16+
(`cat` → "Operation not permitted") while a sibling file opens normally and system services
17+
are unaffected.
1618

1719
## [0.3.0] — external intervention interface (enforcement)
1820

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-collector"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/bin/fileguard.rs

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
//! a3s-observer-fileguard — OPT-IN file-access intervention via fanotify `FAN_OPEN_PERM`.
22
//!
3-
//! The eBPF-native path (LSM `file_open`) needs `bpf` in the kernel's `lsm=` set, which is
4-
//! not enabled by default (checked: `lockdown,capability,landlock,yama,apparmor`). fanotify
5-
//! works on a stock kernel and is userspace-policy-driven — the same external-intervention
6-
//! model: the policy lives outside (a plain deny-prefix file any controller writes), the
7-
//! kernel asks this guard ALLOW/DENY per open. See `docs/enforcement.md`.
3+
//! Marks each file path listed in the policy and denies `open()` of it (EPERM). Marking the
4+
//! specific files (not the whole mount) is deliberate: a mount-wide `FAN_MARK_MOUNT` perm
5+
//! guard gates *every* open on the filesystem, including system services' own I/O, and can
6+
//! wedge them. The eBPF-native equivalent would be LSM `file_open`, but `bpf` is not in this
7+
//! kernel's `lsm=` set (would need a custom boot cmdline); fanotify is stock-kernel and
8+
//! userspace-policy-driven — the same external-intervention model as the egress guard.
89
//!
9-
//! sudo a3s-observer-fileguard <watch-path> <policy-file>
10+
//! sudo a3s-observer-fileguard <policy-file> # one file path per line to deny
1011
//!
11-
//! `<policy-file>`: one path prefix per line (`#` comments); an open whose resolved path
12-
//! starts with any prefix is denied (EPERM). Fail-open on anything else.
12+
//! ponytail: exact-file marks. Blocking a whole subtree by prefix needs FAN_MARK_MOUNT +
13+
//! path filtering, which gates the entire mount — add that only if a real use case needs it.
1314
1415
use anyhow::{anyhow, Context as _};
1516
use std::fs;
1617

17-
const FAN_ALLOW: u32 = 0x01;
1818
const FAN_DENY: u32 = 0x02;
1919

2020
fn main() -> anyhow::Result<()> {
21-
let mut args = std::env::args().skip(1);
22-
let usage = "usage: a3s-observer-fileguard <watch-path> <policy-file>";
23-
let watch = args.next().context(usage)?;
24-
let policy_path = args.next().context(usage)?;
21+
let policy_path = std::env::args()
22+
.nth(1)
23+
.context("usage: a3s-observer-fileguard <policy-file>")?;
24+
let deny = load_policy(&policy_path);
25+
if deny.is_empty() {
26+
return Err(anyhow!("no file paths in policy {policy_path}"));
27+
}
2528

26-
// FAN_CLASS_CONTENT enables permission (allow/deny) events.
2729
let fan = unsafe {
2830
libc::fanotify_init(
2931
libc::FAN_CLASS_CONTENT | libc::FAN_CLOEXEC,
@@ -36,29 +38,27 @@ fn main() -> anyhow::Result<()> {
3638
std::io::Error::last_os_error()
3739
));
3840
}
39-
// Read the policy BEFORE marking: once the mount is marked, any open on it (including this
40-
// process's own) is gated until the read loop responds — opening the policy file after the
41-
// mark would deadlock. ponytail: read-once; restart to reload, add mtime-poll if needed.
42-
let deny = load_policy(&policy_path);
43-
// Watch the whole mount of `watch`; we filter by path against the policy.
44-
let cpath = std::ffi::CString::new(watch.clone())?;
45-
let rc = unsafe {
46-
libc::fanotify_mark(
47-
fan,
48-
libc::FAN_MARK_ADD | libc::FAN_MARK_MOUNT,
49-
libc::FAN_OPEN_PERM,
50-
libc::AT_FDCWD,
51-
cpath.as_ptr(),
52-
)
53-
};
54-
if rc < 0 {
55-
return Err(anyhow!(
56-
"fanotify_mark {watch} failed: {}",
57-
std::io::Error::last_os_error()
58-
));
41+
42+
let mut marked = 0usize;
43+
for p in &deny {
44+
let c = std::ffi::CString::new(p.as_str())?;
45+
let rc = unsafe {
46+
libc::fanotify_mark(
47+
fan,
48+
libc::FAN_MARK_ADD,
49+
libc::FAN_OPEN_PERM,
50+
libc::AT_FDCWD,
51+
c.as_ptr(),
52+
)
53+
};
54+
if rc == 0 {
55+
marked += 1;
56+
} else {
57+
eprintln!("warn: cannot mark {p}: {}", std::io::Error::last_os_error());
58+
}
5959
}
6060
eprintln!(
61-
"a3s-observer-fileguard: FAN_OPEN_PERM on mount of {watch}; {} deny-prefixes from {policy_path}",
61+
"a3s-observer-fileguard: denying open() of {marked}/{} file(s) from {policy_path}",
6262
deny.len()
6363
);
6464

@@ -74,18 +74,20 @@ fn main() -> anyhow::Result<()> {
7474
let mut off = 0usize;
7575
let meta_sz = std::mem::size_of::<libc::fanotify_event_metadata>();
7676
while off + meta_sz <= n as usize {
77-
let meta = unsafe { &*(buf.as_ptr().add(off) as *const libc::fanotify_event_metadata) };
77+
// read_unaligned: the byte buffer isn't 8-aligned, so a &reference would be UB.
78+
let meta = unsafe {
79+
std::ptr::read_unaligned(
80+
buf.as_ptr().add(off) as *const libc::fanotify_event_metadata
81+
)
82+
};
7883
if meta.vers != libc::FANOTIFY_METADATA_VERSION {
7984
break;
8085
}
8186
if meta.mask & u64::from(libc::FAN_OPEN_PERM) != 0 && meta.fd >= 0 {
82-
let path = fs::read_link(format!("/proc/self/fd/{}", meta.fd))
83-
.map(|p| p.to_string_lossy().into_owned())
84-
.unwrap_or_default();
85-
let denied = deny.iter().any(|d| path.starts_with(d.as_str()));
87+
// Only denied files are marked, so every permission event is a denial.
8688
let resp = libc::fanotify_response {
8789
fd: meta.fd,
88-
response: if denied { FAN_DENY } else { FAN_ALLOW },
90+
response: FAN_DENY,
8991
};
9092
unsafe {
9193
libc::write(
@@ -94,9 +96,10 @@ fn main() -> anyhow::Result<()> {
9496
std::mem::size_of::<libc::fanotify_response>(),
9597
);
9698
}
97-
if denied {
98-
eprintln!("[fileguard] DENY open {path}");
99-
}
99+
let path = fs::read_link(format!("/proc/self/fd/{}", meta.fd))
100+
.map(|p| p.to_string_lossy().into_owned())
101+
.unwrap_or_default();
102+
eprintln!("[fileguard] DENY open {path}");
100103
}
101104
if meta.fd >= 0 {
102105
unsafe { libc::close(meta.fd) };

a3s-observer-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-common"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-ebpf/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-ebpf"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

0 commit comments

Comments
 (0)