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: | diff --git a/AGENTS.md b/AGENTS.md index ad55e9a..aaa301f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,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 1860783..ba4269d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,10 +1478,13 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "thiserror 2.0.20", "tokio", + "toml", "tower", "tracing", "tracing-subscriber", + "ufo-types", "uuid", "wiremock", ] @@ -3498,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/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/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-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..b75e821 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 = "1846e6f20c68e8eb614556386a56cd3077446cdb" } [dev-dependencies] reqwest = { workspace = true, features = ["json"] } diff --git a/crates/kr0ki-server/src/app.rs b/crates/kr0ki-server/src/app.rs index 16220cf..847dc38 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)) @@ -176,47 +197,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..665812c 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,12 @@ 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/rust-behavior", 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 +236,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 +353,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 +406,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 +418,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 +449,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 +467,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 +503,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 +541,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 +602,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 +615,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 +633,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 +663,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 +685,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 +719,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 +745,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 e719434..7c97819 100644 --- a/crates/kr0ki-server/tests/http.rs +++ b/crates/kr0ki-server/tests/http.rs @@ -115,6 +115,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