diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d8068..a80ee78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,20 @@ ### 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 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 administrator credential provisioning, rotation, rollout verification, rollback, evidence handling, and the boundary with the separate runtime-authentication fail-closed work tracked in issue #78. +- 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. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c696190..6a1cb1e 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 b2ec231..4041565 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 0000000..9acea34 --- /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 0000000..45c3a58 --- /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 0000000..510a632 --- /dev/null +++ b/crates/agent-artifact-admission/src/artifact_source_identity.rs @@ -0,0 +1,58 @@ +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; + } + + intent.artifacts.iter().any(|artifact| { + !artifact_argument_matches_reviewed_source( + &artifact.ecosystem, + &artifact.name, + &artifact.version, + &artifact.artifact_argument, + ) + }) +} + +/// 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, + } +} 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 0000000..d4cf58c --- /dev/null +++ b/crates/agent-artifact-admission/src/artifact_variant.rs @@ -0,0 +1,191 @@ +use crate::InstallIntent; + +/// 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" if arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") => + { + arguments + .iter() + .skip(2) + .any(requests_unapproved_uv_pip_variant) + } + _ => false, + } +} + +fn requests_unapproved_pip_variant(argument: &String) -> bool { + 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") +} + +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") +} diff --git a/crates/agent-artifact-admission/src/audit.rs b/crates/agent-artifact-admission/src/audit.rs new file mode 100644 index 0000000..efafde9 --- /dev/null +++ b/crates/agent-artifact-admission/src/audit.rs @@ -0,0 +1,315 @@ +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +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; + +/// 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 { + OpenOptions::new() + .create(true) + .append(true) + .open(Path::new(&self.path)) + } +} + +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 0000000..9c33152 --- /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 0000000..4702132 --- /dev/null +++ b/crates/agent-artifact-admission/src/config.rs @@ -0,0 +1,257 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs::File; +use std::io::Read; +use std::net::SocketAddr; +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; + +/// 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_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'+')) +} + +fn read_bounded(path: &Path, maximum_bytes: u64) -> Result, ConfigError> { + let file = File::open(path).map_err(|_| ConfigError::Io)?; + 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 0000000..ec11caf --- /dev/null +++ b/crates/agent-artifact-admission/src/dependency_cardinality.rs @@ -0,0 +1,53 @@ +use crate::InstallIntent; + +/// 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..]; + + let is_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, + }; + + is_pypi_install && !arguments.iter().any(|argument| argument == "--no-deps") +} + +/// 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 0000000..185575b --- /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 0000000..b43de25 --- /dev/null +++ b/crates/agent-artifact-admission/src/lib.rs @@ -0,0 +1,97 @@ +//! 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_hash_mode; + +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 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_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 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 +} diff --git a/crates/agent-artifact-admission/src/main.rs b/crates/agent-artifact-admission/src/main.rs new file mode 100644 index 0000000..c119aa5 --- /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 0000000..8a88e0c --- /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 0000000..9062ea7 --- /dev/null +++ b/crates/agent-artifact-admission/src/policy.rs @@ -0,0 +1,691 @@ +use std::collections::BTreeSet; + +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" if arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") => + { + !arguments + .iter() + .any(|argument| argument == "--require-hashes") + } + "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" if arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") => + { + 2 + } + "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() + .skip(command_prefix_len) + .filter(|argument| !argument.starts_with('-')) + .map(String::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); + } +} + +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 = |flags: &[&str]| { + arguments + .iter() + .any(|argument| flags.iter().any(|flag| matches_cli_flag(argument, flag))) + }; + + match executable { + "pip" | "pip3" => contains_flag(&[ + "-r", + "--requirement", + "-e", + "--editable", + "--requirements-from-script", + ]), + "uv" if arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") => + { + contains_flag(&[ + "-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")) +} + +fn requests_alternate_trust_root(executable: &str, arguments: &[String]) -> bool { + const FORBIDDEN_FLAGS: &[&str] = &[ + "--extra-index-url", + "--index-url", + "--index", + "--default-index", + "--trusted-host", + "--find-links", + "--registry", + "--registry-url", + "--userconfig", + "--globalconfig", + "--ca", + "--cafile", + "--strict-ssl", + "--git", + "--path", + "-i", + "-f", + ]; + 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."))) +} + +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" => { + arguments.first().is_some_and(|argument| argument == "pip") + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + && contains_flag(&[ + "--user", "--target", "-t", "--root", "--prefix", "--system", "--python", "-p", + ]) + } + "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 +} 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 0000000..25f8202 --- /dev/null +++ b/crates/agent-artifact-admission/src/pypi_hash_mode.rs @@ -0,0 +1,18 @@ +use crate::InstallIntent; + +/// Return whether a supported pip install request explicitly disables the +/// hash-checking mode that Wardnet requires for reviewed PyPI 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..]; + + matches!(executable, "pip" | "pip3") + && arguments + .first() + .is_some_and(|argument| argument == "install") + && arguments + .iter() + .any(|argument| argument == "--no-require-hashes") +} 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 0000000..8470b13 --- /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 0000000..824d0f5 --- /dev/null +++ b/crates/agent-artifact-admission/tests/audit_contract.rs @@ -0,0 +1,123 @@ +use std::fs; +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); +} + +#[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/bun_integrity_verification_contract.rs b/crates/agent-artifact-admission/tests/bun_integrity_verification_contract.rs new file mode 100644 index 0000000..2915433 --- /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 0000000..8e05d0f --- /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 0000000..2d16a52 --- /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 0000000..1e84df6 --- /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 0000000..832e24c --- /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 0000000..f317427 --- /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 0000000..0362c10 --- /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 0000000..717b775 --- /dev/null +++ b/crates/agent-artifact-admission/tests/cli_contract.rs @@ -0,0 +1,206 @@ +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +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"); + 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); +} 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 0000000..fb750c6 --- /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 0000000..3eef8b6 --- /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 0000000..2a9566a --- /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 0000000..ff68d4a --- /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 0000000..4970473 --- /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/npm_artifact_source_identity_contract.rs b/crates/agent-artifact-admission/tests/npm_artifact_source_identity_contract.rs new file mode 100644 index 0000000..3f5335d --- /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 0000000..234c275 --- /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 0000000..ef82ad4 --- /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 0000000..f64b2fd --- /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 0000000..18bfe4f --- /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 0000000..e4871af --- /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 0000000..d827b1a --- /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 0000000..553728c --- /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/pnpm_config_override_contract.rs b/crates/agent-artifact-admission/tests/pnpm_config_override_contract.rs new file mode 100644 index 0000000..60411e3 --- /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 0000000..cfc7c61 --- /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 0000000..a5c1424 --- /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 0000000..f9f21cd --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_artifact_source_identity_contract.rs @@ -0,0 +1,118 @@ +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 = "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 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(), + ], + 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_contract.rs b/crates/agent-artifact-admission/tests/pypi_artifact_variant_contract.rs new file mode 100644 index 0000000..45281c2 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_artifact_variant_contract.rs @@ -0,0 +1,180 @@ +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 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 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(), + ]) +} + +fn approved_uv_pypi_install() -> (AdmissionPolicy, InstallIntent) { + approved_pypi_install_with_argv(vec![ + "uv".to_string(), + "pip".to_string(), + "install".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_dependency_cardinality_contract.rs b/crates/agent-artifact-admission/tests/pypi_dependency_cardinality_contract.rs new file mode 100644 index 0000000..85c4ed1 --- /dev/null +++ b/crates/agent-artifact-admission/tests/pypi_dependency_cardinality_contract.rs @@ -0,0 +1,110 @@ +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(), + ARTIFACT_ARGUMENT.to_string(), + ], + _ => vec![ + executable.to_string(), + "install".to_string(), + ARTIFACT_ARGUMENT.to_string(), + ], + }; + argv.push("--require-hashes".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/safety_flag_contract.rs b/crates/agent-artifact-admission/tests/safety_flag_contract.rs new file mode 100644 index 0000000..89c5205 --- /dev/null +++ b/crates/agent-artifact-admission/tests/safety_flag_contract.rs @@ -0,0 +1,163 @@ +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 + ); +} 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 0000000..9ec9bb7 --- /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 0000000..4537fa8 --- /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 0000000..d22c6ab --- /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 0000000..68d3464 --- /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/agent-artifact-admission-context-map.md b/docs/architecture/agent-artifact-admission-context-map.md new file mode 100644 index 0000000..1723b5e --- /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.md b/docs/doctoring/agent-artifact-admission.md new file mode 100644 index 0000000..9f80a65 --- /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 0000000..413380a --- /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 0000000..263457a --- /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 0000000..6459d9d --- /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 0000000..9810f9a --- /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 0000000..33573a3 --- /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 0000000..ee041e1 --- /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 0000000..6092f97 --- /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/pypi-artifact-variant.md b/docs/doctoring/pypi-artifact-variant.md new file mode 100644 index 0000000..a14fe4f --- /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-hash-mode-authority.md b/docs/doctoring/pypi-hash-mode-authority.md new file mode 100644 index 0000000..a199ad3 --- /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/openapi/agent-artifact-admission.openapi.yaml b/docs/openapi/agent-artifact-admission.openapi.yaml new file mode 100644 index 0000000..4e729e6 --- /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 0000000..631a6b0 --- /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 0000000..1e83dad --- /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/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 0000000..c58fd78 --- /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 0000000..e7b58c5 --- /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