diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml new file mode 100644 index 0000000..1399f78 --- /dev/null +++ b/.github/workflows/release-preflight.yml @@ -0,0 +1,228 @@ +name: release-preflight + +# Validate a release candidate without publishing crates, packages, images, tags, or a GitHub +# Release. Pull requests run without registry secrets; a manual run on main also validates tokens. +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-preflight-${{ github.ref }} + cancel-in-progress: true + +jobs: + metadata: + name: Validate release metadata + runs-on: ubuntu-latest + outputs: + rust-version: ${{ steps.versions.outputs.rust-version }} + typescript-version: ${{ steps.versions.outputs.typescript-version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: dtolnay/rust-toolchain@stable + - id: versions + name: Verify versions, locks, changelog, and unused tags + shell: bash + run: | + set -euo pipefail + + RUST_VERSION=$(cargo metadata --locked --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "a3s-sentry") | .version') + TYPESCRIPT_VERSION=$(cargo metadata --locked --no-deps --format-version 1 \ + --manifest-path sdk/typescript/Cargo.toml \ + | jq -r '.packages[] | select(.name == "a3s-sentry-node") | .version') + TYPESCRIPT_PACKAGE_VERSION=$(node -p \ + "require('./sdk/typescript/package.json').version") + + cargo metadata --locked --no-deps --format-version 1 \ + --manifest-path sdk/python/Cargo.toml >/dev/null + + test -n "$RUST_VERSION" + test -n "$TYPESCRIPT_VERSION" + test "$TYPESCRIPT_VERSION" = "$TYPESCRIPT_PACKAGE_VERSION" + grep -Fq "## [$RUST_VERSION]" CHANGELOG.md + grep -Fq "a3s-sentry@$RUST_VERSION" README.md + + if git rev-parse --verify --quiet "refs/tags/v$RUST_VERSION"; then + echo "Rust release tag v$RUST_VERSION already exists" >&2 + exit 1 + fi + if git rev-parse --verify --quiet "refs/tags/ts-v$TYPESCRIPT_VERSION"; then + echo "TypeScript release tag ts-v$TYPESCRIPT_VERSION already exists" >&2 + exit 1 + fi + + echo "rust-version=$RUST_VERSION" >> "$GITHUB_OUTPUT" + echo "typescript-version=$TYPESCRIPT_VERSION" >> "$GITHUB_OUTPUT" + + credentials: + name: Validate registry credentials + if: github.event_name == 'workflow_dispatch' + needs: metadata + runs-on: ubuntu-latest + steps: + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + - name: Authenticate to crates.io without publishing + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }} + shell: bash + run: | + set -euo pipefail + test -n "$CARGO_REGISTRY_TOKEN" + cargo owner --list a3s-sentry >/dev/null + - name: Authenticate to npm without publishing + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + test -n "$NODE_AUTH_TOKEN" + npm whoami --registry https://registry.npmjs.org >/dev/null + if npm view \ + "@a3s-lab/sentry@${{ needs.metadata.outputs.typescript-version }}" \ + version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "npm version ${{ needs.metadata.outputs.typescript-version }} already exists" >&2 + exit 1 + fi + + rust: + name: Rust crate and static Linux binary + needs: metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test --all + - name: Verify publishable crate + run: cargo publish --locked --dry-run + - name: Build static Linux binary + run: cargo build --release --locked --bin sentry --target x86_64-unknown-linux-musl + - name: Verify binary version and static linkage + shell: bash + run: | + set -euo pipefail + BINARY=target/x86_64-unknown-linux-musl/release/sentry + test "$($BINARY --version)" = \ + "a3s-sentry ${{ needs.metadata.outputs.rust-version }}" + file "$BINARY" | grep -Fq "x86-64" + file "$BINARY" | grep -Fq "static-pie linked" + install -D "$BINARY" a3s-sentry-x86_64-linux + sha256sum a3s-sentry-x86_64-linux > a3s-sentry-x86_64-linux.sha256 + - uses: actions/upload-artifact@v4 + with: + name: a3s-sentry-linux-${{ github.sha }} + path: | + a3s-sentry-x86_64-linux + a3s-sentry-x86_64-linux.sha256 + if-no-files-found: error + retention-days: 7 + + typescript-build: + name: TypeScript native build (${{ matrix.target }}) + needs: metadata + strategy: + fail-fast: false + matrix: + include: + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + - host: macos-latest + target: aarch64-apple-darwin + - host: windows-latest + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.host }} + defaults: + run: + working-directory: sdk/typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - run: npm ci + - run: npm run build -- --target ${{ matrix.target }} + - run: npm test + if: runner.os != 'Windows' + - uses: actions/upload-artifact@v4 + with: + name: preflight-bindings-${{ matrix.target }} + path: | + sdk/typescript/*.node + sdk/typescript/index.js + sdk/typescript/index.d.ts + if-no-files-found: error + retention-days: 7 + + typescript-package: + name: Assemble and smoke-test npm package + needs: [metadata, typescript-build] + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: actions/download-artifact@v4 + with: + pattern: preflight-bindings-* + path: sdk/typescript/artifacts + - name: Assemble all native bindings + shell: bash + run: | + set -euo pipefail + cp artifacts/*/*.node . + cp artifacts/preflight-bindings-x86_64-unknown-linux-gnu/index.js . + cp artifacts/preflight-bindings-x86_64-unknown-linux-gnu/index.d.ts . + test "$(find . -maxdepth 1 -name '*.node' | wc -l)" -eq 3 + - name: Pack and install into a clean consumer + shell: bash + run: | + set -euo pipefail + npm pack --json > pack-result.json + TARBALL=$(jq -r '.[0].filename' pack-result.json) + test -f "$TARBALL" + test "$(tar -tf "$TARBALL" | grep -c '\.node$')" -eq 3 + + CONSUMER_DIR=$(mktemp -d) + npm install --prefix "$CONSUMER_DIR" "$PWD/$TARBALL" + node -e "require(process.argv[1])" \ + "$CONSUMER_DIR/node_modules/@a3s-lab/sentry" + - uses: actions/upload-artifact@v4 + with: + name: a3s-sentry-npm-${{ github.sha }} + path: | + sdk/typescript/*.tgz + sdk/typescript/pack-result.json + if-no-files-found: error + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4f713..5927c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ ## [Unreleased] +## [0.8.0] — 2026-08-03 — staged judgment SDK + digest-bound policies + ### Added +- **Staged L1 SDK contract** — Rust, TypeScript and Python callers can run `evaluate_l1` / + `evaluateL1` without invoking L2/L3 or resolving an escalation through the fail mode. Structured + stage status, next-tier eligibility and stop reasons make durable external routing auditable. +- **Explicit L3 dispatch eligibility** — `ThroughL2Result` now states whether an escalation is safe + to dispatch and distinguishes incomplete evidence from an ordinary stage limit. + - **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 diff --git a/Cargo.lock b/Cargo.lock index 7713775..fcc5446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "a3s-sentry" -version = "0.7.0" +version = "0.8.0" dependencies = [ "a3s-acl", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index b737c7e..356cd08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-sentry" -version = "0.7.0" +version = "0.8.0" edition = "2021" license = "MIT" description = "Tiered (L1 rules / L2 LLM / L3 agent) runtime security control for AI agents, built on a3s-observer." diff --git a/README.md b/README.md index de633a5..8255194 100644 --- a/README.md +++ b/README.md @@ -52,19 +52,20 @@ evidence. Published from the repo's own GitHub Actions (a `vX.Y.Z` tag runs [`release.yml`](.github/workflows/release.yml)): -- **Rust crate** — `cargo add a3s-sentry@0.7.0` for embedding the policy engine and inline wire +- **Rust crate** — `cargo add a3s-sentry@0.8.0` for embedding the policy engine and inline wire inspection in another Rust process. -- **Daemon image** — `ghcr.io/a3s-lab/sentry:0.7.0` (and `:latest`). L1 + L2 out of the box; for L3 +- **Daemon image** — `ghcr.io/a3s-lab/sentry:0.8.0` (and `:latest`). L1 + L2 out of the box; for L3 layer Node + `@a3s-lab/code` into a derived image. `docker run --rm -i ghcr.io/a3s-lab/sentry:latest < events.ndjson` - **Daemon binary** — `a3s-sentry-x86_64-linux` on the - [`v0.7.0` release](https://github.com/A3S-Lab/Sentry/releases/tag/v0.7.0). + [`v0.8.0` release](https://github.com/A3S-Lab/Sentry/releases/tag/v0.8.0). - **From source** — `cargo build --release` → `target/release/sentry`. - **SDKs** — `npm install @a3s-lab/sentry` (TypeScript); Python wheels on the [`python-v0.1.0` release](https://github.com/A3S-Lab/Sentry/releases/tag/python-v0.1.0) (see [SDKs](#sdks-python--typescript)). Operating it in production? See the [**operator runbook**](docs/RUNBOOK.md) (rollout, fail mode, -alarms, tuning). +alarms, tuning). Maintainers should follow the [**release guide**](docs/RELEASING.md) before pushing +any version tag. ## Quickstart @@ -226,10 +227,17 @@ firing at `tier=Rules`). const d = sentry.evaluate(egress(1, "169.254.169.254", 80)); if (d?.verdict === "block") console.log(d.reason, d.action); // { kind: "DenyEgress", target: "…" } + // Run L1 only. An escalation is preserved for a caller-owned identity/tier router and no model + // is contacted, even when the ACL contains L2/L3 configuration. + const l1 = sentry.evaluateL1( + fileAccess(1, "/home/u/.aws/credentials", false), + ); + if (l1?.nextTierEligible) await durableFastQueue.send(l1); + const fast = await sentry.evaluateThroughL2( fileAccess(1, "/home/u/.aws/credentials", false), ); - if (fast.stageStatus === "escalated") await durableL3Queue.send(fast); + if (fast.nextTierEligible) await durableL3Queue.send(fast); ``` The `sentry.acl` config — rules, optional `llm {}` (L2) / `agent {}` (L3) backends, and `deny {}` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..66a75c9 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,105 @@ +# Releasing a3s-sentry + +Sentry ships independently versioned Rust, TypeScript, and Python distributions. A tag is a +production action: never use a release tag to test a candidate, never move a published tag, and +never run `git push --tags`. + +| Distribution | Version source | Tag | Result | +|---|---|---|---| +| Rust crate, Linux binary, GHCR image | `Cargo.toml` | `vX.Y.Z` | crates.io, GitHub Release, GHCR | +| TypeScript native SDK | `sdk/typescript/package.json` | `ts-vX.Y.Z` | npm | +| Python native SDK | `sdk/python/Cargo.toml` | `python-vX.Y.Z` | PyPI when configured, GitHub Release | + +## Prepare the release in commits + +The release-preparation commit may live on the feature branch when the feature is ready to ship, or +on a short-lived follow-up branch. Before review: + +1. Update the root crate version in `Cargo.toml`. +2. Refresh `Cargo.lock`, `sdk/typescript/Cargo.lock`, and `sdk/python/Cargo.lock`. +3. Keep the TypeScript and Python package versions independent from the root crate version. +4. Move shipped changes from `[Unreleased]` to a dated version in `CHANGELOG.md`. +5. Update the published installation examples in `README.md`. +6. Run the local checks used by CI and the release workflows. + +For the `0.8.0` release, the expected versions are: + +```text +a3s-sentry 0.8.0 +@a3s-lab/sentry 0.3.0 +a3s-sentry-py 0.2.0 +``` + +## Local preflight + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test --all +cargo publish --locked --dry-run +cargo build --release --locked --bin sentry --target x86_64-unknown-linux-musl +target/x86_64-unknown-linux-musl/release/sentry --version + +cd sdk/typescript +npm ci +npm run build +npm test +npm pack --dry-run --json +``` + +The binary version must match the root `Cargo.toml` version. The dry run must package and verify the +new version, not an already-published version. + +## GitHub full-platform preflight + +Push the candidate commit and open or update its pull request. The `release-preflight` workflow runs +on the pull request without registry credentials and performs no publication. It: + +- verifies version metadata, lockfiles, changelog entries, and unused release tags; +- runs Rust formatting, Clippy, tests, and `cargo publish --dry-run`; +- builds and uploads the static Linux musl binary and SHA-256 file; +- builds TypeScript bindings on Linux x64, macOS ARM64, and Windows x64; +- assembles the npm tarball, verifies all three native bindings, and installs it in a clean consumer. + +Review the workflow summary and download the retained artifacts before approving the release commit. +After merging the reviewed commit, open **Actions → release-preflight → Run workflow**, select +`main`, and run it once more before creating any tag. A manual run repeats the full preflight and +also authenticates `CARGO_TOKEN` and `NPM_TOKEN` without publishing. Registry credentials are never +exposed to pull-request runs. + +## Publish explicitly and sequentially + +After the reviewed release commit is on `main`, update local refs and verify that local `main` is +exactly `origin/main`: + +```bash +git fetch origin --prune --tags +git switch main +git pull --ff-only origin main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +``` + +Record the reviewed commit, create an annotated Rust tag, inspect it, and push only that tag: + +```bash +SENTRY_RELEASE_SHA=$(git rev-parse origin/main) +git tag -a v0.8.0 "$SENTRY_RELEASE_SHA" -m "release: a3s-sentry v0.8.0" +git show --no-patch --decorate v0.8.0 +git push origin refs/tags/v0.8.0 +``` + +Wait for every `release` job to succeed, then verify crates.io, the Linux GitHub Release asset, and +the versioned and `latest` GHCR images. Only then publish TypeScript from the same reviewed commit: + +```bash +git tag -a ts-v0.3.0 "$SENTRY_RELEASE_SHA" \ + -m "release: publish @a3s-lab/sentry 0.3.0" +git show --no-patch --decorate ts-v0.3.0 +git push origin refs/tags/ts-v0.3.0 +``` + +Verify all three native build jobs, the publish job, and a clean install of the exact npm version. +Publish Python separately with `python-vX.Y.Z` when its release is in scope. + +If publication exposes a defect, fix it in a new patch release. Do not delete, move, or force-push +an existing tag, and do not attempt to overwrite an immutable registry version. diff --git a/docs/STAGED_JUDGMENT_SDK.md b/docs/STAGED_JUDGMENT_SDK.md new file mode 100644 index 0000000..0ffc4cf --- /dev/null +++ b/docs/STAGED_JUDGMENT_SDK.md @@ -0,0 +1,55 @@ +# Staged judgment SDK + +Status: implemented +Target branch: `feat/staged-judgment-sdk` + +## Purpose + +Callers that own durable queues and routing policy need to stop after L1 without invoking models or +resolving an escalation through fail-open/fail-closed. The existing Rust `Pipeline::classify_l1` +provides the core behavior, but it is not exposed consistently through `Sentry`, Node N-API, and +Python bindings. + +Sentry remains identity-agnostic. A caller chooses a stage limit; Sentry never imports downstream +concepts such as Confirmed, Candidate, Unknown, or Non-Agent. + +## API + +Expose a structured L1 result through the Rust, Node and Python SDKs: + +```rust +pub struct ThroughL1Result { + pub l1_decision: Decision, + pub stage_status: StageStatus, + pub next_tier_eligible: bool, + pub stop_reason: StageStopReason, +} +``` + +```ts +Sentry.evaluateL1(event: string): ThroughL1Result | null +``` + +```python +sentry.evaluate_l1(event: str) -> ThroughL1Result | None +``` + +An L1 allow/block is completed. A complete-evidence escalation is eligible for a deeper tier. An +incomplete-evidence escalation is preserved but is not eligible for a deeper tier. No L2/L3 judge +or fail-mode resolution runs on this path. + +Add compatible eligibility/stop metadata to `ThroughL2Result`, so an external L3 dispatcher does +not infer safety from human-readable reasons. + +## Compatibility + +Existing `evaluate`, `evaluateThroughL2`, and `evaluateAndEnforce` behavior stays unchanged. New +fields are additive. Node declarations are generated and tested. The Node and Python packages +receive minor version bumps according to repository release policy. + +## Verification + +- Rust unit tests cover final, eligible escalation, incomplete evidence and SAE behavior. +- A counting/mock L2 proves `evaluate_l1` performs zero model calls even when L2/L3 are configured. +- Node and Python binding tests cover result shape and unchanged legacy APIs. +- `cargo fmt`, Clippy, Rust tests, Node build/tests and Python tests must pass. diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index 27facb0..f6d4094 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "a3s-sentry" -version = "0.7.0" +version = "0.8.0" dependencies = [ "a3s-acl", "anyhow", @@ -28,7 +28,7 @@ dependencies = [ [[package]] name = "a3s-sentry-py" -version = "0.1.0" +version = "0.2.0" dependencies = [ "a3s-sentry", "pyo3", diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index 580c734..e6de4a7 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-sentry-py" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" description = "Native (PyO3) Python bindings for a3s-sentry — the in-process tiered security judge." diff --git a/sdk/python/README.md b/sdk/python/README.md index 2bb9df1..af45668 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -41,7 +41,7 @@ rules = [ Build the judge and evaluate observer events: ```python -from a3s_sentry import Sentry, egress, dns, tool_exec +from a3s_sentry import Sentry, egress, dns, file_access, tool_exec # `create` takes a config PATH (if it's a readable file) or inline ACL content. sentry = Sentry.create("sentry.acl") @@ -59,6 +59,11 @@ print(sentry.evaluate(tool_exec(1234, ["ls", "-la"])).verdict) # "allow" # An unparseable line returns None. assert sentry.evaluate("not json") is None + +# Run L1 only. This preserves escalation but never contacts configured L2/L3 backends. +l1 = sentry.evaluate_l1(file_access(1234, "/home/u/.aws/credentials", False)) +print(l1.l1_decision.verdict) # "escalate" +print(l1.next_tier_eligible) # True ``` A `Decision` exposes `verdict` (`"allow"`/`"block"`/`"escalate"`), `tier` (`"Rules"`/`"Llm"`/`"Agent"`), diff --git a/sdk/python/src/lib.rs b/sdk/python/src/lib.rs index 5f2ddf6..46c07e3 100644 --- a/sdk/python/src/lib.rs +++ b/sdk/python/src/lib.rs @@ -11,8 +11,14 @@ //! assert d.verdict == "block" //! ``` -use ::a3s_sentry::verdict::{Decision as CoreDecision, EnforceAction as CoreAction, RiskType as CoreRiskType, Severity, Tier, Verdict}; -use ::a3s_sentry::Sentry as CoreSentry; +use ::a3s_sentry::verdict::{ + Decision as CoreDecision, EnforceAction as CoreAction, RiskType as CoreRiskType, Severity, + Tier, Verdict, +}; +use ::a3s_sentry::{ + Sentry as CoreSentry, StageStatus as CoreStageStatus, StageStopReason as CoreStageStopReason, + ThroughL1Result as CoreThroughL1Result, +}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use serde_json::json; @@ -29,7 +35,10 @@ struct EnforceAction { #[pymethods] impl EnforceAction { fn __repr__(&self) -> String { - format!("EnforceAction(kind={:?}, target={:?})", self.kind, self.target) + format!( + "EnforceAction(kind={:?}, target={:?})", + self.kind, self.target + ) } } @@ -151,6 +160,38 @@ impl From for Decision { } } +/// Structured L1-only result. An eligible escalation is preserved for a caller-owned dispatcher; +/// incomplete evidence remains escalated but is not eligible for a model tier. +#[pyclass(get_all)] +#[derive(Clone)] +struct ThroughL1Result { + l1_decision: Decision, + stage_status: String, + next_tier_eligible: bool, + stop_reason: String, +} + +impl From for ThroughL1Result { + fn from(result: CoreThroughL1Result) -> Self { + let stage_status = match result.stage_status { + CoreStageStatus::Completed => "completed", + CoreStageStatus::Escalated => "escalated", + CoreStageStatus::Stopped => "stopped", + }; + let stop_reason = match result.stop_reason { + CoreStageStopReason::DecisionFinal => "decision_final", + CoreStageStopReason::EvidenceIncomplete => "evidence_incomplete", + CoreStageStopReason::StageLimit => "stage_limit", + }; + Self { + l1_decision: Decision::from(result.l1_decision), + stage_status: stage_status.to_string(), + next_tier_eligible: result.next_tier_eligible, + stop_reason: stop_reason.to_string(), + } + } +} + /// The in-process sentry judge — wraps `a3s_sentry::Sentry`. #[pyclass] struct Sentry { @@ -174,6 +215,11 @@ impl Sentry { self.inner.evaluate(event).map(Decision::from) } + /// Judge through L1 only without invoking L2/L3 or applying fail-open/fail-closed. + fn evaluate_l1(&self, event: &str) -> Option { + self.inner.evaluate_l1(event).map(ThroughL1Result::from) + } + /// Judge one event and, on a `block` carrying a target, write the deny to the configured /// deny-file. Returns `(Decision, enforced_path_or_None)`, or `None` if `event` isn't parseable. fn evaluate_and_enforce(&self, event: &str) -> Option<(Decision, Option)> { @@ -205,37 +251,85 @@ fn wrap( #[pyfunction] #[pyo3(signature = (pid, argv, agent=None, provider=None))] fn tool_exec(pid: u32, argv: Vec, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("ToolExec", json!({ "pid": pid, "argv": argv }), agent, provider) + wrap( + "ToolExec", + json!({ "pid": pid, "argv": argv }), + agent, + provider, + ) } #[pyfunction] #[pyo3(signature = (pid, peer, port=0, agent=None, provider=None))] fn egress(pid: u32, peer: &str, port: u16, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("Egress", json!({ "pid": pid, "peer": peer, "port": port }), agent, provider) + wrap( + "Egress", + json!({ "pid": pid, "peer": peer, "port": port }), + agent, + provider, + ) } #[pyfunction] #[pyo3(signature = (pid, path, write=false, agent=None, provider=None))] -fn file_access(pid: u32, path: &str, write: bool, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("FileAccess", json!({ "pid": pid, "path": path, "write": write }), agent, provider) +fn file_access( + pid: u32, + path: &str, + write: bool, + agent: Option<&str>, + provider: Option<&str>, +) -> String { + wrap( + "FileAccess", + json!({ "pid": pid, "path": path, "write": write }), + agent, + provider, + ) } #[pyfunction] #[pyo3(signature = (pid, query, agent=None, provider=None))] fn dns(pid: u32, query: &str, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("Dns", json!({ "pid": pid, "query": query }), agent, provider) + wrap( + "Dns", + json!({ "pid": pid, "query": query }), + agent, + provider, + ) } #[pyfunction] #[pyo3(signature = (pid, content, is_read=false, agent=None, provider=None))] -fn ssl_content(pid: u32, content: &str, is_read: bool, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("SslContent", json!({ "pid": pid, "is_read": is_read, "content": content }), agent, provider) +fn ssl_content( + pid: u32, + content: &str, + is_read: bool, + agent: Option<&str>, + provider: Option<&str>, +) -> String { + wrap( + "SslContent", + json!({ "pid": pid, "is_read": is_read, "content": content }), + agent, + provider, + ) } #[pyfunction] #[pyo3(signature = (pid, kind, detail=0, agent=None, provider=None))] -fn security_action(pid: u32, kind: &str, detail: u64, agent: Option<&str>, provider: Option<&str>) -> String { - wrap("SecurityAction", json!({ "pid": pid, "kind": kind, "detail": detail }), agent, provider) +fn security_action( + pid: u32, + kind: &str, + detail: u64, + agent: Option<&str>, + provider: Option<&str>, +) -> String { + wrap( + "SecurityAction", + json!({ "pid": pid, "kind": kind, "detail": detail }), + agent, + provider, + ) } #[pymodule] @@ -244,6 +338,7 @@ fn a3s_sentry(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(tool_exec, m)?)?; m.add_function(wrap_pyfunction!(egress, m)?)?; m.add_function(wrap_pyfunction!(file_access, m)?)?; diff --git a/sdk/python/tests/test_sentry.py b/sdk/python/tests/test_sentry.py index 4a234a9..2512014 100644 --- a/sdk/python/tests/test_sentry.py +++ b/sdk/python/tests/test_sentry.py @@ -78,8 +78,23 @@ def test_benign_tool_exec_allows(self): def test_unparseable_event_returns_none(self): self.assertIsNone(self.sentry.evaluate("not json")) + self.assertIsNone(self.sentry.evaluate_l1("not json")) self.assertIsNone(self.sentry.evaluate_and_enforce("still not json")) + def test_l1_only_preserves_escalation_without_invoking_models(self): + s = Sentry.create( + 'fail_closed = true\n' + 'llm { url = "http://127.0.0.1:1/v1" }\n' + ) + result = s.evaluate_l1( + file_access(1, "/home/u/.aws/credentials", False) + ) + self.assertIsNotNone(result) + self.assertEqual(result.l1_decision.verdict, "escalate") + self.assertEqual(result.stage_status, "escalated") + self.assertTrue(result.next_tier_eligible) + self.assertEqual(result.stop_reason, "stage_limit") + # --- all six event builders, with and without identity/provider --- def test_all_six_builders_produce_judgeable_events(self): @@ -225,7 +240,7 @@ def test_bad_config_raises_value_error(self): def test_module_exposes_builders(self): for name in ("tool_exec", "egress", "file_access", "dns", "ssl_content", "security_action"): self.assertTrue(hasattr(a3s_sentry, name), name) - for name in ("Sentry", "Decision", "EnforceAction"): + for name in ("Sentry", "Decision", "EnforceAction", "ThroughL1Result"): self.assertTrue(hasattr(a3s_sentry, name), name) diff --git a/sdk/typescript/Cargo.lock b/sdk/typescript/Cargo.lock index 0383b06..fc75552 100644 --- a/sdk/typescript/Cargo.lock +++ b/sdk/typescript/Cargo.lock @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "a3s-sentry" -version = "0.7.0" +version = "0.8.0" dependencies = [ "a3s-acl", "anyhow", @@ -28,7 +28,7 @@ dependencies = [ [[package]] name = "a3s-sentry-node" -version = "0.2.0" +version = "0.3.0" dependencies = [ "a3s-sentry", "napi", diff --git a/sdk/typescript/Cargo.toml b/sdk/typescript/Cargo.toml index ef0fbb2..466f744 100644 --- a/sdk/typescript/Cargo.toml +++ b/sdk/typescript/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-sentry-node" -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT" description = "Native (napi) Node binding for a3s-sentry's in-process judge." diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 60f41fa..38375c9 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -26,10 +26,16 @@ if (d?.verdict === "block") { const r = sentry.evaluateAndEnforce(toolExec(2, ["/usr/bin/ncat", "host", "4444"])); console.log(r?.decision.verdict, r?.enforced); // "block", "/path/exec.txt" +// Stop after L1. Escalation remains explicit and no L2/L3 backend is contacted. +const l1 = sentry.evaluateL1(fileAccess(3, "/home/u/.aws/credentials", false)); +if (l1?.nextTierEligible) { + console.log(l1.l1Decision, l1.stageStatus, l1.stopReason); +} + // Stop after L2 and preserve an unresolved escalation for a durable external L3 worker. This runs // on the napi worker pool, so a slow L2 request does not block Node's event loop. -const fast = await sentry.evaluateThroughL2(toolExec(3, ["bash", "-c", "base64 -d | sh"])); -if (fast?.stageStatus === "escalated") { +const fast = await sentry.evaluateThroughL2(toolExec(4, ["bash", "-c", "base64 -d | sh"])); +if (fast?.nextTierEligible) { console.log(fast.escalationCause, fast.effectiveDecision); } ``` @@ -52,9 +58,13 @@ Event builders: `toolExec`, `egress`, `fileAccess`, `dns`, `sslContent`, `securi returns the observer event JSON `evaluate` takes. `evaluate` returns `null` for an unparseable event, and a `Decision` (`{ verdict, tier, severity, reason, action? }`) otherwise — including `allow`. +`evaluateL1` returns `l1Decision`, `stageStatus`, `nextTierEligible`, and `stopReason`. It never +invokes L2/L3 and never resolves escalation through `fail_closed`. + `evaluateThroughL2` returns a promise containing `l1Decision`, optional `l2Decision`, -`effectiveDecision`, `stageStatus`, and optional `escalationCause`. It never invokes L3, never -resolves an outstanding escalation through `fail_closed`, and ignores speculative L3 settings. +`effectiveDecision`, `stageStatus`, optional `escalationCause`, `nextTierEligible`, and +`stopReason`. It never invokes L3, never resolves an outstanding escalation through `fail_closed`, +and ignores speculative L3 settings. ## Build / test (from source) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 434ad0b..3c12f17 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/sentry", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/sentry", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2" diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 32da0f2..c9beae4 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/sentry", - "version": "0.2.0", + "version": "0.3.0", "description": "Native (in-process) SDK for a3s-sentry — judge observer events through the embedded L1/L2/L3 pipeline.", "license": "MIT", "repository": { diff --git a/sdk/typescript/src/lib.rs b/sdk/typescript/src/lib.rs index 592787d..ea07b5c 100644 --- a/sdk/typescript/src/lib.rs +++ b/sdk/typescript/src/lib.rs @@ -6,6 +6,7 @@ use a3s_sentry::{ EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity, + StageStatus as CoreStageStatus, StageStopReason as CoreStageStopReason, ThroughL2StageStatus as CoreThroughL2StageStatus, Tier, Verdict, }; use napi::{bindgen_prelude::AsyncTask, Env, Task}; @@ -59,6 +60,19 @@ pub struct ThroughL2Result { pub stage_status: String, /// `l1` | `l2` | `sae`, when the effective decision remains escalated. pub escalation_cause: Option, + pub next_tier_eligible: bool, + /// `decision_final` | `evidence_incomplete` | `stage_limit`. + pub stop_reason: String, +} + +#[napi(object)] +pub struct ThroughL1Result { + pub l1_decision: Decision, + /// `completed` | `escalated` | `stopped`. + pub stage_status: String, + pub next_tier_eligible: bool, + /// `decision_final` | `evidence_incomplete` | `stage_limit`. + pub stop_reason: String, } /// An in-process sentry judge built from one ACL config. @@ -108,6 +122,12 @@ impl Sentry { self.inner.evaluate(&event).map(to_decision) } + /// Judge through L1 only, preserving escalation without invoking L2/L3 or applying fail mode. + #[napi] + pub fn evaluate_l1(&self, event: String) -> Option { + self.inner.evaluate_l1(&event).map(to_through_l1_result) + } + /// Judge through L2 on the napi worker pool, preserving escalation for an external L3 worker. #[napi] pub fn evaluate_through_l2(&self, event: String) -> AsyncTask { @@ -148,6 +168,30 @@ fn to_through_l2_result(result: a3s_sentry::ThroughL2Result) -> ThroughL2Result } .to_string() }), + next_tier_eligible: result.next_tier_eligible, + stop_reason: stage_stop_reason(result.stop_reason).to_string(), + } +} + +fn to_through_l1_result(result: a3s_sentry::ThroughL1Result) -> ThroughL1Result { + let stage_status = match result.stage_status { + CoreStageStatus::Completed => "completed", + CoreStageStatus::Escalated => "escalated", + CoreStageStatus::Stopped => "stopped", + }; + ThroughL1Result { + l1_decision: to_decision(result.l1_decision), + stage_status: stage_status.to_string(), + next_tier_eligible: result.next_tier_eligible, + stop_reason: stage_stop_reason(result.stop_reason).to_string(), + } +} + +fn stage_stop_reason(reason: CoreStageStopReason) -> &'static str { + match reason { + CoreStageStopReason::DecisionFinal => "decision_final", + CoreStageStopReason::EvidenceIncomplete => "evidence_incomplete", + CoreStageStopReason::StageLimit => "stage_limit", } } diff --git a/sdk/typescript/test/sdk.test.mjs b/sdk/typescript/test/sdk.test.mjs index a682c48..292be5f 100644 --- a/sdk/typescript/test/sdk.test.mjs +++ b/sdk/typescript/test/sdk.test.mjs @@ -136,18 +136,59 @@ test("evaluateThroughL2 preserves escalation without invoking L3", async () => { assert.equal(result.effectiveDecision.tier, "Llm"); assert.equal(result.stageStatus, "escalated"); assert.equal(result.escalationCause, "l2"); + assert.equal(result.nextTierEligible, true); + assert.equal(result.stopReason, "stage_limit"); assert.throws(() => readFileSync(marker), /ENOENT/); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test("evaluateL1 preserves escalation and never invokes configured L2/L3", () => { + const dir = mkdtempSync(join(tmpdir(), "sentry-l1-only-")); + try { + const marker = join(dir, "l3-called"); + const bin = join(dir, "mock-agent.sh"); + writeFileSync(bin, `#!/bin/sh\ntouch "${marker}"\necho '{"verdict":"block","severity":"critical","reason":"unexpected L3"}'\n`); + chmodSync(bin, 0o755); + const s = Sentry.create(` + fail_closed = true + llm { url = "http://127.0.0.1:1/v1" } + agent { bin = "${bin}" } + `); + + const result = s.evaluateL1(fileAccess(1, "/home/u/.aws/credentials", false)); + assert.equal(result.l1Decision.verdict, "escalate"); + assert.equal(result.stageStatus, "escalated"); + assert.equal(result.nextTierEligible, true); + assert.equal(result.stopReason, "stage_limit"); + assert.throws(() => readFileSync(marker), /ENOENT/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("evaluateL1 marks incomplete evidence as stopped", () => { + const s = Sentry.create(CFG); + const result = s.evaluateL1(JSON.stringify({ + event: { ToolExec: { pid: 1, argv: ["echo", "safe-prefix"], argv_truncated: true } }, + })); + assert.equal(result.l1Decision.verdict, "escalate"); + assert.equal(result.stageStatus, "stopped"); + assert.equal(result.nextTierEligible, false); + assert.equal(result.stopReason, "evidence_incomplete"); +}); + test("generated declarations preserve the structured through-L2 result", () => { const declarations = readFileSync(new URL("../index.d.ts", import.meta.url), "utf8"); assert.match( declarations, /evaluateThroughL2\(event: string\): Promise/, ); + assert.match( + declarations, + /evaluateL1\(event: string\): ThroughL1Result | null/, + ); }); test("incomplete ToolExec evidence stops at L1 instead of becoming allow", async () => { diff --git a/src/lib.rs b/src/lib.rs index dc270fe..9d7742c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,10 @@ pub use event::{Event, Identity, ObservedEvent}; pub use inline::{Direction, InlineDecision, Redaction}; pub use llm::LlmJudge; pub use metrics::Metrics; -pub use pipeline::{EscalationCause, Judge, Pipeline, ThroughL2Result, ThroughL2StageStatus}; +pub use pipeline::{ + EscalationCause, Judge, Pipeline, StageStatus, StageStopReason, ThroughL1Result, + ThroughL2Result, ThroughL2StageStatus, +}; pub use policy::{ PolicyBinding, PolicyBindingError, PolicyBindingField, PolicyEnvelope, PolicyEnvelopeError, PolicyExpectation, PolicyExpectationError, PolicyVerificationError, POLICY_ENVELOPE_LIMITS, diff --git a/src/pipeline.rs b/src/pipeline.rs index 80cadad..1432f7e 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -48,6 +48,37 @@ pub enum EscalationCause { Sae, } +/// Status of a deliberately bounded staged evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StageStatus { + /// The current tier produced a terminal allow/block decision. + Completed, + /// The current tier requested a deeper judge and the evidence is eligible for dispatch. + Escalated, + /// The current tier requested escalation, but safety constraints prohibit deeper judgment. + Stopped, +} + +/// Why a staged evaluation returned without invoking a deeper tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StageStopReason { + DecisionFinal, + EvidenceIncomplete, + StageLimit, +} + +/// Structured L1 output for callers that select and durably dispatch deeper tiers themselves. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThroughL1Result { + pub l1_decision: Decision, + pub stage_status: StageStatus, + pub next_tier_eligible: bool, + pub stop_reason: StageStopReason, +} + /// Structured L1/L2 output for callers that dispatch L3 outside this process. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -59,6 +90,8 @@ pub struct ThroughL2Result { pub stage_status: ThroughL2StageStatus, #[serde(skip_serializing_if = "Option::is_none")] pub escalation_cause: Option, + pub next_tier_eligible: bool, + pub stop_reason: StageStopReason, } /// The tiered judge. `l2`/`l3` are optional (rules-only, rules+LLM, or all three) and `Arc` so they @@ -179,23 +212,76 @@ impl Pipeline { }; if d1.verdict != Verdict::Escalate { - return through_l2_result(d1, None, None); + return through_l2_result(d1, None, None, false, StageStopReason::DecisionFinal); } if ev.event.evidence_incomplete() { - return through_l2_result(d1, None, Some(EscalationCause::L1)); + return through_l2_result( + d1, + None, + Some(EscalationCause::L1), + false, + StageStopReason::EvidenceIncomplete, + ); } if d1.tier == Tier::Sae { - return through_l2_result(d1, None, Some(EscalationCause::Sae)); + return through_l2_result( + d1, + None, + Some(EscalationCause::Sae), + true, + StageStopReason::StageLimit, + ); } let Some(l2) = &self.l2 else { - return through_l2_result(d1, None, Some(EscalationCause::L1)); + return through_l2_result( + d1, + None, + Some(EscalationCause::L1), + true, + StageStopReason::StageLimit, + ); }; let d2 = l2.judge(ev); let cause = (d2.verdict == Verdict::Escalate).then_some(EscalationCause::L2); - through_l2_result(d1, Some(d2), cause) + let eligible = d2.verdict == Verdict::Escalate; + let stop_reason = if eligible { + StageStopReason::StageLimit + } else { + StageStopReason::DecisionFinal + }; + through_l2_result(d1, Some(d2), cause, eligible, stop_reason) + } + + /// Run only L1 and preserve a dispatchable escalation without invoking L2/L3 or applying the + /// fail mode. Incomplete evidence remains an escalation for audit, but is explicitly ineligible + /// for a deeper model judgment. + pub fn evaluate_through_l1(&self, ev: &ObservedEvent) -> ThroughL1Result { + let l1_decision = self.classify_l1(ev); + if l1_decision.verdict != Verdict::Escalate { + return ThroughL1Result { + l1_decision, + stage_status: StageStatus::Completed, + next_tier_eligible: false, + stop_reason: StageStopReason::DecisionFinal, + }; + } + if ev.event.evidence_incomplete() { + return ThroughL1Result { + l1_decision, + stage_status: StageStatus::Stopped, + next_tier_eligible: false, + stop_reason: StageStopReason::EvidenceIncomplete, + }; + } + ThroughL1Result { + l1_decision, + stage_status: StageStatus::Escalated, + next_tier_eligible: true, + stop_reason: StageStopReason::StageLimit, + } } /// Run only L1 — the cheap, always-on tier. A daemon can call this inline on its ingest thread @@ -299,6 +385,8 @@ fn through_l2_result( l1_decision: Decision, l2_decision: Option, escalation_cause: Option, + next_tier_eligible: bool, + stop_reason: StageStopReason, ) -> ThroughL2Result { let effective_decision = l2_decision.as_ref().unwrap_or(&l1_decision).clone(); let stage_status = if effective_decision.verdict == Verdict::Escalate { @@ -312,6 +400,8 @@ fn through_l2_result( effective_decision, stage_status, escalation_cause, + next_tier_eligible, + stop_reason, } } @@ -462,6 +552,8 @@ mod tests { assert_eq!(result.effective_decision.verdict, Verdict::Allow); assert_eq!(result.stage_status, ThroughL2StageStatus::Completed); assert_eq!(result.escalation_cause, None); + assert!(!result.next_tier_eligible); + assert_eq!(result.stop_reason, StageStopReason::DecisionFinal); assert!(result.l2_decision.is_none()); assert_eq!(l2_calls.load(Ordering::Relaxed), 0); } @@ -513,6 +605,8 @@ mod tests { assert_eq!(result.effective_decision.tier, Tier::Llm); assert_eq!(result.stage_status, ThroughL2StageStatus::Escalated); assert_eq!(result.escalation_cause, Some(EscalationCause::L2)); + assert!(result.next_tier_eligible); + assert_eq!(result.stop_reason, StageStopReason::StageLimit); assert_eq!(l3_calls.load(Ordering::Relaxed), 0); } @@ -524,6 +618,49 @@ mod tests { assert_eq!(result.effective_decision.verdict, Verdict::Escalate); assert_eq!(result.stage_status, ThroughL2StageStatus::Escalated); assert_eq!(result.escalation_cause, Some(EscalationCause::L1)); + assert!(result.next_tier_eligible); + assert_eq!(result.stop_reason, StageStopReason::StageLimit); + } + + #[test] + fn through_l1_never_calls_deeper_judges() { + let l2_calls = Arc::new(AtomicUsize::new(0)); + let l3_calls = Arc::new(AtomicUsize::new(0)); + let p = Pipeline::new(Arc::new(Fixed(Tier::Rules, Verdict::Escalate))) + .with_l2(Arc::new(Counting( + Tier::Llm, + Verdict::Block, + Arc::clone(&l2_calls), + ))) + .with_l3(Arc::new(Counting( + Tier::Agent, + Verdict::Block, + Arc::clone(&l3_calls), + ))) + .fail_closed(true); + + let result = p.evaluate_through_l1(&ev()); + assert_eq!(result.l1_decision.verdict, Verdict::Escalate); + assert_eq!(result.stage_status, StageStatus::Escalated); + assert!(result.next_tier_eligible); + assert_eq!(result.stop_reason, StageStopReason::StageLimit); + assert_eq!(l2_calls.load(Ordering::Relaxed), 0); + assert_eq!(l3_calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn through_l1_marks_incomplete_evidence_ineligible() { + let p = Pipeline::new(Arc::new(RuleEngine::with_defaults_and(None).unwrap())); + let incomplete = ObservedEvent::parse( + r#"{"event":{"ToolExec":{"pid":1,"argv":["echo","safe-prefix"],"argv_truncated":true}}}"#, + ) + .unwrap(); + + let result = p.evaluate_through_l1(&incomplete); + assert_eq!(result.l1_decision.verdict, Verdict::Escalate); + assert_eq!(result.stage_status, StageStatus::Stopped); + assert!(!result.next_tier_eligible); + assert_eq!(result.stop_reason, StageStopReason::EvidenceIncomplete); } #[test] diff --git a/src/sdk.rs b/src/sdk.rs index 8870527..9455980 100644 --- a/src/sdk.rs +++ b/src/sdk.rs @@ -8,7 +8,7 @@ use crate::config::SdkConfig; use crate::enforce::Enforcer; use crate::event::ObservedEvent; use crate::inline::{self, Direction, InlineDecision}; -use crate::pipeline::{Pipeline, ThroughL2Result}; +use crate::pipeline::{Pipeline, ThroughL1Result, ThroughL2Result}; use crate::verdict::{Decision, Verdict}; use std::path::Path; use std::sync::Mutex; @@ -58,6 +58,12 @@ impl Sentry { self.pipeline.evaluate_through_l2(ev) } + /// Judge a parsed event through L1 only, preserving an eligible escalation for a caller-owned + /// deeper-tier dispatcher. + pub fn evaluate_event_l1(&self, ev: &ObservedEvent) -> ThroughL1Result { + self.pipeline.evaluate_through_l1(ev) + } + /// Inline gate for an in-flight LLM/MCP body: run the same tiered judges over the decoded wire /// `content` and return the [`InlineDecision`] (block/allow + secret/PII spans to redact). This is /// the pre-execution path a3s-gateway's wire proxy calls; the reactive [`evaluate`](Sentry::evaluate) @@ -78,6 +84,13 @@ impl Sentry { Some(self.pipeline.evaluate_through_l2(&ev)) } + /// Judge one observer event through L1 only. This never invokes L2/L3 and never resolves an + /// escalation through fail-open/fail-closed. + pub fn evaluate_l1(&self, event_json: &str) -> Option { + let ev = ObservedEvent::parse(event_json)?; + Some(self.pipeline.evaluate_through_l1(&ev)) + } + /// Judge and, on a `block` carrying a target, write it to the configured deny-file. Returns the /// decision plus the deny-file the block landed in (if any). `None` if the event isn't parseable. pub fn evaluate_and_enforce(&self, event_json: &str) -> Option<(Decision, Option)> { @@ -167,4 +180,22 @@ mod tests { assert_eq!(result.effective_decision.verdict, Verdict::Escalate); assert_eq!(result.effective_decision.tier, crate::verdict::Tier::Rules); } + + #[test] + fn evaluate_l1_preserves_escalation_without_fail_mode_resolution() { + let sentry = Sentry::from_acl( + r#" + fail_closed = true + llm { url = "http://127.0.0.1:1/v1" } + "#, + ) + .unwrap(); + let result = sentry + .evaluate_l1( + r#"{"event":{"FileAccess":{"pid":1,"path":"/home/u/.aws/credentials","write":false}}}"#, + ) + .unwrap(); + assert_eq!(result.l1_decision.verdict, Verdict::Escalate); + assert!(result.next_tier_eligible); + } }