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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [Unreleased]

### Added

- **Digest-bound workload policy envelope** — native ACL policy payloads can be canonicalized and
bound to an exact workload, revision, replica, node, generation, and `sha256:` policy digest.
Bounded, closed-schema admission rejects noncanonical bytes, tampering, invalid metadata, stale or
unexpected generations, and every identity/digest mismatch before apply.

## [0.7.0] — inline wire inspection + stable risk taxonomy

### Added
Expand Down
88 changes: 88 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ regex = "1"
ureq = { version = "2", features = ["json"] }
hcl-rs = "0.18"
anyhow = "1"
a3s-acl = "0.3.0"

# Unix-only: SIGKILL the L3 agent's whole process group on timeout (no std API for killpg).
[target.'cfg(unix)'.dependencies]
Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ in-house ruleset) and keep the escalation machinery. `evaluate_through_l2` never
applies `fail_closed`; callers must durably dispatch any `Escalated` result instead of treating it as
an allow.

## Digest-bound workload policy envelope

Cloud/node integrations can construct a canonical [`PolicyEnvelope`](docs/POLICY_ENVELOPE.md) that
binds native ACL policy bytes to an exact workload, revision, replica, node, and positive generation.
The node parser recomputes the `sha256:` canonical policy digest and accepts only canonical envelope
bytes; `verify` then requires trusted desired identity, generation, and digest to match exactly.

```rust
use a3s_sentry::{PolicyBinding, PolicyEnvelope, PolicyExpectation};

let binding = PolicyBinding::new("workload-01", "revision-07", "replica-01", "node-03")?;
let envelope = PolicyEnvelope::from_policy_acl(
binding.clone(),
4,
r#"runtime_policy "sentry-v1" { default = "deny" }"#,
)?;
let received = PolicyEnvelope::parse(envelope.canonical_acl())?;
let expected = PolicyExpectation::new(binding, 4, envelope.policy_digest())?;
received.verify(&expected)?;
```

This is an immutable admission contract, not applied-state evidence. The current deny-file enforcer
is still node-global and identity-blind; a workload must not become ready until a future typed
backend proves that the same digest was completely applied. See the
[policy-envelope contract and boundaries](docs/POLICY_ENVELOPE.md).

## SDKs (Python · TypeScript)

**Native, in-process** SDKs — the Rust L1/L2/L3 judge embedded via PyO3 (Python) and napi-rs
Expand Down Expand Up @@ -364,6 +390,9 @@ Set `A3S_SENTRY_METRICS_ADDR` (e.g. `0.0.0.0:9100`) to expose, with no extra dep
- **Fail-open by default.** If a tier escalates but the next tier is absent or erroring, sentry
*allows*. So **rules-only + fail-open enforces no `escalate` rule** (sentry warns loudly at
startup). Set `A3S_SENTRY_FAIL_CLOSED=1` and/or configure L2/L3 for safety-first deployments.
- **A verified policy envelope is not enforcement evidence.** It proves canonical bytes and their
exact workload/revision/replica/node binding. The current daemon does not apply envelopes,
reconstruct them after restart, or gate workload readiness on an applied digest.
- **Enforcement is coarse and identity-blind.** Denies are per binary-path / per IP, node-global —
blocking `/usr/bin/curl` blocks all curl. A deny-exec on a *bare* name is dropped (observer's guard
matches paths), so exec-deny effectively targets absolute-path payloads (e.g. `/tmp/x`); an attacker
Expand All @@ -386,16 +415,19 @@ cargo build --release
./scripts/soak.sh ./target/release/sentry 30 # sustained-load soak
```

Pure userspace Rust (serde / regex / ureq / hcl) — no kernel components; those live in a3s-observer.
Pure userspace Rust — no kernel components; those live in a3s-observer.

- **Unit** (67) — rules + escalation + enforce + parsing + the speculative/hot-reload/cap logic + the
metrics endpoint.
- **Unit** (72) — rules + escalation + enforce + parsing + the speculative/hot-reload/cap logic +
policy-envelope invariants + the metrics endpoint.
- **Integration** (`tests/integration.rs`, 13) — the real binary end to end: block → deny-file,
dry-run, fail-open/closed, malformed-input, live hot-reload, `--version`, the **L2 round-trip**
against a mock OpenAI endpoint, the **L3 agent** path (mock agent → block → deny-file), **overload
handling** (slow L3 + queue=1 → graceful complete-evidence degradation while incomplete evidence
stays escalated), and the **metrics endpoint** (live `/metrics` counters + `/healthz`). All
CI-reproducible.
- **Policy contract** (`tests/policy_envelope.rs`, 7) — canonical round-trip, semantic digest
stability, payload/digest tamper rejection, all four identity mismatch dimensions, stale/future
generations, bounded schema admission, duplicate-field rejection, and redacted failures.
- **Soak** (`scripts/soak.sh` + `scripts/soak-l2.sh`) — sustained mixed load + policy-rewrite-under-load
(10M+ events, RSS flat, 0 panics, dedup-bounded); and a **worker-pool soak** proving a slow L2 never
head-of-line-blocks the L1 stream (**~1.15M ev/s on Linux with a 0.5s L2**, RSS flat 6.5 MB, graceful
Expand All @@ -421,6 +453,7 @@ Pure userspace Rust (serde / regex / ureq / hcl) — no kernel components; those
| `llm.rs` | **L2** LLM classifier |
| `agent.rs` | **L3** a3s-code investigator |
| `pipeline.rs` | the `Judge` trait + L1→L2→L3 escalation |
| `policy.rs` | canonical workload policy envelope + exact trusted-state verification |
| `enforce.rs` | append blocks to observer deny-files |
| `metrics.rs` | Prometheus `/metrics` + `/healthz` endpoint |
| `bin/sentry.rs` | the daemon (stdin → judge → enforce → audit) |
Expand Down
125 changes: 125 additions & 0 deletions docs/POLICY_ENVELOPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Digest-bound workload policy envelope

`PolicyEnvelope` is the immutable admission contract for assigning one native ACL policy to one
workload instance. It binds:

- workload ID;
- immutable workload revision ID;
- durable replica ID;
- node ID;
- positive policy generation; and
- the lowercase SHA-256 digest of the canonical policy payload.

This contract is deliberately narrower than enforcement. A verified envelope proves which policy
was requested for which identity. It does not prove that egress, exec, or file controls reached the
Linux enforcement backend.

## Canonical ACL form

An envelope is one `policy_envelope` object followed by one or more block-based policy items:

```acl
policy_envelope = {generation = 7, node_id = "node-01", policy_digest = "sha256:fedb025bd0e08c2a9975ac38ce1522613ba2b51b0070acfac84031938fe72cbc", replica_id = "replica-01", revision_id = "revision-sha256-abc", version = 1, workload_id = "workload-01"}
runtime_policy "sentry-v1" {
default = "deny"
egress {
mode = "allowlist"
}
}
```

The digest is calculated with `a3s_acl::canonical_digest` over only the policy blocks after the
header. The trusted expectation carries the identity, generation, and digest together, so reusing
the same policy bytes for another workload still fails identity verification.

`PolicyEnvelope::from_policy_acl` accepts ordinary native ACL formatting and emits the exact
canonical wire form. `PolicyEnvelope::parse` accepts only that canonical form. Requiring canonical
bytes on the node boundary removes duplicate-field, comment, whitespace, and line-ending
ambiguities before any policy can be applied.

## Producer

```rust
use a3s_sentry::{PolicyBinding, PolicyEnvelope};

