Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/skills/add-audit-sink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Skill: add an audit sink backend

Add a new destination for `AuditRecord`s (every allow/deny/ask decision) — e.g.
a file rotator, a SIEM/HTTP forwarder, syslog. Sinks live in `pasu-audit` and
implement the `pasu-core` `AuditSink` trait, so nothing else has to change.

**Prerequisite reading:** [AGENTS.md](../../AGENTS.md), [CLAUDE.md](../../CLAUDE.md) §1.

## The seam

`pasu-core` defines:

```rust
pub trait AuditSink: Send + Sync {
fn record(&self, record: &AuditRecord);
}
```

`AuditRecord` is the flattened decision (`layer`, `subject`, `verdict`, optional
`reason`), already `Serialize`. A `Guard` fans decisions to whatever sink it was
given via `.with_sink(Arc<dyn AuditSink>)`. Existing sinks in `pasu-audit`:
`JsonlSink` (writer), `MemorySink` (ring buffer, for tests/UI), `OtelSink`
(OTLP spans, behind the `otel` feature).

## Steps

1. **`crates/pasu-audit/src/`** — add `pub struct <Name>Sink` and
`impl AuditSink for <Name>Sink`. Keep `record()` cheap and **non-blocking on
the hot path**: buffer/queue rather than doing synchronous network I/O inside
`record()` (it runs per decision). Re-export from `lib.rs`.
2. **Heavy or optional deps** (a network client, a syslog crate) go **behind a
Cargo feature** (mirror the `otel` feature: `#[cfg(feature = "…")]` module +
re-export). Never pull a heavyweight dep into the default build.
3. **Failure handling**: a sink that can't deliver must **not** break the guard —
log and drop, never panic or block the decision. (Audit loss ≠ guard failure;
but never let audit turn a decision fail-open.)
4. **Docs**: mention the sink in the README audit bullet and add a CHANGELOG entry.

## Tests

- Unit test that `record()` actually captures/forwards the record (assert the
serialized output or the queued item). Follow `MemorySink`'s tests.
- If feature-gated, ensure the crate still builds **without** the feature
(`cargo test -p pasu-audit`) and **with** it (`cargo test -p pasu-audit --features <name>`).
- `cargo clippy -p pasu-audit --all-targets -- -D warnings` · `cargo fmt`.

## Boundaries

- Sinks stay in `pasu-audit` and depend only on `pasu-core`. Don't make
`pasu-audit` depend on another library crate.
- Don't change the `AuditSink` trait signature to fit one sink — if a sink needs
more, that's a `pasu-core` design discussion, not a local hack.
59 changes: 59 additions & 0 deletions .github/skills/add-llm-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Skill: add an LLM provider to `pasu-proxy`

Add support for a new provider wire format so the tool-call guard covers another
SDK family. The guard/inspect/policy path is **provider-agnostic** — only the
parse layer changes. (Done three times already: OpenAI, Anthropic, Gemini.)

**Prerequisite reading:** [AGENTS.md](../../AGENTS.md), [CLAUDE.md](../../CLAUDE.md) §1, §3.

## The one insight

A model's tool-call decision (name + arguments) rides in the **provider response
body** (function calling). So a provider is "supported" once we can extract
`ToolCall { name, arguments }` from both its non-streaming and streaming (SSE)
responses. Everything downstream (`inspect` → `Guard` → 403 / passthrough) is
already generic.

## Steps

1. **`crates/pasu-proxy/src/parse.rs`**
- Add a variant to `enum Provider`.
- Add it to the `match` in `extract(body, provider)`.
- Write `extract_<provider>(body) -> Option<Vec<ToolCall>>`: deserialize the
non-streaming response, pull out each proposed tool call. Return `None` when
the body isn't parseable as that provider (the proxy passes it through);
`Some(vec![])` when it parsed but proposed no tools. Serialize object-shaped
arguments to a JSON string so every provider yields the same `ToolCall`.

