cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Harness tier: `cordance_harness_target`.
//!
//! Returns the `pai-axiom-project-harness-target.v1` JSON Cordance would
//! emit for the current target. Read-only metadata; never writes the file.

use camino::Utf8PathBuf;
use cordance_core::harness_target::AxiomProjectHarnessTargetV1;
use cordance_emit::TargetEmitter;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};

#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct HarnessTargetParams {
    #[serde(default)]
    pub target: Option<String>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct HarnessTargetOutput {
    pub schema: String,
    /// The full `pai-axiom-project-harness-target.v1` document as a JSON
    /// value. We do not derive `JsonSchema` on the cordance-core struct (its
    /// shape is governed by the axiom contract, not by Cordance) — clients
    /// validate against the axiom-published schema instead.
    #[schemars(with = "serde_json::Value")]
    pub harness_target: AxiomProjectHarnessTargetV1,
}

pub fn harness_target(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<HarnessTargetOutput> {
    let pack = super::pack::build_pack(target, cfg)?;
    let emitter = cordance_emit::harness_target::HarnessTargetEmitter;
    let rendered = emitter
        .render(&pack)
        .map_err(|e| McpToolError::internal_redacted("harness emit", e))?;

    // The harness-target emitter produces exactly one file. Fail loud if
    // that ever changes; the wire shape promises a single struct.
    //
    // The `other.len()` count is a benign integer (the emitter never embeds
    // path bytes into the rendered file list), so this site stays
    // direct-construct; it is not in scope of R4-redteam-3's redaction.
    let (_, bytes) = match rendered.as_slice() {
        [single] => single.clone(),
        other => {
            return Err(McpToolError::Internal(format!(
                "harness emitter produced {} files, expected exactly 1",
                other.len()
            )));
        }
    };
    let parsed: AxiomProjectHarnessTargetV1 = serde_json::from_slice(&bytes)
        .map_err(|e| McpToolError::internal_redacted("harness target parse", e))?;
    // Round-5 redteam #2 (R5-redteam-2): `#[serde(deny_unknown_fields)]` and
    // `#[non_exhaustive]` block *struct-literal* tampering across crates, but
    // serde-deserialisation still produces values with arbitrary, in-schema
    // field combinations. `validate_invariants` is the construction-time
    // re-assertion (schema literal, classification == read-only-advisory,
    // allowed_operations ⊆ {Inspect, ValidateTarget, EmitCandidateReport},
    // required denied_operations present). The cordance-core module docs at
    // `harness_target.rs:31` make this binding: any external caller obtaining
    // a target via `serde_json::from_*` MUST run this before trusting any
    // field. Today the bytes come from Cordance's own emitter so the risk is
    // defence-in-depth; the call closes the surface against a future refactor
    // that lets target-controlled bytes flow into the rendered JSON.
    parsed
        .validate_invariants()
        .map_err(|e| McpToolError::internal_redacted("harness target invariants", e))?;
    Ok(HarnessTargetOutput {
        schema: cordance_core::harness_target::SCHEMA_LITERAL.to_string(),
        harness_target: parsed,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Round-5 redteam #2: a tampered harness-target JSON that satisfies serde
    /// (no unknown fields, classification is the canonical enum variant) but
    /// violates a *semantic* invariant must be rejected by
    /// `validate_invariants`. Here we plant a target whose
    /// `harness.allowed_operations` contains `WriteProjectFiles` — a forbidden
    /// token per the invariant table. `serde_json::from_slice` succeeds
    /// (`WriteProjectFiles` is a real variant in the kebab-case enum); the
    /// validate step is what catches it.
    #[test]
    fn tampered_harness_target_rejected_by_invariants() {
        // Canonical-looking JSON with one tampered field: allowed_operations
        // includes "write-project-files", which is forbidden. denied_operations
        // still contains the required set so the test isolates the
        // allowed-operations check.
        let tampered = serde_json::json!({
            "schema": cordance_core::harness_target::SCHEMA_LITERAL,
            "version": 1,
            "project": {
                "name": "fixture",
                "repo": ".",
                "access_mode": "read-only-advisory"
            },
            "authority_surfaces": {
                "product_spec": [],
                "adrs": [],
                "doctrine": [],
                "tests_or_evals": [],
                "runtime_roots": [],
                "release_gates": []
            },
            "harness": {
                "classification": "read-only-advisory",
                "allowed_operations": [
                    "inspect",
                    "validate-target",
                    "emit-candidate-report",
                    "write-project-files"
                ],
                "denied_operations": [
                    "promote-project-doctrine",
                    "mutate-runtime-roots",
                    "modify-release-gates",
                    "rewrite-adrs"
                ],
                "claim_ceiling": "candidate"
            }
        });
        let bytes = serde_json::to_vec(&tampered).expect("ser tampered");

        // Mirror the production parse-then-validate sequence from
        // `harness_target` above so a regression in either step fails the test.
        let parsed: AxiomProjectHarnessTargetV1 =
            serde_json::from_slice(&bytes).expect("tampered JSON parses");
        let result = parsed.validate_invariants();
        assert!(
            result.is_err(),
            "validate_invariants must reject write-project-files in allowed_operations; got {result:?}"
        );
    }
}