let binding = PolicyBinding::new(
"workload-01",
"revision-sha256-abc",
"replica-01",
"node-01",
)?;
let envelope = PolicyEnvelope::from_policy_acl(
binding,
7,
r#"
runtime_policy "sentry-v1" {
default = "deny"
egress { mode = "allowlist" }
}
"#,
)?;

persist_command_bytes(envelope.canonical_acl().as_bytes())?;
persist_expected_digest(envelope.policy_digest())?;
```

Persist or transmit `canonical_acl()` without rewriting it. The generation is a positive integer no
larger than JavaScript's exact integer limit (`2^53 - 1`), which keeps Rust and TypeScript ACL
representations equivalent.

## Node admission

The expected values must come from trusted desired state, not from the received envelope:

```rust
use a3s_sentry::{PolicyBinding, PolicyEnvelope, PolicyExpectation};

let envelope = PolicyEnvelope::parse(received_acl)?;
let expected = PolicyExpectation::new(
PolicyBinding::new(
desired.workload_id,
desired.revision_id,
desired.replica_id,
enrolled_node_id,
)?,
desired.policy_generation,
desired.policy_digest,
)?;

envelope.verify(&expected)?;
```

Verification rejects each identity dimension independently, a lower generation as stale, a higher
generation as unexpected, and any digest mismatch. A caller must treat every parse, schema,
canonicalization, or verification error as not admitted.

## Admission invariants

| Invariant | Behavior |
| --- | --- |
| Header | exactly one `policy_envelope` object, first in the document |
| Version | exactly `1` |
| Identity | nonempty, at most 256 UTF-8 bytes, no whitespace or control characters |
| Generation | `1..=2^53-1`, exact match with trusted desired state |
| Digest | `sha256:` plus 64 lowercase hexadecimal digits; recomputed from canonical policy bytes |
| Payload | nonempty top-level blocks; root attributes and another `policy_envelope` are rejected |
| Wire bytes | exact native ACL canonical form, UTF-8, LF, one final newline |
| Resource budget | producer output and parser input at 1 MiB; depth 32; collection 10,000; token 64 KiB; 20 diagnostics |

Schema and error messages do not echo policy values or identity values. Policies must not contain
secret plaintext; reference secret material through a separately authorized delivery mechanism.

## Enforcement boundary

Do not report a workload ready merely because `verify` succeeds. Readiness eventually requires an
enforcement backend to report the same binding, generation, digest, and complete typed-capability
evidence after an atomic apply.

This first contract also treats the policy blocks as opaque native ACL. A capability-specific,
closed schema must admit their egress, exec, and file semantics before an enforcement backend uses
them; envelope verification alone does not establish that the payload is effective or supported.

The current deny-file `Enforcer` remains coarse and node-global. It does not consume
`PolicyEnvelope`, provide workload-scoped egress/exec/file capabilities, make replacement atomic, or
produce restart-safe applied-state evidence. Those enforcement and audit pieces remain follow-up
work under [Sentry issue #2](https://github.com/A3S-Lab/Sentry/issues/2).
Loading
Loading