From 0f4768e7b654b7953e3eaa6d629482adc0333b98 Mon Sep 17 00:00:00 2001 From: Brian H Date: Mon, 5 Oct 2026 09:02:52 +0000 Subject: [PATCH 1/3] feat(assurance): gateway, audit, /assurance routes, sandbox, MCP resources+prompts (KR-A06..A08) - gateway: per-identity grants (tokens stored as SHA-256), every route classified in gateway::ROUTES (a test fails if a route is added unclassified or a row names no route), deny by default, decision audited BEFORE the handler runs, fail closed if the audit log cannot be written. Enforced in the server, so MCP and direct HTTP are covered alike. - audit: hash-chained log called from the gateway; GET /assurance/audit (audit.read). - /assurance/* routes over AssuranceService; propose/validate/commit with expected revision. - KR-A07: deploy/sandbox/run-sandboxed.sh (bubblewrap): read-only root and baseline, one scratch tmpfs, no network, cleared env; /dev closed after the test found it writable. - bridge: resources/* and prompts/* (manifest-driven), X-Kr0ki-Transport: mcp on MCP calls. - acceptance cases VC-A06, VC-A07, VC-A08 incl. the real Python bridge as a subprocess. - baseline: implementation paths for CTL-A06..A08; AGENTS.md env table. cargo fmt/clippy -D warnings clean; cargo test --workspace --no-fail-fast: 44 suites ok. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- AGENTS.md | 3 + Cargo.lock | 3 + containers/kr0ki-mcp/bridge.py | 119 +++- containers/kr0ki-mcp/test_bridge.py | 91 +++ crates/kr0ki-core/tests/assurance_service.rs | 72 +- crates/kr0ki-server/Cargo.toml | 4 + crates/kr0ki-server/src/app.rs | 63 +- crates/kr0ki-server/src/assurance.rs | 668 ++++++++++++++++++ crates/kr0ki-server/src/gateway.rs | 277 ++++++-- crates/kr0ki-server/src/main.rs | 93 ++- crates/kr0ki-server/tests/assurance_a06.rs | 218 ++++++ crates/kr0ki-server/tests/assurance_a07.rs | 215 ++++++ crates/kr0ki-server/tests/assurance_a08.rs | 203 ++++++ crates/kr0ki-server/tests/b00t_graph_live.rs | 1 + .../tests/common/assurance_harness.rs | 266 +++++++ crates/kr0ki-server/tests/http.rs | 1 + crates/kr0ki-server/tests/kubediagram_live.rs | 1 + deploy/sandbox/run-sandboxed.sh | 60 ++ docs/assurance/kr0ki.assurance.toml | 6 +- 19 files changed, 2246 insertions(+), 118 deletions(-) create mode 100644 crates/kr0ki-server/src/assurance.rs create mode 100644 crates/kr0ki-server/tests/assurance_a06.rs create mode 100644 crates/kr0ki-server/tests/assurance_a07.rs create mode 100644 crates/kr0ki-server/tests/assurance_a08.rs create mode 100644 crates/kr0ki-server/tests/common/assurance_harness.rs create mode 100755 deploy/sandbox/run-sandboxed.sh diff --git a/AGENTS.md b/AGENTS.md index ee2adf9..6c8c7cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,9 @@ crates/ | `KR0KI_TEST_BACKEND` | unset | Live render test backend; use the local/private kroki-compatible service | | `KR0KI_SYSMLV2_BASE_URL` | unset | Live SysML-v2 client test target (`just sysml-api-up` serves one on `127.0.0.1:9000`; `just test-live-sysml`) | | `OPENAI_API_URL` | (in `.env`) | The agent's / `/health`'s LLM. Boot default is Qwen3.8 NEO-CODER + mmproj (vision) on `127.0.0.1:8002/v1` via `deploy/systemd/b00t-hive-inference-heretic-neo-coder.service`; Qwen3.6 units are disabled | +| `KR0KI_IDENTITIES_FILE` | unset | Per-identity bearer tokens (stored as SHA-256) and capability grants (`render`, `model.read`, `model.commit`, `assurance.read`, `assurance.propose`, `assurance.verify`, `audit.read`, `ui.steer`, `admin`). When set it **replaces** the shared `KR0KI_AUTH_TOKEN`; every route is classified in `kr0ki-server/src/gateway.rs` and an unclassified route needs `admin`. A tool's visibility or annotations are never what enforces anything | +| `KR0KI_AUDIT_FILE` | `./.kr0ki-audit/audit.jsonl` when identities are set | Hash-chained, append-only audit log (one record per invocation, permitted or denied). If a record cannot be written the request is refused (503) | +| `KR0KI_ASSURANCE_BASELINE` / `KR0KI_REPO_ROOT` / `KR0KI_EVIDENCE_DIR` / `KR0KI_ASSURANCE_PREFIX` | `docs/assurance/kr0ki.assurance.toml` / `.` / `./.kr0ki-evidence` / `kr0ki:assurance:` | Enable `/assurance/*` (the assurance thread) and where its baseline, repository and evidence live | | `KR0KI_SYSML_MCP_URL` | unset | SysML v2 MCP sidecar (`containers/kr0ki-sysml-mcp`, `127.0.0.1:8790`); enables `/sysml/{validate,parse,symbols,summary}` and the `sysml_mcp` health check | --- diff --git a/Cargo.lock b/Cargo.lock index 660e15c..4f6ff73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,10 +1465,13 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "thiserror 2.0.20", "tokio", + "toml", "tower", "tracing", "tracing-subscriber", + "ufo-types", "uuid", "wiremock", ] diff --git a/containers/kr0ki-mcp/bridge.py b/containers/kr0ki-mcp/bridge.py index bbc645a..897dad4 100644 --- a/containers/kr0ki-mcp/bridge.py +++ b/containers/kr0ki-mcp/bridge.py @@ -10,8 +10,10 @@ import base64 import json import os +import re import sys import urllib.error +import urllib.parse import urllib.request import manifest_dispatch @@ -39,6 +41,77 @@ def mcp_tools_list(): ] +MCP_HEADERS = {"X-Kr0ki-Transport": "mcp"} + + +def fetch_list(path): + """A manifest the server publishes (`/mcp/resources`, `/mcp/prompts`).""" + _, body = manifest_dispatch.http_call("GET", f"{BASE_URL}{path}", headers=MCP_HEADERS) + return json.loads(body) + + +def resource_templates(): + return [ + { + "uriTemplate": r["uriTemplate"], + "name": r["name"], + "description": r["description"], + "mimeType": r["mimeType"], + } + for r in fetch_list("/mcp/resources") + ] + + +def match_resource(uri, manifest): + """The manifest entry whose `uriTemplate` matches `uri`, with its variables decoded.""" + for entry in manifest: + pattern = re.escape(entry["uriTemplate"]) + pattern = re.sub(r"\\\{(\w+)\\\}", r"(?P<\1>[^/]+)", pattern) + m = re.fullmatch(pattern, uri) + if m: + return entry, {k: urllib.parse.unquote(v) for k, v in m.groupdict().items()} + return None, None + + +def read_resource(uri): + """Resolve a resource URI to the GET route that serves it. No logic of its own: the server + authorises it exactly as it authorises the same request made directly.""" + entry, variables = match_resource(uri, fetch_list("/mcp/resources")) + if entry is None: + raise KeyError(f"unknown resource: {uri}") + binding = entry["httpBinding"] + path, query = binding["pathTemplate"], {} + for arg in binding["args"]: + value = variables.get(arg["name"]) + if value is None: + continue + if arg["placement"] == "path": + path = path.replace("{" + arg["name"] + "}", urllib.parse.quote(value, safe="")) + else: + query[arg["name"]] = value + url = f"{BASE_URL}{path}" + ("?" + urllib.parse.urlencode(query) if query else "") + content_type, body = manifest_dispatch.http_call("GET", url, headers=MCP_HEADERS) + return {"uri": uri, "mimeType": content_type or entry["mimeType"], "text": body.decode("utf-8", "replace")} + + +def render_prompt(prompt, arguments): + """Fill `{{arg}}` placeholders once (a value is never re-expanded). A required argument must + be present and non-empty; an argument the prompt does not declare is refused.""" + declared = {a["name"]: a for a in prompt["arguments"]} + unknown = [k for k in arguments if k not in declared] + if unknown: + raise ValueError(f"unknown argument: {unknown[0]}") + values = {} + for name, spec in declared.items(): + value = str(arguments.get(name, "")).strip() + if not value: + if spec["required"]: + raise ValueError(f"prompt {prompt['name']} requires argument {name}") + continue + values[name] = value + return re.sub(r"\{\{(\w+)\}\}", lambda m: values.get(m.group(1), m.group(0)), prompt["template"]) + + def call_tool(arguments): name = arguments.get("name") params = arguments.get("arguments", {}) @@ -64,7 +137,9 @@ def call_tool(arguments): output = params.get("output", "svg") try: - content_type, response_body = manifest_dispatch.http_call(method, url, body) + content_type, response_body = manifest_dispatch.http_call( + method, url, body, headers={"X-Kr0ki-Transport": "mcp"} + ) except urllib.error.HTTPError as exc: return tool_error(f"kr0ki rejected the request ({exc.code}): {exc.read().decode('utf-8', 'replace')}") except urllib.error.URLError as exc: @@ -91,7 +166,7 @@ def handle(message): request_id, { "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, + "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, "serverInfo": {"name": "kr0ki-mcp", "version": "0.2.0"}, }, ) @@ -100,6 +175,46 @@ def handle(message): return response(request_id, {"tools": mcp_tools_list()}) except (urllib.error.URLError, urllib.error.HTTPError) as exc: return error(request_id, -32000, f"failed to fetch tool manifest: {exc}") + if method == "resources/templates/list": + try: + return response(request_id, {"resourceTemplates": resource_templates()}) + except (urllib.error.URLError, urllib.error.HTTPError) as exc: + return error(request_id, -32000, f"failed to fetch resources: {exc}") + if method == "resources/list": + return response(request_id, {"resources": []}) + if method == "resources/read": + uri = message.get("params", {}).get("uri", "") + try: + return response(request_id, {"contents": [read_resource(uri)]}) + except KeyError as exc: + return error(request_id, -32602, str(exc.args[0])) + except urllib.error.HTTPError as exc: + return error(request_id, -32000, f"kr0ki rejected the request ({exc.code}): {exc.read().decode('utf-8', 'replace')}") + except urllib.error.URLError as exc: + return error(request_id, -32000, f"kr0ki request failed: {exc}") + if method == "prompts/list": + try: + return response( + request_id, + {"prompts": [{"name": p["name"], "description": p["description"], "arguments": p["arguments"]} for p in fetch_list("/mcp/prompts")]}, + ) + except (urllib.error.URLError, urllib.error.HTTPError) as exc: + return error(request_id, -32000, f"failed to fetch prompts: {exc}") + if method == "prompts/get": + params = message.get("params", {}) + try: + prompt = next((p for p in fetch_list("/mcp/prompts") if p["name"] == params.get("name")), None) + if prompt is None: + return error(request_id, -32602, f"unknown prompt: {params.get('name')}") + text = render_prompt(prompt, params.get("arguments") or {}) + except ValueError as exc: + return error(request_id, -32602, str(exc)) + except (urllib.error.URLError, urllib.error.HTTPError) as exc: + return error(request_id, -32000, f"failed to fetch prompts: {exc}") + return response( + request_id, + {"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}, + ) if method == "tools/call": return response(request_id, call_tool(message.get("params", {}))) if method == "ping": diff --git a/containers/kr0ki-mcp/test_bridge.py b/containers/kr0ki-mcp/test_bridge.py index ce2d56d..8e8ae7b 100644 --- a/containers/kr0ki-mcp/test_bridge.py +++ b/containers/kr0ki-mcp/test_bridge.py @@ -148,5 +148,96 @@ def test_http_call_headers_override_defaults(self, urlopen): self.assertEqual(request.get_header("Content-type"), "application/json") + +RESOURCES = [ + { + "uriTemplate": "kr0ki://requirement/{id}", + "name": "requirement", + "description": "d", + "mimeType": "application/json", + "httpBinding": {"method": "GET", "pathTemplate": "/assurance/requirements/{id}", "args": [{"name": "id", "placement": "path"}]}, + }, + { + "uriTemplate": "kr0ki://evidence/{requirement}", + "name": "evidence", + "description": "d", + "mimeType": "application/json", + "httpBinding": {"method": "GET", "pathTemplate": "/assurance/evidence", "args": [{"name": "requirement", "placement": "query"}]}, + }, +] + +PROMPTS = [ + { + "name": "explain_gap", + "description": "Explain a gap.", + "arguments": [{"name": "id", "description": "Requirement id.", "required": True}], + "template": "Explain {{id}}: call trace_requirement with id={{id}}.", + }, + {"name": "next", "description": "d", "arguments": [], "template": "Do the next slice."}, +] + + +class ResourcesAndPromptsTest(unittest.TestCase): + def test_a_resource_uri_resolves_to_its_get_route_with_path_and_query_args(self): + with patch("manifest_dispatch.http_call") as call: + call.side_effect = [ + ("application/json", json.dumps(RESOURCES).encode()), + ("application/json", b'{"requirement": {"id": "KR-A01"}}'), + ] + result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/KR-A01"}}) + self.assertEqual(call.call_args_list[1][0][:2], ("GET", f"{bridge.BASE_URL}/assurance/requirements/KR-A01")) + self.assertEqual(call.call_args_list[1][1]["headers"], {"X-Kr0ki-Transport": "mcp"}) + self.assertIn("KR-A01", result["result"]["contents"][0]["text"]) + + call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), ("application/json", b"{}")] + bridge.handle({"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "kr0ki://evidence/KR-A02"}}) + self.assertEqual(call.call_args_list[3][0][1], f"{bridge.BASE_URL}/assurance/evidence?requirement=KR-A02") + + def test_a_path_variable_is_percent_encoded_so_it_cannot_change_the_route(self): + with patch("manifest_dispatch.http_call") as call: + call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), ("application/json", b"{}")] + bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/..%2Fadmin"}}) + self.assertNotIn("/../admin", call.call_args_list[1][0][1]) + self.assertTrue(call.call_args_list[1][0][1].endswith("..%2Fadmin")) + + def test_an_unknown_resource_is_an_error_not_a_guess(self): + with patch("manifest_dispatch.http_call") as call: + call.return_value = ("application/json", json.dumps(RESOURCES).encode()) + result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://nope/x"}}) + self.assertEqual(result["error"]["code"], -32602) + + def test_a_server_refusal_on_a_resource_read_is_surfaced_with_its_status(self): + err = urllib.error.HTTPError("u", 403, "Forbidden", {}, __import__("io").BytesIO(b'{"error":"forbidden"}')) + with patch("manifest_dispatch.http_call") as call: + call.side_effect = [("application/json", json.dumps(RESOURCES).encode()), err] + result = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "kr0ki://requirement/KR-A01"}}) + self.assertIn("403", result["error"]["message"]) + + def test_prompts_list_and_get_render_arguments_once(self): + with patch("manifest_dispatch.http_call") as call: + call.return_value = ("application/json", json.dumps(PROMPTS).encode()) + listed = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "prompts/list"}) + self.assertEqual([p["name"] for p in listed["result"]["prompts"]], ["explain_gap", "next"]) + got = bridge.handle({"jsonrpc": "2.0", "id": 2, "method": "prompts/get", "params": {"name": "explain_gap", "arguments": {"id": "KR-A06"}}}) + text = got["result"]["messages"][0]["content"]["text"] + self.assertIn("trace_requirement with id=KR-A06", text) + self.assertNotIn("{{", text) + + def test_prompt_arguments_are_checked_and_never_re_expanded(self): + with patch("manifest_dispatch.http_call") as call: + call.return_value = ("application/json", json.dumps(PROMPTS).encode()) + def get(args): + return bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "prompts/get", "params": {"name": "explain_gap", "arguments": args}}) + self.assertEqual(get({})["error"]["code"], -32602) + self.assertEqual(get({"id": " "})["error"]["code"], -32602) + self.assertEqual(get({"id": "x", "evil": "y"})["error"]["code"], -32602) + injected = get({"id": "{{id}}"})["result"]["messages"][0]["content"]["text"] + self.assertIn("id={{id}}", injected) + + def test_initialize_advertises_tools_resources_and_prompts(self): + caps = bridge.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"})["result"]["capabilities"] + self.assertEqual(set(caps), {"tools", "resources", "prompts"}) + + if __name__ == "__main__": unittest.main() diff --git a/crates/kr0ki-core/tests/assurance_service.rs b/crates/kr0ki-core/tests/assurance_service.rs index a66670c..480d50d 100644 --- a/crates/kr0ki-core/tests/assurance_service.rs +++ b/crates/kr0ki-core/tests/assurance_service.rs @@ -121,17 +121,14 @@ fn filters_narrow_the_answer_so_only_what_is_needed_is_returned() { 8 ); assert_eq!(f1(&|x| x.owner = Some("nobody".into())).total, 0); - // Implementation gaps: the controls the baseline records no implementation for. - let unimplemented = f1(&|x| x.gap = Some("control_not_implemented".into())); - assert!( - unimplemented.total >= 1 && unimplemented.total < 8, - "{}", - unimplemented.total - ); - assert!(unimplemented.requirements.iter().all(|r| r + // A gap filter selects exactly the requirements with that gap kind. + let untested = f1(&|x| x.gap = Some("satisfied_untested".into())); + assert_eq!(untested.total, 8); + assert!(untested.requirements.iter().all(|r| r .gaps .iter() - .any(|g| matches!(g, GapKind::ControlNotImplemented { .. })))); + .any(|g| matches!(g, GapKind::SatisfiedUntested)))); + assert_eq!(f1(&|x| x.gap = Some("no_such_gap".into())).total, 0); // A filtered answer is strictly smaller than the whole. let whole = serde_json::to_string( &f.service @@ -139,7 +136,7 @@ fn filters_narrow_the_answer_so_only_what_is_needed_is_returned() { .unwrap(), ) .unwrap(); - let narrow = serde_json::to_string(&unimplemented).unwrap(); + let narrow = serde_json::to_string(&f1(&|x| x.state = Some("verified".into()))).unwrap(); assert!(narrow.len() < whole.len()); } @@ -189,17 +186,58 @@ fn trace_resolves_links_at_the_current_revision_and_reports_dangling_ones() { .links .iter() .any(|l| l.locator.ends_with("tests/assurance_a01.rs"))); - // A requirement whose control records an implementation that does not yet exist, or whose - // test target is missing, shows it as dangling rather than hiding it. - let all: usize = ["KR-A06", "KR-A07", "KR-A08"] - .iter() - .map(|id| f.service.trace_requirement(id).unwrap().dangling) - .sum(); - assert!(all >= 1, "later stages' test targets do not exist yet"); assert!(matches!( f.service.trace_requirement("nope"), Err(ServiceError::UnknownRequirement(_)) )); + + // A baseline whose control names a file that does not exist and whose case names a test + // target that does not exist: both are reported dangling, with the reason. + let dir = std::env::temp_dir().join(format!("kr0ki-service-dangling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let src = repo_root().join("docs/assurance"); + let toml = std::fs::read_to_string(src.join("kr0ki.assurance.toml")) + .unwrap() + .replacen( + "crates/kr0ki-core/src/assurance_trace.rs", + "crates/kr0ki-core/src/does_not_exist.rs", + 1, + ) + .replacen( + "--test\", \"assurance_a01\"", + "--test\", \"no_such_test\"", + 1, + ); + std::fs::write(dir.join("kr0ki.assurance.toml"), toml).unwrap(); + std::fs::copy( + src.join("kr0ki-assurance.sysml"), + dir.join("kr0ki-assurance.sysml"), + ) + .unwrap(); + let broken = AssuranceService::new( + AssuranceConfig { + baseline_path: dir.join("kr0ki.assurance.toml"), + repo_root: repo_root(), + evidence_dir: dir.join("evidence"), + run_timeout: Duration::from_secs(5), + }, + f.probe.clone(), + f.stub.clone(), + ); + let dangling_in = |id: &str| broken.trace_requirement(id).unwrap(); + let a02 = dangling_in("KR-A02"); + assert!(a02.dangling >= 1, "{:#?}", a02.links); + assert!(a02 + .links + .iter() + .any(|l| l.locator.ends_with("does_not_exist.rs"))); + let a01 = dangling_in("KR-A01"); + assert!(a01.dangling >= 1, "{:#?}", a01.links); + assert!(a01 + .links + .iter() + .any(|l| l.locator.ends_with("no_such_test.rs"))); } #[test] diff --git a/crates/kr0ki-server/Cargo.toml b/crates/kr0ki-server/Cargo.toml index 4d6b657..ad83fa9 100644 --- a/crates/kr0ki-server/Cargo.toml +++ b/crates/kr0ki-server/Cargo.toml @@ -31,6 +31,10 @@ reqwest = { workspace = true } serde_yaml = "0.9" sha2 = { workspace = true } hmac = "0.12" +thiserror.workspace = true +toml = "0.8" +# The assurance profile types the /assurance routes and the gateway use (same pin as kr0ki-core). +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4" } [dev-dependencies] reqwest = { workspace = true, features = ["json"] } diff --git a/crates/kr0ki-server/src/app.rs b/crates/kr0ki-server/src/app.rs index 57d4cf2..de08bfa 100644 --- a/crates/kr0ki-server/src/app.rs +++ b/crates/kr0ki-server/src/app.rs @@ -27,6 +27,10 @@ pub type Service = RenderService; // Declared here (not in main.rs) so every build context that includes app.rs // — the bin crate and the integration-test binaries — resolves this module // relative to src/, where health.rs lives. +#[path = "assurance.rs"] +pub mod assurance; +#[path = "gateway.rs"] +pub mod gateway; #[path = "health.rs"] pub mod health; @@ -72,14 +76,31 @@ pub struct AppState { pub auth_token: Option, /// Ledgrrr contract reference for response headers. pub contract: Arc, + /// The assurance thread (`/assurance/*`). `None` makes those routes return 503. + pub assurance: Option>, } -/// If `auth_token` is Some, inject a `RequireAuth` layer that rejects requests -/// missing `Authorization: Bearer `. +/// The legacy entry point: `auth_token` is the single shared `KR0KI_AUTH_TOKEN` (or none). +/// It builds a gateway in `SharedToken` / `Open` mode with no audit log; use +/// [`router_with_gateway`] for per-identity grants and auditing. +#[allow(dead_code)] // the bin uses router_with_gateway; the integration tests use this pub fn router( state: AppState, auth_token: Option, contract: Arc, +) -> Router { + let mode = match auth_token { + Some(token) => gateway::AuthMode::SharedToken(token), + None => gateway::AuthMode::Open, + }; + router_with_gateway(state, Arc::new(gateway::Gateway::new(mode, None)), contract) +} + +/// The router with a [`gateway::Gateway`] in front of every route. +pub fn router_with_gateway( + state: AppState, + gateway: Arc, + contract: Arc, ) -> Router { let r = Router::new() .route("/", get(root)) @@ -172,47 +193,21 @@ pub fn router( .route("/b00t-graph/:tag", get(b00t_graph)) .route("/cache/:key", get(cache_get)) .merge(crate::docs::routes()) + .merge(assurance::routes()) .with_state(state); - let r = match auth_token { - Some(token) => r.layer(axum::middleware::from_fn(move |req, next| { - require_bearer(req, next, token.clone()) - })), - None => r, - }; + let r = r.layer(axum::middleware::from_fn(move |req, next| { + gateway::gateway_middleware(req, next, gateway.clone()) + })); // Added last so it is the *outermost* layer (axum layers wrap what came before): - // every response, including the 401 the auth layer returns itself, then carries the - // contract and request-id headers, and handlers still see the RequestId extension. + // every response, including a 401/403 the gateway returns itself, then carries the + // contract and request-id headers, and the gateway sees the RequestId extension. r.layer(axum::middleware::from_fn(move |req, next| { super::contract::contract_middleware(req, next, contract.clone()) })) } -async fn require_bearer( - req: axum::extract::Request, - next: axum::middleware::Next, - token: String, -) -> Response { - // /health is always exempt: readiness probes and `just validate-server` must work without - // a token. Exact match only -- nothing under /health/ or merely prefixed with it is exempt. - if req.uri().path() == "/health" { - return next.run(req).await; - } - let hdr = req - .headers() - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()); - match hdr { - Some(v) if v == format!("Bearer {token}") => next.run(req).await, - _ => error_json( - StatusCode::UNAUTHORIZED, - "unauthorized", - "missing or invalid bearer token", - ), - } -} - async fn health(State(state): State) -> Json { Json(health::collect(&state).await) } diff --git a/crates/kr0ki-server/src/assurance.rs b/crates/kr0ki-server/src/assurance.rs new file mode 100644 index 0000000..cd4f56b --- /dev/null +++ b/crates/kr0ki-server/src/assurance.rs @@ -0,0 +1,668 @@ +//! The `/assurance/*` routes (and the `/mcp/resources`, `/mcp/prompts` manifests): the HTTP face +//! of `kr0ki_core::assurance_service::AssuranceService`, and what the assurance MCP tools call. +//! +//! These handlers do **no authorisation of their own**. Every route here is classified in +//! `gateway::ROUTES`, and the gateway middleware has already authenticated the caller, checked +//! its grant and written the audit record before a handler runs. Keeping that in one place is +//! the point: a handler that checked for itself could drift from the table. + +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use kr0ki_core::assurance_service::{AssuranceService, RequirementFilter, ServiceError}; +use kr0ki_core::assurance_view::{to_d2, to_table}; +use kr0ki_core::audit_log::{AuditLog, AuditQuery, Decision, Phase}; +use kr0ki_core::cache::OutputKind; +use kr0ki_core::change_service::{ + ChangeError, ChangeService, Committed, ModelBackend, ProposedChange, Validation, +}; +use kr0ki_core::format::DiagramFormat; +use kr0ki_core::mcp_surface; +use kr0ki_core::sync_engine::DesiredElement; +use kr0ki_core::verification_runner::cases_from_graph; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use ufo_types::mbse::assurance::{statement_issues, RequirementStatus, SourceKind}; + +use crate::app::{error_json, AppState}; + +const SKILL_REQUIREMENTS_AUTHORING: &str = + include_str!("../../../skills/requirements-authoring/SKILL.md"); + +/// Boxed future, so [`ChangeApi`] is object-safe. +pub type BoxFut<'a, T> = Pin + Send + 'a>>; + +/// `ChangeService` behind a trait object, so `AppState` holds one concrete type whichever +/// model backend (the real server, or an in-memory one in tests) is behind it. +pub trait ChangeApi: Send + Sync { + fn propose<'a>( + &'a self, + project: &'a str, + branch: Option<&'a str>, + prefix: &'a str, + desired: &'a [DesiredElement], + ) -> BoxFut<'a, Result>; + fn validate<'a>(&'a self, change_id: &'a str) -> BoxFut<'a, Result>; + fn commit<'a>( + &'a self, + change_id: &'a str, + expected: Option<&'a str>, + ) -> BoxFut<'a, Result>; +} + +impl ChangeApi for ChangeService { + fn propose<'a>( + &'a self, + project: &'a str, + branch: Option<&'a str>, + prefix: &'a str, + desired: &'a [DesiredElement], + ) -> BoxFut<'a, Result> { + Box::pin(ChangeService::propose( + self, project, branch, prefix, desired, + )) + } + + fn validate<'a>(&'a self, change_id: &'a str) -> BoxFut<'a, Result> { + Box::pin(ChangeService::validate(self, change_id)) + } + + fn commit<'a>( + &'a self, + change_id: &'a str, + expected: Option<&'a str>, + ) -> BoxFut<'a, Result> { + Box::pin(ChangeService::commit(self, change_id, expected)) + } +} + +pub struct AssuranceRuntime { + pub service: Arc, + /// `None` makes the change routes return 503 (no model backend configured). + pub changes: Option>, + pub audit: Option>, + /// Identifier prefix of the elements this service manages on the model server. + pub managed_prefix: String, +} + +pub fn routes() -> Router { + Router::new() + .route("/assurance/requirements", get(list_requirements)) + .route("/assurance/requirements/:id", get(get_requirement)) + .route("/assurance/requirements/:id/trace", get(trace_requirement)) + .route("/assurance/view", get(view)) + .route("/assurance/evidence", get(evidence)) + .route("/assurance/changes", post(propose_change)) + .route("/assurance/changes/:change_id", get(validate_change)) + .route("/assurance/changes/:change_id/commit", post(commit_change)) + .route("/assurance/verify/:case_id", post(run_verification)) + .route("/assurance/audit", get(audit_records)) + .route("/assurance/skills/:name", get(skill)) + .route("/mcp/resources", get(mcp_resources)) + .route("/mcp/prompts", get(mcp_prompts)) +} + +fn runtime(state: &AppState) -> Result, Box> { + state.assurance.clone().ok_or_else(|| { + Box::new(error_json( + StatusCode::SERVICE_UNAVAILABLE, + "assurance_not_configured", + "set KR0KI_ASSURANCE_BASELINE (and KR0KI_EVIDENCE_DIR) to enable /assurance", + )) + }) +} + +fn service_error(e: ServiceError) -> Response { + match e { + ServiceError::UnknownRequirement(_) | ServiceError::UnknownCase(_) => { + error_json(StatusCode::NOT_FOUND, "not_found", &e.to_string()) + } + ServiceError::RevisionMismatch { + ref expected, + ref actual, + } => ( + StatusCode::CONFLICT, + Json(json!({ + "error": "revision_mismatch", + "message": e.to_string(), + "expected": expected, + "actual": actual, + })), + ) + .into_response(), + ServiceError::Runner(ref r) => error_json( + StatusCode::UNPROCESSABLE_ENTITY, + "case_refused", + &r.to_string(), + ), + other => error_json( + StatusCode::INTERNAL_SERVER_ERROR, + "assurance_unavailable", + &other.to_string(), + ), + } +} + +fn change_error(e: ChangeError) -> Response { + match e { + ChangeError::StaleBase { + ref expected, + ref current, + } => ( + StatusCode::CONFLICT, + Json(json!({ + "error": "stale_base", + "message": e.to_string(), + "expected": expected, + "current": current, + })), + ) + .into_response(), + ChangeError::ProposalMismatch { .. } => { + error_json(StatusCode::CONFLICT, "proposal_mismatch", &e.to_string()) + } + ChangeError::UnknownChange(_) => { + error_json(StatusCode::NOT_FOUND, "unknown_change", &e.to_string()) + } + ChangeError::NoChanges(_) => error_json(StatusCode::OK, "no_changes", &e.to_string()), + ChangeError::Sync(_) => error_json( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_change", + &e.to_string(), + ), + ChangeError::Backend(_) => error_json( + StatusCode::BAD_GATEWAY, + "model_backend_error", + &e.to_string(), + ), + } +} + +async fn blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result> { + match tokio::task::spawn_blocking(f).await { + Ok(Ok(v)) => Ok(v), + Ok(Err(e)) => Err(Box::new(service_error(e))), + Err(e) => Err(Box::new(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + "task_failed", + &e.to_string(), + ))), + } +} + +#[derive(Deserialize, Default)] +struct ListParams { + status: Option, + owner: Option, + state: Option, + gap: Option, +} + +async fn list_requirements(State(state): State, Query(p): Query) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let filter = RequirementFilter { + status: p.status, + owner: p.owner, + state: p.state, + gap: p.gap, + }; + match blocking(move || rt.service.list_requirements(&filter)).await { + Ok(v) => Json(v).into_response(), + Err(r) => *r, + } +} + +async fn get_requirement(State(state): State, Path(id): Path) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + match blocking(move || rt.service.get_requirement(&id)).await { + Ok(v) => Json(v).into_response(), + Err(r) => *r, + } +} + +async fn trace_requirement(State(state): State, Path(id): Path) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + match blocking(move || rt.service.trace_requirement(&id)).await { + Ok(v) => Json(v).into_response(), + Err(r) => *r, + } +} + +#[derive(Deserialize, Default)] +struct ViewParams { + format: Option, +} + +async fn view(State(state): State, Query(p): Query) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let snap = match blocking(move || rt.service.snapshot()).await { + Ok(s) => s, + Err(r) => return *r, + }; + match p.format.as_deref().unwrap_or("json") { + "json" => Json(&snap.view).into_response(), + "table" => ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + to_table(&snap.view), + ) + .into_response(), + "d2" => ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + to_d2(snap.graph(), &snap.view), + ) + .into_response(), + "svg" => { + let d2 = to_d2(snap.graph(), &snap.view); + match state + .service + .render(DiagramFormat::D2, OutputKind::Svg, &d2) + .await + { + Ok(r) => ([(header::CONTENT_TYPE, "image/svg+xml")], r.bytes).into_response(), + Err(e) => error_json(StatusCode::BAD_GATEWAY, "render_failed", &e.to_string()), + } + } + other => error_json( + StatusCode::BAD_REQUEST, + "bad_format", + &format!("unknown format `{other}` (json|table|d2|svg)"), + ), + } +} + +#[derive(Deserialize, Default)] +struct EvidenceParams { + requirement: Option, + case: Option, +} + +async fn evidence(State(state): State, Query(p): Query) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + match blocking(move || { + rt.service + .evidence(p.requirement.as_deref(), p.case.as_deref()) + }) + .await + { + Ok(v) => Json(v).into_response(), + Err(r) => *r, + } +} + +// ── changes ───────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProposedRequirement { + id: String, + title: String, + statement: String, + source: String, + /// Parsed only to reject an unknown vocabulary value; the model server does not store it. + #[allow(dead_code)] + source_kind: SourceKind, + owner: String, + rationale: String, + verification_id: String, + acceptance: String, + /// Parsed only to reject an unknown vocabulary value; the model server does not store it. + #[allow(dead_code)] + status: RequirementStatus, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProposeBody { + project_id: String, + #[serde(default)] + branch_id: Option, + requirements: Vec, +} + +#[derive(Serialize)] +struct Diagnostic { + requirement_id: String, + severity: &'static str, + code: &'static str, + message: String, +} + +fn diagnose(body: &ProposeBody, known_cases: &BTreeSet) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for r in &body.requirements { + let mut err = |code: &'static str, message: String| { + out.push(Diagnostic { + requirement_id: r.id.clone(), + severity: "error", + code, + message, + }); + }; + if !seen.insert(r.id.as_str()) { + err( + "duplicate_id", + format!("`{}` appears more than once in this proposal", r.id), + ); + } + for (field, value) in [ + ("id", &r.id), + ("title", &r.title), + ("statement", &r.statement), + ("source", &r.source), + ("owner", &r.owner), + ("rationale", &r.rationale), + ("verification_id", &r.verification_id), + ("acceptance", &r.acceptance), + ] { + if value.trim().is_empty() { + err("empty_field", format!("`{field}` must not be empty")); + } + } + } + for r in &body.requirements { + for issue in statement_issues(&r.statement) { + out.push(Diagnostic { + requirement_id: r.id.clone(), + severity: "warning", + code: "statement_lint", + message: issue.to_string(), + }); + } + if !r.verification_id.trim().is_empty() && !known_cases.contains(&r.verification_id) { + out.push(Diagnostic { + requirement_id: r.id.clone(), + severity: "warning", + code: "unknown_verification_case", + message: format!( + "`{}` is not a verification case in the baseline", + r.verification_id + ), + }); + } + } + out +} + +async fn propose_change(State(state): State, body: Bytes) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let Some(changes) = rt.changes.clone() else { + return error_json( + StatusCode::SERVICE_UNAVAILABLE, + "model_backend_not_configured", + "KR0KI_SYSMLV2_BASE_URL is not set on this server", + ); + }; + let parsed: ProposeBody = match serde_json::from_slice(&body) { + Ok(p) => p, + Err(e) => return error_json(StatusCode::BAD_REQUEST, "bad_change", &e.to_string()), + }; + let svc = rt.service.clone(); + let known_cases: BTreeSet = match blocking(move || { + let snap = svc.snapshot()?; + Ok(cases_from_graph(snap.graph()) + .into_iter() + .map(|c| c.id) + .collect()) + }) + .await + { + Ok(c) => c, + Err(r) => return *r, + }; + let diagnostics = diagnose(&parsed, &known_cases); + if diagnostics.iter().any(|d| d.severity == "error") { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({ + "error": "invalid_change", + "message": "the proposal has errors; nothing was proposed", + "diagnostics": diagnostics, + })), + ) + .into_response(); + } + let desired: Vec = parsed + .requirements + .iter() + .map(|r| { + let mut fields = Map::new(); + fields.insert("name".into(), json!(r.title)); + fields.insert("text".into(), json!([r.statement])); + DesiredElement { + identifier: format!("{}{}", rt.managed_prefix, r.id), + type_: "RequirementUsage", + fields, + } + }) + .collect(); + match changes + .propose( + &parsed.project_id, + parsed.branch_id.as_deref(), + &rt.managed_prefix, + &desired, + ) + .await + { + Ok(change) => Json(json!({ + "change": change, + "diagnostics": diagnostics, + // The model server keeps the title and statement; the other profile fields are + // authored in the baseline file, not stored on the server. + "persisted_fields": ["name", "text"], + })) + .into_response(), + Err(ChangeError::NoChanges(base)) => Json(json!({ + "change": null, + "no_changes": true, + "base_revision": base, + "diagnostics": diagnostics, + })) + .into_response(), + Err(e) => change_error(e), + } +} + +async fn validate_change(State(state): State, Path(change_id): Path) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let Some(changes) = rt.changes.clone() else { + return error_json( + StatusCode::SERVICE_UNAVAILABLE, + "model_backend_not_configured", + "KR0KI_SYSMLV2_BASE_URL is not set on this server", + ); + }; + match changes.validate(&change_id).await { + Ok(v) => Json(v).into_response(), + Err(e) => change_error(e), + } +} + +#[derive(Deserialize)] +struct CommitParams { + expected_revision: String, +} + +async fn commit_change( + State(state): State, + Path(change_id): Path, + Query(p): Query, +) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let Some(changes) = rt.changes.clone() else { + return error_json( + StatusCode::SERVICE_UNAVAILABLE, + "model_backend_not_configured", + "KR0KI_SYSMLV2_BASE_URL is not set on this server", + ); + }; + // An empty expected revision means "the branch has no commits yet". + let expected = (!p.expected_revision.is_empty()).then_some(p.expected_revision.as_str()); + match changes.commit(&change_id, expected).await { + Ok(c) => Json(c).into_response(), + Err(e) => change_error(e), + } +} + +#[derive(Deserialize)] +struct VerifyParams { + revision: String, +} + +async fn run_verification( + State(state): State, + Path(case_id): Path, + Query(p): Query, +) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + match blocking(move || rt.service.run_verification(&case_id, &p.revision)).await { + Ok(v) => Json(v).into_response(), + Err(r) => *r, + } +} + +#[derive(Deserialize, Default)] +struct AuditParams { + caller: Option, + operation: Option, + decision: Option, + correlation_id: Option, + phase: Option, + limit: Option, +} + +async fn audit_records(State(state): State, Query(p): Query) -> Response { + let rt = match runtime(&state) { + Ok(r) => r, + Err(r) => return *r, + }; + let Some(audit) = rt.audit.clone() else { + return error_json( + StatusCode::SERVICE_UNAVAILABLE, + "audit_not_configured", + "no audit log is configured", + ); + }; + let decision = match p.decision.as_deref() { + None => None, + Some("permit") => Some(Decision::Permit), + Some("deny") => Some(Decision::Deny), + Some(o) => { + return error_json( + StatusCode::BAD_REQUEST, + "bad_decision", + &format!("`{o}` (permit|deny)"), + ) + } + }; + let phase = match p.phase.as_deref() { + None => None, + Some("decision") => Some(Phase::Decision), + Some("outcome") => Some(Phase::Outcome), + Some(o) => { + return error_json( + StatusCode::BAD_REQUEST, + "bad_phase", + &format!("`{o}` (decision|outcome)"), + ) + } + }; + let q = AuditQuery { + caller: p.caller, + operation: p.operation, + decision, + correlation_id: p.correlation_id, + phase, + }; + let limit = p.limit.unwrap_or(200).min(1000); + let result = tokio::task::spawn_blocking(move || { + let chain = audit.verify()?; + let mut records = audit.query(&q)?; + if records.len() > limit { + records.drain(..records.len() - limit); + } + Ok::<_, kr0ki_core::audit_log::AuditError>((chain, records)) + }) + .await; + match result { + Ok(Ok((chain, records))) => Json(json!({ + "chain": match chain { + Ok(n) => json!({"ok": true, "records": n}), + Err(b) => json!({"ok": false, "line": b.line, "seq": b.seq, "reason": b.reason}), + }, + "records": records, + })) + .into_response(), + Ok(Err(e)) => error_json( + StatusCode::INTERNAL_SERVER_ERROR, + "audit_unreadable", + &e.to_string(), + ), + Err(e) => error_json( + StatusCode::INTERNAL_SERVER_ERROR, + "task_failed", + &e.to_string(), + ), + } +} + +async fn skill(Path(name): Path) -> Response { + match name.as_str() { + "requirements-authoring" => ( + [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], + SKILL_REQUIREMENTS_AUTHORING, + ) + .into_response(), + _ => error_json( + StatusCode::NOT_FOUND, + "unknown_skill", + &format!("no skill `{name}`"), + ), + } +} + +async fn mcp_resources() -> Json { + Json(mcp_surface::resources_manifest_json()) +} + +async fn mcp_prompts() -> Json { + Json(mcp_surface::prompts_manifest_json()) +} diff --git a/crates/kr0ki-server/src/gateway.rs b/crates/kr0ki-server/src/gateway.rs index c0f22ad..453bfad 100644 --- a/crates/kr0ki-server/src/gateway.rs +++ b/crates/kr0ki-server/src/gateway.rs @@ -1,4 +1,3 @@ -// DRAFT - NOT YET COMPILED OR WIRED. Not declared as a module in app.rs; see the PR description for what remains. //! KR-A06: the tool gateway — authenticate the caller, check its capability grant for the //! operation, audit the decision, and only then run the handler. **For every transport.** //! @@ -84,10 +83,6 @@ impl Identity { pub fn permits(&self, grant: &str) -> bool { self.grants.contains("admin") || self.grants.contains(grant) } - - pub fn grants(&self) -> &BTreeSet { - &self.grants - } } #[derive(Debug, thiserror::Error)] @@ -100,7 +95,9 @@ pub enum IdentityError { DuplicateToken(String, String), #[error("identity `{id}` has unknown grant `{grant}` (known: {})", GRANTS.join(", "))] UnknownGrant { id: String, grant: String }, - #[error("identity `{0}`: token_sha256 must be 64 lowercase hex digits (the SHA-256 of the token)")] + #[error( + "identity `{0}`: token_sha256 must be 64 lowercase hex digits (the SHA-256 of the token)" + )] BadDigest(String), #[error("the identities file declares no identity")] Empty, @@ -133,12 +130,17 @@ pub fn parse_identities(text: &str) -> Result, IdentityError> { return Err(IdentityError::DuplicateId(i.id)); } let digest_ok = i.token_sha256.len() == 64 - && i.token_sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + && i.token_sha256 + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); if !digest_ok { return Err(IdentityError::BadDigest(i.id)); } if let Some(g) = i.grants.iter().find(|g| !GRANTS.contains(&g.as_str())) { - return Err(IdentityError::UnknownGrant { id: i.id.clone(), grant: g.clone() }); + return Err(IdentityError::UnknownGrant { + id: i.id.clone(), + grant: g.clone(), + }); } if let Some(other) = out.iter().find(|o| o.token_sha256 == i.token_sha256) { return Err(IdentityError::DuplicateToken(other.id.clone(), i.id)); @@ -153,7 +155,11 @@ pub fn parse_identities(text: &str) -> Result, IdentityError> { } fn constant_time_eq(a: &str, b: &str) -> bool { - a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 + a.len() == b.len() + && a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 } #[derive(Debug, Clone)] @@ -209,7 +215,11 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("POST", "/render/kubediagram", Some("render")), ("POST", "/render/k8s-topology", Some("render")), ("POST", "/render/rust-source", Some("render")), - ("POST", "/render/sysmlv2/projects/:project_id/commits/:commit_id", Some("render")), + ( + "POST", + "/render/sysmlv2/projects/:project_id/commits/:commit_id", + Some("render"), + ), ("GET", "/cache/:key", Some("render")), ("GET", "/ui/:session/events", Some("render")), // read-only computation and model reads @@ -225,40 +235,86 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("POST", "/requirements/export", Some("model.read")), ("POST", "/requirements/views", Some("model.read")), ("GET", "/model/projects", Some("model.read")), - ("GET", "/model/projects/:project_id/commits", Some("model.read")), - ("GET", "/model/projects/:project_id/commits/:commit_id/snapshot", Some("model.read")), - ("GET", "/model/projects/:project_id/commits/:commit_id/elements", Some("model.read")), - ("GET", "/model/projects/:project_id/commits/:commit_id/roots", Some("model.read")), + ( + "GET", + "/model/projects/:project_id/commits", + Some("model.read"), + ), + ( + "GET", + "/model/projects/:project_id/commits/:commit_id/snapshot", + Some("model.read"), + ), + ( + "GET", + "/model/projects/:project_id/commits/:commit_id/elements", + Some("model.read"), + ), + ( + "GET", + "/model/projects/:project_id/commits/:commit_id/roots", + Some("model.read"), + ), ( "GET", "/model/projects/:project_id/commits/:commit_id/elements/:element_id/relationships", Some("model.read"), ), - ("POST", "/model/projects/:project_id/recompute", Some("model.read")), + ( + "POST", + "/model/projects/:project_id/recompute", + Some("model.read"), + ), ("GET", "/model/graph/query", Some("model.read")), ("GET", "/b00t-graph/:tag", Some("model.read")), // writes to the model - ("POST", "/model/projects/:project_id/sync", Some("model.commit")), - ("POST", "/model/projects/:project_id/requirements", Some("model.commit")), + ( + "POST", + "/model/projects/:project_id/sync", + Some("model.commit"), + ), + ( + "POST", + "/model/projects/:project_id/requirements", + Some("model.commit"), + ), // steering the UI ("POST", "/ui/:session/navigate", Some("ui.steer")), // the assurance thread ("GET", "/assurance/requirements", Some("assurance.read")), ("GET", "/assurance/requirements/:id", Some("assurance.read")), - ("GET", "/assurance/requirements/:id/trace", Some("assurance.read")), + ( + "GET", + "/assurance/requirements/:id/trace", + Some("assurance.read"), + ), ("GET", "/assurance/view", Some("assurance.read")), ("GET", "/assurance/evidence", Some("assurance.read")), ("POST", "/assurance/changes", Some("assurance.propose")), - ("GET", "/assurance/changes/:change_id", Some("assurance.read")), - ("POST", "/assurance/changes/:change_id/commit", Some("model.commit")), - ("POST", "/assurance/verify/:case_id", Some("assurance.verify")), + ( + "GET", + "/assurance/changes/:change_id", + Some("assurance.read"), + ), + ( + "POST", + "/assurance/changes/:change_id/commit", + Some("model.commit"), + ), + ( + "POST", + "/assurance/verify/:case_id", + Some("assurance.verify"), + ), ("GET", "/assurance/audit", Some("audit.read")), - ("GET", "/assurance/resources/*uri", Some("assurance.read")), + ("GET", "/assurance/skills/:name", Some("assurance.read")), ]; /// The grant a `(method, pattern)` requires. Unknown → `admin` (deny by default). pub fn required_grant(method: &str, pattern: Option<&str>) -> Option<&'static str> { - let Some(pattern) = pattern else { return Some("admin") }; + let Some(pattern) = pattern else { + return Some("admin"); + }; ROUTES .iter() .find(|(m, p, _)| *m == method && *p == pattern) @@ -296,32 +352,43 @@ pub enum Authn { impl Gateway { pub fn new(mode: AuthMode, audit: Option>) -> Self { - Self { mode, audit, tools: tool_names(), model_revision: None } + Self { + mode, + audit, + tools: tool_names(), + model_revision: None, + } } /// How to learn "the model revision in play" for routes that name none in the request /// (the assurance routes evaluate against the service's current model revision). - pub fn with_model_revision(mut self, f: impl Fn() -> Option + Send + Sync + 'static) -> Self { + pub fn with_model_revision( + mut self, + f: impl Fn() -> Option + Send + Sync + 'static, + ) -> Self { self.model_revision = Some(Box::new(f)); self } - pub fn audit(&self) -> Option<&Arc> { - self.audit.as_ref() - } - pub fn authenticate(&self, authorization: Option<&str>) -> Authn { let bearer = authorization.and_then(|v| v.strip_prefix("Bearer ")); match &self.mode { AuthMode::Open => Authn::Caller(Identity::new("anonymous", &["admin"], "")), AuthMode::SharedToken(token) => match bearer { - Some(b) if constant_time_eq(&sha256_hex(b.as_bytes()), &sha256_hex(token.as_bytes())) => { + Some(b) + if constant_time_eq( + &sha256_hex(b.as_bytes()), + &sha256_hex(token.as_bytes()), + ) => + { Authn::Caller(Identity::new("shared-token", &["admin"], token)) } _ => Authn::Failed, }, AuthMode::Identities(identities) => { - let Some(b) = bearer else { return Authn::Failed }; + let Some(b) = bearer else { + return Authn::Failed; + }; let presented = sha256_hex(b.as_bytes()); // Compare against every identity: no early exit on a match. let mut found: Option<&Identity> = None; @@ -338,7 +405,10 @@ impl Gateway { pub fn operation(&self, method: &Method, pattern: Option<&str>) -> Operation { let grant = required_grant(method.as_str(), pattern); let name = pattern - .and_then(|p| self.tools.get(&(method.as_str().to_string(), p.to_string()))) + .and_then(|p| { + self.tools + .get(&(method.as_str().to_string(), p.to_string())) + }) .map(|n| (*n).to_string()) .unwrap_or_else(|| format!("{} {}", method, pattern.unwrap_or("(no route)"))); Operation { name, grant } @@ -347,7 +417,12 @@ impl Gateway { /// The model revision a request is about: a commit id in the path, an `expected_revision` /// or `revision` in the query, else the service's current model revision for assurance /// routes, else none. - fn revision_for(&self, path: &str, query: Option<&str>, pattern: Option<&str>) -> Option { + fn revision_for( + &self, + path: &str, + query: Option<&str>, + pattern: Option<&str>, + ) -> Option { let mut segments = path.split('/'); while let Some(s) = segments.next() { if s == "commits" { @@ -373,7 +448,7 @@ impl Gateway { fn deny_response(status: StatusCode, code: &str, detail: &str, request_id: &str) -> Response { ( status, - Json(serde_json::json!({"error": code, "detail": detail, "request_id": request_id})), + Json(serde_json::json!({"error": code, "message": detail, "detail": detail, "request_id": request_id})), ) .into_response() } @@ -391,13 +466,27 @@ pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) return next.run(req).await; } - let request_id = req.extensions().get::().map(|r| cap(&r.0)).unwrap_or_default(); - let pattern = req.extensions().get::().map(|m| m.as_str().to_string()); - let transport = match req.headers().get("x-kr0ki-transport").and_then(|v| v.to_str().ok()) { + let request_id = req + .extensions() + .get::() + .map(|r| cap(&r.0)) + .unwrap_or_default(); + let pattern = req + .extensions() + .get::() + .map(|m| m.as_str().to_string()); + let transport = match req + .headers() + .get("x-kr0ki-transport") + .and_then(|v| v.to_str().ok()) + { Some("mcp") => "mcp", _ => "http", }; - let authorization = req.headers().get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()); + let authorization = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); let authn = gateway.authenticate(authorization); let op = gateway.operation(&method, pattern.as_deref()); let revision = gateway.revision_for(&path, req.uri().query(), pattern.as_deref()); @@ -413,7 +502,10 @@ pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) Some(grant) if !identity.permits(grant) => ( identity.id.clone(), Decision::Deny, - Some(format!("identity `{}` has no `{grant}` grant for `{}`", identity.id, op.name)), + Some(format!( + "identity `{}` has no `{grant}` grant for `{}`", + identity.id, op.name + )), StatusCode::FORBIDDEN, ), _ => (identity.id.clone(), Decision::Permit, None, StatusCode::OK), @@ -448,17 +540,32 @@ pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) } if decision == Decision::Deny { - let code = if deny_status == StatusCode::UNAUTHORIZED { "unauthorized" } else { "forbidden" }; - let response = deny_response(deny_status, code, reason.as_deref().unwrap_or("denied"), &request_id); + let code = if deny_status == StatusCode::UNAUTHORIZED { + "unauthorized" + } else { + "forbidden" + }; + let response = deny_response( + deny_status, + code, + reason.as_deref().unwrap_or("denied"), + &request_id, + ); if let Some(audit) = &gateway.audit { - let _ = audit.append(draft(Phase::Outcome, Some(deny_status.as_u16())), &now_rfc3339()); + let _ = audit.append( + draft(Phase::Outcome, Some(deny_status.as_u16())), + &now_rfc3339(), + ); } return response; } let response = next.run(req).await; if let Some(audit) = &gateway.audit { - if let Err(e) = audit.append(draft(Phase::Outcome, Some(response.status().as_u16())), &now_rfc3339()) { + if let Err(e) = audit.append( + draft(Phase::Outcome, Some(response.status().as_u16())), + &now_rfc3339(), + ) { tracing::error!("audit outcome write failed: {e}"); } } @@ -494,7 +601,8 @@ mod tests { #[test] fn every_registered_route_is_classified_and_every_classification_names_a_real_route() { let registered = registered(); - let classified: BTreeSet = ROUTES.iter().map(|(_, p, _)| (*p).to_string()).collect(); + let classified: BTreeSet = + ROUTES.iter().map(|(_, p, _)| (*p).to_string()).collect(); // `/health` is exempt before classification; `/assurance/*` routes are registered by the // assurance module, which this scan also reads. let mut registered = registered; @@ -506,7 +614,10 @@ mod tests { "routes registered but not classified in gateway::ROUTES (they would require admin): {unclassified:?}" ); let stale: Vec<_> = classified.difference(®istered).collect(); - assert!(stale.is_empty(), "gateway::ROUTES entries that match no registered route: {stale:?}"); + assert!( + stale.is_empty(), + "gateway::ROUTES entries that match no registered route: {stale:?}" + ); } #[test] @@ -521,19 +632,29 @@ mod tests { #[test] fn unknown_and_unmatched_routes_require_admin() { assert_eq!(required_grant("POST", Some("/not/a/route")), Some("admin")); - assert_eq!(required_grant("DELETE", Some("/model/projects")), Some("admin"), "right path, wrong method"); + assert_eq!( + required_grant("DELETE", Some("/model/projects")), + Some("admin"), + "right path, wrong method" + ); assert_eq!(required_grant("GET", None), Some("admin")); } #[test] fn every_write_to_the_model_requires_model_commit() { + // A POST under /model/ (other than the read-only recompute) or one ending `/commit` + // writes to the model. `/render/...` routes only read it, whatever their path says. for (m, p, g) in ROUTES { - let writes_model = (p.starts_with("/model/") || p.contains("/commit")) && *m == "POST"; - let is_compute = p.ends_with("/recompute"); - if writes_model && !is_compute { + let writes_model = *m == "POST" + && (p.starts_with("/model/") || p.ends_with("/commit")) + && !p.ends_with("/recompute"); + if writes_model { assert_eq!(*g, Some("model.commit"), "{m} {p}"); } } + assert!(ROUTES.iter().any(|(m, p, g)| *m == "POST" + && *p == "/assurance/changes/:change_id/commit" + && *g == Some("model.commit"))); } #[test] @@ -541,10 +662,15 @@ mod tests { let ok = |s: &str| parse_identities(s); let d = sha256_hex(b"t1"); let d2 = sha256_hex(b"t2"); - assert!(ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"model.read\"]\n")).is_ok()); + assert!(ok(&format!( + "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"model.read\"]\n" + )) + .is_ok()); assert!(matches!(ok(""), Err(IdentityError::Empty))); assert!(matches!( - ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"root\"]\n")), + ok(&format!( + "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"root\"]\n" + )), Err(IdentityError::UnknownGrant { .. }) )); assert!(matches!( @@ -558,9 +684,15 @@ mod tests { let dup_tok = format!( "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[]\n[[identity]]\nid=\"b\"\ntoken_sha256=\"{d}\"\ngrants=[]\n" ); - assert!(matches!(ok(&dup_tok), Err(IdentityError::DuplicateToken(_, _)))); + assert!(matches!( + ok(&dup_tok), + Err(IdentityError::DuplicateToken(_, _)) + )); // a typo'd key is an error, not a silently dropped field - assert!(ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrant=[\"admin\"]\n")).is_err()); + assert!(ok(&format!( + "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrant=[\"admin\"]\n" + )) + .is_err()); // a plaintext token in the digest field is rejected, never stored assert!(matches!( ok("[[identity]]\nid=\"a\"\ntoken_sha256=\"my-secret-token\"\ngrants=[]\n"), @@ -586,7 +718,9 @@ mod tests { assert_eq!(who(None), None); let shared = Gateway::new(AuthMode::SharedToken("s3cret".into()), None); - assert!(matches!(shared.authenticate(Some("Bearer s3cret")), Authn::Caller(i) if i.id == "shared-token" && i.permits("anything"))); + assert!( + matches!(shared.authenticate(Some("Bearer s3cret")), Authn::Caller(i) if i.id == "shared-token" && i.permits("anything")) + ); assert_eq!(shared.authenticate(Some("Bearer other")), Authn::Failed); let open = Gateway::new(AuthMode::Open, None); @@ -610,25 +744,48 @@ mod tests { let op = gw.operation(&Method::POST, Some("/sparql")); assert_eq!(op.name, "POST /sparql"); let op = gw.operation(&Method::GET, None); - assert_eq!((op.name.as_str(), op.grant), ("GET (no route)", Some("admin"))); + assert_eq!( + (op.name.as_str(), op.grant), + ("GET (no route)", Some("admin")) + ); } #[test] fn the_model_revision_comes_from_the_path_the_query_or_the_service() { - let gw = Gateway::new(AuthMode::Open, None).with_model_revision(|| Some("model-now".into())); + let gw = + Gateway::new(AuthMode::Open, None).with_model_revision(|| Some("model-now".into())); assert_eq!( - gw.revision_for("/model/projects/p/commits/c-42/elements", None, Some("/model/projects/:project_id/commits/:commit_id/elements")).as_deref(), + gw.revision_for( + "/model/projects/p/commits/c-42/elements", + None, + Some("/model/projects/:project_id/commits/:commit_id/elements") + ) + .as_deref(), Some("c-42") ); assert_eq!( - gw.revision_for("/assurance/changes/x/commit", Some("expected_revision=r7&x=1"), Some("/assurance/changes/:change_id/commit")).as_deref(), + gw.revision_for( + "/assurance/changes/x/commit", + Some("expected_revision=r7&x=1"), + Some("/assurance/changes/:change_id/commit") + ) + .as_deref(), Some("r7") ); assert_eq!( - gw.revision_for("/assurance/view", None, Some("/assurance/view")).as_deref(), + gw.revision_for("/assurance/view", None, Some("/assurance/view")) + .as_deref(), Some("model-now") ); assert_eq!(gw.revision_for("/formats", None, Some("/formats")), None); - assert_eq!(gw.revision_for("/assurance/view", Some("revision="), Some("/assurance/view")).as_deref(), Some("model-now")); + assert_eq!( + gw.revision_for( + "/assurance/view", + Some("revision="), + Some("/assurance/view") + ) + .as_deref(), + Some("model-now") + ); } } diff --git a/crates/kr0ki-server/src/main.rs b/crates/kr0ki-server/src/main.rs index 226c727..9833a07 100644 --- a/crates/kr0ki-server/src/main.rs +++ b/crates/kr0ki-server/src/main.rs @@ -37,7 +37,9 @@ use std::sync::Arc; use anyhow::Context; use kr0ki_core::{cache::FsCache, render::HttpKrokiBackend, RenderService}; -use crate::app::{router, AppState}; +use crate::app::assurance::{AssuranceRuntime, ChangeApi}; +use crate::app::gateway::{AuthMode, Gateway}; +use crate::app::{router_with_gateway, AppState}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -117,6 +119,92 @@ async fn main() -> anyhow::Result<()> { FsCache::new(&cache_dir), ); let contract = Arc::new(contract::ContractReference::from_env()); + // The assurance thread (`/assurance/*`) and the gateway in front of every route. + // KR0KI_ASSURANCE_BASELINE baseline TOML (default docs/assurance/kr0ki.assurance.toml if present) + // KR0KI_REPO_ROOT repository root for paths and git revisions (default .) + // KR0KI_EVIDENCE_DIR evidence store (default ./.kr0ki-evidence) + // KR0KI_IDENTITIES_FILE per-identity tokens (sha256) and grants; replaces the shared token + // KR0KI_AUDIT_FILE audit log (default ./.kr0ki-audit/audit.jsonl when identities are set) + let repo_root = + std::path::PathBuf::from(std::env::var("KR0KI_REPO_ROOT").unwrap_or_else(|_| ".".into())); + let baseline_path = std::env::var("KR0KI_ASSURANCE_BASELINE") + .map(std::path::PathBuf::from) + .ok() + .or_else(|| { + let default = repo_root.join("docs/assurance/kr0ki.assurance.toml"); + default.is_file().then_some(default) + }); + let identities_file = std::env::var("KR0KI_IDENTITIES_FILE") + .ok() + .filter(|p| !p.trim().is_empty()); + let audit_path = std::env::var("KR0KI_AUDIT_FILE") + .ok() + .filter(|p| !p.trim().is_empty()) + .or_else(|| { + identities_file + .as_ref() + .map(|_| "./.kr0ki-audit/audit.jsonl".to_string()) + }); + let audit = match &audit_path { + Some(path) => Some(Arc::new( + kr0ki_core::audit_log::AuditLog::open(path) + .with_context(|| format!("opening audit log {path}"))?, + )), + None => None, + }; + let auth_mode = match &identities_file { + Some(path) => { + let text = std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?; + let identities = crate::app::gateway::parse_identities(&text) + .with_context(|| format!("parsing {path}"))?; + if auth_token.is_some() { + tracing::warn!( + "KR0KI_IDENTITIES_FILE is set: the shared KR0KI_AUTH_TOKEN is ignored" + ); + } + tracing::info!(identities = identities.len(), "per-identity grants enabled"); + AuthMode::Identities(identities) + } + None => match &auth_token { + Some(token) => AuthMode::SharedToken(token.clone()), + None => AuthMode::Open, + }, + }; + let assurance = baseline_path.map(|baseline_path| { + let service = Arc::new(kr0ki_core::assurance_service::AssuranceService::new( + kr0ki_core::assurance_service::AssuranceConfig { + baseline_path, + repo_root: repo_root.clone(), + evidence_dir: std::env::var("KR0KI_EVIDENCE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| repo_root.join(".kr0ki-evidence")), + run_timeout: std::time::Duration::from_secs(1800), + }, + Arc::new(kr0ki_core::verification_runner::GitRevisionProbe::new( + repo_root.clone(), + )), + Arc::new(kr0ki_core::verification_runner::ProcessExecutor), + )); + let changes: Option> = sysmlv2_client.clone().map(|client| { + Arc::new(kr0ki_core::change_service::ChangeService::new( + kr0ki_core::change_service::SysmlBackend::new(client), + )) as Arc + }); + Arc::new(AssuranceRuntime { + service, + changes, + audit: audit.clone(), + managed_prefix: std::env::var("KR0KI_ASSURANCE_PREFIX") + .unwrap_or_else(|_| "kr0ki:assurance:".into()), + }) + }); + let mut gateway = Gateway::new(auth_mode, audit.clone()); + if let Some(rt) = &assurance { + let service = rt.service.clone(); + gateway = gateway.with_model_revision(move || service.model_revision()); + } + let gateway = Arc::new(gateway); + let state = AppState { service: Arc::new(service), playbook_dir, @@ -135,6 +223,7 @@ async fn main() -> anyhow::Result<()> { boot_wall_clock: std::time::SystemTime::now(), auth_token: auth_token.clone(), contract: contract.clone(), + assurance, }; let listener = tokio::net::TcpListener::bind(&bind) @@ -146,7 +235,7 @@ async fn main() -> anyhow::Result<()> { local_addr.port() ); - axum::serve(listener, router(state, auth_token, contract)) + axum::serve(listener, router_with_gateway(state, gateway, contract)) .await .context("server error")?; Ok(()) diff --git a/crates/kr0ki-server/tests/assurance_a06.rs b/crates/kr0ki-server/tests/assurance_a06.rs new file mode 100644 index 0000000..ba5fae8 --- /dev/null +++ b/crates/kr0ki-server/tests/assurance_a06.rs @@ -0,0 +1,218 @@ +//! KR-A06 acceptance case (VC-A06). +//! +//! "The tool gateway shall deny operations outside the caller identity's capability grant." +//! +//! Acceptance: permit model reads; deny a commit from the same read-only identity through MCP +//! and direct HTTP. +//! +//! Both transports are real: plain HTTP with the identity's bearer token, and the actual +//! `bridge.py` MCP bridge started with the *same* token. The refusal comes from the server's +//! gateway on both, because nothing in the bridge or in a tool's description is enforcement. + +use axum::http::StatusCode; +use reqwest::Method; + +// The server crate is a bin; pull its modules in via path (as `tests/http.rs` does). +#[path = "../src/app.rs"] +mod app; +#[path = "../src/contract.rs"] +mod contract; +#[path = "../src/docs.rs"] +mod docs; +#[path = "common/assurance_harness.rs"] +mod harness; + +use harness::*; + +#[tokio::test] +async fn a_read_only_identity_reads_but_cannot_commit_over_http() { + let h = start("a06-http").await; + + // Permitted: a read within its grant. + let (status, body) = http( + Method::GET, + &format!("{}/assurance/requirements/KR-A01", h.base), + READER, + None, + None, + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["requirement"]["id"], "KR-A01"); + assert!(body["revisions"]["model_revision"] + .as_str() + .unwrap() + .starts_with("sha256:")); + + // Denied: the same identity trying to commit. + let commit = format!( + "{}/assurance/changes/chg-any/commit?expected_revision=r0", + h.base + ); + let (status, body) = http(Method::POST, &commit, READER, None, None).await; + assert_eq!(status, StatusCode::FORBIDDEN.as_u16(), "{body}"); + assert_eq!(body["error"], "forbidden"); + assert!( + body["message"].as_str().unwrap().contains("model.commit"), + "{body}" + ); + + // The grant is what decided it: an identity that holds `model.commit` reaches the handler + // (which then says the change is unknown: a 404, not a 403). + let (status, body) = http(Method::POST, &commit, COMMITTER, None, None).await; + assert_eq!(status, 404, "{body}"); + assert_eq!(body["error"], "unknown_change"); +} + +#[tokio::test] +async fn the_same_identity_is_denied_through_the_mcp_bridge_too() { + let h = start("a06-mcp").await; + let base = h.base.clone(); + let result = tokio::task::spawn_blocking(move || { + let mut bridge = Bridge::start(&base, READER); + bridge.initialize(); + // The commit tool is *listed*: visibility is not what protects it. + assert!(bridge.tool_names().contains(&"commit_change".to_string())); + + // Permitted read. + let (is_error, text) = bridge.call("get_requirement", serde_json::json!({"id": "KR-A01"})); + assert!(!is_error, "{text}"); + assert!(text.contains("\"KR-A01\"")); + + // Denied commit with the same token. + bridge.call( + "commit_change", + serde_json::json!({"change_id": "chg-any", "expected_revision": "r0"}), + ) + }) + .await + .unwrap(); + let (is_error, text) = result; + assert!(is_error, "the commit must be refused: {text}"); + assert!(text.contains("403"), "{text}"); + assert!(text.contains("forbidden"), "{text}"); + drop(h); +} + +#[tokio::test] +async fn an_unknown_token_is_unauthenticated_not_merely_unauthorised() { + let h = start("a06-unauth").await; + let (status, body) = http( + Method::GET, + &format!("{}/assurance/requirements", h.base), + "tok-nobody", + None, + None, + ) + .await; + assert_eq!(status, 401, "{body}"); + assert_eq!(body["error"], "unauthorized"); +} + +#[tokio::test] +async fn an_unclassified_route_requires_admin_so_a_new_route_is_never_open_by_accident() { + let h = start("a06-default").await; + // `/nonexistent` matches no route, so it is classified `admin`: a non-admin is refused + // before the router can say 404. + let (status, _) = http( + Method::GET, + &format!("{}/nonexistent", h.base), + READER, + None, + None, + ) + .await; + assert_eq!(status, 403); +} + +#[tokio::test] +async fn health_needs_no_token_and_is_not_audited() { + let h = start("a06-health").await; + let resp = reqwest::get(format!("{}/health", h.base)).await.unwrap(); + assert!(resp.status().as_u16() < 500); + assert!( + h.audit.read_all().unwrap().is_empty(), + "a readiness probe is not a tool invocation" + ); +} + +#[tokio::test] +async fn a_token_is_never_logged_and_the_audit_chain_verifies() { + let h = start("a06-secret").await; + http( + Method::GET, + &format!("{}/assurance/requirements", h.base), + READER, + None, + None, + ) + .await; + http( + Method::POST, + &format!("{}/assurance/changes/x/commit?expected_revision=r", h.base), + READER, + None, + None, + ) + .await; + let raw = std::fs::read_to_string(h.audit.path()).unwrap(); + for token in [READER, COMMITTER, AUDITOR] { + assert!( + !raw.contains(token), + "a bearer token leaked into the audit log" + ); + } + assert!(h.audit.verify().unwrap().is_ok()); +} + +#[tokio::test] +async fn resource_reads_pass_through_the_same_gateway_as_the_equivalent_http_request() { + let h = start("a06-resources").await; + let base = h.base.clone(); + let (reader, auditor, prompts) = tokio::task::spawn_blocking(move || { + let mut as_reader = Bridge::start(&base, READER); + as_reader.initialize(); + let reader = as_reader.request( + "resources/read", + serde_json::json!({"uri": "kr0ki://requirement/KR-A01"}), + ); + let skill = as_reader.request( + "resources/read", + serde_json::json!({"uri": "kr0ki://skill/requirements-authoring"}), + ); + let prompts = as_reader.request( + "prompts/get", + serde_json::json!({"name": "explain_gap", "arguments": {"id": "KR-A06"}}), + ); + // The auditor has `audit.read` only: no `assurance.read`. + let mut as_auditor = Bridge::start(&base, AUDITOR); + as_auditor.initialize(); + let auditor = as_auditor.request( + "resources/read", + serde_json::json!({"uri": "kr0ki://requirement/KR-A01"}), + ); + (reader, auditor, (skill, prompts)) + }) + .await + .unwrap(); + + let text = reader["result"]["contents"][0]["text"].as_str().unwrap(); + assert!(text.contains("\"KR-A01\""), "{reader}"); + assert!( + auditor["error"]["message"] + .as_str() + .unwrap() + .contains("403"), + "{auditor}" + ); + + let (skill, prompt) = prompts; + assert!(skill["result"]["contents"][0]["text"] + .as_str() + .unwrap() + .contains("name: requirements-authoring")); + let p = prompt["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap(); + assert!(p.contains("trace_requirement with id=KR-A06"), "{prompt}"); +} diff --git a/crates/kr0ki-server/tests/assurance_a07.rs b/crates/kr0ki-server/tests/assurance_a07.rs new file mode 100644 index 0000000..bae7eae --- /dev/null +++ b/crates/kr0ki-server/tests/assurance_a07.rs @@ -0,0 +1,215 @@ +//! KR-A07 acceptance case (VC-A07). +//! +//! "The workload sandbox shall deny writes outside its declared writable mounts." +//! +//! Acceptance: permit temporary output; deny a write to the mounted requirement baseline. +//! +//! The sandbox is a *deployment component* (`deploy/sandbox/run-sandboxed.sh`); kr0ki does not +//! enforce it. This case is kr0ki's evidence that the component does what the requirement says, +//! exercised for real: the script runs commands under bubblewrap and the host's baseline file is +//! digested before and after. Needs `bwrap`; without it the case fails rather than passing on +//! nothing, because a case that could not run is not evidence. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +fn baseline_dir() -> PathBuf { + repo_root().join("docs/assurance") +} + +fn baseline_file() -> PathBuf { + baseline_dir().join("kr0ki.assurance.toml") +} + +fn digest(path: &Path) -> String { + format!("{:x}", Sha256::digest(std::fs::read(path).unwrap())) +} + +/// Run `command` in the sandbox with the repository's baseline mounted; extra args go before `--`. +fn sandboxed(extra: &[&str], command: &[&str]) -> Output { + assert!( + Command::new("bwrap").arg("--version").output().is_ok(), + "bubblewrap (bwrap) is required to verify KR-A07: this case cannot be verified without it" + ); + let mut cmd = Command::new(repo_root().join("deploy/sandbox/run-sandboxed.sh")); + cmd.arg("--baseline") + .arg(baseline_dir()) + .args(extra) + .arg("--") + .args(command); + cmd.env("KR0KI_SECRET_PROBE", "leaked-from-host"); + cmd.output().expect("the sandbox script must run") +} + +fn text(o: &[u8]) -> String { + String::from_utf8_lossy(o).into_owned() +} + +#[test] +fn temporary_output_is_permitted() { + let o = sandboxed( + &[], + &[ + "sh", + "-c", + "echo scratch-ok > /scratch/out.txt && cat /scratch/out.txt", + ], + ); + assert!(o.status.success(), "{}", text(&o.stderr)); + assert_eq!(text(&o.stdout).trim(), "scratch-ok"); +} + +#[test] +fn a_write_to_the_mounted_requirement_baseline_is_denied_and_the_host_file_is_untouched() { + let before = digest(&baseline_file()); + for cmd in [ + "echo tampered >> /baseline/kr0ki.assurance.toml", + "echo tampered > /baseline/kr0ki.assurance.toml", + "echo x > /baseline/new-file", + "rm /baseline/kr0ki.assurance.toml", + "mv /baseline/kr0ki.assurance.toml /scratch/stolen", + "echo x > /baseline/../escaped", + ] { + let o = sandboxed(&[], &["sh", "-c", cmd]); + assert!( + !o.status.success(), + "`{cmd}` must fail; stdout={}", + text(&o.stdout) + ); + let err = text(&o.stderr); + assert!( + err.contains("Read-only file system") || err.contains("Operation not permitted"), + "`{cmd}` failed for an unexpected reason: {err}" + ); + } + assert_eq!( + digest(&baseline_file()), + before, + "the host's baseline must be byte-identical" + ); + assert!(!baseline_dir().join("new-file").exists()); + assert!(!baseline_dir().parent().unwrap().join("escaped").exists()); +} + +#[test] +fn writes_anywhere_else_are_denied() { + for path in ["/x", "/usr/x", "/etc-x", "/dev/x", "/proc/x", "/baseline"] { + let o = sandboxed(&[], &["sh", "-c", &format!("echo x > {path}")]); + assert!(!o.status.success(), "a write to {path} must be denied"); + } + // Device nodes remain usable. + let o = sandboxed(&[], &["sh", "-c", "echo discarded > /dev/null"]); + assert!(o.status.success(), "{}", text(&o.stderr)); +} + +#[test] +fn scratch_starts_empty_and_nothing_survives_the_run() { + let o = sandboxed( + &[], + &[ + "sh", + "-c", + "ls -A /scratch | wc -l; echo persist > /scratch/leftover", + ], + ); + assert_eq!(text(&o.stdout).trim(), "0"); + let again = sandboxed(&[], &["sh", "-c", "ls -A /scratch | wc -l"]); + assert_eq!( + text(&again.stdout).trim(), + "0", + "scratch must not persist between runs" + ); +} + +#[test] +fn the_host_environment_and_network_are_not_visible() { + let o = sandboxed(&[], &["sh", "-c", "env"]); + assert!( + !text(&o.stdout).contains("leaked-from-host"), + "{}", + text(&o.stdout) + ); + assert!(text(&o.stdout).contains("HOME=/scratch")); + let o = sandboxed(&[], &["sh", "-c", "cat /proc/net/dev"]); + let interfaces: Vec = text(&o.stdout) + .lines() + .skip(2) + .filter_map(|l| l.split(':').next().map(|s| s.trim().to_string())) + .collect(); + assert_eq!( + interfaces, + vec!["lo".to_string()], + "only loopback: no network" + ); +} + +#[test] +fn a_declared_writable_mount_is_writable_and_only_that() { + let out = std::env::temp_dir().join(format!("kr0ki-a07-out-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + let mount = format!("{}:/out", out.display()); + let o = sandboxed( + &["--writable", &mount], + &["sh", "-c", "echo declared > /out/result.txt"], + ); + assert!(o.status.success(), "{}", text(&o.stderr)); + assert_eq!( + std::fs::read_to_string(out.join("result.txt")) + .unwrap() + .trim(), + "declared" + ); + // ...while everything not declared is still read-only. + let o = sandboxed( + &["--writable", &mount], + &["sh", "-c", "echo x > /baseline/x"], + ); + assert!(!o.status.success()); +} + +#[test] +fn the_baseline_and_system_paths_cannot_be_declared_writable() { + let tmp = std::env::temp_dir(); + for dest in ["/baseline", "/baseline/sub", "/usr", "/proc", "/dev", ""] { + let mount = format!("{}:{dest}", tmp.display()); + let o = sandboxed(&["--writable", &mount], &["true"]); + assert_eq!( + o.status.code(), + Some(2), + "`{dest}` must be refused: {}", + text(&o.stderr) + ); + assert!(text(&o.stderr).contains("refusing"), "{}", text(&o.stderr)); + } +} + +#[test] +fn bad_invocations_are_refused_before_anything_runs() { + let script = repo_root().join("deploy/sandbox/run-sandboxed.sh"); + let run = |args: &[&str]| Command::new(&script).args(args).output().unwrap(); + assert_eq!(run(&[]).status.code(), Some(2), "no --baseline"); + assert_eq!( + run(&["--baseline", "/definitely/not/a/dir", "--", "true"]) + .status + .code(), + Some(2) + ); + assert_eq!( + run(&["--baseline", &baseline_dir().to_string_lossy()]) + .status + .code(), + Some(2), + "no command" + ); + assert_eq!(run(&["--surprise"]).status.code(), Some(2)); +} diff --git a/crates/kr0ki-server/tests/assurance_a08.rs b/crates/kr0ki-server/tests/assurance_a08.rs new file mode 100644 index 0000000..efe2bbf --- /dev/null +++ b/crates/kr0ki-server/tests/assurance_a08.rs @@ -0,0 +1,203 @@ +//! KR-A08 acceptance case (VC-A08). +//! +//! "The audit service shall record each tool invocation with caller, operation, decision, model +//! revision and correlation identifier." +//! +//! Acceptance: retrieve records for both the permitted and denied KR-A06 calls. + +use reqwest::Method; + +#[path = "../src/app.rs"] +mod app; +#[path = "../src/contract.rs"] +mod contract; +#[path = "../src/docs.rs"] +mod docs; +#[path = "common/assurance_harness.rs"] +mod harness; + +use harness::*; + +fn records(body: &serde_json::Value) -> Vec { + body["records"].as_array().unwrap().clone() +} + +#[tokio::test] +async fn records_exist_for_the_permitted_and_the_denied_calls_over_both_transports() { + let h = start("a08").await; + let base = h.base.clone(); + + // Direct HTTP, with caller-supplied correlation ids. + let read = format!("{base}/assurance/requirements/KR-A01"); + let commit = format!("{base}/assurance/changes/chg-any/commit?expected_revision=r0"); + let (s, _) = http(Method::GET, &read, READER, Some("corr-http-read"), None).await; + assert_eq!(s, 200); + let (s, _) = http( + Method::POST, + &commit, + READER, + Some("corr-http-commit"), + None, + ) + .await; + assert_eq!(s, 403); + + // The MCP bridge, same identity. + let b = base.clone(); + tokio::task::spawn_blocking(move || { + let mut bridge = Bridge::start(&b, READER); + bridge.initialize(); + bridge.call("get_requirement", serde_json::json!({"id": "KR-A01"})); + bridge.call( + "commit_change", + serde_json::json!({"change_id": "chg-any", "expected_revision": "r0"}), + ); + }) + .await + .unwrap(); + + // Retrieve the records (an identity with `audit.read`). + let (s, body) = http( + Method::GET, + &format!("{base}/assurance/audit?caller=reader&phase=decision"), + AUDITOR, + None, + None, + ) + .await; + assert_eq!(s, 200, "{body}"); + assert_eq!(body["chain"]["ok"], true, "{}", body["chain"]); + let recs = records(&body); + + let find = |op: &str, decision: &str, transport: &str| { + recs.iter() + .find(|r| { + r["operation"] == op && r["decision"] == decision && r["transport"] == transport + }) + .unwrap_or_else(|| panic!("no {decision} record of {op} over {transport}: {recs:#?}")) + .clone() + }; + let permitted_http = find("get_requirement", "permit", "http"); + let denied_http = find("commit_change", "deny", "http"); + let permitted_mcp = find("get_requirement", "permit", "mcp"); + let denied_mcp = find("commit_change", "deny", "mcp"); + + for r in [&permitted_http, &denied_http, &permitted_mcp, &denied_mcp] { + // caller, operation, decision, model revision, correlation id: all five, every time. + assert_eq!(r["caller"], "reader"); + assert!(r["operation"].as_str().unwrap().len() > 3); + assert!(["permit", "deny"].contains(&r["decision"].as_str().unwrap())); + assert!( + !r["model_revision"].as_str().unwrap_or("").is_empty(), + "{r}" + ); + assert!( + !r["correlation_id"].as_str().unwrap_or("").is_empty(), + "{r}" + ); + assert!(r["at"].as_str().unwrap().ends_with('Z')); + } + // The caller-supplied correlation ids are carried through. + assert_eq!(permitted_http["correlation_id"], "corr-http-read"); + assert_eq!(denied_http["correlation_id"], "corr-http-commit"); + // A read is recorded at the service's model revision; a commit at the revision it expected. + assert!(permitted_http["model_revision"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!(denied_http["model_revision"], "r0"); + // A denial says why, and names no token. + assert!(denied_http["reason"] + .as_str() + .unwrap() + .contains("model.commit")); + assert!(permitted_http.get("reason").is_none()); +} + +#[tokio::test] +async fn each_call_has_a_decision_record_before_and_an_outcome_record_after_under_one_correlation_id( +) { + let h = start("a08-phases").await; + let read = format!("{}/assurance/requirements/KR-A01", h.base); + let commit = format!( + "{}/assurance/changes/chg-any/commit?expected_revision=r0", + h.base + ); + http(Method::GET, &read, READER, Some("corr-read"), None).await; + http(Method::POST, &commit, READER, Some("corr-commit"), None).await; + + let all = h.audit.read_all().unwrap(); + let for_corr = |c: &str| { + all.iter() + .filter(|r| r.correlation_id == c) + .collect::>() + }; + for (corr, status) in [("corr-read", 200u16), ("corr-commit", 403u16)] { + let r = for_corr(corr); + assert_eq!(r.len(), 2, "{corr}: {r:#?}"); + assert_eq!( + format!("{:?}", r[0].phase), + "Decision", + "the decision is written first" + ); + assert_eq!(format!("{:?}", r[1].phase), "Outcome"); + assert_eq!(r[1].status, Some(status)); + assert!(r[0].seq < r[1].seq); + } +} + +#[tokio::test] +async fn reading_the_audit_log_needs_the_audit_grant_and_the_attempt_is_itself_recorded() { + let h = start("a08-grant").await; + let url = format!("{}/assurance/audit", h.base); + let (s, _) = http(Method::GET, &url, READER, Some("corr-snoop"), None).await; + assert_eq!(s, 403, "a read-only identity must not read the audit log"); + let (s, body) = http( + Method::GET, + &format!("{url}?correlation_id=corr-snoop"), + AUDITOR, + None, + None, + ) + .await; + assert_eq!(s, 200); + let recs = records(&body); + assert!( + recs.iter().any(|r| r["operation"] == "get_audit_records" + && r["decision"] == "deny" + && r["caller"] == "reader"), + "{recs:#?}" + ); +} + +#[tokio::test] +async fn a_failing_audit_log_fails_closed() { + let h = start("a08-closed").await; + // Make the log unwritable: replace the file with a directory. + let path = h.audit.path().to_path_buf(); + // A first call succeeds and creates the file. + let (s, _) = http( + Method::GET, + &format!("{}/assurance/requirements", h.base), + READER, + None, + None, + ) + .await; + assert_eq!(s, 200); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + let (s, body) = http( + Method::GET, + &format!("{}/assurance/requirements", h.base), + READER, + None, + None, + ) + .await; + assert_eq!( + s, 503, + "an operation that cannot be recorded must not run: {body}" + ); + assert_eq!(body["error"], "audit_unavailable"); +} diff --git a/crates/kr0ki-server/tests/b00t_graph_live.rs b/crates/kr0ki-server/tests/b00t_graph_live.rs index 41e337a..b4c2196 100644 --- a/crates/kr0ki-server/tests/b00t_graph_live.rs +++ b/crates/kr0ki-server/tests/b00t_graph_live.rs @@ -67,6 +67,7 @@ async fn b00t_graph_renders_a_real_turtle_fixture_to_svg() { boot_wall_clock: std::time::SystemTime::now(), auth_token: None, contract: contract.clone(), + assurance: None, }; let app = router(state, None, contract); diff --git a/crates/kr0ki-server/tests/common/assurance_harness.rs b/crates/kr0ki-server/tests/common/assurance_harness.rs new file mode 100644 index 0000000..da9ec19 --- /dev/null +++ b/crates/kr0ki-server/tests/common/assurance_harness.rs @@ -0,0 +1,266 @@ +#![allow(dead_code)] // each acceptance test uses a different part of this shared harness + +//! Shared harness for the KR-A06 / KR-A08 acceptance cases: the real router behind the real +//! gateway, listening on an ephemeral port, with an in-memory model server behind the change +//! service, three identities, and an audit log. Callers reach it either with plain HTTP or +//! through the actual Python MCP bridge, so both transports hit the same server. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use kr0ki_core::audit_log::AuditLog; +use kr0ki_core::change_service::in_memory::InMemoryServer; +use kr0ki_core::change_service::ChangeService; +use kr0ki_core::verification_runner::{CommandExecutor, ExecRequest, ExecResult, RevisionProbe}; +use kr0ki_core::{cache::FsCache, render::HttpKrokiBackend, RenderService}; + +use crate::app::assurance::{AssuranceRuntime, ChangeApi}; +use crate::app::gateway::{AuthMode, Gateway, Identity}; +use crate::app::{router_with_gateway, AppState}; + +/// Tokens exist only here; the gateway holds their SHA-256. +pub const READER: &str = "tok-reader-read-only"; +pub const COMMITTER: &str = "tok-committer"; +pub const AUDITOR: &str = "tok-auditor"; + +pub struct Probe; + +impl RevisionProbe for Probe { + fn implementation_revision(&self) -> Result { + Ok("impl-test".into()) + } + + fn toolchain(&self) -> String { + "rustc-test".into() + } +} + +pub struct NoExec; + +impl CommandExecutor for NoExec { + fn run(&self, _: &ExecRequest) -> ExecResult { + ExecResult { + spawn_error: Some("not used by these cases".into()), + ..Default::default() + } + } +} + +pub fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +pub struct Harness { + pub base: String, + pub audit: Arc, + pub tmp: PathBuf, +} + +/// Start the server. `tag` keeps concurrent tests' files apart. +pub async fn start(tag: &str) -> Harness { + let tmp = std::env::temp_dir().join(format!("kr0ki-gw-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let service = RenderService::new( + HttpKrokiBackend::new("http://127.0.0.1:1"), + FsCache::new(tmp.join("cache")), + ); + let audit = Arc::new(AuditLog::open(tmp.join("audit").join("audit.jsonl")).unwrap()); + let assurance_service = Arc::new(kr0ki_core::assurance_service::AssuranceService::new( + kr0ki_core::assurance_service::AssuranceConfig { + baseline_path: repo_root().join("docs/assurance/kr0ki.assurance.toml"), + repo_root: repo_root(), + evidence_dir: tmp.join("evidence"), + run_timeout: Duration::from_secs(5), + }, + Arc::new(Probe), + Arc::new(NoExec), + )); + let changes: Arc = Arc::new(ChangeService::new(InMemoryServer::new(true))); + let runtime = Arc::new(AssuranceRuntime { + service: assurance_service.clone(), + changes: Some(changes), + audit: Some(audit.clone()), + managed_prefix: "kr0ki:assurance:".into(), + }); + let contract = Arc::new(crate::contract::ContractReference::default()); + let state = AppState { + service: Arc::new(service), + playbook_dir: tmp.join("no-playbook"), + b00t_graph_artifacts_path: None, + capabilities_path: None, + kubediagram_worker_url: None, + sysmlv2_client: None, + model_graph: Arc::new(kr0ki_core::graph_store::GraphStore::new()), + ui_bus: Arc::new(kr0ki_core::ui_bus::UiBus::new()), + brand_dir: tmp.join("no-brands"), + sysml_mcp: None, + storyb00k_agent_url: None, + llm_api_url: None, + llm_api_key: None, + started_at: std::time::Instant::now(), + boot_wall_clock: std::time::SystemTime::now(), + auth_token: None, + contract: contract.clone(), + assurance: Some(runtime), + }; + let identities = vec![ + Identity::new("reader", &["assurance.read", "model.read"], READER), + Identity::new( + "committer", + &[ + "assurance.read", + "assurance.propose", + "model.read", + "model.commit", + ], + COMMITTER, + ), + Identity::new("auditor", &["audit.read"], AUDITOR), + ]; + let svc = assurance_service.clone(); + let gateway = Arc::new( + Gateway::new(AuthMode::Identities(identities), Some(audit.clone())) + .with_model_revision(move || svc.model_revision()), + ); + let app = router_with_gateway(state, gateway, contract); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Harness { + base: format!("http://{addr}"), + audit, + tmp, + } +} + +/// A running `containers/kr0ki-mcp/bridge.py`, speaking MCP over stdio, authenticated with one +/// token — exactly how an agent's MCP client would reach the server. +pub struct Bridge { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: Mutex, +} + +impl Bridge { + pub fn start(base: &str, token: &str) -> Bridge { + let script = repo_root().join("containers/kr0ki-mcp/bridge.py"); + let mut child = Command::new("python3") + .arg(&script) + .current_dir(script.parent().unwrap()) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("KR0KI_URL", base) + .env("KR0KI_AUTH_TOKEN", token) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("python3 is required to run the MCP bridge: this case cannot be verified without it"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Bridge { + child, + stdin, + stdout, + next_id: Mutex::new(1), + } + } + + fn rpc(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value { + let id = { + let mut n = self.next_id.lock().unwrap(); + *n += 1; + *n + }; + let line = + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + let mut out = String::new(); + self.stdout.read_line(&mut out).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&out).unwrap_or_else(|e| panic!("bridge said {out:?}: {e}")); + assert_eq!(v["id"], id, "{v}"); + v + } + + pub fn initialize(&mut self) { + let v = self.rpc("initialize", serde_json::json!({})); + assert_eq!(v["result"]["serverInfo"]["name"], "kr0ki-mcp"); + } + + /// `tools/call`; returns `(is_error, text)`. + pub fn call(&mut self, tool: &str, arguments: serde_json::Value) -> (bool, String) { + let v = self.rpc( + "tools/call", + serde_json::json!({"name": tool, "arguments": arguments}), + ); + let result = &v["result"]; + ( + result["isError"].as_bool().unwrap_or(false), + result["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(), + ) + } + + /// Any MCP request; returns the whole JSON-RPC reply. + pub fn request(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value { + self.rpc(method, params) + } + + pub fn tool_names(&mut self) -> Vec { + let v = self.rpc("tools/list", serde_json::json!({})); + v["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + } +} + +impl Drop for Bridge { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Plain HTTP with a bearer token and an optional correlation id. +pub async fn http( + method: reqwest::Method, + url: &str, + token: &str, + request_id: Option<&str>, + body: Option, +) -> (u16, serde_json::Value) { + let mut req = reqwest::Client::new() + .request(method, url) + .bearer_auth(token); + if let Some(id) = request_id { + req = req.header("x-request-id", id); + } + if let Some(b) = body { + req = req.header("content-type", "text/plain").body(b.to_string()); + } + let resp = req.send().await.unwrap(); + let status = resp.status().as_u16(); + let text = resp.text().await.unwrap(); + ( + status, + serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)), + ) +} diff --git a/crates/kr0ki-server/tests/http.rs b/crates/kr0ki-server/tests/http.rs index 648d0e6..13b4c9d 100644 --- a/crates/kr0ki-server/tests/http.rs +++ b/crates/kr0ki-server/tests/http.rs @@ -42,6 +42,7 @@ fn test_state(tag: &str) -> AppState { boot_wall_clock: std::time::SystemTime::now(), auth_token: None, contract: Arc::new(contract::ContractReference::default()), + assurance: None, } } diff --git a/crates/kr0ki-server/tests/kubediagram_live.rs b/crates/kr0ki-server/tests/kubediagram_live.rs index e1653f1..5ea509f 100644 --- a/crates/kr0ki-server/tests/kubediagram_live.rs +++ b/crates/kr0ki-server/tests/kubediagram_live.rs @@ -58,6 +58,7 @@ async fn kr0ki_renders_its_own_deployment_manifest() { boot_wall_clock: std::time::SystemTime::now(), auth_token: None, contract: contract.clone(), + assurance: None, }; let app = router(state, None, contract); diff --git a/deploy/sandbox/run-sandboxed.sh b/deploy/sandbox/run-sandboxed.sh new file mode 100755 index 0000000..4630dd5 --- /dev/null +++ b/deploy/sandbox/run-sandboxed.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# The execution sandbox (assurance part `KrOKiAssurance::ExecutionSandbox`, requirement KR-A07): +# run a command that can write ONLY to its declared writable mounts. +# +# run-sandboxed.sh --baseline DIR [--writable HOSTDIR:/CONTAINER/PATH]... -- COMMAND [ARG...] +# +# What the command sees: +# /baseline the requirement baseline directory, READ-ONLY +# /scratch an empty tmpfs, writable, gone when the command exits (the only default writable mount) +# /usr and the system directories, read-only; everything else does not exist +# no network, no host environment (PATH, HOME=/scratch, nothing else), its own PID namespace +# +# The root filesystem is read-only, so a write anywhere but a declared mount fails with EROFS. +# This is the deployment component's enforcement; kr0ki displays its relationships and its +# evidence (tests/assurance_a07.rs), it does not enforce it. The same policy in a pod is +# `readOnlyRootFilesystem: true`, the baseline as a `readOnly: true` volumeMount, an +# `emptyDir` for /scratch, and a deny-all NetworkPolicy. +set -euo pipefail + +baseline="" +writable=() +while [ $# -gt 0 ]; do + case "$1" in + --baseline) baseline="${2:?--baseline needs a directory}"; shift 2 ;; + --writable) writable+=("${2:?--writable needs HOSTDIR:/PATH}"); shift 2 ;; + --) shift; break ;; + *) echo "run-sandboxed: unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$baseline" ] || { echo "run-sandboxed: --baseline is required" >&2; exit 2; } +[ -d "$baseline" ] || { echo "run-sandboxed: baseline directory not found: $baseline" >&2; exit 2; } +[ $# -gt 0 ] || { echo "run-sandboxed: no command given (put it after --)" >&2; exit 2; } +command -v bwrap >/dev/null || { echo "run-sandboxed: bubblewrap (bwrap) is not installed" >&2; exit 127; } + +args=(--unshare-all --die-with-parent --new-session --clearenv + --setenv PATH /usr/bin:/bin --setenv HOME /scratch + --proc /proc --dev /dev) + +# System directories, read-only. Usually /bin, /lib, /lib64, /sbin are symlinks into /usr. +args+=(--ro-bind /usr /usr) +for d in bin lib lib64 sbin; do + if [ -L "/$d" ]; then args+=(--symlink "$(readlink "/$d")" "/$d") + elif [ -d "/$d" ]; then args+=(--ro-bind "/$d" "/$d"); fi +done + +args+=(--ro-bind "$(cd "$baseline" && pwd -P)" /baseline --tmpfs /scratch) + +for w in "${writable[@]+"${writable[@]}"}"; do + host="${w%%:*}"; dest="${w#*:}" + case "$dest" in + /baseline*|/usr*|/proc*|/dev*|"") echo "run-sandboxed: refusing to make $dest writable" >&2; exit 2 ;; + esac + [ -d "$host" ] || { echo "run-sandboxed: writable host directory not found: $host" >&2; exit 2; } + args+=(--bind "$(cd "$host" && pwd -P)" "$dest") +done + +# Last: make the root and /dev (a separate tmpfs) read-only. Mounts made above (/scratch, +# --writable) stay writable; device nodes such as /dev/null stay usable. +args+=(--remount-ro / --remount-ro /dev --chdir /scratch) +exec bwrap "${args[@]}" -- "$@" diff --git a/docs/assurance/kr0ki.assurance.toml b/docs/assurance/kr0ki.assurance.toml index 0d8f52a..e2972a5 100644 --- a/docs/assurance/kr0ki.assurance.toml +++ b/docs/assurance/kr0ki.assurance.toml @@ -153,19 +153,19 @@ implementation = "crates/kr0ki-core/src/change_service.rs" id = "CTL-A06" title = "Capability-grant check in the gateway, deny by default, on every transport" component = "kr0ki" -implementation = "" +implementation = "crates/kr0ki-server/src/gateway.rs; crates/kr0ki-server/src/assurance.rs" [[control]] id = "CTL-A07" title = "Read-only mount of the requirement baseline; writes only to declared mounts" component = "deployment" -implementation = "" +implementation = "deploy/sandbox/run-sandboxed.sh" [[control]] id = "CTL-A08" title = "Audit record appended before the response, for permit and deny alike" component = "kr0ki" -implementation = "" +implementation = "crates/kr0ki-core/src/audit_log.rs; crates/kr0ki-server/src/gateway.rs" # ── Verification cases ────────────────────────────────────────────────────────── # `command` is an argv, never a shell string. The runner only executes commands that match From abb46f1005146b6c17157dd7d6af2ec795c04fc3 Mon Sep 17 00:00:00 2001 From: Brian H Date: Mon, 5 Oct 2026 09:16:52 +0000 Subject: [PATCH 2/3] chore(assurance): pin ufo-types merge commit; classify /render/rust-behavior in the gateway The route-consistency guard failed after merging main: /render/rust-behavior (new on main) was not in gateway::ROUTES, so it would have required admin. It parses a request body and renders; it writes nothing to the model, so it is a render operation. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- Cargo.lock | 2 +- crates/kr0ki-core/Cargo.toml | 2 +- crates/kr0ki-server/Cargo.toml | 2 +- crates/kr0ki-server/src/gateway.rs | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 474c679..ba4269d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3501,7 +3501,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ufo-types" version = "0.15.0" -source = "git+https://github.com/PromptExecution/ufo-types?rev=db8e22fc2c09ff1b58cc4effce6096d17a89f3e4#db8e22fc2c09ff1b58cc4effce6096d17a89f3e4" +source = "git+https://github.com/PromptExecution/ufo-types?rev=1846e6f20c68e8eb614556386a56cd3077446cdb#1846e6f20c68e8eb614556386a56cd3077446cdb" dependencies = [ "anyhow", "chrono", diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml index 607c4ad..b596813 100644 --- a/crates/kr0ki-core/Cargo.toml +++ b/crates/kr0ki-core/Cargo.toml @@ -35,7 +35,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" } # exists after ufo-types#29's squash-merge into main (2026-09-20). Re-pinned # to that merge commit on main -- bump to a `tag =` pin once ufo-types cuts # a release past v0.15.0 including it. -ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4", features = ["reqif"] } +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "1846e6f20c68e8eb614556386a56cd3077446cdb", features = ["reqif"] } # kr0ki#13 — box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph / # CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked # 2026-09-16); pinned by git rev per this issue's own operator decision, to be diff --git a/crates/kr0ki-server/Cargo.toml b/crates/kr0ki-server/Cargo.toml index ad83fa9..b75e821 100644 --- a/crates/kr0ki-server/Cargo.toml +++ b/crates/kr0ki-server/Cargo.toml @@ -34,7 +34,7 @@ hmac = "0.12" thiserror.workspace = true toml = "0.8" # The assurance profile types the /assurance routes and the gateway use (same pin as kr0ki-core). -ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4" } +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "1846e6f20c68e8eb614556386a56cd3077446cdb" } [dev-dependencies] reqwest = { workspace = true, features = ["json"] } diff --git a/crates/kr0ki-server/src/gateway.rs b/crates/kr0ki-server/src/gateway.rs index 453bfad..665812c 100644 --- a/crates/kr0ki-server/src/gateway.rs +++ b/crates/kr0ki-server/src/gateway.rs @@ -215,6 +215,7 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("POST", "/render/kubediagram", Some("render")), ("POST", "/render/k8s-topology", Some("render")), ("POST", "/render/rust-source", Some("render")), + ("POST", "/render/rust-behavior", Some("render")), ( "POST", "/render/sysmlv2/projects/:project_id/commits/:commit_id", From dc98913c85767c50322e813b6443ebcc5c9c3def Mon Sep 17 00:00:00 2001 From: Brian H Date: Wed, 7 Oct 2026 01:03:47 +0000 Subject: [PATCH 3/3] ci: install bubblewrap so the KR-A07 sandbox cases can run Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ae8b5..afbdc70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: cache-dependency-path: playbook/pnpm-lock.yaml - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings + # KR-A07's sandbox cases run under bubblewrap and fail (by design) without it. + - name: Install bubblewrap and allow unprivileged user namespaces + run: | + sudo apt-get update && sudo apt-get install -y bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + bwrap --ro-bind / / true - run: cargo test --workspace - name: kr0ki-mcp Python tests run: |