2. **`crates/pasu-proxy/src/stream.rs`**
- Add the variant to the `match` in `extract_stream(body, provider)`.
- Write `reassemble_<provider>(datas)`: reassemble tool calls from the SSE
`data:` chunks (providers fragment differently — e.g. OpenAI concatenates
`arguments` per index, Anthropic pairs `content_block_start` with
`input_json_delta`). Keep it deny-safe: a partial/garbled stream should not
silently produce an empty allowlisted result if a tool call was intended.

3. **`crates/pasu-proxy/src/main.rs`** — add the provider string to
`parse_provider` (the `--provider` flag), and to its error message.

4. **Docs** — update the README (both `README.md` and `README.en.md`) status
line and the `--provider` list; add a CHANGELOG entry.

## Tests (mandatory — this is a security tool)

- **`parse.rs` unit tests**: TP (a response with a tool call → extracted with the
right name/args) **and** TN (a text-only response → no tool calls). Add both.
- **`stream.rs` unit tests**: reassembly across chunks (args split), and a
text-only stream → no tool calls.
- **`tests/proxy_e2e.rs`** (over the wire): if you extend the mock upstream,
keep the denied→403 / allowed→200 assertions holding for the new format.
- Run: `cargo test -p pasu-proxy` · `cargo clippy -p pasu-proxy --all-targets -- -D warnings` · `cargo fmt`.

## Boundaries

- **Don't** add provider-specific logic outside `parse.rs`/`stream.rs`. If the
verdict path needs to change, stop — that's a redesign, not a provider add.
- Arguments are always a **string** in `ToolCall` (serialize objects); the guard
treats them opaquely.
- Streaming stays **buffered** (the proxy reads the whole body, then reassembles)
unless/until incremental relay lands — don't half-implement streaming here.
76 changes: 76 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# AGENTS.md

Orientation for coding agents (and new contributors) working in **pasu**. This
is the vendor-neutral entry point; the binding rules live in
[CLAUDE.md](CLAUDE.md) and the contributor summary in
[CONTRIBUTING.md](CONTRIBUTING.md). When those conflict with this file,
**CLAUDE.md wins**.

## What pasu is (one paragraph)

pasu is a Rust security guard for AI agents. Two layers, one policy: an
**LLM-API proxy** (`pasu-proxy`, cooperative) parses provider responses and
gates tool calls for any SDK; a **kernel eBPF egress guard** (`pasu-egress` /
`pasu-ebpf`, enforcing) drops egress the policy doesn't allow — unbypassable.
See [README.md](README.md) for the full picture.

## Prime directive

**This is a security tool. Fail-closed.** If the guard cannot operate, it must
deny — never add a fail-open path for convenience. Every rule/logic change needs
tests (TP + TN pairs, and a bypass test where relevant). No exceptions; see
CLAUDE.md §3–4.

## Setup, build, test

```bash
cargo test # portable crates (stable): core, rules, ui, audit, proxy
cargo clippy --all-targets -- -D warnings # lint (warnings are errors)
cargo fmt --check # formatting

# eBPF stack — Linux + nightly + bpf-linker (kept out of default-members):
cargo build -p pasu-egress
```

`cargo test` / the stable CI job never touch the eBPF stack, so you don't need a
bpf toolchain to work on the portable crates. The kernel paths are validated by
the privileged **eBPF E2E** CI job on a real Ubuntu kernel — you generally
**cannot build the eBPF crates on macOS**, so lean on CI for those.

CI is 4 jobs (all must be green): `fmt·clippy·test`, `eBPF build+unit`,
`eBPF E2E` (privileged), `cargo-deny`.

## Crate map (acyclic — everything depends only on `pasu-core`)

