Skip to main content

a3s_code_core/harness_evidence/
tool_request.rs

1use super::{measure, require_digest, HarnessEvidenceError};
2use serde::{Deserialize, Serialize};
3
4pub const TOOL_REQUEST_SNAPSHOT_V1_SCHEMA: &str = "a3s.code.tool-request-snapshot.v1";
5
6const TOOL_REQUEST_SNAPSHOT_DOMAIN: &str = "a3s.code.tool-request-snapshot.v1";
7const TOOL_REQUEST_ID_DOMAIN: &str = "a3s.code.tool-request-id.v1";
8const TOOL_REQUEST_NAME_DOMAIN: &str = "a3s.code.tool-request-name.v1";
9const TOOL_REQUEST_ARGUMENTS_DOMAIN: &str = "a3s.code.tool-request-arguments.v1";
10
11/// Runtime path that submitted a governed Tool request.
12///
13/// Version 1 keeps trusted and governed host control-plane requests distinct,
14/// including their nested descendants, so replay consumers do not have to
15/// infer authority from a Tool name or call identifier.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolRequestOriginV1 {
19    Agent,
20    Nested,
21    HostDirectTrusted,
22    HostDirectGoverned,
23    HostDirectNestedTrusted,
24    HostDirectNestedGoverned,
25}
26
27/// Immutable, bounded evidence for one validated Tool request.
28///
29/// The snapshot binds the correlation identifiers, origin, and exact JSON
30/// arguments through domain-separated digests. It deliberately retains none
31/// of those plaintext values; the surrounding event owns the Tool identifier
32/// and name needed for lifecycle correlation.
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ToolRequestSnapshotV1 {
36    pub schema: String,
37    pub origin: ToolRequestOriginV1,
38    pub tool_id_digest: String,
39    pub tool_name_digest: String,
40    pub arguments_bytes: u64,
41    pub arguments_digest: String,
42    pub snapshot_digest: String,
43}
44
45impl ToolRequestSnapshotV1 {
46    pub(crate) fn capture(
47        tool_id: &str,
48        tool_name: &str,
49        arguments: &serde_json::Value,
50        origin: ToolRequestOriginV1,
51    ) -> Result<Self, HarnessEvidenceError> {
52        let arguments = measure(TOOL_REQUEST_ARGUMENTS_DOMAIN, arguments)?;
53        let mut snapshot = Self {
54            schema: TOOL_REQUEST_SNAPSHOT_V1_SCHEMA.to_owned(),
55            origin,
56            tool_id_digest: measure(TOOL_REQUEST_ID_DOMAIN, tool_id)?.digest,
57            tool_name_digest: measure(TOOL_REQUEST_NAME_DOMAIN, tool_name)?.digest,
58            arguments_bytes: arguments.bytes,
59            arguments_digest: arguments.digest,
60            snapshot_digest: String::new(),
61        };
62        snapshot.snapshot_digest = snapshot.expected_digest()?;
63        snapshot.validate()?;
64        Ok(snapshot)
65    }
66
67    pub fn validate(&self) -> Result<(), HarnessEvidenceError> {
68        if self.schema != TOOL_REQUEST_SNAPSHOT_V1_SCHEMA {
69            return Err(HarnessEvidenceError::UnsupportedSchema);
70        }
71        if self.arguments_bytes == 0 {
72            return Err(HarnessEvidenceError::InvalidContents(
73                "serialized Tool request arguments are non-empty",
74            ));
75        }
76        for (field, digest) in [
77            ("tool_id_digest", self.tool_id_digest.as_str()),
78            ("tool_name_digest", self.tool_name_digest.as_str()),
79            ("arguments_digest", self.arguments_digest.as_str()),
80            ("snapshot_digest", self.snapshot_digest.as_str()),
81        ] {
82            require_digest(field, digest)?;
83        }
84        if self.snapshot_digest != self.expected_digest()? {
85            return Err(HarnessEvidenceError::DigestMismatch("snapshot_digest"));
86        }
87        Ok(())
88    }
89
90    /// Validate this snapshot against the correlated Tool event and the exact
91    /// post-hook arguments submitted to governance and execution.
92    pub fn validate_against(
93        &self,
94        tool_id: &str,
95        tool_name: &str,
96        arguments: &serde_json::Value,
97        origin: ToolRequestOriginV1,
98    ) -> Result<(), HarnessEvidenceError> {
99        self.validate()?;
100        if self.origin != origin {
101            return Err(HarnessEvidenceError::InvalidContents(
102                "Tool request origins agree",
103            ));
104        }
105        if self.tool_id_digest != measure(TOOL_REQUEST_ID_DOMAIN, tool_id)?.digest {
106            return Err(HarnessEvidenceError::DigestMismatch("tool_id_digest"));
107        }
108        if self.tool_name_digest != measure(TOOL_REQUEST_NAME_DOMAIN, tool_name)?.digest {
109            return Err(HarnessEvidenceError::DigestMismatch("tool_name_digest"));
110        }
111        let arguments = measure(TOOL_REQUEST_ARGUMENTS_DOMAIN, arguments)?;
112        if self.arguments_digest != arguments.digest {
113            return Err(HarnessEvidenceError::DigestMismatch("arguments_digest"));
114        }
115        if self.arguments_bytes != arguments.bytes {
116            return Err(HarnessEvidenceError::InvalidContents(
117                "Tool request argument byte measurements agree",
118            ));
119        }
120        Ok(())
121    }
122
123    fn expected_digest(&self) -> Result<String, HarnessEvidenceError> {
124        #[derive(Serialize)]
125        struct Identity<'a> {
126            schema: &'a str,
127            origin: ToolRequestOriginV1,
128            tool_id_digest: &'a str,
129            tool_name_digest: &'a str,
130            arguments_bytes: u64,
131            arguments_digest: &'a str,
132        }
133
134        Ok(measure(
135            TOOL_REQUEST_SNAPSHOT_DOMAIN,
136            &Identity {
137                schema: &self.schema,
138                origin: self.origin,
139                tool_id_digest: &self.tool_id_digest,
140                tool_name_digest: &self.tool_name_digest,
141                arguments_bytes: self.arguments_bytes,
142                arguments_digest: &self.arguments_digest,
143            },
144        )?
145        .digest)
146    }
147}