diff --git a/docs/probes/pr-demos/command-client/README.md b/docs/probes/pr-demos/command-client/README.md new file mode 100644 index 0000000..23750b9 --- /dev/null +++ b/docs/probes/pr-demos/command-client/README.md @@ -0,0 +1,28 @@ +# Command-level request attribution + +The recorded real CLI requests change from `X-Quipu-Client: seeds` to +`seeds/show`. A different subject argument keeps the same bounded label. +All three invocations intentionally return exit 3 for a missing synthetic subject. + +The before binary is the retained actor-compatibility proposal at `6f9300ad`; +its actor changes are not exercised by this read-only fixture. It is not a +build of the current base. The after binary is built from `ac0e758a` after the +command-label change and current-main integration. Exact binary SHA256 values +appear in the transcript. This fixture proves actual HTTP headers, not real +server authentication, deployment, release, or canary acceptance. + +The output and timing were captured together with util-linux `script`. +`transcript.txt` only normalizes terminal CRLF to LF for reading. + +Replay the original bytes: + +```sh +gzip -dc output.txt.gz > /tmp/command-client-output.txt +scriptreplay --log-timing timing.txt --log-out /tmp/command-client-output.txt +``` + +Reproduce with two existing CLI binaries (Python standard library only): + +```sh +python3 reproduce.py /path/to/before/sd /path/to/after/sd +``` diff --git a/docs/probes/pr-demos/command-client/output.txt.gz b/docs/probes/pr-demos/command-client/output.txt.gz new file mode 100644 index 0000000..e8a6fe9 Binary files /dev/null and b/docs/probes/pr-demos/command-client/output.txt.gz differ diff --git a/docs/probes/pr-demos/command-client/reproduce.py b/docs/probes/pr-demos/command-client/reproduce.py new file mode 100644 index 0000000..185d890 --- /dev/null +++ b/docs/probes/pr-demos/command-client/reproduce.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Compare real CLI headers against one private, synthetic HTTP read fixture.""" +import hashlib +import http.server +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading + +seen = [] + + +class Fixture(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reply(self): + size = int(self.headers.get('Content-Length', '0')) + if size > 1024 * 1024: + raise RuntimeError('oversized fixture request') + body = self.rfile.read(size).decode() + seen.append((self.command, self.path, self.headers.get('X-Quipu-Client'))) + if self.path == '/query': + if 'SELECT ?probe WHERE' in body: + value = {'head': {'vars': ['probe']}, 'results': {'bindings': [ + {'probe': {'type': 'literal', 'value': 'complete'}}]}, 'truncated': False} + elif 'ASK' in body: + value = {'head': {}, 'boolean': False, 'result': False} + else: + value = {'head': {'vars': []}, 'results': {'bindings': []}, + 'rows': [], 'count': 0, 'truncated': False} + elif self.path == '/health': + value = {'status': 'ok', 'graphs': []} + else: + raise RuntimeError('unexpected fixture endpoint') + data = json.dumps(value).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + do_GET = reply + do_POST = reply + + +def run(binary, label, expected, url, subject): + begin = len(seen) + print(f'{label}: sd --quipu {url} --graph https://example.test/project/demo ' + f'show {subject} --json', flush=True) + with tempfile.TemporaryDirectory(prefix='command-client-demo-') as home: + result = subprocess.run( + [str(Path(binary).resolve()), '--quipu', url, '--graph', + 'https://example.test/project/demo', 'show', subject, '--json'], + env={'PATH': '/usr/bin:/bin', 'HOME': home, 'XDG_CONFIG_HOME': home, + 'XDG_STATE_HOME': home}, + capture_output=True, text=True, timeout=10) + if result.returncode != 3: + raise RuntimeError(f'expected synthetic missing-subject exit3, got {result.returncode}') + requests = seen[begin:] + labels = sorted({client for _, _, client in requests}) + if len(requests) != 5 or labels != [expected]: + raise RuntimeError(f'unexpected fixture request attribution: {requests!r}') + print(f' exit={result.returncode} (synthetic subject deliberately absent)') + print(f' actual requests={len(requests)}; X-Quipu-Client={labels!r}', flush=True) + + +def main(): + if len(sys.argv) != 3: + raise SystemExit('usage: reproduce.py BEFORE_SD AFTER_SD') + before, after = sys.argv[1:] + for label, binary in [('before', before), ('after', after)]: + print(f'{label} binary SHA256={hashlib.sha256(Path(binary).read_bytes()).hexdigest()}') + server = http.server.HTTPServer(('127.0.0.1', 0), Fixture) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f'http://127.0.0.1:{server.server_port}' + run(before, 'before', 'seeds', url, 'sample-1') + run(after, 'after', 'seeds/show', url, 'sample-1') + run(after, 'argument-control', 'seeds/show', url, 'different-2') + print('PASS: command label changes; different arguments do not change it.') + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +if __name__ == '__main__': + main() diff --git a/docs/probes/pr-demos/command-client/timing.txt b/docs/probes/pr-demos/command-client/timing.txt new file mode 100644 index 0000000..53ddb9a --- /dev/null +++ b/docs/probes/pr-demos/command-client/timing.txt @@ -0,0 +1,7 @@ +0.244593 87 +0.052106 86 +0.001198 106 +0.008542 202 +0.008174 102 +0.000004 119 +0.005268 170 diff --git a/docs/probes/pr-demos/command-client/transcript.txt b/docs/probes/pr-demos/command-client/transcript.txt new file mode 100644 index 0000000..455c934 --- /dev/null +++ b/docs/probes/pr-demos/command-client/transcript.txt @@ -0,0 +1,15 @@ +Script started on 2026-10-24 10:34:43-04:00 [COMMAND="python3 reproduce.py "$BEFORE_SD" "$AFTER_SD"" ] +before binary SHA256=d516378a79352816b02d32cba96ced88b1a0e203b8348d12aab81a7833a42e2d +after binary SHA256=4f7d5de23e74a6cc0e3b673c2f9bee4030298dfe7a156ef3cc751ac8c838376a +before: sd --quipu http://127.0.0.1:35531 --graph https://example.test/project/demo show sample-1 --json + exit=3 (synthetic subject deliberately absent) + actual requests=5; X-Quipu-Client=['seeds'] +after: sd --quipu http://127.0.0.1:35531 --graph https://example.test/project/demo show sample-1 --json + exit=3 (synthetic subject deliberately absent) + actual requests=5; X-Quipu-Client=['seeds/show'] +argument-control: sd --quipu http://127.0.0.1:35531 --graph https://example.test/project/demo show different-2 --json + exit=3 (synthetic subject deliberately absent) + actual requests=5; X-Quipu-Client=['seeds/show'] +PASS: command label changes; different arguments do not change it. + +Script done on 2026-10-24 10:34:44-04:00 [COMMAND_EXIT_CODE="0"] diff --git a/src/native/command_context.rs b/src/native/command_context.rs index 54116da..e1beb2a 100644 --- a/src/native/command_context.rs +++ b/src/native/command_context.rs @@ -2,6 +2,7 @@ use std::cell::Cell; thread_local! { + static CLIENT_COMMAND: Cell> = const { Cell::new(None) }; static SOURCE: Cell<&'static str> = const { Cell::new("seeds") }; } @@ -9,7 +10,19 @@ pub(super) fn source() -> &'static str { SOURCE.with(Cell::get) } -pub(super) struct Scope(&'static str); +/// The metric label uses only the parsed top-level command. Library calls +/// outside a CLI scope retain the historical producer label. +pub(super) fn client() -> String { + CLIENT_COMMAND.with(|slot| match slot.get() { + Some(command) => format!("seeds/{command}"), + None => "seeds".into(), + }) +} + +pub(super) struct Scope { + source: &'static str, + client: Option<&'static str>, +} impl Scope { pub(super) fn new(command: &super::cli::Command) -> Self { @@ -29,17 +42,31 @@ impl Scope { Command::Info(_) => "sd-info", _ => "sd-other", }; - Self(SOURCE.with(|slot| { + let source = SOURCE.with(|slot| { let outer = slot.get(); slot.set(if outer == "seeds" { source } else { outer }); outer - })) + }); + // Command::name is an exhaustive enum match of static names. No + // argument, subject, actor, raw argv or environment enters this label. + let command = command + .name() + .split_ascii_whitespace() + .next() + .expect("command name"); + let client = CLIENT_COMMAND.with(|slot| { + let outer = slot.get(); + slot.set(outer.or(Some(command))); + outer + }); + Self { source, client } } } impl Drop for Scope { fn drop(&mut self) { - SOURCE.with(|slot| slot.set(self.0)); + SOURCE.with(|slot| slot.set(self.source)); + CLIENT_COMMAND.with(|slot| slot.set(self.client)); } } @@ -48,6 +75,57 @@ mod tests { use super::*; use clap::Parser; + #[test] + fn metric_client_is_command_only_for_every_request_kind() { + let cli = + super::super::cli::Cli::try_parse_from(["sd", "show", "private-subject"]).unwrap(); + assert_eq!(client(), "seeds"); + { + let _scope = Scope::new(&cli.command); + assert_eq!(client(), "seeds/show"); + let requests = [ + super::super::provenance::apply(ureq::get("http://localhost/unused")), + super::super::provenance::apply_read(ureq::get("http://localhost/unused"), "query"), + super::super::provenance::apply_signed(ureq::get("http://localhost/unused")), + ]; + for request in requests { + assert_eq!(request.header("X-Quipu-Client"), Some("seeds/show")); + } + } + assert_eq!(client(), "seeds"); + } + + #[test] + fn nested_command_labels_and_parallel_scopes_do_not_leak() { + let cli = super::super::cli::Cli::try_parse_from(["sd", "list"]).unwrap(); + let _outer = Scope::new(&cli.command); + let inner = super::super::cli::Cli::try_parse_from(["sd", "show", "private-id"]).unwrap(); + { + let _inner = Scope::new(&inner.command); + assert_eq!(client(), "seeds/list"); + } + let other = std::thread::spawn(|| { + assert_eq!(client(), "seeds"); + let cli = super::super::cli::Cli::try_parse_from(["sd", "ready"]).unwrap(); + let _scope = Scope::new(&cli.command); + assert_eq!(client(), "seeds/ready"); + }); + other.join().unwrap(); + assert_eq!(client(), "seeds/list"); + } + + #[test] + fn nested_verbs_share_one_bounded_top_level_label() { + for argv in [ + vec!["sd", "comments", "add", "private-id", "private-body"], + vec!["sd", "comments", "list", "other-private-id"], + ] { + let cli = super::super::cli::Cli::try_parse_from(argv).unwrap(); + let _scope = Scope::new(&cli.command); + assert_eq!(client(), "seeds/comments"); + } + } + #[test] fn demo_command_attribution_before_and_after() { let before = super::super::provenance::read_headers_from(|_| None, || None, "query"); diff --git a/src/native/provenance.rs b/src/native/provenance.rs index b141c22..0d93048 100644 --- a/src/native/provenance.rs +++ b/src/native/provenance.rs @@ -1,6 +1,6 @@ //! Structured write provenance for every request seeds sends to quipu. //! -//! Who wrote a fact must be machine-readable. Beside `X-Quipu-Client: seeds`, +//! Who wrote a fact must be machine-readable. Beside the bounded `X-Quipu-Client: seeds/` metric label, //! each request carries up to six headers from the environment. The task //! label is explicitly declared diagnostic metadata; no task is inferred: //! @@ -130,6 +130,7 @@ pub fn headers() -> &'static [(&'static str, String)] { /// Set this process's provenance headers on a request. pub fn apply(mut req: ureq::Request) -> ureq::Request { + req = req.set("X-Quipu-Client", &super::command_context::client()); for (name, value) in headers() { req = req.set(name, value); } @@ -175,6 +176,7 @@ pub fn read_headers_from( /// Apply read metadata only. Writer provenance/signatures use `apply` unchanged. pub fn apply_read(mut req: ureq::Request, operation: &str) -> ureq::Request { + req = req.set("X-Quipu-Client", &super::command_context::client()); // Preserve the writer's legacy first-request snapshot, even when that // request is a read. Read declarations remain separate from that snapshot. let _ = headers(); @@ -206,6 +208,7 @@ pub(super) fn hostname() -> Option { /// Diagnostic metadata cannot assert the agent/session on a signed request. /// The receiving protected registry supplies both fields from the verified key. pub fn apply_signed(mut req: ureq::Request) -> ureq::Request { + req = req.set("X-Quipu-Client", &super::command_context::client()); for (name, value) in headers() { if !matches!(*name, "X-Quipu-Agent" | "X-Quipu-Session") { req = req.set(name, value); diff --git a/src/native/remote.rs b/src/native/remote.rs index 1c6d9f9..4554727 100644 --- a/src/native/remote.rs +++ b/src/native/remote.rs @@ -229,11 +229,11 @@ pub struct RemoteBackend { /// process's structured write provenance (`X-Quipu-Agent`, `-Harness`, /// `-Model`, `-Session`, `-Host`; see [`super::provenance`]). fn quipu_request(req: ureq::Request) -> ureq::Request { - super::provenance::apply(req.set("X-Quipu-Client", "seeds")) + super::provenance::apply(req) } fn quipu_read_request(req: ureq::Request, operation: &str) -> ureq::Request { - super::provenance::apply_read(req.set("X-Quipu-Client", "seeds"), operation) + super::provenance::apply_read(req, operation) } fn transport(url: &str, e: &ureq::Transport) -> SdError { @@ -409,8 +409,7 @@ impl RemoteBackend { body: &[u8], ) -> Result { let request = self.agent.request(method, &format!("{}{path}", self.base)); - let mut req = super::provenance::apply_signed(request.set("X-Quipu-Client", "seeds")) - .set("Content-Type", content_type); + let mut req = super::provenance::apply_signed(request).set("Content-Type", content_type); if let Some(signer) = &self.signer { req = req.set( super::attest::HEADER, @@ -2619,7 +2618,7 @@ mod tests { .map(|(_, v)| v.as_str()) .collect::>() }; - assert_eq!(get("X-Quipu-Client"), ["seeds"], "{line}"); + assert_eq!(get("X-Quipu-Client"), ["seeds/show"], "{line}"); for (name, value) in expected { assert_eq!(get(name), [value.as_str()], "{line}: {name}"); } diff --git a/tests/read_attribution.rs b/tests/read_attribution.rs index 223b034..dce2341 100644 --- a/tests/read_attribution.rs +++ b/tests/read_attribution.rs @@ -136,7 +136,7 @@ fn probe(extra: &[(&str, &str)]) -> Vec { discovery, the facts reads and the ephemeral existence check (aegis-83l)" ); for headers in &result { - assert_eq!(headers["x-quipu-client"], "seeds"); + assert_eq!(headers["x-quipu-client"], "seeds/show"); let expected_source = extra .iter() .find(|(key, _)| *key == "QUIPU_SOURCE")