Skip to content

Commit 4927d2e

Browse files
RoyLinRoyLin
authored andcommitted
release: v0.9.0 — file deletions (FileDelete via unlinkat)
sys_enter_unlinkat -> AgentEvent::FileDelete {pid, path} (opt-in A3S_OBSERVER_FILES), completing the file lifecycle (write + delete) — destructive actions now visible (rm -rf shows which files went). Host-validated: deleting a marker -> FileDelete{path}. Shares the file ring via a flags sentinel (no extra ring/struct). 0.8.0 -> 0.9.0.
1 parent bcba3ec commit 4927d2e

12 files changed

Lines changed: 65 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

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

5+
## [0.9.0] — file deletions (destructive actions)
6+
7+
### Added
8+
9+
- **`FileDelete` event**`sys_enter_unlinkat` captures which files an agent **deletes**
10+
(opt-in with `A3S_OBSERVER_FILES=1`), completing the file lifecycle (write + delete). You now
11+
see destructive actions: `rm -rf /data` shows *which* files went, not just that `rm` ran.
12+
Host-validated: deleting a marker file surfaced `FileDelete{path:…}`. Shares the file ring via
13+
a `flags` sentinel (no extra ring/struct).
14+
515
## [0.8.0] — process lifecycle (exit + exit code)
616

717
### Added

Cargo.lock

Lines changed: 4 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.8.0"
3+
version = "0.9.0"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ latency / TTFT, or plaintext) / **where** (peer IP / hostname).
5050
| `dns` | `sendto` / `sendmsg` / `sendmmsg` to :53 | `Dns` — resolved hostname |
5151
| llm metrics | per-socket `read`/`recv` + `close` | `LlmCall` — req/resp wire bytes, latency, TTFT |
5252
| `file`\* | `sys_enter_openat` (write opens) | `FileAccess` — files written (`A3S_OBSERVER_FILES=1`) |
53+
| `unlink`\* | `sys_enter_unlinkat` | `FileDelete` — files deleted (`A3S_OBSERVER_FILES=1`) |
5354
| `ssl`\* | OpenSSL `SSL_write` / `SSL_read` uprobes | `SslContent` — request/response plaintext (`A3S_OBSERVER_SSL=1`) |
5455

5556
Userspace enriches each event with **identity** (k8s cgroup→pod, `/proc` comm+ppid, or an

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.8.0"
3+
version = "0.9.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/main.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use a3s_observer::{
1212
};
1313
use a3s_observer_common::{
1414
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
15+
FILE_DELETE_FLAG,
1516
};
1617
use anyhow::Context as _;
1718
use aya::{
@@ -72,6 +73,7 @@ async fn main() -> anyhow::Result<()> {
7273
];
7374
if files {
7475
probes.push(("file_open", "sys_enter_openat"));
76+
probes.push(("file_unlink", "sys_enter_unlinkat"));
7577
}
7678
// Per-probe attach is non-fatal: kernels vary, and one missing tracepoint shouldn't take
7779
// down the whole collector — degrade to whatever attaches, fail only if nothing does.
@@ -305,14 +307,20 @@ async fn main() -> anyhow::Result<()> {
305307
if let Some(ev) = read_pod::<FileEvent>(&item) {
306308
let path = cstr(&ev.path);
307309
if !path.is_empty() {
308-
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
309-
identity: identity_for(&resolver, ev.pid, &ev.comm),
310-
provider: None,
311-
event: AgentEvent::FileAccess {
310+
// Same ring carries opens and deletes — the sentinel flag tells them apart.
311+
let event = if ev.flags == FILE_DELETE_FLAG {
312+
AgentEvent::FileDelete { pid: ev.pid, path }
313+
} else {
314+
AgentEvent::FileAccess {
312315
pid: ev.pid,
313316
path,
314317
write: ev.flags & 0x3 != 0,
315-
},
318+
}
319+
};
320+
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
321+
identity: identity_for(&resolver, ev.pid, &ev.comm),
322+
provider: None,
323+
event,
316324
});
317325
}
318326
}
@@ -467,6 +475,7 @@ fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
467475
AgentEvent::Egress { .. } => stats.egress += 1,
468476
AgentEvent::Dns { .. } => stats.dns += 1,
469477
AgentEvent::FileAccess { .. } => stats.file += 1,
478+
AgentEvent::FileDelete { .. } => stats.file += 1,
470479
AgentEvent::LlmCall { .. } => stats.llm += 1,
471480
AgentEvent::SslContent { .. } => stats.ssl += 1,
472481
}

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.8.0"
3+
version = "0.9.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-common/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ pub struct FileEvent {
9393
pub path: [u8; PATH_SNAP_LEN],
9494
}
9595

96+
/// `FileEvent.flags` sentinel marking a deletion (`unlinkat`) rather than an open — no real
97+
/// `openat` flag combination equals `u32::MAX`, so userspace can tell them apart on one ring.
98+
pub const FILE_DELETE_FLAG: u32 = u32::MAX;
99+
96100
/// Metrics for one LLM call, emitted when its TLS socket closes. Bytes/timing are
97101
/// accumulated in-kernel per `(pid,fd)`; userspace joins this with the SNI/provider/peer it
98102
/// recorded at ClientHello time to build the full `LlmCall`.

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.8.0"
3+
version = "0.9.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

a3s-observer-ebpf/src/main.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use a3s_observer_common::{
55
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
6-
ARGV_SLOTS, ARG_LEN, DNS_SNAP_LEN, PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN,
6+
ARGV_SLOTS, ARG_LEN, DNS_SNAP_LEN, FILE_DELETE_FLAG, PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN,
77
};
88
use aya_ebpf::{
99
helpers::gen::bpf_probe_read_user,
@@ -525,6 +525,30 @@ fn try_open(ctx: &TracePointContext) -> Result<u32, i64> {
525525
Ok(0)
526526
}
527527

528+
// ---- file deleted (sys_enter_unlinkat) — the "which files did the agent destroy" signal ----
529+
530+
#[tracepoint]
531+
pub fn file_unlink(ctx: TracePointContext) -> u32 {
532+
try_unlink(&ctx).unwrap_or(0)
533+
}
534+
535+
fn try_unlink(ctx: &TracePointContext) -> Result<u32, i64> {
536+
// sys_enter_unlinkat: dfd @16, pathname @24, flag @32.
537+
let pathname: *const u8 = unsafe { ctx.read_at(24)? };
538+
let Some(mut entry) = reserve_or_drop::<FileEvent>(&FILE_EVENTS) else {
539+
return Ok(0);
540+
};
541+
let ev = entry.as_mut_ptr();
542+
unsafe {
543+
(*ev).pid = (bpf_get_current_pid_tgid() >> 32) as u32;
544+
(*ev).flags = FILE_DELETE_FLAG; // distinguish from an openat on the shared FILE_EVENTS ring
545+
(*ev).path = [0u8; PATH_SNAP_LEN];
546+
let _ = bpf_probe_read_user_str_bytes(pathname, &mut (*ev).path);
547+
}
548+
entry.submit(0);
549+
Ok(0)
550+
}
551+
528552
// ---- LLM-call metrics: response bytes + TTFT (read/recv enter+exit), flush on close ----
529553
// Response side needs the byte count, which is the syscall *return* value (exit), but the
530554
// fd is only on enter — so enter stashes the fd (for tracked sockets only) and exit reads it.

0 commit comments

Comments
 (0)