1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
//! 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:?}"
);
}
}