diff --git a/.github/skills/add-audit-sink.md b/.github/skills/add-audit-sink.md new file mode 100644 index 0000000..105957a --- /dev/null +++ b/.github/skills/add-audit-sink.md @@ -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)`. 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 Sink` and + `impl AuditSink for 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 `). +- `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. diff --git a/.github/skills/add-llm-provider.md b/.github/skills/add-llm-provider.md new file mode 100644 index 0000000..50ac83f --- /dev/null +++ b/.github/skills/add-llm-provider.md @@ -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_(body) -> Option>`: 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_(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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a264764 --- /dev/null +++ b/AGENTS.md @@ -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 -- `) | + +**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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6babd7..b57eed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/CLAUDE.md b/CLAUDE.md index 4fa4122..8634a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43311e1..52ae8e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 —