| crate | role |
|-------|------|
| `pasu-core` | shared `Event`/`Verdict` types + traits (`RuleEngine`, `Layer`, `Approver`, `AuditSink`) + the `Guard` facade |
| `pasu-rules` | the `RuleEngine` — Falco-inspired YAML ruleset; also lowers policy to the kernel allowlist |
| `pasu-proxy` | LLM-API reverse proxy: parses tool calls from provider responses (OpenAI/Anthropic/Gemini, streaming + not) |
| `pasu-ui` | lightweight web UI: HITL approval, audit, egress dashboard |
| `pasu-audit` | `AuditSink` backends: JSONL, in-memory, OpenTelemetry (`otel` feature) |
| `pasu-egress` · `pasu-ebpf` · `pasu-ebpf-common` | kernel eBPF cgroup egress (Linux) |
| `pasu-daemon` | composition root: policy YAML → kernel guard |
| `pasu-cli` | the `pasu` command (`pasu run -- <agent>`) |

**Rule:** library crates stay pure behind `pasu-core`. Only app-level binaries
(`pasu-proxy`, `pasu-daemon`, `pasu-cli`) may wire concrete crates (e.g.
`pasu-rules`, `pasu-ui`). Never add a dependency between two library crates.

## Working rules (essentials — full text in CLAUDE.md)

- **Conventional Commits**: `type(scope): description` (scope = `proxy`/`egress`/`ebpf`/`rules`/`ui`/`audit`/`core`/`ci`/`docs`).
- **DCO sign-off**: `git commit -s`. **No AI-authorship attribution** (no `Co-Authored-By: Claude`).
- **Branch → PR → CI green → merge.** Feature branches off `release/vX.Y`, never commit to `main` or a release branch directly. The user merges to `main`.
- **One PR = one problem.**
- Rust style: early returns, no needless mutation, no `unwrap`/`expect`/`panic` on user/network input.

## Common tasks (recipes)

Repeatable changes have step-by-step skills under [`.github/skills/`](.github/skills/):

- **Add an LLM provider to the proxy** → [`.github/skills/add-llm-provider.md`](.github/skills/add-llm-provider.md)
- **Add an audit sink backend** → [`.github/skills/add-audit-sink.md`](.github/skills/add-audit-sink.md)

Follow the matching skill when your change fits one; it encodes the file set,
the tests to add, and the fail-closed/boundary rules for that task.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ its first tagged release.
## [Unreleased]

### Added
- **`AGENTS.md` + `.github/skills/`** — a vendor-neutral orientation guide for
coding agents and new contributors (build/test, crate map, working rules,
deferring to CLAUDE.md as the binding authority), plus step-by-step task
recipes for repeatable changes (`add-llm-provider`, `add-audit-sink`).
- **Layered policy: `default/` + `user/`** — `pasu-rules` gains `Ruleset::from_dir`
(loads `*.yaml` in a directory, sorted by filename — the `rules.d`/`sudoers.d`
convention) and `Ruleset::layered` (a user overlay whose rules take precedence,
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
This file defines rules that Claude (and contributors) **must follow** when working in Pasu.
These rules take precedence over default behavior.

> New here? Start at [AGENTS.md](AGENTS.md) for orientation (build/test, crate map,
> common-task recipes). **This file is the binding authority** — if AGENTS.md and
> this file ever disagree, CLAUDE.md wins.

## Project

Pasu is a Rust security guard for AI agents. Its core is a kernel **eBPF egress guard** (language-agnostic, enforcing), extended by an **LLM-API proxy** that guards tool calls by parsing provider responses (framework-agnostic — any SDK, `base_url` only). The differentiator is **enforcing** (the kernel actually blocks egress, unbypassable) rather than cooperative (only sees what is declared).
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Thanks for helping build pasu. It's a security tool, so a few rules keep it
trustworthy. (Maintainer/AI working rules live in [CLAUDE.md](CLAUDE.md); this is
the short version for contributors.)

Working with a coding agent? Point it at [AGENTS.md](AGENTS.md) — it has the
orientation, build/test commands, and step-by-step recipes for common changes
(under [`.github/skills/`](.github/skills/)).

## Ground rules

- **Conventional Commits**: `type(scope): description`. Scope aligns with area —
Expand Down
Loading