Files
OpenShell/crates/openshell-prover
Evie Howard fd95354cc0 refactor(prover): remove legacy unused reachability constraints (#2441)
* refactor(prover): remove legacy unused reachability constraints

Remove can_write_to_endpoint() and the solver variables and encoding
methods that exist solely to support it. No active query calls this
method; confirmed zero callers across the monorepo. Crate is not
published externally, so no public API concern.

Removed as root cause:
- can_write_to_endpoint(): zero callers in the codebase

Removed as direct consumers of can_write_to_endpoint():
- binary_can_write: only read inside can_write_to_endpoint()
- credential_has_write: only read inside can_write_to_endpoint()

Removed as directly dead (no query ever read them):
- credential_has_destructive: flagged by compiler, silenced with
  #[allow(dead_code)] rather than removed
- filesystem_readable: flagged by compiler, silenced with
  #[allow(dead_code)] rather than removed

Removed as now-empty encoding methods:
- encode_credentials(): exclusively populated credential_has_write
  and credential_has_destructive
- encode_filesystem(): exclusively populated filesystem_readable

Also removed two #[allow(dead_code)] allowances and the corresponding
HashMap::new() initialisers and build() call sites.

Active query behavior is unchanged. All existing prover tests pass
without modification and cover all four finding categories end-to-end.
No new tests added as they would duplicate existing coverage exactly.

Note: removing encode_credentials() leaves write_actions_for_scopes()
and destructive_actions_for_scopes() in credentials.rs with no callers.
Left as a follow-up outside the scope of this change.

Closes #2412

Signed-off-by: Evie Howard <evhoward@redhat.com>

* refactor(prover): remove dead write-capability helpers from binary registry

BinaryProtocol::can_write(), BinaryCapability::can_write(), and
write_mechanisms() lost their only callers when can_write_to_endpoint()
and its supporting model fields were removed. Delete the now-dead methods.

write_mechanisms() had no callers prior to this series; the two can_write()
methods were made dead by the preceding model.rs cleanup.

Signed-off-by: Evie Howard <evhoward@redhat.com>

* docs(prover): remove stale write-bypass mention from module doc comment

The module doc incorrectly listed write-bypass violation detection as a
prover capability. The prover only checks reachability queries; write-bypass
detection was never implemented.

Signed-off-by: Evie Howard <evhoward@redhat.com>

---------

Signed-off-by: Evie Howard <evhoward@redhat.com>
2026-10-13 14:05:58 +00:00
..

openshell-prover

Formal verifier for OpenShell sandbox policies. Encodes a policy + its attached credential set + a binary capability registry as a Z3 SMT model, then runs reachability queries to detect credentialed-reach and capability changes a reviewer should be aware of.

The crate also exposes an independent containment API for checking whether a fully composed candidate policy stays within an operator-supplied boundary. The openshell-prover-cli package wraps that API for local files. Containment and the legacy proposal-risk queries answer different questions; gateway callers continue to use the proposal-risk API until the managed-policy migration.

Both prover entrypoints use the bounded openshell-policy-schema parser. Containment rejects unknown fields and managed metadata/review annotations as invalid input, then checks schema-valid controls against its supported model. It uses shared filesystem defaults, port selection, and access-preset semantics.

The containment model accepts ASCII literals in network binary selectors, endpoint host and path selectors, and REST allow and deny method and path selectors. It returns unsupported_policy_shape when either policy uses a non-ASCII literal in one of those fields. This boundary does not apply to filesystem paths or unrelated policy text. Embedded NUL bytes in network selector fields are also unsupported. ASCII wildcards are modeled over the runtime match language and can therefore match non-ASCII runtime values. A solver string that cannot be decoded and validated exactly produces invalid_witness rather than counterexample evidence.

Containment API compatibility

The containment module is a reusable Rust API, but this contract does not make the entire openshell-prover crate a stable published SDK. Construct options through CheckOptions::new, and accept a policy only when the result is CheckResult::Within:

use std::time::Duration;
use openshell_prover::containment::{
    CheckOptions, CheckResult, check_within_boundary, parse_policy_str,
};

let boundary = parse_policy_str("version: 1\n")?;
let candidate = parse_policy_str("version: 1\n")?;
let result = check_within_boundary(
    &boundary,
    &candidate,
    CheckOptions::new(Duration::from_secs(10)),
);
let authorized = matches!(result, CheckResult::Within(_));
# Ok::<(), Box<dyn std::error::Error>>(())

ReasonCode, CheckDomain, Protocol, and Counterexample are open to new variants. CheckOptions, CheckCoverage, WithinEvidence, and existing counterexample variants are open to new fields. Match these types with .. and wildcard arms, use their accessors and as_str() identifiers, and treat unknown values as a fail-closed result. Existing identifier strings are stable.

CheckResult is intentionally closed to the four states Within, Exceeds, Unsupported, and Inconclusive. FilesystemAccess is likewise closed to Read and Write. Adding a result state or filesystem access mode is a breaking API change. Rust source compatibility is separate from the CLI JSON schema contract. The CLI's numeric schema_version versions that JSON contract, while prover_version identifies the implementation that produced a result. The result's coverage.domains list is the machine-readable declaration of modeled authority.

Used by the gateway to gate auto-approval of agent-authored policy proposals: any finding blocks auto-approval, an empty delta lets the chunk pass through (when the reviewer opts in via the proposal_approval_mode setting at either gateway or sandbox scope).

What it decides

The prover answers four formal questions. Each "yes" answer is its own categorical finding — there is no severity grade. The categories live in finding::category.

Category Question the prover decides
link_local_reach Does this policy grant reach to a host in 169.254.0.0/16 or fe80::/10?
l7_bypass_credentialed Does it let a binary using a non-HTTP wire protocol (per the binary registry's bypasses_l7 flag) reach a host where a credential is in scope?
credential_reach_expansion Does it let a binary reach a (host, port) with a credential in scope, where the binary couldn't reach that endpoint before?
capability_expansion On a (binary, host, port) the binary already reaches with credentials, does it add a new HTTP method?

The first two are unconditional risks. The latter two are delta properties — the gateway runs the prover on both the baseline policy and the merged policy and surfaces only the new paths.

Evidence shape

Each finding carries one or more FindingPath::Exfil entries:

pub struct ExfilPath {
    pub binary: String,
    pub endpoint_host: String,
    pub endpoint_port: u16,
    pub mechanism: String,        // human-readable description
    pub policy_name: String,      // rule the path traverses
    pub category: String,         // one of the category constants
    pub method: String,           // populated for capability_expansion; empty otherwise
}

The gateway's finding_delta keys paths by (finding query, binary, host:port, path category, method) so that adding a new method on an already-reached host surfaces as exactly one new path (not the whole re-emission of the existing method set).

Category suppression at the delta layer

capability_expansion paths whose (binary, host, port) tuple is also in the credential_reach_expansion delta are suppressed by the gateway. A brand-new credentialed reach is described by the reach-expansion finding alone, not also by N per-method findings.

Adding a new category

  1. Add a constant to src/finding.rs::category.
  2. In src/queries.rs::check_credential_safety, add the branch that detects the new category and emits one ExfilPath per evidence row. Set path.category to the new constant.
  3. In src/report.rs::format_path_line, add a match arm rendering the per-path display string the reviewer sees.
  4. (Gateway) If the new category should be suppressed by another, add the suppression rule to crates/openshell-server/src/grpc/policy.rs::finding_delta.
  5. Add a unit test in src/queries.rs and an integration test in crates/openshell-server/src/grpc/policy.rs::tests.

The four v1 categories cover the formal properties the OpenShell auto-approval gate cares about today. Additional categories (e.g., "destructive method introduced," "new outbound TLS without SNI") would be additive — they don't displace existing categories.

What the prover does not decide

  • Semantic risk of an action. The prover models can the binary do this?, not is this destructive?. PUT /repos/.../contents/file.md and GET /repos/.../contents/file.md are both authenticated actions; the reviewer (or a downstream layer like an LLM contextual reviewer or an intent file) decides if the action is desired.
  • Cross-sandbox or cross-binary intent. The model is per-sandbox. If two sandboxes share a credential through external policy, the prover reasons about each independently.
  • Runtime behavior. The prover analyzes the policy as written; it doesn't observe the proxy's actual decisions. The proxy is the enforcement layer; the prover is the change-review layer.

Inputs

  • Policy — authored YAML decoded by openshell-policy-schema with the shared fail-closed parser, then projected into the prover-only PolicyModel. Missing version and unknown authored fields are rejected consistently with runtime parsing. An absent filesystem_policy uses the runtime-effective include_workdir: true; an explicitly present empty object remains false. Explicit protocol: tcp is treated as L4, matching runtime classification.
  • Credential set — built from the sandbox's attached providers in crates/openshell-server/src/grpc/policy.rs::build_credential_set_for_sandbox. v1 captures presence only (host-coarse); no scope modeling.
  • Binary registry — YAML descriptors at crates/openshell-prover/registry/binaries/*.yaml. Each describes the binary's protocols, bypasses_l7 flag, and can_exfiltrate capability.

Accepted-risk files, credential descriptors, and binary registries are separate file formats. They continue to use their own serde definitions and do not form part of the authored policy schema.

Outputs

  • A list of Finding values, one per fired category. Each finding's query field holds the category name.
  • The legacy report renderers (report::render_compact / render_report) format proposal-risk findings for terminal consumers.
  • The gateway calls report::finding_shorthand to build the validation_result string persisted on each draft chunk.
  • The containment API returns typed evidence to openshell-prover-cli, which owns the standalone command's text and JSON formats and exit codes.

Z3 model layout

See src/model.rs. Briefly:

  • Endpoint facts are indexed by rule, endpoint position, and effective port. Sibling endpoints retain their own grants even when their destinations overlap.

  • Query entrypoints return an error if the base constraints are inconsistent or the solver cannot reach a conclusion. Callers must propagate that error; it cannot be accepted as a finding or compared as an empty finding list.

  • Bool sorts per (binary, endpoint) pair encode policy reachability, filtered by binary capability flags (can_exfiltrate, bypasses_l7).

  • Bool sorts per (binary, host) encode credential-in-scope (one credential set per sandbox).

  • The reachability formula composes these into the SAT query the queries::check_credential_safety loop iterates over.

Tests

  • Unit tests in each module (src/queries.rs, src/report.rs, src/policy.rs) cover individual primitives and category emission.
  • Integration tests in src/lib.rs::tests exercise the full parse → build_model → run_all_queries pipeline against testdata policies in testdata/.
  • Gateway-level acceptance tests in crates/openshell-server/src/grpc/policy.rs::tests lock in the end-to-end validation_result shape and the auto-approval gate.