From 574a19b75c397738c380b20d76844f6f6c2fc4e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:08:23 +0000 Subject: [PATCH 01/12] security: MCP write authority comes from the person, not the model The distributed v1.0.0 let an AI client change a target, or load the runtime into it, without the person having allowed writes, through mutation_permission (a boolean the client wrote itself), batch_run, cortex_attach and cortex/private/route. - cortex.exe mcp takes --allow-writes; without it every control/mutate/native call is refused (write_authority_required). The authority travels on the authenticated pipe envelope; mutation_permission stays as declared intent. - batch_run is classified from its ops against an allowlist, re-checked after $from_step resolution; semantic steps use the same gate. - cortex_attach does not inject without authority; only initialize, ping, server/discover, tools/list, tools/call and notifications/* are forwarded; the private route is accepted only from the desktop adapter. - scan_* with pause_process and prompt_* are control operations. - A tool no rule classifies is refused (tool_not_classified) instead of defaulting to analyze; tests/fixtures/mcp_tool_risks.json records the class of every primitive. - Refused calls are logged to cortex_denied_mutations.jsonl; debugger attach/detach is journaled (AI Activity + cortex_debugger_events.jsonl). - Semantic results no longer carry confidence/hypothesis fields. - tests/security_no_writes_regression.py calls every listed tool with mutation_permission=true and requires an unchanged target state hash; tests/run_security_regression_vm.ps1 replays it on Windows without --skip-tool. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/contracts.yml | 12 + .github/workflows/imgui-gui-tests.yml | 26 + CHANGELOG.md | 22 + README.md | 8 +- agent/agents.md | 18 +- agent/semantic-tools.md | 26 +- app_imgui/application/re_model.cpp | 1 + app_imgui/application/runtime_model_base.cpp | 4 + app_imgui/desktop_ui.cpp | 3 + core/api/mcp_contract.h | 100 +- core/api/mcp_pipe.cpp | 17 +- core/api/mcp_tools.cpp | 117 +- core/api/mcp_tools.h | 15 +- core/api/routes_mcp.cpp | 8 +- core/api/semantic_tools.h | 24 +- core/dllmain.cpp | 3 + core/security/denial_log.h | 113 ++ core/services/payload_client.cpp | 18 +- core/services/payload_client.h | 20 +- core/services/runtime_transport.h | 5 + docs/getting-started.md | 2 +- docs/mcp.md | 21 +- docs/ui-guide.md | 2 +- docs/unified-app-architecture.md | 4 +- mcp_bridge/host_mode.cpp | 64 +- mcp_bridge/main.cpp | 24 +- test_target/main.cpp | 53 +- tests/app_models_tests.cpp | 32 + tests/fixtures/mcp_tool_risks.json | 1043 ++++++++++++++++++ tests/mcp_authority_tests.cpp | 167 +++ tests/run_security_regression_vm.ps1 | 56 + tests/security_no_writes_regression.py | 558 ++++++++++ tests/semantic_plan_p1_tests.cpp | 16 +- tests/semantic_tools_tests.cpp | 12 +- 34 files changed, 2502 insertions(+), 112 deletions(-) create mode 100644 core/security/denial_log.h create mode 100644 tests/fixtures/mcp_tool_risks.json create mode 100644 tests/mcp_authority_tests.cpp create mode 100644 tests/run_security_regression_vm.ps1 create mode 100644 tests/security_no_writes_regression.py diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index eaf2b5cc..155f88b6 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -51,6 +51,9 @@ on: - "tests/mcp_query_schema_tests.cpp" - "tests/mcp_bridge_policy_tests.cpp" - "tests/semantic_tools_tests.cpp" + - "tests/mcp_authority_tests.cpp" + - "tests/fixtures/mcp_tool_risks.json" + - "core/security/**" - "host/unified_main.cpp" - "host/diagnostics/**" - "test_target/main_opengl.cpp" @@ -279,6 +282,15 @@ jobs: -static -static-libgcc -static-libstdc++ && build/p2-${{ matrix.arch }}/mcp_protocol_tests.exe + - name: Build and run MCP write-authority tests + shell: msys2 {0} + run: >- + c++ -std=c++17 -O2 -Icore tests/mcp_authority_tests.cpp + -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include + -o build/p2-${{ matrix.arch }}/mcp_authority_tests.exe + -static -static-libgcc -static-libstdc++ + && build/p2-${{ matrix.arch }}/mcp_authority_tests.exe + - name: Build and run native pipe contract tests shell: msys2 {0} run: >- diff --git a/.github/workflows/imgui-gui-tests.yml b/.github/workflows/imgui-gui-tests.yml index 94b15bdc..bf085f60 100644 --- a/.github/workflows/imgui-gui-tests.yml +++ b/.github/workflows/imgui-gui-tests.yml @@ -14,6 +14,11 @@ on: - "core/prompt/**" - "runtime_helper/**" - "tests/gui/**" + - "tests/security_no_writes_regression.py" + - "tests/fixtures/mcp_tool_risks.json" + - "core/security/**" + - "mcp_bridge/**" + - "test_target/**" - "CMakeLists.txt" - ".github/workflows/imgui-gui-tests.yml" pull_request: @@ -26,6 +31,11 @@ on: - "core/prompt/**" - "runtime_helper/**" - "tests/gui/**" + - "tests/security_no_writes_regression.py" + - "tests/fixtures/mcp_tool_risks.json" + - "core/security/**" + - "mcp_bridge/**" + - "test_target/**" - "CMakeLists.txt" - ".github/workflows/imgui-gui-tests.yml" workflow_dispatch: @@ -257,6 +267,22 @@ jobs: $env:PATH = $oldPath } + - name: Without --allow-writes no MCP call changes a target + shell: pwsh + run: | + # Calls every listed tool, always claiming mutation_permission, and + # requires the target's state hash to be unchanged. See + # tests/security_no_writes_regression.py. + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/security_no_writes_regression.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_x64.exe" + if ($LASTEXITCODE -ne 0) { throw "x64 write-authority regression failed" } + python tests/security_no_writes_regression.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_x86.exe" + if ($LASTEXITCODE -ne 0) { throw "x86 write-authority regression failed" } + - name: Inject a 32-bit target from the command line shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d88efc..8d34ef87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to Cortex are documented in this file. +## [Unreleased] + +### Security + +The distributed v1.0.0 is affected: through `mutation_permission` (a boolean the client wrote itself), `batch_run`, `cortex_attach` and `cortex/private/route`, an AI client could change a target, or load the runtime into it, without the person having allowed writes. This was found by auditing the MCP path and reproduced against the real `cortex.exe mcp`. It is fixed as follows. + +- **Write authority now comes from the person, not the model.** `cortex.exe mcp` (and the `cortex_host mcp` bridge) take `--allow-writes`; without it every state-changing call is refused with `write_authority_required`. The flag reaches the runtime on the authenticated pipe envelope, never inside a tool call. `mutation_permission=true` on the call is still required, as a declaration of intent, and no longer sufficient. The desktop supplies its own authority from the Writes allowed switch. **This is a behaviour change:** an MCP client that wrote before must now be started with `--allow-writes`. +- **`batch_run` no longer bypasses the permission.** It was classed as an analysis although its `ops` write memory, freeze values and patch code. It is now classified from its operations against an allowlist, an unknown or computed operation is refused, and the same classification is re-checked after `$from_step` references are resolved so a reference cannot make an authorized step riskier. +- **`cortex_attach` (and `--pid` / `--process`) no longer inject without authority.** Loading the runtime puts code in the target; without `--allow-writes` the server connects only to a runtime that is already there (for example loaded with `cortex.exe inject `). +- **The private desktop channel is closed to MCP clients.** A raw `cortex/private/route` message sent by an MCP client was forwarded to the runtime, which ran any native route with no check. The server now forwards only `initialize`, `ping`, `server/discover`, `tools/list`, `tools/call` and `notifications/*`, and the runtime accepts a private route only from the desktop's own adapter. +- **`scan_new` / `scan_next` with `pause_process`, and all `prompt_*` tools, are now control operations** that need authority. +- **A tool that no rule classifies is refused.** Every listed tool now needs an explicit risk class; a tool with none is `unclassified` and is refused with `tool_not_classified` whatever authority the caller holds (before, an unknown name defaulted to `analyze`). `tests/fixtures/mcp_tool_risks.json` records the class of every primitive tool and a test fails if one changes or is missing. +- **Debugger attach and detach are journaled.** The `debug_*` read tools stay available without write authority, because they do not write memory, but a debugger attached to a process suspends its threads. Each attach/detach is shown in the AI Activity tab and appended to `cortex_debugger_events.jsonl`. +- **Refused calls are logged** to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, `runtime` or `host`), beside `cortex_core.dll` and `cortex.exe`, so attempts can be counted. +- **Regression test.** `tests/security_no_writes_regression.py` starts the test target, then calls every tool `tools/list` returns, with plausible arguments and `mutation_permission=true`, against a server started without `--allow-writes`, and requires the target's code and data hash to be unchanged. Controls prove the hash does change when writes are allowed. +- **Known limit, documented:** the pipe token is a file readable by the same Windows user. A client that also has a shell or file access can read it and bypass these controls. An authority granted by the desktop for a limited time is planned. + +### Changed + +- **Semantic tool results no longer carry `confidence`, `evidence_confidence`, `candidates`, `alternative_hypotheses` or `tested_hypotheses`.** Cortex never computed them (`confidence` was a constant 1.0), and a figure that looks like a measure misleads the agent reading it. `status` is now documented as what it is: `plan_ready`, `completed`, `failed`, `cancelled` or `timed_out`. +- The documentation and README no longer say that the application enforces writes for every call where that was only true of the desktop. + ## [v1.0.0] - 2026-09-30 First stable release. It is the Dear ImGui desktop that was prepared as the unpublished 0.8 candidate, validated against a real game (AssaultCube) in addition to the automated suites. Versions 0.7.0 and 0.8.0 were not published; their changes are all below. diff --git a/README.md b/README.md index 40ba0c23..0cd319db 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Why Cortex - **One app, not a toolbox.** Scanner, memory viewer, disassembler, debugger, patcher, tracer, RE workspace, scripting and diagnostics share one target, one address menu and one project. -- **Safe by default.** Every session starts **Read-only**. Writes, freezes, patches, breakpoints and even loading the runtime into the target require an explicit **Writes allowed** switch, enforced by the application for every call — not just by greyed-out buttons. +- **Safe by default.** Every session starts **Read-only**. In the desktop, writes, freezes, patches, breakpoints and even loading the runtime into the target require the **Writes allowed** switch. An AI client starts read-only too and cannot lift that itself: only the person who launches `cortex.exe mcp --allow-writes` can, and a call that changes the target must also say so (`mutation_permission`). Every refused attempt is logged to `cortex_denied_mutations.jsonl`. - **Built for agents too.** `cortex.exe mcp` is a native MCP server: attach processes on the fly, drive several targets at once, and let the person at the screen watch every tool call live. - **Light and portable.** A native Dear ImGui + Direct3D 11 desktop, no Qt or runtime installer. Unzip, run, and keep your settings per user — or beside the executable with a `cortex.portable` file. @@ -128,10 +128,12 @@ MCP is built into `cortex.exe`. Configure your client once; the server starts ta } ``` +That server is read-only and attaches only to a process whose runtime is already loaded (`cortex.exe inject `, or the desktop). To let the agent load the runtime and change the target, add `"--allow-writes"` to `args`; that is your decision, not the agent's. + - **Dynamic targets** — `cortex_processes`, `cortex_attach`, `cortex_targets`, `cortex_detach`, with `tools/list_changed` notifications. - **Many targets, no races** — each attached process has its own runtime connection; tools take a `_cortex_target` selector. -- **Compact by default** — 30 evidence-oriented semantic tools; `--tools all` exposes every primitive. -- **Guarded** — state-changing calls need `mutation_permission=true` and run inside rollback-aware transactions. Human test prompts can only be answered by the person, in the desktop. +- **Compact by default** — 30 semantic tools that run the bounded steps the agent writes and return the raw evidence; `--tools all` exposes every primitive. Cortex does not score or interpret results for the agent. +- **Guarded** — a state-changing call needs two things: `mutation_permission=true` in the call (the agent saying it means to) and write authority from outside the model (`--allow-writes` on the command line). Supported changes run inside rollback-aware transactions. Human test prompts can only be answered by the person, in the desktop. Known limit: the pipe token is a file the same Windows user can read, so an agent that also has a shell or file access is not held back by any of this. - **Visible** — the desktop's **AI Activity** panel shows each agent session and tool call as it happens. ```text diff --git a/agent/agents.md b/agent/agents.md index bb1e469d..f847c5d8 100644 --- a/agent/agents.md +++ b/agent/agents.md @@ -68,10 +68,20 @@ exposes, so everything in this guide applies to both. ## Permission to change the target -Attaching grants no write permission. Control, mutation and native-call -operations require `mutation_permission=true` on that call; every mutation is -journaled and can be rolled back. Use it only for the specific operation that -needs it. +Attaching grants no write permission. Cortex starts read-only for you: a call +that changes the target (control, mutation, native call) is refused with +`write_authority_required` unless the person who started `cortex.exe mcp` passed +`--allow-writes`, and you cannot turn that on. Writing `mutation_permission=true` +on a call only declares that you mean to change something; it is required, and +it is not sufficient. Do not retry a refused call with different arguments; tell +the person what you needed and why. Every refused attempt is logged. + +Without `--allow-writes`, `cortex_attach` only connects to a runtime that is +already loaded in the process; it will not load one. Inside `batch_run`, only +the operations on its allowlist run. + +When writes are allowed, every mutation is journaled and can be rolled back. +Use `mutation_permission` only for the specific operation that needs it. ## Core conventions diff --git a/agent/semantic-tools.md b/agent/semantic-tools.md index c1578dfa..4792d7e5 100644 --- a/agent/semantic-tools.md +++ b/agent/semantic-tools.md @@ -2,26 +2,21 @@ Since v0.6, Cortex includes a semantic layer for AI agents with bounded server-side execution. These tools describe goals in terms of observable runtime behaviour rather than game-specific concepts such as health, ammunition, money, or score. -The semantic layer does not invent domain objects. Every conclusion must include evidence, confidence, alternatives, and a recommended next action. When evidence is insufficient, return `status: not_found` or `status: inconclusive` instead of guessing. +The semantic layer does not invent domain objects, and it does not interpret results. Cortex runs the primitive steps you write, in order, and returns what they returned. It computes no confidence, no ranking and no hypotheses; drawing conclusions, and saying how sure you are, is your job. Prefer `status: not_found` or `inconclusive` in your own reasoning to guessing. -## Common result contract +## Result contract ```json { - "status": "candidate_found | confirmed | not_found | inconclusive | failed | cancelled | timed_out", - "confidence": 0.0, - "summary": "human-readable conclusion", - "evidence": [], - "candidates": [], - "alternative_hypotheses": [], - "tested_hypotheses": [], - "recommended_next_tool": "tool_name", - "artifacts": {}, - "reversible_actions": [] + "status": "plan_ready | completed | failed | cancelled | timed_out", + "summary": "what the tool did", + "evidence": [{ "step": 0, "tool": "...", "risk": "...", "arguments": {}, "output": {} }], + "reversible_actions": [], + "lifecycle": { "current": "..." } } ``` -Confidence is not a substitute for validation. A value scan only identifies correlation. Use `test_candidate_causality` or another controlled experiment before persisting a finding. +`evidence` is the raw output of each step you asked for. A value scan only identifies correlation. Use `test_candidate_causality` or another controlled experiment before persisting a finding. ## Server-side execution @@ -33,7 +28,8 @@ Execution rules: - every step must belong to that semantic tool's `_primitives` allowlist; - nested semantic execution is rejected; - `timeout_ms` is a cooperative orchestration deadline from 100 ms to 120000 ms, defaulting to 30000 ms; -- `mutation_permission: true` is required before control, mutation, or native-call operations can run; +- `mutation_permission: true` is required before control, mutation, or native-call operations can run, and it is not sufficient: the person must also have started `cortex.exe mcp` with `--allow-writes` (otherwise the step fails with `write_authority_required`); +- `batch_run` is checked from the operations it contains; an operation that is not on its allowlist is refused, and a `$from_step` reference may not make a step riskier than the one that was authorized; - active operations without a known rollback contract are rejected before execution; - supported mutations run inside an action transaction and roll back on failure, observed cancellation, or observed timeout; - `rollback_on_success: true` can be used for reversible causal experiments; @@ -111,7 +107,7 @@ Every pull request touching the semantic or MCP layer runs validation on Windows - native pipe tests lock token-derived rendezvous names and frame-size limits; - real DLL injection validates HTTP MCP planning and server-side read-only execution; - the native stdio path validates `cortex.exe mcp` -> authenticated Named Pipe -> semantic executor -> native route dispatcher; -- mutation permission gates are checked before dangerous arguments reach a primitive; +- mutation permission gates are checked before dangerous arguments reach a primitive, and a regression test (`tests/security_no_writes_regression.py`) calls every listed tool without `--allow-writes` and requires the target's state hash to be unchanged; - the action journal is compared around plan-only and read-only execution; - CTest, unified-host checks, and release packaging still run. diff --git a/app_imgui/application/re_model.cpp b/app_imgui/application/re_model.cpp index c63c6605..32d734a1 100644 --- a/app_imgui/application/re_model.cpp +++ b/app_imgui/application/re_model.cpp @@ -56,6 +56,7 @@ bool ReModel::Call(const std::string& tool, json arguments, if (error) *error = "mutation_permission_required"; return false; } + payload_.SetWritesAuthority(mutationAllowed); if (!EnsureRuntime(mutation, mutationAllowed, error)) return false; if (mutation) arguments["mutation_permission"] = true; diff --git a/app_imgui/application/runtime_model_base.cpp b/app_imgui/application/runtime_model_base.cpp index c751d36d..ae86753e 100644 --- a/app_imgui/application/runtime_model_base.cpp +++ b/app_imgui/application/runtime_model_base.cpp @@ -39,6 +39,10 @@ bool RuntimeModelBase::Call(const std::string& tool, json arguments, json& resul if (error) *error = "mutation_permission_required"; return false; } + // The runtime enforces the same switch on its side: authority is the + // person's Writes allowed state, and `mutation_permission` below only says + // this call means to change something. + payload_.SetWritesAuthority(mutationAllowed); if (!EnsureRuntime(mutation, mutationAllowed, error)) return false; if (mutation) arguments["mutation_permission"] = true; diff --git a/app_imgui/desktop_ui.cpp b/app_imgui/desktop_ui.cpp index cfd0c2ec..9fc8810a 100644 --- a/app_imgui/desktop_ui.cpp +++ b/app_imgui/desktop_ui.cpp @@ -1041,6 +1041,9 @@ void DrawApp(AppState& app) { // cannot answer, so the calls are refused up front instead of // freezing this window until they time out. app.payload.SetSuspended(app.ui.targetPaused); + // The runtime only changes the target while the person has Writes allowed + // on; the desktop is the authority for its own calls. + app.payload.SetWritesAuthority(app.ui.mutationAllowed); app.debuggerModel.SetTargetPaused(app.ui.targetPaused); } diff --git a/core/api/mcp_contract.h b/core/api/mcp_contract.h index 6f35144a..97303bd4 100644 --- a/core/api/mcp_contract.h +++ b/core/api/mcp_contract.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -18,7 +19,9 @@ enum class ToolRisk { Analyze, Control, Mutate, - NativeCall + NativeCall, + // No rule names this tool. It is refused whatever authority the caller holds. + Unclassified }; inline const char* RiskName(ToolRisk risk) { @@ -28,21 +31,43 @@ inline const char* RiskName(ToolRisk risk) { case ToolRisk::Control: return "control"; case ToolRisk::Mutate: return "mutate"; case ToolRisk::NativeCall: return "native_call"; + case ToolRisk::Unclassified: return "unclassified"; } return "analyze"; } inline bool RequiresMutationPermission(ToolRisk risk) { - return risk == ToolRisk::Control || risk == ToolRisk::Mutate || risk == ToolRisk::NativeCall; + return risk == ToolRisk::Control || risk == ToolRisk::Mutate || risk == ToolRisk::NativeCall || + risk == ToolRisk::Unclassified; } inline bool StartsWith(const std::string& value, const std::string& prefix) { return value.rfind(prefix, 0) == 0; } +// Non-GET tools that read or analyze without changing the target. This list is +// the only way a POST/DELETE tool reaches `Analyze`: a tool that no rule below +// and no entry here names is `Unclassified` and is refused, so a route added +// later stays unreachable from MCP until someone classifies it on purpose. +inline const std::set& ExplicitAnalyzeTools() { + static const std::set names = { + "session_export", "session_diff", "ocr", "memory_read", + "memory_read_batch", "scan_new", "scan_next", "scan_delete", + "scan_aob", "scan_pointers", "scan_pointer_path", "scan_strings", + "scan_intersect", "scan_code_caves", "analysis_functions", "analysis_cfg", + "analysis_xrefs", "analysis_vtable", "analysis_structure", "analysis_scan_patches", + "dissect_snapshot", "dissect_delete", "dissect_diff", "batch_run", + "snapshot_create", "snapshot_diff", "snapshot_last_change", "ghidra_export"}; + return names; +} + inline ToolRisk ClassifyTool(const std::string& name, const std::string& method, const std::string& path) { + // A prompt puts a request in front of the person at the screen, so every + // prompt tool needs authority, including the status poll that follows one. + if (StartsWith(name, "prompt_")) return ToolRisk::Control; + // GET routes are observational even when their names share a subsystem // prefix with the mutating/control half of that subsystem. if (method == "GET") return ToolRisk::Observe; @@ -76,7 +101,8 @@ inline ToolRisk ClassifyTool(const std::string& name, return ToolRisk::Control; } - return ToolRisk::Analyze; + if (ExplicitAnalyzeTools().count(name)) return ToolRisk::Analyze; + return ToolRisk::Unclassified; } inline bool IsUnreserved(unsigned char c) { @@ -97,6 +123,74 @@ inline std::string ScalarToString(const json& value) { return value.is_string() ? value.get() : value.dump(); } +// --------------------------------------------------------------------------- +// batch_run is an envelope: its `ops` can write memory, freeze values and patch +// code. Its risk is therefore decided from the operations it contains, with an +// explicit allowlist. An operation that is not listed here is refused, whatever +// permission the caller holds, so a new op added to /batch/run stays unreachable +// from MCP until someone classifies it on purpose. +inline const std::set& BatchReadOnlyOps() { + static const std::set ops = { + "memory_read", "aob_scan", "string_scan", "disasm", "analysis_xrefs", + "analysis_vtable", "struct_read", "dissect_snapshot", "dissect_diff"}; + return ops; +} + +inline const std::set& BatchMutatingOps() { + static const std::set ops = { + "memory_write", "freeze_add", "freeze_remove", "patch_apply", "patch_nop", + "patch_revert", "struct_write"}; + return ops; +} + +struct BatchVerdict { + bool ok = true; + bool mutating = false; + std::string error; +}; + +inline BatchVerdict ClassifyBatchRun(const json& arguments) { + BatchVerdict verdict; + if (!arguments.is_object() || !arguments.contains("ops") || !arguments["ops"].is_array()) { + verdict.ok = false; + verdict.error = "batch_ops_required"; + return verdict; + } + for (const auto& step : arguments["ops"]) { + // The op name must be a literal string: a reference that resolves to a + // name later would be classified before it is known. + if (!step.is_object() || !step.contains("op") || !step["op"].is_string()) { + verdict.ok = false; + verdict.error = "batch_op_not_literal"; + return verdict; + } + const std::string op = step["op"].get(); + if (BatchMutatingOps().count(op)) verdict.mutating = true; + else if (!BatchReadOnlyOps().count(op)) { + verdict.ok = false; + verdict.error = "batch_op_not_allowlisted:" + op; + return verdict; + } + } + return verdict; +} + +// The risk of one concrete call. The tool name gives a floor; some arguments +// raise it. +inline ToolRisk EffectiveRisk(const std::string& name, ToolRisk risk, const json& arguments) { + if (!arguments.is_object()) return risk; + if (name == "struct_infer" && arguments.value("define", false) && risk < ToolRisk::Control) + return ToolRisk::Control; + // Suspending every other thread of the target is a control operation. + if (StartsWith(name, "scan_") && arguments.value("pause_process", false) && risk < ToolRisk::Control) + return ToolRisk::Control; + if (name == "batch_run") { + const auto verdict = ClassifyBatchRun(arguments); + if (verdict.ok && verdict.mutating && risk < ToolRisk::Mutate) return ToolRisk::Mutate; + } + return risk; +} + inline std::vector PathParameters(const std::string& path) { std::vector result; size_t cursor = 0; diff --git a/core/api/mcp_pipe.cpp b/core/api/mcp_pipe.cpp index d66275f8..9c4c5934 100644 --- a/core/api/mcp_pipe.cpp +++ b/core/api/mcp_pipe.cpp @@ -191,16 +191,29 @@ void ServeClient(const std::shared_ptr& worker, const std::string& expec // It is intentionally not part of HTTP MCP/tools/list: Cortex // Desktop uses it for UI-side adapters such as human prompts // without making those controls callable by an AI client. - responsePayload = HandlePrivateRouteRequest(envelope["message"]).dump(); + // It runs any native route with no further check, so only the + // desktop's own adapter (which marks the envelope) may use it; a + // message that merely arrived from an MCP client is refused. + if (envelope.value("channel", std::string()) != "desktop") { + responsePayload = RpcErrorForEnvelope(envelope, -32601, "private_route_not_available").dump(); + } else { + responsePayload = HandlePrivateRouteRequest(envelope["message"]).dump(); + } } else { const auto profile = mcp_tools::ParseProfile( envelope.value("tools", std::string("compact")), mcp_protocol::ToolProfile::Compact); + // Authority comes from the host that built this envelope (the + // human's launch flag or the desktop), never from the message. + mcp_tools::Authority authority; + if (envelope.contains("writes_allowed") && envelope["writes_allowed"].is_boolean()) + authority.writesAllowed = envelope["writes_allowed"].get(); const auto result = mcp_tools::Handle( envelope["message"], profile, envelope.value("protocolVersion", std::string()), - envelope.value("session", std::string())); + envelope.value("session", std::string()), + authority); if (result.hasResponse) responsePayload = result.response.dump(); } } catch (const json::parse_error& error) { diff --git a/core/api/mcp_tools.cpp b/core/api/mcp_tools.cpp index 9dfc2b27..626d359a 100644 --- a/core/api/mcp_tools.cpp +++ b/core/api/mcp_tools.cpp @@ -5,6 +5,7 @@ #include "semantic_tools.h" #include "mcp_contract.h" #include "../action/action.h" +#include "../security/denial_log.h" #include #include @@ -236,9 +237,7 @@ bool SemanticAllowsPrimitive(const json& semanticTool, const std::string& wanted mcp_contract::ToolRisk EffectiveRiskForCall(const std::string& name, mcp_contract::ToolRisk risk, const json& arguments) { - if (name == "struct_infer" && arguments.is_object() && arguments.value("define", false)) - return mcp_contract::ToolRisk::Control; - return risk; + return mcp_contract::EffectiveRisk(name, risk, arguments); } bool RequiresMutationPermissionForCall(const std::string& name, @@ -246,6 +245,58 @@ bool RequiresMutationPermissionForCall(const std::string& name, const json& arguments) { return mcp_contract::RequiresMutationPermission(EffectiveRiskForCall(name, risk, arguments)); } + +// The one place that decides whether a call may run. A call that changes the +// target needs BOTH the caller's declared intent (`mutation_permission`) and +// authority from outside the model. Anything refused is recorded. +struct Gate { + bool allowed = true; + json error; +}; + +Gate CheckAuthority(const std::string& name, + mcp_contract::ToolRisk risk, + const json& arguments, + bool declaredIntent, + const Authority& authority, + const std::string& semanticTool = {}) { + Gate gate; + const auto refuse = [&](const std::string& code, const mcp_contract::ToolRisk shown) { + gate.allowed = false; + gate.error = {{"ok", false}, + {"error", code}, + {"tool", name}, + {"risk", mcp_contract::RiskName(shown)}}; + if (code == "write_authority_required") + gate.error["message"] = + "Writes are not authorized for this session. The person running Cortex must start " + "cortex.exe mcp with --allow-writes (or allow writes in the desktop); the " + "mutation_permission argument alone is not enough."; + cortex::security::RecordDenial("runtime", name, code, arguments, semanticTool); + }; + + if (name == "batch_run") { + const auto verdict = mcp_contract::ClassifyBatchRun(arguments); + if (!verdict.ok) { + refuse(verdict.error, mcp_contract::ToolRisk::Mutate); + return gate; + } + } + const auto effective = EffectiveRiskForCall(name, risk, arguments); + if (effective == mcp_contract::ToolRisk::Unclassified) { + refuse("tool_not_classified", effective); + gate.error["message"] = "This tool has no risk classification and is refused until it is classified."; + return gate; + } + if (!mcp_contract::RequiresMutationPermission(effective)) return gate; + if (!declaredIntent) { + refuse("mutation_permission_required", effective); + return gate; + } + if (!authority.writesAllowed) refuse("write_authority_required", effective); + return gate; +} + bool SupportsTransactionalRollback(const std::string& name, mcp_contract::ToolRisk risk, const json& arguments) { @@ -353,7 +404,8 @@ json TerminateExecution(json plan, json ExecuteSemantic(const std::string& wanted, const json& arguments, const json& requestId, - const std::string& cancellationScope) { + const std::string& cancellationScope, + const Authority& authority) { json plan = semantic::PlanFor(wanted, arguments); if (IsToolError(plan) || !arguments.value("execute", false)) return plan; @@ -408,11 +460,13 @@ json ExecuteSemantic(const std::string& wanted, const json stepArguments = rawStep.value("arguments", json::object()); const auto effectiveRisk = EffectiveRiskForCall(canonical, risk, stepArguments); - if (RequiresMutationPermissionForCall(canonical, effectiveRisk, stepArguments) && !mutationPermission) { + const Gate gate = CheckAuthority(canonical, risk, stepArguments, mutationPermission, authority, wanted); + if (!gate.allowed) { plan["status"] = "failed"; - plan["error"] = "mutation_permission_required"; + plan["error"] = gate.error.value("error", std::string("mutation_permission_required")); plan["rejected_tool"] = canonical; - plan["risk"] = mcp_contract::RiskName(effectiveRisk); + plan["risk"] = gate.error.value("risk", std::string(mcp_contract::RiskName(effectiveRisk))); + if (gate.error.contains("message")) plan["message"] = gate.error["message"]; plan["lifecycle"]["current"] = "failed"; return plan; } @@ -459,6 +513,27 @@ json ExecuteSemantic(const std::string& wanted, std::move(evidence), transaction); } + // A reference can change an argument that the risk depends on (the ops + // of a batch, `define`, `pause_process`). Classify what will actually run, + // and refuse anything riskier than what was authorized in preparation. + { + const auto baseRisk = mcp_contract::ClassifyTool( + steps[index].canonicalName, + steps[index].manifest.value("method", std::string("GET")), + steps[index].manifest.value("path", std::string())); + const Gate resolvedGate = CheckAuthority(steps[index].canonicalName, baseRisk, resolvedArguments, + mutationPermission, authority, wanted); + const auto resolvedRisk = EffectiveRiskForCall(steps[index].canonicalName, baseRisk, resolvedArguments); + if (!resolvedGate.allowed || static_cast(resolvedRisk) > static_cast(steps[index].risk)) { + UnregisterCancellation(cancellationScope, requestId, cancelled); + plan["failed_step"] = index; + return TerminateExecution(std::move(plan), "failed", + resolvedGate.allowed ? std::string("reference_changed_step_risk") + : resolvedGate.error.value("error", std::string("step_not_authorized")), + std::move(evidence), transaction); + } + } + json output = DispatchPrimitive(steps[index].manifest, resolvedArguments); evidence.push_back({ {"step", index}, @@ -512,10 +587,11 @@ json CallToolScoped(const std::string& wanted, const json& arguments, mcp_protocol::ToolProfile profile, const json& requestId, - const std::string& cancellationScope) { + const std::string& cancellationScope, + const Authority& authority) { json semanticTool; if (FindSemanticTool(wanted, semanticTool)) - return ToolCallPayload(ExecuteSemantic(wanted, arguments, requestId, cancellationScope)); + return ToolCallPayload(ExecuteSemantic(wanted, arguments, requestId, cancellationScope, authority)); if (profile != mcp_protocol::ToolProfile::All) { return ToolCallPayload({ @@ -533,14 +609,9 @@ json CallToolScoped(const std::string& wanted, manifest.value("name", wanted), manifest.value("method", std::string("GET")), manifest.value("path", std::string())); - const auto effectiveRisk = EffectiveRiskForCall(manifest.value("name", wanted), risk, arguments); - if (RequiresMutationPermissionForCall(manifest.value("name", wanted), effectiveRisk, arguments) && - !arguments.value("mutation_permission", false)) { - return ToolCallPayload({{"ok", false}, - {"error", "mutation_permission_required"}, - {"tool", manifest.value("name", wanted)}, - {"risk", mcp_contract::RiskName(effectiveRisk)}}); - } + const Gate gate = CheckAuthority(manifest.value("name", wanted), risk, arguments, + arguments.is_object() && arguments.value("mutation_permission", false), authority); + if (!gate.allowed) return ToolCallPayload(gate.error); return ToolCallPayload(DispatchPrimitive(manifest, arguments), manifest.value("ok_false_is_error", true)); } @@ -569,23 +640,25 @@ json ListTools(mcp_protocol::ToolProfile profile) { json CallTool(const std::string& wanted, const json& arguments, mcp_protocol::ToolProfile profile, - const json& requestId) { - return CallToolScoped(wanted, arguments, profile, requestId, {}); + const json& requestId, + const Authority& authority) { + return CallToolScoped(wanted, arguments, profile, requestId, {}, authority); } mcp_protocol::Result Handle(const json& input, mcp_protocol::ToolProfile profile, const std::string& transportProtocolVersion, - const std::string& cancellationScope) { + const std::string& cancellationScope, + const Authority& authority) { mcp_protocol::Handler handler; handler.profile = profile; handler.transportProtocolVersion = transportProtocolVersion; handler.listTools = ListTools; - handler.callTool = [cancellationScope](const std::string& name, + handler.callTool = [cancellationScope, authority](const std::string& name, const json& arguments, mcp_protocol::ToolProfile toolProfile, const json& requestId) { - return CallToolScoped(name, arguments, toolProfile, requestId, cancellationScope); + return CallToolScoped(name, arguments, toolProfile, requestId, cancellationScope, authority); }; handler.notification = [cancellationScope](const json& notification) { HandleNotification(notification, cancellationScope); diff --git a/core/api/mcp_tools.h b/core/api/mcp_tools.h index 54e99cac..500fde09 100644 --- a/core/api/mcp_tools.h +++ b/core/api/mcp_tools.h @@ -10,17 +10,28 @@ namespace api::mcp_tools { using json = nlohmann::json; +// Who may let a call change the target. `mutation_permission` in a tool call is +// only the caller declaring its intent; it never grants anything by itself. +// `writesAllowed` comes from outside the model: the human's launch flag +// (`cortex.exe mcp --allow-writes`) or the desktop, carried on the +// authenticated pipe envelope. It defaults to false. +struct Authority { + bool writesAllowed = false; +}; + mcp_protocol::ToolProfile ParseProfile(const std::string& value, mcp_protocol::ToolProfile fallback = mcp_protocol::ToolProfile::All); json ListTools(mcp_protocol::ToolProfile profile); json CallTool(const std::string& name, const json& arguments, mcp_protocol::ToolProfile profile, - const json& requestId = nullptr); + const json& requestId = nullptr, + const Authority& authority = {}); mcp_protocol::Result Handle(const json& input, mcp_protocol::ToolProfile profile, const std::string& transportProtocolVersion = {}, - const std::string& cancellationScope = {}); + const std::string& cancellationScope = {}, + const Authority& authority = {}); } // namespace api::mcp_tools diff --git a/core/api/routes_mcp.cpp b/core/api/routes_mcp.cpp index fb377b55..7c1e7137 100644 --- a/core/api/routes_mcp.cpp +++ b/core/api/routes_mcp.cpp @@ -18,11 +18,17 @@ void RegisterMcpRoutes(httplib::Server& server) { const auto profile = mcp_tools::ParseProfile( request.get_header_value("X-Cortex-MCP-Tools"), mcp_protocol::ToolProfile::All); + // Same rule as the native pipe: authority is declared by whoever + // runs the bridge (cortex_host mcp --allow-writes), not by a tool + // call. Absent header means no authority. + mcp_tools::Authority authority; + authority.writesAllowed = request.get_header_value("X-Cortex-MCP-Writes") == "allowed"; const auto result = mcp_tools::Handle( json::parse(request.body), profile, request.get_header_value("MCP-Protocol-Version"), - request.get_header_value("X-Cortex-MCP-Session")); + request.get_header_value("X-Cortex-MCP-Session"), + authority); if (!result.hasResponse) { response.status = 202; diff --git a/core/api/semantic_tools.h b/core/api/semantic_tools.h index ee60388d..adcc5a6c 100644 --- a/core/api/semantic_tools.h +++ b/core/api/semantic_tools.h @@ -103,12 +103,6 @@ inline std::string StablePlanId(const std::string& wanted, const json& arguments return out.str(); } -inline double EvidenceConfidence(const json& observations) { - if (!observations.is_array() || observations.empty()) return 0.20; - const double value = 0.20 + static_cast(observations.size()) * 0.10; - return (std::min)(0.90, value); -} - inline json PlanFor(const std::string& wanted, const json& arguments) { for (const auto& tool : Catalog()) { if (tool.value("name", std::string()) != wanted) continue; @@ -158,8 +152,6 @@ inline json PlanFor(const std::string& wanted, const json& arguments) { json result = { {"status", execute ? "execution_requested" : "plan_ready"}, {"plan_id", StablePlanId(wanted, arguments)}, - {"confidence", 1.0}, - {"evidence_confidence", EvidenceConfidence(observations)}, {"summary", execute ? "Validated semantic execution request. The server will execute only the explicit allowlisted primitive steps and will roll back transactional mutations on cancellation, timeout, or failure." : "Semantic orchestration plan generated. Execute the listed primitive tools, attach their outputs as evidence, and validate before persisting conclusions."}, @@ -182,18 +174,12 @@ inline json PlanFor(const std::string& wanted, const json& arguments) { {"rollback_required_for_mutations", true}, {"evidence_required_for_confirmation", true} }}, - {"evidence_model", { - {"states", json::array({"observed", "candidate", "hypothesis", "confirmed", "rejected", "inconclusive"})}, - {"confidence_source", "evidence_only"} - }}, + // Cortex does not rank, score or interpret anything here: it runs the + // steps the caller wrote and returns their raw outputs. Conclusions, + // confidence and hypotheses are the caller's to draw. {"result_contract", { - {"status", "candidate_found|confirmed|not_found|inconclusive|failed|cancelled|timed_out"}, - {"confidence", "0.0..1.0"}, - {"evidence", "array"}, - {"candidates", "array"}, - {"alternative_hypotheses", "array"}, - {"tested_hypotheses", "array"}, - {"recommended_next_tool", "string|null"}, + {"status", "plan_ready|completed|failed|cancelled|timed_out"}, + {"evidence", "array of {step, tool, risk, arguments, output}, the raw primitive outputs"}, {"reversible_actions", "array"} }}, {"rules", json::array({ diff --git a/core/dllmain.cpp b/core/dllmain.cpp index e9491412..dcfff81b 100644 --- a/core/dllmain.cpp +++ b/core/dllmain.cpp @@ -5,6 +5,7 @@ #include #include "config.h" +#include "security/denial_log.h" #include "log.h" #include "diagnostics/diagnostics.h" #include "diagnostics/registry.h" @@ -61,6 +62,8 @@ namespace { dbglog::Init(); dbglog::Line("InitThread start"); config::Config cfg = config::Load(); + // Refused state-changing calls are logged beside this DLL. + cortex::security::SetDenialDirectory(config::GetModuleDir()); if (cfg.log_console) SetupConsole(); diff --git a/core/security/denial_log.h b/core/security/denial_log.h new file mode 100644 index 00000000..cc7c47bc --- /dev/null +++ b/core/security/denial_log.h @@ -0,0 +1,113 @@ +#pragma once + +// Append-only record of state-changing calls that Cortex refused. +// +// An AI client has no way to grant itself write authority, so a refused +// mutating call is a meaningful signal: it was attempted and it did not +// happen. Every refusal is kept in memory and, once a directory is set, +// appended as one JSON object per line to /cortex_denied_mutations.jsonl. +// +// {"ts_ms":1760000000000,"ts_utc":"2026-10-07T12:00:00Z","source":"runtime", +// "tool":"memory_write","reason":"write_authority_required","arguments":{...}} +// +// Header-only so the injected runtime, the desktop host and the tests share +// one implementation. + +#include + +#include +#include +#include +#include +#include +#include + +namespace cortex::security { + +namespace detail { +struct DenialState { + std::mutex mutex; + std::string directory; + std::atomic count{0}; +}; +inline DenialState& State() { + static DenialState state; + return state; +} +inline constexpr size_t kMaxArgumentBytes = 4096; +} // namespace detail + +// Where the log file goes. Empty (the default) keeps refusals in memory only. +inline void SetDenialDirectory(const std::string& directory) { + auto& state = detail::State(); + std::lock_guard lock(state.mutex); + state.directory = directory; +} + +inline unsigned long long DenialCount() { + return detail::State().count.load(std::memory_order_relaxed); +} + +inline std::string DenialLogName() { return "cortex_denied_mutations.jsonl"; } + +// `source` says who refused ("runtime" or "host"); `reason` is the stable +// error code returned to the caller. +inline void RecordDenial(const std::string& source, + const std::string& tool, + const std::string& reason, + const nlohmann::json& arguments, + const std::string& semanticTool = {}) { + auto& state = detail::State(); + const auto now = std::chrono::system_clock::now(); + const auto ms = std::chrono::duration_cast(now.time_since_epoch()).count(); + + nlohmann::json entry = {{"ts_ms", ms}, {"source", source}, {"tool", tool}, {"reason", reason}}; + if (!semanticTool.empty()) entry["semantic_tool"] = semanticTool; + std::string dumped = arguments.is_null() ? std::string("{}") : arguments.dump(); + if (dumped.size() > detail::kMaxArgumentBytes) { + entry["arguments_truncated"] = true; + dumped.resize(detail::kMaxArgumentBytes); + entry["arguments"] = dumped; + } else { + entry["arguments"] = arguments.is_null() ? nlohmann::json::object() : arguments; + } + + std::lock_guard lock(state.mutex); + std::time_t seconds = static_cast(ms / 1000); + std::tm utc{}; +#if defined(_WIN32) + gmtime_s(&utc, &seconds); +#else + gmtime_r(&seconds, &utc); +#endif + char stamp[32] = {}; + std::strftime(stamp, sizeof(stamp), "%Y-%m-%dT%H:%M:%SZ", &utc); + entry["ts_utc"] = stamp; + state.count.fetch_add(1, std::memory_order_relaxed); + if (state.directory.empty()) return; + std::ofstream out(state.directory + "/" + DenialLogName(), std::ios::app | std::ios::binary); + if (out) out << entry.dump() << '\n'; +} + +// Debugger attach/detach on a target. A debugger attached to a process +// suspends its threads while it handles events, so "read-only" tools that use +// it still have an effect on the target. Each attach/detach is appended to +// /cortex_debugger_events.jsonl so a benchmark can count them. +inline std::string DebuggerLogName() { return "cortex_debugger_events.jsonl"; } + +inline void RecordDebuggerEvent(const std::string& phase, + unsigned long long processId, + const std::string& backend, + const std::string& detail = {}) { + auto& state = detail::State(); + const auto now = std::chrono::system_clock::now(); + const auto ms = std::chrono::duration_cast(now.time_since_epoch()).count(); + nlohmann::json entry = {{"ts_ms", ms}, {"phase", phase}, {"pid", processId}, {"backend", backend}}; + if (!detail.empty()) entry["detail"] = detail; + std::lock_guard lock(state.mutex); + if (state.directory.empty()) return; + std::ofstream out(state.directory + "/" + DebuggerLogName(), std::ios::app | std::ios::binary); + if (out) out << entry.dump() << '\n'; +} + +} // namespace cortex::security diff --git a/core/services/payload_client.cpp b/core/services/payload_client.cpp index 6660cbbe..6965861f 100644 --- a/core/services/payload_client.cpp +++ b/core/services/payload_client.cpp @@ -448,6 +448,19 @@ bool PayloadClient::EnsureReady(std::string* error) { if (ConnectExisting(target, nullptr, 1)) return true; Reset(); + if (!InjectionAllowed()) { + // A runtime loaded a moment ago may not be listening yet. + for (int attempt = 0; attempt < 40; ++attempt) { + if (ConnectExisting(target, nullptr, 1)) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + Reset(); + SetError(error, + "write_authority_required: loading the runtime puts code in the target. Start " + "cortex.exe mcp with --allow-writes, or load it yourself beforehand with " + "`cortex.exe inject `"); + return false; + } if (!InjectPayload(target, error)) return false; std::string connectError; @@ -466,7 +479,8 @@ bool PayloadClient::RoundTrip(const json& message, const std::string& toolProfile, const std::string& transportProtocolVersion, bool allowEmptyResponse, - bool* hasResponse) const { + bool* hasResponse, + const char* channel) const { response = json(); if (hasResponse) *hasResponse = false; #if !defined(_WIN32) @@ -502,8 +516,10 @@ bool PayloadClient::RoundTrip(const json& message, {"token", token}, {"tools", toolProfile}, {"session", sessionId}, + {"writes_allowed", WritesAuthority()}, {"message", message} }; + if (channel && *channel) envelope["channel"] = channel; if (!transportProtocolVersion.empty()) envelope["protocolVersion"] = transportProtocolVersion; diff --git a/core/services/payload_client.h b/core/services/payload_client.h index b1f98fec..ff456a5a 100644 --- a/core/services/payload_client.h +++ b/core/services/payload_client.h @@ -35,6 +35,19 @@ class PayloadClient : public RuntimeTransport { void SetSuspended(bool suspended) { suspended_.store(suspended, std::memory_order_relaxed); } bool Suspended() const { return suspended_.load(std::memory_order_relaxed); } + // Authority to change the target, sent to the runtime with every call. It + // is set by whoever owns this client: the desktop (from its Writes allowed + // switch) or `cortex.exe mcp` (from its --allow-writes flag). It is not + // something a tool call can set. Default: none. + void SetWritesAuthority(bool allowed) override { writesAuthority_.store(allowed, std::memory_order_relaxed); } + bool WritesAuthority() const { return writesAuthority_.load(std::memory_order_relaxed); } + + // Loading the runtime puts code in the target. When injection is not + // allowed, EnsureReady only connects to a runtime that is already there + // (for example one loaded beforehand with `cortex.exe inject `). + void SetInjectionAllowed(bool allowed) { injectionAllowed_.store(allowed, std::memory_order_relaxed); } + bool InjectionAllowed() const { return injectionAllowed_.load(std::memory_order_relaxed); } + // Checks whether the currently selected target can be instrumented with // the runtime assets available beside the desktop application. This does // not inject anything and is safe to call from UI enable/disable logic. @@ -106,7 +119,7 @@ class PayloadClient : public RuntimeTransport { }; json response; - if (!RoundTrip(message, response, error, 4, "all", "2026-07-28", false)) { + if (!RoundTrip(message, response, error, 4, "all", "2026-07-28", false, nullptr, "desktop")) { Reset(); return false; } @@ -167,7 +180,8 @@ class PayloadClient : public RuntimeTransport { const std::string& toolProfile, const std::string& transportProtocolVersion, bool allowEmptyResponse, - bool* hasResponse = nullptr) const; + bool* hasResponse = nullptr, + const char* channel = nullptr) const; target::SessionManager& sessions_; mutable std::mutex mutex_; @@ -178,6 +192,8 @@ class PayloadClient : public RuntimeTransport { uint64_t verifiedProcessId_ = 0; std::atomic requestSequence_{0}; std::atomic suspended_{false}; + std::atomic writesAuthority_{false}; + std::atomic injectionAllowed_{true}; }; } // namespace cortex::services diff --git a/core/services/runtime_transport.h b/core/services/runtime_transport.h index e7fb60a9..ff7761fd 100644 --- a/core/services/runtime_transport.h +++ b/core/services/runtime_transport.h @@ -30,6 +30,11 @@ class RuntimeTransport { nlohmann::json& output, std::string* error = nullptr) = 0; + // Tells the runtime whether the person has allowed writes. Sent with every + // call; the runtime refuses state-changing calls without it, whatever the + // call says about itself. The default does nothing (scripted transports). + virtual void SetWritesAuthority(bool allowed) { (void)allowed; } + // Calls a desktop-only private route on an already-connected runtime. virtual bool CallRouteExisting(const std::string& method, const std::string& path, diff --git a/docs/getting-started.md b/docs/getting-started.md index 32d3e433..9c3af345 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -83,7 +83,7 @@ Project names and pointer paths resolve too. A resolved location opens in the Me ## 8. Write permission -**Writes allowed** is an explicit safety permission, enforced by the application for every state-changing call. +**Writes allowed** is an explicit safety permission. The desktop applies it to every state-changing call it makes, and the runtime refuses a state-changing call that arrives without it. An AI client connected through `cortex.exe mcp` has no such switch: only `--allow-writes` on its command line, chosen by you, lets it change the target. ### Read-only diff --git a/docs/mcp.md b/docs/mcp.md index a34af402..e2586edd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,7 @@ AI client -> cortex.exe mcp +-- cortex_attach(pid=5678) ``` -Attaching a target does not grant write permission. State-changing runtime calls still require their explicit `mutation_permission=true` contract. +Attaching a target does not grant write permission. A call that changes the target needs write authority from outside the model (`--allow-writes` on the `cortex.exe mcp` command line) **and** the call's own `mutation_permission=true`. Without `--allow-writes` the server cannot load the runtime into a process: `cortex_attach` (and `--pid` / `--process`) connect only to a runtime that is already there. ## Multiple attached targets @@ -211,7 +211,7 @@ Execution rules: - later steps may reference evidence from an earlier step with `$from_step` plus an optional JSON pointer; - outputs are recorded in the returned `evidence` array; - a failed primitive stops the sequence; -- control, mutation, and native-call primitives require `mutation_permission=true`; +- control, mutation, and native-call primitives require `mutation_permission=true` and write authority (`--allow-writes`); `batch_run` is classified from the operations it contains, an operation not on its allowlist is refused, and a reference may not change what a step does after it was authorized; - active primitives without a known rollback contract are rejected before execution; - mutations with a supported contract execute inside `action::Transaction`; - failure, observed cancellation, or observed timeout rolls that transaction back; @@ -238,9 +238,22 @@ Native stdio remains responsive to `notifications/cancelled` because normal requ ## Mutation permission -Attaching a target does not grant permission to modify it. Control, mutation, and native-call operations continue to require explicit `mutation_permission=true` in the relevant MCP call. +Two conditions must hold for a call that changes the target (control, mutation and native-call risk): -This permission remains per operation; multi-target routing does not implicitly grant it on either target. The desktop applies the same rule: its Read-only / Writes allowed toggle decides whether it sends `mutation_permission=true`. +1. **Write authority**, which comes from outside the model. For `cortex.exe mcp` it is the `--allow-writes` flag the person puts in the client configuration; for the legacy `cortex_host mcp` bridge it is the same flag. The desktop supplies its own: the Read-only / Writes allowed switch. The flag travels to the runtime on the authenticated pipe envelope, never inside a tool call, so an AI client cannot set it. (An authority granted by the desktop for a limited time to an MCP session is planned, not built.) +2. **Declared intent**: `mutation_permission=true` on the call, or on the semantic request that runs the steps. On its own it grants nothing. + +Without authority the answer is `write_authority_required`; without the declaration it is `mutation_permission_required`. Every refused call is appended to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, who refused: `runtime` beside `cortex_core.dll`, `host` beside `cortex.exe`), so an attempt that was blocked can still be counted. + +Attaching a target does not grant permission to modify it, and loading the runtime counts as a modification: without `--allow-writes`, `cortex_attach` connects only to a runtime that was loaded beforehand (for example with `cortex.exe inject `). The server forwards only `initialize`, `ping`, `server/discover`, `tools/list`, `tools/call` and `notifications/*`; any other method, including the desktop's private `cortex/private/route`, is refused and logged. + +This remains per operation; multi-target routing does not implicitly grant it on either target. + +A tool with no explicit risk classification is refused (`tool_not_classified`), with or without authority. `tests/fixtures/mcp_tool_risks.json` lists the class of every primitive tool. + +**"Read-only" means no write to the target's memory, not no effect on the target.** `debug_threads`, `debug_registers`, `debug_breakpoint_list` and `debug_paused` need no write authority, but they use a debugger, and a debugger attached to a process suspends its threads while it handles events. Cortex does not hide this: every debugger attach and detach is published as an AI Activity event (kind `debugger`) and appended to `cortex_debugger_events.jsonl` beside `cortex.exe` and `cortex_core.dll` (phase, pid, backend, epoch `ts_ms`). A separate `--allow-debug` switch is not built. + +**Known limit.** The runtime's pipe token is a file (`cortex.mcp..token`) readable by the Windows user that runs Cortex. A client that has a shell or file access as that user can read it and speak to the pipe directly, outside every rule above. These controls hold for an agent whose only access to Cortex is its MCP tool calls. ## AI activity in the desktop diff --git a/docs/ui-guide.md b/docs/ui-guide.md index 87a50cdb..d0f1c518 100644 --- a/docs/ui-guide.md +++ b/docs/ui-guide.md @@ -225,6 +225,6 @@ A tab is hidden while the same content is open as a full workspace; the panel th ## Write permission model -Cortex keeps observation and state changes distinct. Attaching and exploring stay read-only until **Writes allowed** is switched on. The application models enforce the permission for every mutating call, independently of which buttons the UI enables, and the runtime checks the `mutation_permission` flag on its side. +Cortex keeps observation and state changes distinct. Attaching and exploring stay read-only until **Writes allowed** is switched on. The application models enforce the permission for every mutating call, independently of which buttons the UI enables, and the runtime refuses a state-changing call unless the desktop (or, for `cortex.exe mcp`, the `--allow-writes` flag) has granted write authority; `mutation_permission` on the call alone is not enough. For a first-session walkthrough, see [Getting started](getting-started.md). diff --git a/docs/unified-app-architecture.md b/docs/unified-app-architecture.md index 6185ec13..580c57de 100644 --- a/docs/unified-app-architecture.md +++ b/docs/unified-app-architecture.md @@ -52,9 +52,9 @@ MCP is integrated into `cortex.exe`. The normal local path is stdio -> authentic The injected runtime starts the authenticated native pipe and route registry without opening a TCP listener by default. The legacy loopback HTTP API is compatibility/debug-only and requires `http_api_enabled=true` in `cortex.ini`. -Primitive and semantic execution share the same route/executor contracts used by the application. Mutating/control/native operations require explicit mutation permission. Semantic execution additionally supports bounded execution, cancellation, evidence and transactional rollback where a safe compensation contract exists. +Primitive and semantic execution share the same route/executor contracts used by the application. Mutating/control/native operations require write authority from outside the model (the desktop switch or `cortex.exe mcp --allow-writes`) plus the call's own `mutation_permission` declaration. Semantic execution additionally supports bounded execution, cancellation, evidence and transactional rollback where a safe compensation contract exists. -Human prompt answering is kept off the public MCP tool surface. The Desktop communicates with prompt state over a private local channel so an agent cannot answer its own human-verification prompt. +Human prompt answering is kept off the public MCP tool surface. The Desktop communicates with prompt state over a private local channel (`cortex/private/route`, only accepted from the desktop's own adapter) so an agent cannot answer its own human-verification prompt through MCP. The pipe token is a readable file, so this does not stop a client that also has a shell; see the limit in `docs/mcp.md`. ## Instrumentation boundary diff --git a/mcp_bridge/host_mode.cpp b/mcp_bridge/host_mode.cpp index 06c97ae7..c2512b87 100644 --- a/mcp_bridge/host_mode.cpp +++ b/mcp_bridge/host_mode.cpp @@ -7,6 +7,7 @@ #include "services/operation_manager.h" #include "services/payload_client.h" #include "services/user_data.h" +#include "security/denial_log.h" #include "target/catalog.h" #include "target/local_backend.h" #include "target/session_manager.h" @@ -45,7 +46,7 @@ constexpr std::size_t kMaxStdioMessageBytes = 4u * 1024u * 1024u; constexpr std::size_t kMaxConcurrentRequests = 64; struct TargetSelector { std::optional pid; std::string process; }; -struct Options { std::vector targets; std::string toolProfile = "compact"; bool help = false; }; +struct Options { std::vector targets; std::string toolProfile = "compact"; bool help = false; bool allowWrites = false; }; struct TargetRuntime { TargetDescriptor target; @@ -54,6 +55,7 @@ struct TargetRuntime { std::string debuggerBackend; std::unique_ptr debugger; std::mutex debuggerMutex; + bool debuggerNoted = false; // an attach was journaled and no detach has followed }; using TargetRuntimePtr = std::shared_ptr; using RuntimeList = std::vector; @@ -74,6 +76,7 @@ struct RunState { RuntimeList runtimes; cortex::target::Catalog* catalog = nullptr; std::string runtimeDirectory, toolProfile; + bool allowWrites = false; cortex::services::OperationManager operations; std::mutex operationRouteMutex; @@ -97,16 +100,20 @@ bool IsNotification(const json& message) { return message.is_object() && !messag std::string RequestKey(const json& id) { return id.dump(); } void PrintUsage(std::ostream& stream) { - stream << "Cortex MCP stdio mode\n\nUsage:\n cortex.exe mcp [--tools compact|all]\n" + stream << "Cortex MCP stdio mode\n\nUsage:\n cortex.exe mcp [--tools compact|all] [--allow-writes]\n" << " cortex.exe mcp --pid [--pid ...] [--tools compact|all]\n" << " cortex.exe mcp --process [--process ...] [--tools compact|all]\n\n" - << "Without selectors Cortex starts targetless. Use cortex_processes/cortex_attach dynamically.\n"; + << "Without selectors Cortex starts targetless. Use cortex_processes/cortex_attach dynamically.\n\n" + << "Read-only by default. --allow-writes lets this server load the runtime into a target and apply\n" + << "state-changing calls (a call must still carry mutation_permission). Only the person who starts\n" + << "the server can set it; the AI client cannot. Refused calls are logged to cortex_denied_mutations.jsonl.\n"; } bool ParseOptions(int argc, char** argv, Options& options, std::string& error) { for (int i=1;i& state, json event) { cortex::ai_activity::Publish(event.dump()); } +std::weak_ptr g_runState; + +// A debugger attached to the target suspends its threads, so tools that look +// read-only still have an effect on it. Every attach and detach is journaled. +void NoteDebugger(const TargetRuntimePtr& r, const char* phase, const std::string& detail = {}) { + if (!r) return; + cortex::security::RecordDebuggerEvent(phase, r->target.processId, r->debuggerBackend, detail); + if (auto state = g_runState.lock()) + PublishActivity(state, {{"kind", "debugger"}, {"phase", phase}, {"summary", std::string("Debugger ") + phase + " on pid " + std::to_string(r->target.processId)}, + {"details", {{"pid", r->target.processId}, {"backend", r->debuggerBackend}, {"detail", detail}}}}); +} + +bool AttachLogged(const TargetRuntimePtr& r, std::string* error) { + const bool ok = r->debugger->Ready() || r->debugger->Attach(error); + if (ok && !r->debuggerNoted) { + r->debuggerNoted = true; + NoteDebugger(r, "attached"); + } else if (!ok) { + NoteDebugger(r, "attach_failed", error ? *error : std::string()); + } + return ok; +} + +void DetachLogged(const TargetRuntimePtr& r) { + r->debugger->Detach(); + if (r->debuggerNoted) { + r->debuggerNoted = false; + NoteDebugger(r, "detached"); + } +} + json ToolPayload(const json& value,bool isError=false){return{{"content",json::array({{{"type","text"},{"text",value.dump(2)}}})},{"structuredContent",value},{"isError",isError}};} json LocalToolResponse(const json&id,const json&value,bool isError=false){return{{"jsonrpc","2.0"},{"id",id},{"result",ToolPayload(value,isError)}};} json LocalToolFailure(const std::string&code,const std::string&message){return{{"ok",false},{"error",{{"code",code},{"message",message}}}};} @@ -143,7 +181,7 @@ RuntimeList RuntimeSnapshot(const std::shared_ptr&s){std::lock_guard&s,const std::string&type,const TargetRuntimePtr&r,json data=json::object()) { HostEvent e;e.id=s->nextEventId.fetch_add(1);e.timestampMs=NowMs();e.type=type;if(r){e.targetId=r->target.id;e.targetGeneration=r->target.generation;}e.data=std::move(data);std::lock_guardl(s->eventMutex);s->events.push_back(std::move(e));while(s->events.size()>2048)s->events.pop_front(); } json EventsJson(const std::shared_ptr&s,uint64_t since,size_t limit,const TargetRuntimePtr&filter=nullptr){json out=json::array();std::lock_guardl(s->eventMutex);for(const auto&e:s->events){if(e.id<=since)continue;if(filter&&(e.targetId!=filter->target.id||(filter->target.generation&&e.targetGeneration!=filter->target.generation)))continue;out.push_back({{"id",e.id},{"timestamp_ms",e.timestampMs},{"type",e.type},{"target_id",e.targetId},{"target_generation",e.targetGeneration},{"data",e.data}});if(out.size()>=limit)break;}return out;} -bool PruneDeadRuntimes(const std::shared_ptr&s){RuntimeList removed;{std::lock_guardl(s->runtimeMutex);auto it=s->runtimes.begin();while(it!=s->runtimes.end()){auto r=*it;bool dead=!r||!r->sessions||!r->sessions->Active()||!r->sessions->Active()->Alive();if(!dead){++it;continue;}if(r)removed.push_back(r);it=s->runtimes.erase(it);}}for(auto&r:removed){if(r&&r->debugger){std::lock_guardl(r->debuggerMutex);r->debugger->Detach();}RecordEvent(s,"target.exited",r,{{"reason","target_no_longer_alive"}});}return!removed.empty();} +bool PruneDeadRuntimes(const std::shared_ptr&s){RuntimeList removed;{std::lock_guardl(s->runtimeMutex);auto it=s->runtimes.begin();while(it!=s->runtimes.end()){auto r=*it;bool dead=!r||!r->sessions||!r->sessions->Active()||!r->sessions->Active()->Alive();if(!dead){++it;continue;}if(r)removed.push_back(r);it=s->runtimes.erase(it);}}for(auto&r:removed){if(r&&r->debugger){std::lock_guardl(r->debuggerMutex);DetachLogged(r);}RecordEvent(s,"target.exited",r,{{"reason","target_no_longer_alive"}});}return!removed.empty();} void EmitToolsChanged(const std::shared_ptr&s){WriteOutput(s,{{"jsonrpc","2.0"},{"method","notifications/tools/list_changed"}});} json TargetJson(const TargetDescriptor&t,bool attached,bool alive=true){return{{"id",t.id},{"name",t.name},{"pid",t.processId},{"selector",std::to_string(t.processId)},{"platform",cortex::target::PlatformName(t.platform)},{"architecture",cortex::target::ArchitectureName(t.architecture)},{"generation",t.generation},{"executable_path",t.executablePath},{"window_title",t.windowTitle},{"attached",attached},{"alive",alive}};} @@ -191,7 +229,7 @@ json LocalTools(bool requireTarget=false){json t=json::array({ {{"name","cortex_capabilities"},{"description","Return normalized target/debugger/runtime capabilities."},{"inputSchema",{{"type","object"},{"properties",json::object()}}},{"_cortex",{{"read_only",true}}}}, {{"name","cortex_debugger_backend"},{"description","Read or explicitly select the debugger backend for one target. Windows is external/recommended; VEH is in-process."},{"inputSchema",{{"type","object"},{"properties",{{"backend",{{"type","string"},{"enum",json::array({"windows","veh"})}}}}}}},{"_cortex",{{"host_control",true}}}}, {{"name","cortex_operations"},{"description","List recent tracked Cortex operations and their state/progress/timeout."},{"inputSchema",{{"type","object"},{"properties",{{"limit",{{"type","integer"},{"minimum",1},{"maximum",512}}}}}}},{"_cortex",{{"read_only",true}}}}, -{{"name","cortex_operation_cancel"},{"description","Request cooperative cancellation of a running Cortex operation."},{"inputSchema",{{"type","object"},{"properties",{{"operation_id",{{"type","integer"},{"minimum",1}}}}},{"required",json::array({"operation_id"})}}}}, +{{"name","cortex_operation_cancel"},{"description","Request cooperative cancellation of a running Cortex operation."},{"inputSchema",{{"type","object"},{"properties",{{"operation_id",{{"type","integer"},{"minimum",1}}}}},{"required",json::array({"operation_id"})}}},{"_cortex",{{"host_control",true}}}}, {{"name","cortex_events"},{"description","Read bounded host target/debugger/operation event history."},{"inputSchema",{{"type","object"},{"properties",{{"since_id",{{"type","integer"},{"minimum",0}}},{"limit",{{"type","integer"},{"minimum",1},{"maximum",512}}},{"_cortex_target",{{"oneOf",json::array({{{"type","integer"}},{{"type","string"}}})}}}}}}},{"_cortex",{{"read_only",true}}}}, {{"name","cortex_crash_report"},{"description","Load the latest Cortex diagnostics crash bundle for a target and recent host events."},{"inputSchema",{{"type","object"},{"properties",json::object()}}},{"_cortex",{{"read_only",true}}}}, {{"name","cortex_hooks"},{"description","Inspect the runtime HookManager registry, ownership, conflicts and hit/exception counters."},{"inputSchema",{{"type","object"},{"properties",json::object()}}},{"_cortex",{{"read_only",true}}}} @@ -208,7 +246,7 @@ void RememberInitialized(const std::shared_ptr&s){std::lock_guard&s,const TargetRuntimePtr&r,std::string&error){std::optionalinit;bool initialized=false;{std::lock_guardl(s->protocolMutex);init=s->initializeMessage;initialized=s->initializedNotificationSeen;}if(!init)return true;json rr;bool has=false;if(!r->payload->ForwardMcp(*init,s->toolProfile,rr,has,&error))return false;if(initialized){json n={{"jsonrpc","2.0"},{"method","notifications/initialized"}};if(!r->payload->ForwardMcp(n,s->toolProfile,rr,has,&error))return false;}return true;} -TargetRuntimePtr CreateRuntime(cortex::target::Catalog&catalog,const TargetDescriptor&t,const std::string&dir,std::string&code,std::string&msg){auto r=std::make_shared();r->target=t;r->sessions=std::make_unique(catalog);std::string e;if(!r->sessions->Attach(t,&e)){code="target_attach_failed";msg=e;return{};}r->payload=std::make_unique(*r->sessions,dir);if(!r->payload->EnsureReady(&e)){code="target_runtime_unavailable";msg=e;return{};}r->debuggerBackend=ConfiguredDebuggerBackend(dir);r->debugger=CreateDebugProvider(*r,r->debuggerBackend);if(!r->debugger){code="debugger_provider_unavailable";msg="Could not create debugger provider";return{};}return r;} +TargetRuntimePtr CreateRuntime(cortex::target::Catalog&catalog,const TargetDescriptor&t,const std::string&dir,bool allowWrites,std::string&code,std::string&msg){auto r=std::make_shared();r->target=t;r->sessions=std::make_unique(catalog);std::string e;if(!r->sessions->Attach(t,&e)){code="target_attach_failed";msg=e;return{};}r->payload=std::make_unique(*r->sessions,dir);r->payload->SetWritesAuthority(allowWrites);r->payload->SetInjectionAllowed(allowWrites);if(!r->payload->EnsureReady(&e)){code="target_runtime_unavailable";msg=e;if(e.rfind("write_authority_required",0)==0){code="write_authority_required";cortex::security::RecordDenial("host","cortex_attach",code,{{"pid",t.processId},{"process",t.name}});}return{};}r->debuggerBackend=ConfiguredDebuggerBackend(dir);r->debugger=CreateDebugProvider(*r,r->debuggerBackend);if(!r->debugger){code="debugger_provider_unavailable";msg="Could not create debugger provider";return{};}return r;} json ProcessList(const std::shared_ptr&s,const json&a,std::string&code,std::string&msg){if(!s->catalog){code="target_catalog_unavailable";msg="Local target catalog unavailable";return{};}std::string q;if(a.contains("query")&&a["query"].is_string())q=LowerAscii(a["query"].get());size_t limit=static_cast(std::clamp(UnsignedArg(a,"limit",256),1,2048));auto attached=RuntimeSnapshot(s);json rows=json::array();size_t total=0;for(const auto&t:s->catalog->Targets()){if(!q.empty()&&LowerAscii(t.name+"\n"+t.executablePath+"\n"+t.windowTitle+"\n"+t.id).find(q)==std::string::npos)continue;++total;if(rows.size()>=limit)continue;bool isAttached=false;for(auto&r:attached)if(r->target.id==t.id&&(!r->target.generation||!t.generation||r->target.generation==t.generation))isAttached=true;rows.push_back(TargetJson(t,isAttached));}return{{"ok",true},{"count",rows.size()},{"total_matches",total},{"truncated",total>rows.size()},{"processes",rows}};} @@ -221,7 +259,7 @@ bool IsProviderDebugTool(const std::string&n){return n=="debug_threads"||n=="deb std::string AddressExpr(const json&a){const json*v=nullptr;if(a.contains("address"))v=&a["address"];if(!v)return{};if(v->is_string())return v->get();if(v->is_number_unsigned())return std::to_string(v->get());if(v->is_number_integer())return std::to_string(v->get());return{};} json DebugEnvelope(json result,int status=200){return{{"status",status},{"result",std::move(result)}};} -bool HandleProviderDebug(const TargetRuntimePtr&r,const json&m,const std::string&name,const json&a,json&response){if(!r||!r->debugger||!IsProviderDebugTool(name))return false;const bool mut=name=="debug_breakpoint_add"||name=="debug_breakpoint_delete"||name=="debug_pause"||name=="debug_continue"||name=="debug_step"||name=="debug_step_over";if(mut&&!a.value("mutation_permission",false)){response=LocalToolResponse(MessageId(m),DebugEnvelope({{"ok",false},{"error","mutation_permission_required"}},403),true);return true;}std::lock_guardlock(r->debuggerMutex);std::string e;if(!r->debugger->Ready()&&!r->debugger->Attach(&e)){response=LocalToolResponse(MessageId(m),DebugEnvelope({{"ok",false},{"error",e.empty()?"debugger_attach_failed":e}},409),true);return true;}json result={{"ok",true},{"backend",r->debuggerBackend}}; +bool HandleProviderDebug(const TargetRuntimePtr&r,const json&m,const std::string&name,const json&a,json&response){if(!r||!r->debugger||!IsProviderDebugTool(name))return false;const bool mut=name=="debug_breakpoint_add"||name=="debug_breakpoint_delete"||name=="debug_pause"||name=="debug_continue"||name=="debug_step"||name=="debug_step_over";if(mut){const bool intent=a.value("mutation_permission",false);const bool authority=r->payload&&r->payload->WritesAuthority();if(!intent||!authority){const std::string why=!intent?"mutation_permission_required":"write_authority_required";cortex::security::RecordDenial("host",name,why,a);response=LocalToolResponse(MessageId(m),DebugEnvelope({{"ok",false},{"error",why},{"tool",name}},403),true);return true;}}std::lock_guardlock(r->debuggerMutex);std::string e;if(!AttachLogged(r,&e)){response=LocalToolResponse(MessageId(m),DebugEnvelope({{"ok",false},{"error",e.empty()?"debugger_attach_failed":e}},409),true);return true;}json result={{"ok",true},{"backend",r->debuggerBackend}}; if(name=="debug_threads"){json ids=json::array();for(auto id:r->debugger->Threads(&e))ids.push_back(id);if(e.empty())result["thread_ids"]=ids;} else if(name=="debug_registers"){uint64_t tid=UnsignedArg(a,"thread_id");cortex::target::ThreadRegisterSnapshot s;if(!tid||!r->debugger->GetRegisters(tid,s,&e))result={{"ok",false},{"error",e.empty()?"thread_not_readable":e}};else result["registers"]=RegistersJson(s);} else if(name=="debug_breakpoint_add"){if(a.contains("condition")||a.contains("if")||a.contains("capture")||a.value("auto_capture",false))result={{"ok",false},{"error","debugger_capability_unsupported"},{"message","selected backend does not support conditional/capture breakpoints"}};else{std::string addr=AddressExpr(a);int id=addr.empty()?-1:r->debugger->SetBreakpoint(addr,a.value("kind",std::string("software")),a.value("size",4),a.value("action",std::string("pause"))=="pause",a.value("process_global",true),UnsignedArg(a,"thread_id"),&e);if(id<0)result={{"ok",false},{"error",e.empty()?"add_breakpoint_failed":e}};else result["id"]=id;}} @@ -241,20 +279,20 @@ if(name=="cortex_targets"){response=LocalToolResponse(MessageId(m),TargetList(Ru if(name=="cortex_processes"){std::string c,x;auto r=ProcessList(s,a,c,x);response=c.empty()?LocalToolResponse(MessageId(m),r):LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;} if(name=="cortex_operations"){json rows=json::array();for(const auto&o:s->operations.List(static_cast(std::clamp(UnsignedArg(a,"limit",128),1,512))))rows.push_back(OperationJson(o));response=LocalToolResponse(MessageId(m),{{"ok",true},{"count",rows.size()},{"operations",rows}});return true;} if(name=="cortex_operation_cancel"){uint64_t op=UnsignedArg(a,"operation_id");std::string e;if(!op||!s->operations.RequestCancel(op,&e)){response=LocalToolResponse(MessageId(m),LocalToolFailure(e.empty()?"invalid_operation_id":e,e.empty()?"operation_id must identify a running operation":e),true);return true;}json id;{std::lock_guardl(s->operationRouteMutex);auto it=s->operationRequestIds.find(op);if(it!=s->operationRequestIds.end())id=it->second;}if(!id.is_null())BroadcastCancel(s,id);RecordEvent(s,"operation.cancel_requested",nullptr,{{"operation_id",op}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"operation_id",op},{"state","cancel_requested"}});return true;} -if(name=="cortex_attach"){std::lock_guardml(s->targetMutationMutex);bool hp=a.contains("pid"),hn=a.contains("process");if(hp==hn){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_attach_selector","Provide exactly one of pid or process"),true);return true;}TargetSelector sel;if(hp){auto p=UnsignedArg(a,"pid");if(!p){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_pid","pid must be positive"),true);return true;}sel.pid=p;}else{if(!a["process"].is_string()||a["process"].get().empty()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_process","process must be non-empty"),true);return true;}sel.process=a["process"].get();}std::string er;auto target=ResolveUniqueTarget(sel,s->catalog->Targets(),er);if(!target){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_not_found",er),true);return true;}for(auto&r:RuntimeSnapshot(s))if(r->target.id==target->id&&(!r->target.generation||!target->generation||r->target.generation==target->generation)){response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","already_attached"},{"target",TargetJson(r->target,true)}});return true;}std::string c,x;auto r=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,c,x);if(!r){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;}if(!PrimeRuntime(s,r,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_protocol_init_failed",x),true);return true;}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&old){return old&&old->target.id==r->target.id&&old->target.generation!=r->target.generation;}),s->runtimes.end());s->runtimes.push_back(r);}RecordEvent(s,"target.attached",r,{{"debugger_backend",r->debuggerBackend}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","attached"},{"target",TargetJson(r->target,true)},{"debugger_backend",r->debuggerBackend},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} +if(name=="cortex_attach"){std::lock_guardml(s->targetMutationMutex);bool hp=a.contains("pid"),hn=a.contains("process");if(hp==hn){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_attach_selector","Provide exactly one of pid or process"),true);return true;}TargetSelector sel;if(hp){auto p=UnsignedArg(a,"pid");if(!p){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_pid","pid must be positive"),true);return true;}sel.pid=p;}else{if(!a["process"].is_string()||a["process"].get().empty()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_process","process must be non-empty"),true);return true;}sel.process=a["process"].get();}std::string er;auto target=ResolveUniqueTarget(sel,s->catalog->Targets(),er);if(!target){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_not_found",er),true);return true;}for(auto&r:RuntimeSnapshot(s))if(r->target.id==target->id&&(!r->target.generation||!target->generation||r->target.generation==target->generation)){response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","already_attached"},{"target",TargetJson(r->target,true)}});return true;}std::string c,x;auto r=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,s->allowWrites,c,x);if(!r){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;}if(!PrimeRuntime(s,r,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_protocol_init_failed",x),true);return true;}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&old){return old&&old->target.id==r->target.id&&old->target.generation!=r->target.generation;}),s->runtimes.end());s->runtimes.push_back(r);}RecordEvent(s,"target.attached",r,{{"debugger_backend",r->debuggerBackend}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","attached"},{"target",TargetJson(r->target,true)},{"debugger_backend",r->debuggerBackend},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} std::string c,x;auto r=ResolveRuntime(RuntimeSnapshot(s),a,c,x);if(!r||!ValidateGeneration(r,a,c,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;} if(name=="cortex_capabilities"){response=LocalToolResponse(MessageId(m),{{"ok",true},{"capabilities",CapabilityDocument(*r)}});return true;} -if(name=="cortex_debugger_backend"){if(a.contains("backend")){if(!a["backend"].is_string()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_debugger_backend","backend must be windows or veh"),true);return true;}const std::string backend=LowerAscii(a["backend"].get());if(backend!="windows"&&backend!="veh"){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_debugger_backend","backend must be windows or veh"),true);return true;}std::lock_guarddl(r->debuggerMutex);if(r->debugger)r->debugger->Detach();r->debuggerBackend=backend;r->debugger=CreateDebugProvider(*r,backend);if(!r->debugger){response=LocalToolResponse(MessageId(m),LocalToolFailure("debugger_provider_unavailable","Could not create selected debugger provider"),true);return true;}RecordEvent(s,"debugger.backend_selected",r,{{"backend",backend}});}response=LocalToolResponse(MessageId(m),{{"ok",true},{"backend",r->debuggerBackend},{"capabilities",CapabilityDocument(*r)}});return true;} +if(name=="cortex_debugger_backend"){if(a.contains("backend")){if(!a["backend"].is_string()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_debugger_backend","backend must be windows or veh"),true);return true;}const std::string backend=LowerAscii(a["backend"].get());if(backend!="windows"&&backend!="veh"){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_debugger_backend","backend must be windows or veh"),true);return true;}std::lock_guarddl(r->debuggerMutex);if(r->debugger)DetachLogged(r);r->debuggerBackend=backend;r->debugger=CreateDebugProvider(*r,backend);if(!r->debugger){response=LocalToolResponse(MessageId(m),LocalToolFailure("debugger_provider_unavailable","Could not create selected debugger provider"),true);return true;}RecordEvent(s,"debugger.backend_selected",r,{{"backend",backend}});}response=LocalToolResponse(MessageId(m),{{"ok",true},{"backend",r->debuggerBackend},{"capabilities",CapabilityDocument(*r)}});return true;} if(name=="cortex_events"){auto ev=EventsJson(s,UnsignedArg(a,"since_id"),static_cast(std::clamp(UnsignedArg(a,"limit",128),1,512)),r);response=LocalToolResponse(MessageId(m),{{"ok",true},{"count",ev.size()},{"events",ev}});return true;} if(name=="cortex_hooks"){std::string e;json out;if(!r->payload->Ready())r->payload->TryConnectExisting(&e);if(!r->payload->Ready()||!r->payload->CallRouteExisting("GET","/diagnostics/hooks",json::object(),out,&e)){response=LocalToolResponse(MessageId(m),LocalToolFailure("hook_registry_unavailable",e.empty()?"runtime hook registry not connected":e),true);return true;}response=LocalToolResponse(MessageId(m),{{"ok",true},{"target",TargetJson(r->target,true)},{"hook_manager",out}});return true;} if(name=="cortex_crash_report"){std::string configured=ConfiguredCrashDirectory(s->runtimeDirectory);std::string e;auto bundle=cortex::services::CrashReportService::Latest(cortex::services::CrashReportService::DefaultRoots(s->runtimeDirectory,r->target.architecture,configured),r->target.processId,&e);json out={{"ok",e.empty()},{"found",bundle.found},{"target",TargetJson(r->target,true)},{"recent_events",EventsJson(s,0,64,r)}};if(bundle.found){out["directory"]=bundle.directory.u8string();out["report"]=bundle.report;out["symbolized"]=bundle.symbolized;out["hooks"]=bundle.hooks;out["breadcrumbs"]=bundle.breadcrumbs;}if(!e.empty())out["error"]=e;response=LocalToolResponse(MessageId(m),out,!e.empty());return true;} -if(name=="cortex_detach"){{std::lock_guardml(s->targetMutationMutex);if(r->debugger){std::lock_guarddl(r->debuggerMutex);r->debugger->Detach();}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&v){return v&&v->target.id==r->target.id&&v->target.generation==r->target.generation;}),s->runtimes.end());}}RecordEvent(s,"target.detached",r);response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","detached"},{"target",TargetJson(r->target,false)},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} +if(name=="cortex_detach"){{std::lock_guardml(s->targetMutationMutex);if(r->debugger){std::lock_guarddl(r->debuggerMutex);DetachLogged(r);}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&v){return v&&v->target.id==r->target.id&&v->target.generation==r->target.generation;}),s->runtimes.end());}}RecordEvent(s,"target.detached",r);response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","detached"},{"target",TargetJson(r->target,false)},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} response=LocalToolResponse(MessageId(m),LocalToolFailure("unknown_host_tool","Unknown Cortex host-control tool"),true);return true;} void AugmentTools(json&r,const RuntimeList&rs){if(!r.is_object()||!r.contains("result")||!r["result"].is_object()||!r["result"].contains("tools")||!r["result"]["tools"].is_array())return;bool req=rs.size()>1;json out=LocalTools(req);std::string d="Select target by PID/id/name. Available: "+TargetSummary(rs);for(auto tool:r["result"]["tools"]){if(!tool.is_object()){out.push_back(tool);continue;}auto&schema=tool["inputSchema"];if(!schema.is_object())schema={{"type","object"},{"properties",json::object()}};if(!schema.contains("properties")||!schema["properties"].is_object())schema["properties"]=json::object();schema["properties"]["_cortex_target"]={{"oneOf",json::array({{{"type","integer"}},{{"type","string"}}})},{"description",d}};schema["properties"]["_cortex_generation"]={{"type","integer"},{"minimum",1},{"description","Optional generation from cortex_targets; stale generations are rejected."}};schema["properties"]["_cortex_timeout_ms"]={{"type","integer"},{"minimum",100},{"maximum",120000},{"description","Optional cooperative operation timeout."}};if(req){if(!schema.contains("required")||!schema["required"].is_array())schema["required"]=json::array();if(std::find(schema["required"].begin(),schema["required"].end(),"_cortex_target")==schema["required"].end())schema["required"].push_back("_cortex_target");}tool["_cortex"]["target_routed"]=true;out.push_back(std::move(tool));}r["result"]["tools"]=std::move(out);} -bool ForwardOne(const std::shared_ptr&s,const json&m,json&r,bool&has,std::string*error){r=json();has=false;if(error)error->clear();if(PruneDeadRuntimes(s))EmitToolsChanged(s);auto rs=RuntimeSnapshot(s);if(!m.is_object()){if(rs.empty())return HandleLocalProtocol(s,m,r,has);return rs.front()->payload->ForwardMcp(m,s->toolProfile,r,has,error);}if(IsNotification(m)){std::string method=m.value("method",std::string());if(method=="notifications/initialized")RememberInitialized(s);if(method=="notifications/cancelled"&&m.contains("params")&&m["params"].is_object()&&m["params"].contains("requestId")){std::lock_guardl(s->operationRouteMutex);auto it=s->requestOperations.find(RequestKey(m["params"]["requestId"]));if(it!=s->requestOperations.end())s->operations.RequestCancel(it->second,nullptr);}if(rs.empty())return HandleLocalProtocol(s,m,r,has);std::string first;for(auto&runtime:rs){json rr;bool h=false;std::string e;if(runtime->payload->ForwardMcp(m,s->toolProfile,rr,h,&e)){if(!has&&h){r=rr;has=true;}}else if(first.empty())first=e;}if(!first.empty()&&!has){if(error)*error=first;return false;}return true;} +bool ForwardOne(const std::shared_ptr&s,const json&m,json&r,bool&has,std::string*error){r=json();has=false;if(error)error->clear();if(m.is_object()){const std::string mm=m.value("method",std::string());const bool note=IsNotification(m);const bool known=note?mm.rfind("notifications/",0)==0:(mm=="initialize"||mm=="server/discover"||mm=="ping"||mm=="tools/list"||mm=="tools/call");if(!known){cortex::security::RecordDenial("host",mm.empty()?std::string("?"):mm,"method_not_allowed",m.value("params",json::object()));if(!note){r={{"jsonrpc","2.0"},{"id",MessageId(m)},{"error",{{"code",-32601},{"message","method_not_found"}}}};has=true;}return true;}}if(PruneDeadRuntimes(s))EmitToolsChanged(s);auto rs=RuntimeSnapshot(s);if(!m.is_object()){if(rs.empty())return HandleLocalProtocol(s,m,r,has);return rs.front()->payload->ForwardMcp(m,s->toolProfile,r,has,error);}if(IsNotification(m)){std::string method=m.value("method",std::string());if(method=="notifications/initialized")RememberInitialized(s);if(method=="notifications/cancelled"&&m.contains("params")&&m["params"].is_object()&&m["params"].contains("requestId")){std::lock_guardl(s->operationRouteMutex);auto it=s->requestOperations.find(RequestKey(m["params"]["requestId"]));if(it!=s->requestOperations.end())s->operations.RequestCancel(it->second,nullptr);}if(rs.empty())return HandleLocalProtocol(s,m,r,has);std::string first;for(auto&runtime:rs){json rr;bool h=false;std::string e;if(runtime->payload->ForwardMcp(m,s->toolProfile,rr,h,&e)){if(!has&&h){r=rr;has=true;}}else if(first.empty())first=e;}if(!first.empty()&&!has){if(error)*error=first;return false;}return true;} std::string method=m.value("method",std::string());if(method=="initialize"){std::lock_guardml(s->targetMutationMutex);RememberInitialize(s,m);for(auto&runtime:RuntimeSnapshot(s)){json rr;bool h=false;std::string e;runtime->payload->ForwardMcp(m,s->toolProfile,rr,h,&e);}return HandleLocalProtocol(s,m,r,has);}if(method=="server/discover"||method=="ping")return HandleLocalProtocol(s,m,r,has);if(method=="tools/list"){if(rs.empty())return HandleLocalProtocol(s,m,r,has);std::string first;for(auto&runtime:rs){std::string e;if(runtime->payload->ForwardMcp(m,s->toolProfile,r,has,&e)){if(has)AugmentTools(r,rs);return true;}if(first.empty())first=e;}HandleLocalProtocol(s,m,r,has);if(has)r["result"]["_cortex"]={{"runtime_catalog_available",false},{"runtime_catalog_error",first}};return true;} if(method=="tools/call"){json p=m.value("params",json::object());std::string name=p.is_object()?p.value("name",std::string()):std::string();json a=p.is_object()?p.value("arguments",json::object()):json::object();if(!a.is_object()){r=TransportError(MessageId(m),"invalid_arguments","tools/call arguments must be object");has=true;return true;}if(HandleLocalTool(s,m,name,a,r)){has=true;return true;}std::string c,x;auto runtime=ResolveRuntime(RuntimeSnapshot(s),a,c,x);if(!runtime){if(c=="no_attached_targets")x+=". Use cortex_processes and cortex_attach.";r=TransportError(MessageId(m),c,x);has=true;return true;}if(!ValidateGeneration(runtime,a,c,x)){r=TransportError(MessageId(m),c,x);has=true;return true;}if(IsProviderDebugTool(name)&&HandleProviderDebug(runtime,m,name,a,r)){has=true;return true;}json routed=m;auto&ra=routed["params"]["arguments"];ra.erase("_cortex_target");ra.erase("_cortex_generation");ra.erase("_cortex_timeout_ms");if(!runtime->payload->ForwardMcp(routed,s->toolProfile,r,has,error))return false;if(has&&r.is_object()&&r.contains("result")&&r["result"].is_object()&&r["result"].contains("structuredContent")&&r["result"]["structuredContent"].is_object()){r["result"]["structuredContent"]["_cortex_target"]=runtime->target.id;r["result"]["structuredContent"]["_cortex_generation"]=runtime->target.generation;}return true;} if(rs.empty())return HandleLocalProtocol(s,m,r,has);return rs.front()->payload->ForwardMcp(m,s->toolProfile,r,has,error);} @@ -266,5 +304,5 @@ void Watchdog(const std::shared_ptr&s){while(s->watchdogRunning.load() } // namespace -int RunFullMcpHost(int argc,char**argv,const std::string&runtimeDirectory){Options options;std::string error;if(!ParseOptions(argc,argv,options,error)){std::cerr<<"cortex mcp: "<())){std::cerr<<"cortex mcp: local backend unavailable\n";return 3;}auto state=std::make_shared();state->catalog=&catalog;state->runtimeDirectory=runtimeDirectory;state->toolProfile=options.toolProfile;{std::ostringstream activityId;activityId<activitySessionId=activityId.str();}auto available=catalog.Targets();for(const auto&selector:options.targets){auto target=ResolveUniqueTarget(selector,available,error);if(!target){std::cerr<<"cortex mcp: "<runtimes.push_back(runtime);RecordEvent(state,"target.attached",runtime,{{"startup",true},{"debugger_backend",runtime->debuggerBackend}});}PublishActivity(state,{{"kind","session"},{"phase","started"},{"summary","AI/MCP session started"}});state->watchdogRunning=true;state->watchdog=std::thread([state]{Watchdog(state);});std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::string line;while(std::getline(std::cin,line)){if(line.empty())continue;if(line.size()>kMaxStdioMessageBytes){WriteOutput(state,TransportError(nullptr,"message_too_large","MCP stdio message exceeds 4 MiB"));continue;}json m;try{m=json::parse(line);}catch(const std::exception&e){WriteOutput(state,{{"jsonrpc","2.0"},{"id",nullptr},{"error",{{"code",-32700},{"message",e.what()}}}});continue;}if(IsNotification(m)){json r;bool has=false;std::string e;if(!ForwardOne(state,m,r,has,&e))std::cerr<<"cortex mcp: notification forwarding failed: "<watchdogRunning=false;if(state->watchdog.joinable())state->watchdog.join();for(auto&r:RuntimeSnapshot(state))if(r&&r->debugger){std::lock_guardl(r->debuggerMutex);r->debugger->Detach();}PublishActivity(state,{{"kind","session"},{"phase","ended"},{"summary","AI/MCP session ended"}});return 0;} +int RunFullMcpHost(int argc,char**argv,const std::string&runtimeDirectory){Options options;std::string error;if(!ParseOptions(argc,argv,options,error)){std::cerr<<"cortex mcp: "<())){std::cerr<<"cortex mcp: local backend unavailable\n";return 3;}auto state=std::make_shared();state->catalog=&catalog;state->runtimeDirectory=runtimeDirectory;state->toolProfile=options.toolProfile;state->allowWrites=options.allowWrites;g_runState=state;cortex::security::SetDenialDirectory(runtimeDirectory);{std::ostringstream activityId;activityId<activitySessionId=activityId.str();}auto available=catalog.Targets();for(const auto&selector:options.targets){auto target=ResolveUniqueTarget(selector,available,error);if(!target){std::cerr<<"cortex mcp: "<runtimes.push_back(runtime);RecordEvent(state,"target.attached",runtime,{{"startup",true},{"debugger_backend",runtime->debuggerBackend}});}PublishActivity(state,{{"kind","session"},{"phase","started"},{"summary","AI/MCP session started"}});state->watchdogRunning=true;state->watchdog=std::thread([state]{Watchdog(state);});std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::string line;while(std::getline(std::cin,line)){if(line.empty())continue;if(line.size()>kMaxStdioMessageBytes){WriteOutput(state,TransportError(nullptr,"message_too_large","MCP stdio message exceeds 4 MiB"));continue;}json m;try{m=json::parse(line);}catch(const std::exception&e){WriteOutput(state,{{"jsonrpc","2.0"},{"id",nullptr},{"error",{{"code",-32700},{"message",e.what()}}}});continue;}if(IsNotification(m)){json r;bool has=false;std::string e;if(!ForwardOne(state,m,r,has,&e))std::cerr<<"cortex mcp: notification forwarding failed: "<watchdogRunning=false;if(state->watchdog.joinable())state->watchdog.join();for(auto&r:RuntimeSnapshot(state))if(r&&r->debugger){std::lock_guardl(r->debuggerMutex);DetachLogged(r);}PublishActivity(state,{{"kind","session"},{"phase","ended"},{"summary","AI/MCP session ended"}});return 0;} diff --git a/mcp_bridge/main.cpp b/mcp_bridge/main.cpp index 473599fb..e56339bd 100644 --- a/mcp_bridge/main.cpp +++ b/mcp_bridge/main.cpp @@ -156,12 +156,30 @@ HANDLE OpenPipe(const std::string& pipeName, int attempts = 100) { return INVALID_HANDLE_VALUE; } +// Set once from the command line by the person who starts the bridge. An AI +// client cannot change it. +bool g_allowWrites = false; + +// `cortex/...` methods are Cortex's private desktop channel; they run any +// native route with no further check and are never forwarded for an MCP client. +bool RefusePrivateMethod(const json& message, std::string& response) { + if (!message.is_object()) return false; + const std::string method = message.value("method", std::string()); + if (method.rfind("cortex/", 0) != 0) return false; + response = message.contains("id") + ? json({{"jsonrpc", "2.0"}, {"id", message["id"]}, + {"error", {{"code", -32601}, {"message", "method_not_found"}}}}).dump() + : std::string(); + return true; +} + bool NativeRoundTrip(const std::string& pipeName, const std::string& token, const std::string& toolProfile, const std::string& session, const json& message, std::string& response) { + if (RefusePrivateMethod(message, response)) return true; HANDLE pipe = OpenPipe(pipeName, 100); if (pipe == INVALID_HANDLE_VALUE) return false; @@ -169,6 +187,7 @@ bool NativeRoundTrip(const std::string& pipeName, {"token", token}, {"tools", toolProfile}, {"session", session}, + {"writes_allowed", g_allowWrites}, {"message", message} }; const std::string payload = envelope.dump(); @@ -196,15 +215,17 @@ bool HttpRoundTrip(const std::string& host, const std::string& session, const json& message, std::string& response) { + if (RefusePrivateMethod(message, response)) return true; httplib::Client client(host.c_str(), port); client.set_connection_timeout(2, 0); client.set_read_timeout(60, 0); - const httplib::Headers headers = { + httplib::Headers headers = { {"X-Cortex-Token", token}, {"Host", host}, {"X-Cortex-MCP-Tools", toolProfile}, {"X-Cortex-MCP-Session", session} }; + if (g_allowWrites) headers.emplace("X-Cortex-MCP-Writes", "allowed"); const auto result = client.Post("/mcp", headers, message.dump(), "application/json"); if (!result) return false; if (result->status == 202 || result->body.empty()) response.clear(); @@ -564,6 +585,7 @@ int CortexMcpMain(int argc, char** argv) { else if ((argument == "--process" || argument == "--pid") && i + 1 < argc) processTarget = argv[++i]; else if (argument == "--dll" && i + 1 < argc) dllPath = argv[++i]; + else if (argument == "--allow-writes") g_allowWrites = true; else { std::cerr << "cortex_host mcp: unknown or incomplete argument: " << argument << '\n'; return 2; diff --git a/test_target/main.cpp b/test_target/main.cpp index e945da0f..4c4d7788 100644 --- a/test_target/main.cpp +++ b/test_target/main.cpp @@ -239,11 +239,57 @@ void WriteManifest(const E2EControl& control, HWND window) { << " \"crash_event\": \"" << EscapeJson(control.crashEventName) << "\",\n" << " \"hang_event\": \"" << EscapeJson(control.hangEventName) << "\",\n" << " \"stop_event\": \"" << EscapeJson(control.stopEventName) << "\",\n" - << " \"hw_thread_event\": \"" << EscapeJson(control.hwThreadEventName) << "\"\n" + << " \"hw_thread_event\": \"" << EscapeJson(control.hwThreadEventName) << "\",\n" + << " \"hash_file\": \"" << EscapeJson(control.manifestPath + ".hash") << "\"\n" << "}\n"; file.flush(); } +// State hash for tests that must show the target was not changed. It covers the +// code of this executable and the globals that nothing in the target writes; +// the counters and the health value change on their own and are left out. In +// E2E mode it is rewritten to ".hash" every 200 ms. +void HashBytes(uint64_t& hash, const void* data, size_t size) { + const unsigned char* bytes = static_cast(data); + for (size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= 1099511628211ull; + } +} + +uint64_t ComputeStateHash() { + uint64_t hash = 1469598103934665603ull; + const unsigned char* base = reinterpret_cast(GetModuleHandleA(nullptr)); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(nt); + for (unsigned i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++section) { + if (section->Characteristics & IMAGE_SCN_MEM_EXECUTE) + HashBytes(hash, base + section->VirtualAddress, section->Misc.VirtualSize); + } + HashBytes(hash, &g_cortex_u32, sizeof(g_cortex_u32)); + HashBytes(hash, &g_cortex_u64, sizeof(g_cortex_u64)); + HashBytes(hash, &g_cortex_float, sizeof(g_cortex_float)); + HashBytes(hash, &g_cortex_double, sizeof(g_cortex_double)); + HashBytes(hash, g_cortex_str, sizeof(g_cortex_str)); + HashBytes(hash, &g_cortex_fake_object, sizeof(g_cortex_fake_object)); + HashBytes(hash, g_cortex_fake_vtable_primary, sizeof(g_cortex_fake_vtable_primary)); + HashBytes(hash, g_cortex_fake_vtable_secondary, sizeof(g_cortex_fake_vtable_secondary)); + return hash; +} + +void WriteStateHash(const E2EControl& control) { + char text[40]{}; + std::snprintf(text, sizeof(text), "%016llx\n", static_cast(ComputeStateHash())); + const std::string path = control.manifestPath + ".hash"; + const std::string temp = path + ".tmp"; + { + std::ofstream file(temp, std::ios::binary | std::ios::trunc); + file << text; + } + MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING); +} + void CloseE2E(E2EControl& control) { if (control.crashEvent) CloseHandle(control.crashEvent); if (control.hangEvent) CloseHandle(control.hangEvent); @@ -303,6 +349,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int show) { if (!InitializeE2E(arguments, e2e)) return 3; WriteManifest(e2e, window); + ULONGLONG lastHashWrite = 0; const bool crashNull = HasArgument(arguments, "--crash-null"); uint32_t crashCountdown = crashNull ? 60 : 0; @@ -328,6 +375,10 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int show) { HANDLE writer = CreateThread(nullptr, 0, CortexHwWriterThread, nullptr, 0, nullptr); if (writer) CloseHandle(writer); } + if (e2e.enabled && GetTickCount64() - lastHashWrite >= 200) { + lastHashWrite = GetTickCount64(); + WriteStateHash(e2e); + } if (e2e.enabled && WaitForSingleObject(e2e.hangEvent, 0) == WAIT_OBJECT_0) { while (WaitForSingleObject(e2e.stopEvent, 100) == WAIT_TIMEOUT) {} CloseE2E(e2e); diff --git a/tests/app_models_tests.cpp b/tests/app_models_tests.cpp index 62b93eb3..51f52d29 100644 --- a/tests/app_models_tests.cpp +++ b/tests/app_models_tests.cpp @@ -54,8 +54,13 @@ class FakeTransport final : public cortex::services::RuntimeTransport { std::map toolResults; // tool -> route result std::function routeHandler; + bool writesAuthority = false; + std::vector authorityPerCall; // the authority in force when each tool was called + bool Ready() const override { return connected; } + void SetWritesAuthority(bool allowed) override { writesAuthority = allowed; } + bool TryConnectExisting(std::string* error) override { if (error) error->clear(); if (!loaded) { @@ -80,6 +85,7 @@ class FakeTransport final : public cortex::services::RuntimeTransport { bool CallTool(const std::string& name, const json& arguments, json& output, std::string* error) override { if (error) error->clear(); + authorityPerCall.push_back(writesAuthority); calls.push_back({name, arguments}); const auto found = toolResults.find(name); output = {{"status", 200}, @@ -370,6 +376,32 @@ nop 5 check(transport.calls.empty(), "write gate sends no tool call"); } + // The runtime enforces the person's switch on its side: every call carries + // the Writes allowed state as write authority, and `mutation_permission` + // on the call only declares intent. + { + FakeTransport transport; + transport.loaded = true; + WatchesModel watches(transport); + std::string error; + check(watches.AddWatch("0x1000", "i32", "hp", true, &error), "AddWatch with writes allowed succeeds"); + bool addCarried = false; + for (size_t i = 0; i < transport.calls.size(); ++i) + if (transport.calls[i].name == "watch_add") addCarried = transport.authorityPerCall[i]; + check(addCarried, "a call made with writes allowed carries write authority"); + const auto* call = transport.Last("watch_add"); + check(call && call->arguments.value("mutation_permission", false), + "the same call also declares mutation_permission"); + check(watches.Refresh(&error), "a read-only refresh works"); + transport.writesAuthority = true; + transport.calls.clear(); + transport.authorityPerCall.clear(); + WatchesModel readOnly(transport); + readOnly.Refresh(&error); + const bool refreshAuthority = !transport.authorityPerCall.empty() && transport.authorityPerCall.back(); + check(!refreshAuthority, "a call made with writes not allowed carries no write authority"); + } + // Read-only refresh never injects: it only connects to a loaded runtime. { FakeTransport transport; diff --git a/tests/fixtures/mcp_tool_risks.json b/tests/fixtures/mcp_tool_risks.json new file mode 100644 index 00000000..09e14996 --- /dev/null +++ b/tests/fixtures/mcp_tool_risks.json @@ -0,0 +1,1043 @@ +{ + "_comment": "Risk class of every primitive MCP tool, as shipped in cortex v1.0.0 plus the batch/prompt rules of the write-authority fix. Changing a line changes what an agent may do and invalidates benchmark results taken before the change.", + "tools": [ + { + "name": "actions_clear", + "method": "POST", + "path": "/actions/clear", + "risk": "control" + }, + { + "name": "actions_list", + "method": "GET", + "path": "/actions", + "risk": "observe" + }, + { + "name": "actions_rollback", + "method": "POST", + "path": "/actions/rollback", + "risk": "mutate" + }, + { + "name": "analysis_cfg", + "method": "POST", + "path": "/analysis/cfg", + "risk": "analyze" + }, + { + "name": "analysis_functions", + "method": "POST", + "path": "/analysis/functions", + "risk": "analyze" + }, + { + "name": "analysis_pe_headers", + "method": "GET", + "path": "/analysis/pe_headers", + "risk": "observe" + }, + { + "name": "analysis_scan_patches", + "method": "POST", + "path": "/analysis/scan_patches", + "risk": "analyze" + }, + { + "name": "analysis_structure", + "method": "POST", + "path": "/analysis/structure", + "risk": "analyze" + }, + { + "name": "analysis_vtable", + "method": "POST", + "path": "/analysis/vtable", + "risk": "analyze" + }, + { + "name": "analysis_xrefs", + "method": "POST", + "path": "/analysis/xrefs", + "risk": "analyze" + }, + { + "name": "batch_run", + "method": "POST", + "path": "/batch/run", + "risk": "analyze" + }, + { + "name": "call_function", + "method": "POST", + "path": "/call/function", + "risk": "native_call" + }, + { + "name": "call_game_thread_status", + "method": "GET", + "path": "/call/game-thread/status", + "risk": "observe" + }, + { + "name": "call_on_game_thread", + "method": "POST", + "path": "/call/game-thread", + "risk": "native_call" + }, + { + "name": "call_on_thread", + "method": "POST", + "path": "/call/thread", + "risk": "native_call" + }, + { + "name": "debug_breakpoint_add", + "method": "POST", + "path": "/debug/breakpoint", + "risk": "control" + }, + { + "name": "debug_breakpoint_delete", + "method": "DELETE", + "path": "/debug/breakpoint/{id}", + "risk": "control" + }, + { + "name": "debug_breakpoint_list", + "method": "GET", + "path": "/debug/breakpoint/list", + "risk": "observe" + }, + { + "name": "debug_breakpoint_log", + "method": "GET", + "path": "/debug/breakpoint/{id}/log", + "risk": "observe" + }, + { + "name": "debug_breakpoint_trigger_clear", + "method": "DELETE", + "path": "/debug/breakpoint/{id}/trigger", + "risk": "control" + }, + { + "name": "debug_breakpoint_trigger_set", + "method": "POST", + "path": "/debug/breakpoint/{id}/trigger", + "risk": "control" + }, + { + "name": "debug_continue", + "method": "POST", + "path": "/debug/continue", + "risk": "control" + }, + { + "name": "debug_pause", + "method": "POST", + "path": "/debug/pause", + "risk": "control" + }, + { + "name": "debug_paused", + "method": "GET", + "path": "/debug/paused", + "risk": "observe" + }, + { + "name": "debug_registers", + "method": "GET", + "path": "/debug/registers", + "risk": "observe" + }, + { + "name": "debug_stack", + "method": "GET", + "path": "/debug/stack", + "risk": "observe" + }, + { + "name": "debug_step", + "method": "POST", + "path": "/debug/step", + "risk": "control" + }, + { + "name": "debug_step_over", + "method": "POST", + "path": "/debug/step_over", + "risk": "control" + }, + { + "name": "debug_threads", + "method": "GET", + "path": "/debug/threads", + "risk": "observe" + }, + { + "name": "disasm", + "method": "GET", + "path": "/disasm", + "risk": "observe" + }, + { + "name": "dissect_delete", + "method": "DELETE", + "path": "/dissect/{id}", + "risk": "analyze" + }, + { + "name": "dissect_diff", + "method": "POST", + "path": "/dissect/diff", + "risk": "analyze" + }, + { + "name": "dissect_list", + "method": "GET", + "path": "/dissect/snapshots", + "risk": "observe" + }, + { + "name": "dissect_snapshot", + "method": "POST", + "path": "/dissect/snapshot", + "risk": "analyze" + }, + { + "name": "events", + "method": "GET", + "path": "/events", + "risk": "observe" + }, + { + "name": "freeze_add", + "method": "POST", + "path": "/freeze", + "risk": "mutate" + }, + { + "name": "freeze_delete", + "method": "DELETE", + "path": "/freeze/{id}", + "risk": "mutate" + }, + { + "name": "freeze_list", + "method": "GET", + "path": "/freeze/list", + "risk": "observe" + }, + { + "name": "ghidra_export", + "method": "POST", + "path": "/ghidra/export", + "risk": "analyze" + }, + { + "name": "ghidra_import", + "method": "POST", + "path": "/ghidra/import", + "risk": "control" + }, + { + "name": "ghidra_import_symbols", + "method": "POST", + "path": "/ghidra/import", + "risk": "control" + }, + { + "name": "health", + "method": "GET", + "path": "/health", + "risk": "observe" + }, + { + "name": "input_key", + "method": "POST", + "path": "/input/key", + "risk": "mutate" + }, + { + "name": "input_key_tap", + "method": "POST", + "path": "/input/key_tap", + "risk": "mutate" + }, + { + "name": "input_mouse_button", + "method": "POST", + "path": "/input/mouse_button", + "risk": "mutate" + }, + { + "name": "input_mouse_move", + "method": "POST", + "path": "/input/mouse_move", + "risk": "mutate" + }, + { + "name": "input_record_start", + "method": "POST", + "path": "/input/record/start", + "risk": "mutate" + }, + { + "name": "input_record_stop", + "method": "POST", + "path": "/input/record/stop", + "risk": "mutate" + }, + { + "name": "input_sequence", + "method": "POST", + "path": "/input/sequence", + "risk": "mutate" + }, + { + "name": "input_sequence_cancel", + "method": "DELETE", + "path": "/input/sequence/{id}", + "risk": "mutate" + }, + { + "name": "input_sequence_status", + "method": "GET", + "path": "/input/sequence/{id}", + "risk": "observe" + }, + { + "name": "input_text", + "method": "POST", + "path": "/input/text", + "risk": "mutate" + }, + { + "name": "lua_exec", + "method": "POST", + "path": "/lua/exec", + "risk": "mutate" + }, + { + "name": "lua_scripts", + "method": "GET", + "path": "/lua/scripts", + "risk": "observe" + }, + { + "name": "lua_scripts_delete", + "method": "DELETE", + "path": "/lua/scripts/{name}", + "risk": "mutate" + }, + { + "name": "lua_scripts_get", + "method": "GET", + "path": "/lua/scripts/{name}", + "risk": "observe" + }, + { + "name": "lua_scripts_run", + "method": "POST", + "path": "/lua/scripts/{name}/run", + "risk": "mutate" + }, + { + "name": "lua_scripts_save", + "method": "POST", + "path": "/lua/scripts", + "risk": "mutate" + }, + { + "name": "memory_dump_typed", + "method": "GET", + "path": "/memory/dump_typed", + "risk": "observe" + }, + { + "name": "memory_fill", + "method": "POST", + "path": "/memory/fill", + "risk": "mutate" + }, + { + "name": "memory_ownership", + "method": "GET", + "path": "/memory/ownership", + "risk": "observe" + }, + { + "name": "memory_read", + "method": "POST", + "path": "/memory/read", + "risk": "analyze" + }, + { + "name": "memory_read_batch", + "method": "POST", + "path": "/memory/read_batch", + "risk": "analyze" + }, + { + "name": "memory_regions", + "method": "GET", + "path": "/memory/regions", + "risk": "observe" + }, + { + "name": "memory_write", + "method": "POST", + "path": "/memory/write", + "risk": "mutate" + }, + { + "name": "memory_write_batch", + "method": "POST", + "path": "/memory/write_batch", + "risk": "mutate" + }, + { + "name": "modules", + "method": "GET", + "path": "/modules", + "risk": "observe" + }, + { + "name": "network_capture", + "method": "POST", + "path": "/network/capture", + "risk": "control" + }, + { + "name": "network_events", + "method": "GET", + "path": "/network/events", + "risk": "observe" + }, + { + "name": "ocr", + "method": "POST", + "path": "/ocr", + "risk": "analyze" + }, + { + "name": "openapi", + "method": "GET", + "path": "/openapi.json", + "risk": "observe" + }, + { + "name": "patch_alloc_cave", + "method": "POST", + "path": "/patch/alloc_cave", + "risk": "mutate" + }, + { + "name": "patch_assemble", + "method": "POST", + "path": "/patch/assemble", + "risk": "mutate" + }, + { + "name": "patch_detour", + "method": "POST", + "path": "/patch/detour", + "risk": "mutate" + }, + { + "name": "patch_list", + "method": "GET", + "path": "/patch/list", + "risk": "observe" + }, + { + "name": "patch_nop", + "method": "POST", + "path": "/patch/nop", + "risk": "mutate" + }, + { + "name": "patch_revert", + "method": "DELETE", + "path": "/patch/{id}", + "risk": "mutate" + }, + { + "name": "patch_trampoline", + "method": "POST", + "path": "/patch/trampoline", + "risk": "mutate" + }, + { + "name": "patch_write", + "method": "POST", + "path": "/patch/write", + "risk": "mutate" + }, + { + "name": "pointermap_capture", + "method": "POST", + "path": "/pointermap/capture", + "risk": "control" + }, + { + "name": "pointermap_delete", + "method": "DELETE", + "path": "/pointermap/{name}", + "risk": "control" + }, + { + "name": "pointermap_intersect", + "method": "POST", + "path": "/pointermap/intersect", + "risk": "analyze" + }, + { + "name": "pointermap_list", + "method": "GET", + "path": "/pointermap/list", + "risk": "observe" + }, + { + "name": "project_address_delete", + "method": "DELETE", + "path": "/project/address/{name}", + "risk": "control" + }, + { + "name": "project_address_set", + "method": "POST", + "path": "/project/address", + "risk": "control" + }, + { + "name": "project_get", + "method": "GET", + "path": "/project", + "risk": "observe" + }, + { + "name": "project_note_add", + "method": "POST", + "path": "/project/note", + "risk": "control" + }, + { + "name": "project_note_delete", + "method": "DELETE", + "path": "/project/note/{id}", + "risk": "control" + }, + { + "name": "project_pointer_path_delete", + "method": "DELETE", + "path": "/project/pointer_path/{name}", + "risk": "control" + }, + { + "name": "project_pointer_path_resolve", + "method": "GET", + "path": "/project/resolve/{name}", + "risk": "observe" + }, + { + "name": "project_pointer_path_set", + "method": "POST", + "path": "/project/pointer_path", + "risk": "control" + }, + { + "name": "prompt_status", + "method": "GET", + "path": "/prompt/{id}", + "risk": "control" + }, + { + "name": "prompt_timed_test", + "method": "POST", + "path": "/prompt/timed_test", + "risk": "control" + }, + { + "name": "prompt_value_change", + "method": "POST", + "path": "/prompt/value_change", + "risk": "control" + }, + { + "name": "re_checkpoint_create", + "method": "POST", + "path": "/re/checkpoint", + "risk": "control" + }, + { + "name": "re_checkpoint_delete", + "method": "DELETE", + "path": "/re/checkpoint/{id}", + "risk": "control" + }, + { + "name": "re_checkpoint_list", + "method": "GET", + "path": "/re/checkpoints", + "risk": "observe" + }, + { + "name": "re_checkpoint_rollback", + "method": "POST", + "path": "/re/checkpoint/{id}/rollback", + "risk": "control" + }, + { + "name": "re_cpp_subobjects", + "method": "POST", + "path": "/re/cpp/subobjects", + "risk": "analyze" + }, + { + "name": "re_experiment_run", + "method": "POST", + "path": "/re/experiment/run", + "risk": "control" + }, + { + "name": "re_find_last_writer", + "method": "POST", + "path": "/re/last-writer", + "risk": "control" + }, + { + "name": "re_object_compare", + "method": "POST", + "path": "/re/object/compare", + "risk": "analyze" + }, + { + "name": "re_object_delete", + "method": "DELETE", + "path": "/re/object/{id}", + "risk": "control" + }, + { + "name": "re_object_events", + "method": "GET", + "path": "/re/object/{id}/events", + "risk": "observe" + }, + { + "name": "re_object_get", + "method": "GET", + "path": "/re/object/{id}", + "risk": "observe" + }, + { + "name": "re_object_tracks", + "method": "GET", + "path": "/re/object/tracks", + "risk": "observe" + }, + { + "name": "re_session", + "method": "GET", + "path": "/re/session", + "risk": "observe" + }, + { + "name": "re_session_apply_breakpoints", + "method": "POST", + "path": "/re/session/apply-breakpoints", + "risk": "control" + }, + { + "name": "re_session_breakpoints", + "method": "POST", + "path": "/re/session/breakpoints", + "risk": "control" + }, + { + "name": "re_session_fact_delete", + "method": "DELETE", + "path": "/re/session/fact", + "risk": "control" + }, + { + "name": "re_session_fact_set", + "method": "POST", + "path": "/re/session/fact", + "risk": "control" + }, + { + "name": "re_test_run", + "method": "POST", + "path": "/re/test/run", + "risk": "control" + }, + { + "name": "re_trace_transition", + "method": "POST", + "path": "/re/transition/trace", + "risk": "control" + }, + { + "name": "re_track_object", + "method": "POST", + "path": "/re/object/track", + "risk": "control" + }, + { + "name": "scan_aob", + "method": "POST", + "path": "/scan/aob", + "risk": "analyze" + }, + { + "name": "scan_code_caves", + "method": "POST", + "path": "/scan/code_caves", + "risk": "analyze" + }, + { + "name": "scan_delete", + "method": "DELETE", + "path": "/scan/{scan_id}", + "risk": "analyze" + }, + { + "name": "scan_intersect", + "method": "POST", + "path": "/scan/intersect", + "risk": "analyze" + }, + { + "name": "scan_list", + "method": "GET", + "path": "/scan/list", + "risk": "observe" + }, + { + "name": "scan_new", + "method": "POST", + "path": "/scan/new", + "risk": "analyze" + }, + { + "name": "scan_next", + "method": "POST", + "path": "/scan/next", + "risk": "analyze" + }, + { + "name": "scan_pointer_path", + "method": "POST", + "path": "/scan/pointer_path", + "risk": "analyze" + }, + { + "name": "scan_pointers", + "method": "POST", + "path": "/scan/pointers", + "risk": "analyze" + }, + { + "name": "scan_results", + "method": "GET", + "path": "/scan/results/{scan_id}", + "risk": "observe" + }, + { + "name": "scan_strings", + "method": "POST", + "path": "/scan/strings", + "risk": "analyze" + }, + { + "name": "schema_validate", + "method": "GET", + "path": "/schema/validate", + "risk": "observe" + }, + { + "name": "screenshot", + "method": "GET", + "path": "/screenshot", + "risk": "observe" + }, + { + "name": "session_diff", + "method": "POST", + "path": "/session/diff", + "risk": "analyze" + }, + { + "name": "session_export", + "method": "POST", + "path": "/session/export", + "risk": "analyze" + }, + { + "name": "session_list", + "method": "GET", + "path": "/session/list", + "risk": "observe" + }, + { + "name": "snapshot_create", + "method": "POST", + "path": "/snapshot/create", + "risk": "analyze" + }, + { + "name": "snapshot_delete", + "method": "DELETE", + "path": "/snapshot/{id}", + "risk": "control" + }, + { + "name": "snapshot_diff", + "method": "POST", + "path": "/snapshot/diff", + "risk": "analyze" + }, + { + "name": "snapshot_last_change", + "method": "POST", + "path": "/snapshot/last_change", + "risk": "analyze" + }, + { + "name": "snapshot_list", + "method": "GET", + "path": "/snapshot/list", + "risk": "observe" + }, + { + "name": "snapshot_rewind", + "method": "POST", + "path": "/snapshot/{id}/rewind", + "risk": "mutate" + }, + { + "name": "status", + "method": "GET", + "path": "/status", + "risk": "observe" + }, + { + "name": "struct_define", + "method": "POST", + "path": "/struct/define", + "risk": "control" + }, + { + "name": "struct_delete", + "method": "DELETE", + "path": "/struct/{name}", + "risk": "control" + }, + { + "name": "struct_infer", + "method": "POST", + "path": "/struct/infer", + "risk": "analyze" + }, + { + "name": "struct_list", + "method": "GET", + "path": "/struct/list", + "risk": "observe" + }, + { + "name": "struct_read", + "method": "POST", + "path": "/struct/read", + "risk": "analyze" + }, + { + "name": "struct_write", + "method": "POST", + "path": "/struct/write", + "risk": "mutate" + }, + { + "name": "symbols_lookup", + "method": "GET", + "path": "/symbols/lookup", + "risk": "observe" + }, + { + "name": "symbols_module", + "method": "GET", + "path": "/symbols/module", + "risk": "observe" + }, + { + "name": "symbols_resolve", + "method": "GET", + "path": "/symbols/resolve", + "risk": "observe" + }, + { + "name": "tools", + "method": "GET", + "path": "/tools", + "risk": "observe" + }, + { + "name": "trace_callgraph", + "method": "GET", + "path": "/trace/{id}/callgraph", + "risk": "observe" + }, + { + "name": "trace_compare", + "method": "POST", + "path": "/trace/compare", + "risk": "analyze" + }, + { + "name": "trace_coverage", + "method": "GET", + "path": "/trace/{id}/coverage", + "risk": "observe" + }, + { + "name": "trace_delete", + "method": "DELETE", + "path": "/trace/{id}", + "risk": "control" + }, + { + "name": "trace_events", + "method": "GET", + "path": "/trace/{id}/events", + "risk": "observe" + }, + { + "name": "trace_list", + "method": "GET", + "path": "/trace/list", + "risk": "observe" + }, + { + "name": "trace_start", + "method": "POST", + "path": "/trace/start", + "risk": "control" + }, + { + "name": "trace_stop", + "method": "POST", + "path": "/trace/{id}/stop", + "risk": "control" + }, + { + "name": "watch_add", + "method": "POST", + "path": "/watch", + "risk": "control" + }, + { + "name": "watch_allocations", + "method": "POST", + "path": "/watch/allocations", + "risk": "control" + }, + { + "name": "watch_allocations_events", + "method": "GET", + "path": "/watch/allocations/events", + "risk": "observe" + }, + { + "name": "watch_allocations_events_snapshot", + "method": "GET", + "path": "/watch/allocations/events_snapshot", + "risk": "observe" + }, + { + "name": "watch_allocations_status", + "method": "GET", + "path": "/watch/allocations/status", + "risk": "observe" + }, + { + "name": "watch_delete", + "method": "DELETE", + "path": "/watch/{id}", + "risk": "control" + }, + { + "name": "watch_events", + "method": "GET", + "path": "/watch/events", + "risk": "observe" + }, + { + "name": "watch_list", + "method": "GET", + "path": "/watch/list", + "risk": "observe" + }, + { + "name": "watch_page_access", + "method": "POST", + "path": "/watch/page_access", + "risk": "control" + }, + { + "name": "watch_page_access_delete", + "method": "DELETE", + "path": "/watch/page_access/{id}", + "risk": "control" + }, + { + "name": "watch_page_access_events", + "method": "GET", + "path": "/watch/page_access/events", + "risk": "observe" + }, + { + "name": "watch_page_access_events_snapshot", + "method": "GET", + "path": "/watch/page_access/events_snapshot", + "risk": "observe" + }, + { + "name": "watch_page_access_list", + "method": "GET", + "path": "/watch/page_access/list", + "risk": "observe" + }, + { + "name": "window_focus", + "method": "POST", + "path": "/window/focus", + "risk": "control" + }, + { + "name": "window_get", + "method": "GET", + "path": "/window", + "risk": "observe" + }, + { + "name": "window_minimize", + "method": "POST", + "path": "/window/minimize", + "risk": "control" + }, + { + "name": "window_move", + "method": "POST", + "path": "/window/move", + "risk": "control" + }, + { + "name": "window_restore", + "method": "POST", + "path": "/window/restore", + "risk": "control" + } + ] +} \ No newline at end of file diff --git a/tests/mcp_authority_tests.cpp b/tests/mcp_authority_tests.cpp new file mode 100644 index 00000000..45fe4944 --- /dev/null +++ b/tests/mcp_authority_tests.cpp @@ -0,0 +1,167 @@ +// Unit tests for how Cortex decides that a tool call may change the target. +// Header-only pieces, so this builds and runs anywhere. + +#include "api/mcp_contract.h" +#include "security/denial_log.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +using api::mcp_contract::BatchReadOnlyOps; +using api::mcp_contract::BatchMutatingOps; +using api::mcp_contract::ClassifyBatchRun; +using api::mcp_contract::ClassifyTool; +using api::mcp_contract::EffectiveRisk; +using api::mcp_contract::RequiresMutationPermission; +using api::mcp_contract::ToolRisk; +using json = nlohmann::json; + +void TestBatchRun() { + // Read-only batch: no authority needed. + const json reads = {{"ops", json::array({{{"op", "memory_read"}, {"address", 1}}, {{"op", "disasm"}}})}}; + auto verdict = ClassifyBatchRun(reads); + CHECK(verdict.ok && !verdict.mutating); + CHECK(!RequiresMutationPermission(EffectiveRisk("batch_run", ToolRisk::Analyze, reads))); + + // Every mutating op the route knows is classified as a mutation. + for (const auto& op : BatchMutatingOps()) { + const json batch = {{"ops", json::array({{{"op", "memory_read"}}, {{"op", op}}})}}; + verdict = ClassifyBatchRun(batch); + CHECK(verdict.ok && verdict.mutating); + CHECK(EffectiveRisk("batch_run", ToolRisk::Analyze, batch) == ToolRisk::Mutate); + } + + // The mutation that used to slip through. + const json write = {{"ops", json::array({{{"op", "memory_write"}, {"address", 4096}, {"value", 1}}})}}; + CHECK(RequiresMutationPermission(EffectiveRisk("batch_run", ToolRisk::Analyze, write))); + + // Unknown operations are refused by default. + verdict = ClassifyBatchRun({{"ops", json::array({{{"op", "format_disk"}}})}}); + CHECK(!verdict.ok && verdict.error == "batch_op_not_allowlisted:format_disk"); + verdict = ClassifyBatchRun({{"ops", json::array({{{"op", "memory_read"}}, {{"op", "brand_new_op"}}})}}); + CHECK(!verdict.ok); + + // An op name has to be a literal string, not something resolved later. + verdict = ClassifyBatchRun({{"ops", json::array({{{"op", {{"$ref", 0}}}}})}}); + CHECK(!verdict.ok && verdict.error == "batch_op_not_literal"); + verdict = ClassifyBatchRun({{"ops", json::array({json::array()})}}); + CHECK(!verdict.ok); + + // Missing or malformed ops. + CHECK(!ClassifyBatchRun(json::object()).ok); + CHECK(!ClassifyBatchRun({{"ops", "memory_write"}}).ok); + CHECK(!ClassifyBatchRun({{"ops", {{"$from_step", 0}}}}).ok); + + // The two lists must not overlap. + for (const auto& op : BatchMutatingOps()) CHECK(BatchReadOnlyOps().count(op) == 0); +} + +void TestEffectiveRisk() { + // Suspending the target is a control operation, not an analysis. + CHECK(EffectiveRisk("scan_new", ToolRisk::Analyze, {{"pause_process", true}}) == ToolRisk::Control); + CHECK(EffectiveRisk("scan_next", ToolRisk::Analyze, {{"pause_process", true}}) == ToolRisk::Control); + CHECK(EffectiveRisk("scan_new", ToolRisk::Analyze, {{"pause_process", false}}) == ToolRisk::Analyze); + CHECK(EffectiveRisk("scan_new", ToolRisk::Analyze, json::object()) == ToolRisk::Analyze); + // A risk is only ever raised. + CHECK(EffectiveRisk("scan_new", ToolRisk::Mutate, {{"pause_process", true}}) == ToolRisk::Mutate); + CHECK(EffectiveRisk("struct_infer", ToolRisk::Analyze, {{"define", true}}) == ToolRisk::Control); + CHECK(EffectiveRisk("struct_infer", ToolRisk::Analyze, {{"define", false}}) == ToolRisk::Analyze); +} + +void TestPromptTools() { + for (const char* name : {"prompt_timed_test", "prompt_value_change", "prompt_status"}) { + CHECK(ClassifyTool(name, "POST", "/prompt/x") == ToolRisk::Control); + CHECK(ClassifyTool(name, "GET", "/prompt/1") == ToolRisk::Control); + } + // Reading memory is still observation. + CHECK(ClassifyTool("memory_read", "POST", "/memory/read") == ToolRisk::Analyze); + CHECK(ClassifyTool("status", "GET", "/status") == ToolRisk::Observe); +} + +// Every primitive tool keeps the class recorded in the fixture, and none is +// left without one. A tool no rule names is refused, never defaulted. +void TestEveryToolIsClassified(const std::string& fixturePath) { + std::ifstream file(fixturePath); + CHECK(file.good()); + if (!file.good()) return; + const json fixture = json::parse(file); + CHECK(fixture["tools"].size() > 150); + for (const auto& tool : fixture["tools"]) { + const std::string name = tool["name"]; + const auto risk = ClassifyTool(name, tool["method"], tool["path"]); + CHECK(risk != ToolRisk::Unclassified); + CHECK(std::string(api::mcp_contract::RiskName(risk)) == tool["risk"].get()); + } + // A route nobody classified is refused by default, with any method. + for (const char* method : {"POST", "PUT", "DELETE"}) { + CHECK(ClassifyTool("brand_new_route", method, "/brand/new") == ToolRisk::Unclassified); + CHECK(RequiresMutationPermission(ToolRisk::Unclassified)); + } + CHECK(std::string(api::mcp_contract::RiskName(ToolRisk::Unclassified)) == "unclassified"); +} + +void TestDenialLog() { + namespace fs = std::filesystem; + const fs::path directory = fs::temp_directory_path() / "cortex_denial_log_test"; + fs::remove_all(directory); + fs::create_directories(directory); + cortex::security::SetDenialDirectory(directory.string()); + + const auto before = cortex::security::DenialCount(); + cortex::security::RecordDenial("runtime", "memory_write", "write_authority_required", + {{"address", 4096}, {"value", 7}}); + cortex::security::RecordDenial("host", "cortex_attach", "write_authority_required", {{"pid", 42}}, + "test_candidate_causality"); + cortex::security::RecordDenial("runtime", "batch_run", "batch_op_not_allowlisted:x", + json(std::string(10000, 'a'))); + CHECK(cortex::security::DenialCount() == before + 3); + + std::ifstream file(directory / cortex::security::DenialLogName()); + std::string line; + int lines = 0; + bool sawTool = false, sawSemantic = false, sawTruncated = false; + while (std::getline(file, line)) { + ++lines; + const auto entry = json::parse(line); + CHECK(entry.contains("ts_ms") && entry.contains("ts_utc") && entry.contains("reason")); + sawTool = sawTool || (entry["tool"] == "memory_write" && entry["arguments"]["address"] == 4096); + sawSemantic = sawSemantic || entry.value("semantic_tool", std::string()) == "test_candidate_causality"; + sawTruncated = sawTruncated || entry.value("arguments_truncated", false); + } + CHECK(lines == 3 && sawTool && sawSemantic && sawTruncated); + cortex::security::SetDenialDirectory({}); + fs::remove_all(directory); +} + +} // namespace + +int main(int argc, char** argv) { + TestBatchRun(); + TestEffectiveRisk(); + TestPromptTools(); + TestEveryToolIsClassified(argc > 1 ? argv[1] : "tests/fixtures/mcp_tool_risks.json"); + TestDenialLog(); + if (failures) { + std::cerr << failures << " check(s) failed" << std::endl; + return 1; + } + std::cout << "mcp authority tests passed" << std::endl; + return 0; +} diff --git a/tests/run_security_regression_vm.ps1 b/tests/run_security_regression_vm.ps1 new file mode 100644 index 00000000..4d538978 --- /dev/null +++ b/tests/run_security_regression_vm.ps1 @@ -0,0 +1,56 @@ +<# +.SYNOPSIS + Replays the MCP write-authority regression test on a real Windows machine. + +.DESCRIPTION + Runs tests/security_no_writes_regression.py against an unpacked Cortex bundle + for the x64 and x86 test targets, WITHOUT any --skip-tool. If a tool such as + screenshot or debug_registers freezes the target on Windows, the test fails + and names the call: that is a Cortex bug to fix, not something to skip. + + Usage (from the repository root, on the VM): + powershell -ExecutionPolicy Bypass -File tests\run_security_regression_vm.ps1 -Bundle C:\Cortex + + -Bundle folder that holds cortex.exe, cortex_core.dll, runtime\ and e2e\ + -Out folder for the logs (default: .\security-regression-logs) + + Exit code 0 only if both architectures pass. The commit that was tested and + both logs are written to -Out so the run can be cited. +#> +param( + [Parameter(Mandatory = $true)][string]$Bundle, + [string]$Out = ".\security-regression-logs" +) + +$ErrorActionPreference = "Stop" +$bundlePath = (Resolve-Path $Bundle).Path +New-Item -ItemType Directory -Force -Path $Out | Out-Null +$Out = (Resolve-Path $Out).Path + +$commit = (git rev-parse HEAD 2>$null) +if (-not $commit) { $commit = "unknown" } +"commit: $commit" | Tee-Object -FilePath "$Out\summary.txt" +"bundle: $bundlePath" | Tee-Object -FilePath "$Out\summary.txt" -Append +Get-FileHash "$bundlePath\cortex.exe", "$bundlePath\cortex_core.dll" -Algorithm SHA256 | + ForEach-Object { "{0} {1}" -f $_.Hash, (Split-Path $_.Path -Leaf) } | + Tee-Object -FilePath "$Out\summary.txt" -Append + +$failed = $false +foreach ($arch in @("x64", "x86")) { + $target = "$bundlePath\e2e\cortex_test_target_$arch.exe" + if (-not (Test-Path $target)) { throw "missing $target" } + $log = "$Out\regression-$arch.log" + "== $arch ==" | Tee-Object -FilePath "$Out\summary.txt" -Append + # No --skip-tool: every tool listed by tools/list is called. + python tests\security_no_writes_regression.py --cortex "$bundlePath\cortex.exe" --target $target 2>&1 | + Tee-Object -FilePath $log + if ($LASTEXITCODE -ne 0) { + $failed = $true + "$arch FAILED (see $log)" | Tee-Object -FilePath "$Out\summary.txt" -Append + } else { + "$arch passed" | Tee-Object -FilePath "$Out\summary.txt" -Append + } +} + +if ($failed) { exit 1 } +"both architectures passed without --skip-tool" | Tee-Object -FilePath "$Out\summary.txt" -Append diff --git a/tests/security_no_writes_regression.py b/tests/security_no_writes_regression.py new file mode 100644 index 00000000..e4a8fab2 --- /dev/null +++ b/tests/security_no_writes_regression.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 +"""Regression test: without --allow-writes, no MCP call can change the target. + +It starts the test target, loads the runtime into it the way a person would +(`cortex.exe inject`), then starts `cortex.exe mcp --tools all` WITHOUT +--allow-writes and calls every tool the server lists with plausible arguments, +always including mutation_permission=true. The target publishes a hash of its +code and of the globals nothing in it writes; the hash must be identical before +and after. Controls show that the test can see a change when one is allowed. + + python tests/security_no_writes_regression.py \ + --cortex stage/Cortex/cortex.exe \ + --target stage/Cortex/e2e/cortex_test_target_x64.exe + +Exit code 0 = pass. Anything else prints what went wrong. +""" + +import argparse +import json +import os +import shlex +import subprocess +import sys +import tempfile +import threading +import time + +DENIAL_REASONS_PREFIXES = ( + "mutation_permission_required", + "write_authority_required", + "batch_op_", + "batch_ops_required", + "method_not_allowed", +) + + +class Mcp: + """Minimal MCP stdio client (newline-delimited JSON-RPC).""" + + def __init__(self, command, cwd): + self.proc = subprocess.Popen( + command, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, bufsize=1) + self.stderr = [] + threading.Thread(target=lambda: [self.stderr.append(l) for l in self.proc.stderr], daemon=True).start() + self.next_id = 0 + + def request(self, method, params=None, timeout=60): + self.next_id += 1 + message = {"jsonrpc": "2.0", "id": self.next_id, "method": method} + if params is not None: + message["params"] = params + self.proc.stdin.write(json.dumps(message) + "\n") + self.proc.stdin.flush() + result = {} + + def read(): + while True: + line = self.proc.stdout.readline() + if not line: + return + try: + reply = json.loads(line) + except ValueError: + continue + if reply.get("id") == message["id"]: + result["reply"] = reply + return + + reader = threading.Thread(target=read, daemon=True) + reader.start() + reader.join(timeout) + return result.get("reply") + + def notify(self, method): + self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n") + self.proc.stdin.flush() + + def initialize(self): + reply = self.request("initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "security-regression", "version": "1"}}) + self.notify("notifications/initialized") + return reply + + def call(self, name, arguments, timeout=60): + reply = self.request("tools/call", {"name": name, "arguments": arguments}, timeout) + if reply is None: + return {"_transport": "no_reply"} + if "error" in reply: + return {"_rpc_error": reply["error"]} + return reply.get("result", {}).get("structuredContent", reply.get("result", {})) + + def close(self): + try: + self.proc.stdin.close() + self.proc.wait(timeout=15) + except Exception: + self.proc.kill() + + +def error_code(result): + """Best-effort extraction of the stable error code of a tool result.""" + if not isinstance(result, dict): + return None + if "_rpc_error" in result: + data = result["_rpc_error"] + return (data.get("data") or {}).get("code") or data.get("message") + for container in (result, result.get("result") if isinstance(result.get("result"), dict) else {}): + if isinstance(container.get("error"), str): + return container["error"] + return None + + +def is_denial(code): + return bool(code) and str(code).startswith(DENIAL_REASONS_PREFIXES) + + +class Context: + def __init__(self, manifest): + self.pid = manifest["pid"] + self.address = manifest["u32"] + self.object = manifest["fake_object"] + self.native_add = manifest["native_add"] + self.thread = manifest.get("main_thread_id", 1) + self.health = manifest["health"] + + +ADDRESS_NAMES = ("address", "addr", "start", "begin", "target", "base", "from", "ptr", "pointer", + "object", "site", "location") + + +def plausible(schema, name, ctx, depth=0): + if depth > 6 or not isinstance(schema, dict): + return 1 + if schema.get("oneOf"): + options = schema["oneOf"] + wanted = "string" if (name.lower() in ADDRESS_NAMES or name.lower().endswith("address")) else None + for option in options: + if option.get("type") == wanted: + return plausible(option, name, ctx, depth) + return plausible(options[0], name, ctx, depth) + kind = schema.get("type") + lowered = name.lower() + if kind == "object": + out = {} + required = schema.get("required", []) + for key, sub in (schema.get("properties") or {}).items(): + if key.startswith("_cortex"): + continue + if key in required or key in ("mutation_permission",): + out[key] = plausible(sub, key, ctx, depth + 1) + return out + if kind == "array": + if lowered == "ops": + return [{"op": "memory_write", "address": ctx.address, "type": "u32", "value": 7}] + if lowered == "steps": + return [] + if lowered in ("bytes", "values"): + return [144, 144] + return [] + if kind == "boolean": + return True + if kind in ("integer", "number"): + if lowered in ("pid", "process_id"): + return int(ctx.pid) + if lowered in ("thread_id", "tid"): + return int(ctx.thread) + if lowered in ("size", "count", "length", "bytes", "limit", "width", "len"): + return 4 + if "timeout" in lowered or lowered.endswith("_ms"): + return 1000 + if lowered in ("id", "scan_id", "snapshot_id", "a", "b"): + return 1 + return 1 + if kind == "string" or kind is None: + if lowered in ADDRESS_NAMES or lowered.endswith("address"): + return ctx.address + if lowered == "end": + return hex(int(ctx.address, 16) + 0x1000) + if lowered in ("type", "value_type", "data_type", "scan_type"): + return "u32" + if lowered == "kind": + return "hw_write" + if lowered in ("value", "new_value"): + return "1" + if lowered in ("code", "script", "source", "lua", "expression", "text"): + return "writeInteger(%s, 1)" % ctx.address + if lowered in ("asm", "assembly", "instructions"): + return "nop" + if lowered in ("pattern", "signature", "hex", "data"): + return "90 90" + if lowered in ("module", "module_name"): + return "cortex_test_target_x64.exe" + if lowered in ("backend",): + return "veh" + if lowered in ("objective",): + return "write a value into the target" + if lowered in ("method",): + return "POST" + if lowered in ("path",): + return "/memory/write" + if lowered in ("process", "name", "label"): + return "cortex_test_target_x64.exe" if lowered == "process" else "x" + return "x" + return 1 + + +def build_arguments(tool, ctx): + """Plausible arguments for one tool, always claiming mutation intent.""" + arguments = plausible(tool.get("inputSchema", {}), tool["name"], ctx) + if not isinstance(arguments, dict): + arguments = {} + props = (tool.get("inputSchema") or {}).get("properties") or {} + if "mutation_permission" in props: + arguments["mutation_permission"] = True + if tool["name"] == "cortex_attach": + arguments = {"pid": int(ctx.pid)} + if tool["name"] == "cortex_debugger_backend": + arguments = {"backend": "veh"} + return arguments + + +def semantic_calls(tool, primitives_by_name, ctx): + """For a semantic tool: one execution per allowed primitive, mutating or not.""" + calls = [] + for primitive in tool.get("_primitives", []): + manifest = primitives_by_name.get(primitive) + if not manifest: + continue # nested semantic tool: rejected by design + step_arguments = build_arguments(manifest, ctx) + calls.append((primitive, { + "objective": "write a value into the target", + "execute": True, + "mutation_permission": True, + "steps": [{"tool": primitive, "arguments": step_arguments}], + })) + return calls + + +def read_hash(path, timeout=10.0): + """The target rewrites the hash file every 200 ms; wait for a fresh one.""" + deadline = time.time() + timeout + last_mtime = os.path.getmtime(path) if os.path.exists(path) else 0 + while time.time() < deadline: + time.sleep(0.45) + if os.path.exists(path) and os.path.getmtime(path) > last_mtime: + with open(path) as handle: + return handle.read().strip() + raise RuntimeError("the target did not publish a state hash: " + path) + + +def start_target(runner, target, workdir, tag): + manifest_path = os.path.join(workdir, "manifest-%s.json" % tag) + process = subprocess.Popen(runner + [target, "--e2e-manifest", manifest_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + 30 + while time.time() < deadline: + if os.path.exists(manifest_path): + try: + with open(manifest_path) as handle: + manifest = json.load(handle) + if manifest.get("hash_file"): + return process, manifest, manifest_path + except ValueError: + pass + time.sleep(0.2) + process.kill() + raise RuntimeError("target manifest never appeared") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--runner", default="", help="command prefix, e.g. wine") + parser.add_argument("--skip-tool", action="append", default=[], + help="do not call this tool (for hosts whose debugger backend is broken, e.g. Wine)") + parser.add_argument("--expect-tools", type=int, default=0, + help="fail unless tools/list has exactly this many tools (0 = just report)") + parser.add_argument("--dump-tools", default="", + help="write name/method/path/risk of every listed tool to this JSON file, then continue") + arguments = parser.parse_args() + + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + target = os.path.abspath(arguments.target) + bundle = os.path.dirname(cortex) + failures = [] + workdir = tempfile.mkdtemp(prefix="cortex-sec-") + + def check(condition, message): + print(("ok " if condition else "FAIL ") + message) + if not condition: + failures.append(message) + + # Denial logs from earlier runs would hide a missing log. + for root, _, files in os.walk(bundle): + for name in files: + if name in ("cortex_denied_mutations.jsonl", "cortex_debugger_events.jsonl"): + os.remove(os.path.join(root, name)) + + # ------------------------------------------------------------ scenario 1 + process, manifest, manifest_path = start_target(runner, target, workdir, "sweep") + ctx = Context(manifest) + hash_file = manifest["hash_file"] + server = None + try: + inject = subprocess.run(runner + [cortex, "inject", str(ctx.pid)], cwd=bundle, + capture_output=True, text=True, timeout=120) + check(inject.returncode == 0, "the person loads the runtime beforehand (cortex.exe inject): " + + (inject.stdout + inject.stderr).strip()[-200:]) + + server = Mcp(runner + [cortex, "mcp", "--pid", str(ctx.pid), "--tools", "all"], bundle) + init = server.initialize() + check(bool(init and init.get("result")), "server initializes without --allow-writes") + listing = server.request("tools/list", {}) + tools = (listing or {}).get("result", {}).get("tools", []) + check(len(tools) > 0, "tools/list returns tools (%d)" % len(tools)) + if arguments.dump_tools: + with open(arguments.dump_tools, "w") as dump: + json.dump([{"name": t.get("name"), "risk": (t.get("_cortex") or {}).get("risk"), + "method": (t.get("_http") or {}).get("method"), + "path": (t.get("_http") or {}).get("path")} for t in tools], dump, indent=1) + if arguments.expect_tools: + check(len(tools) == arguments.expect_tools, + "tools/list has exactly %d tools (found %d)" % (arguments.expect_tools, len(tools))) + + # Every listed tool must carry a risk class. A tool nobody classified is + # refused by the runtime, so it must never appear as a usable tool. + known_risks = ("observe", "analyze", "control", "mutate", "native_call") + unclassified = [] + for tool in tools: + meta = tool.get("_cortex") or {} + if tool.get("_http"): + ok = meta.get("risk") in known_risks + elif tool.get("_semantic"): + ok = True # its steps are classified one by one as primitives + else: + ok = bool(meta.get("host_control") or meta.get("read_only")) + if not ok: + unclassified.append("%s (%s)" % (tool.get("name"), meta.get("risk"))) + check(not unclassified, "every listed tool has a risk classification" + + ("" if not unclassified else ": " + ", ".join(unclassified))) + tools = [t for t in tools if (t.get("_cortex") or {}).get("risk") != "unclassified"] + + before = read_hash(hash_file) + print("state hash before:", before) + + died = [] + progress = {"mtime": os.path.getmtime(hash_file), "at": time.time(), "recent": []} + + def alive(label): + """Name the call during which the target stopped or froze, if one did.""" + if died: + return False + progress["recent"] = (progress["recent"] + [label])[-4:] + reason = None + if process.poll() is not None: + reason = "exited" + else: + modified = os.path.getmtime(hash_file) + if modified > progress["mtime"]: + progress["mtime"], progress["at"] = modified, time.time() + elif time.time() - progress["at"] > 1.0: + # A stall is not a change of state; only a target that does not come back is a problem. + deadline = time.time() + 6.0 + while time.time() < deadline and os.path.getmtime(hash_file) <= progress["mtime"]: + time.sleep(0.2) + if os.path.getmtime(hash_file) > progress["mtime"]: + progress["mtime"], progress["at"] = os.path.getmtime(hash_file), time.time() + print("warn the target stalled for over a second near: " + " / ".join(progress["recent"])) + else: + reason = "stopped responding" + if reason: + died.append(label) + shown = " / ".join(progress["recent"]) + print("FAIL the target %s during: %s" % (reason, shown)) + failures.append("the target %s during: %s" % (reason, shown)) + return not died + + semantic = [t for t in tools if t.get("_semantic")] + primitives = {t["name"]: t for t in tools if not t.get("_semantic") and not t["name"].startswith("cortex_")} + calls = 0 + denied = 0 + not_denied_but_mutating = [] + for tool in tools: + name = tool["name"] + if name in ("cortex_detach",): + continue # last, below + if name in arguments.skip_tool: + print("skip " + name) + continue + if tool.get("_semantic"): + for primitive, call_arguments in semantic_calls(tool, primitives, ctx): + if primitive in arguments.skip_tool: + continue + result = server.call(name, call_arguments, timeout=60) + calls += 1 + if not alive("%s (step %s)" % (name, primitive)): + break + code = result.get("error") if isinstance(result, dict) else None + risk = (primitives[primitive].get("_cortex") or {}).get("risk") + mutating = risk in ("control", "mutate", "native_call") + if is_denial(code) or (isinstance(code, str) and code.startswith("primitive_lacks_safe_rollback")): + denied += 1 + elif mutating: + not_denied_but_mutating.append("%s via %s -> %s" % (primitive, name, code or result.get("status"))) + continue + result = server.call(name, build_arguments(tool, ctx), timeout=60) + calls += 1 + if not alive(name): + break + code = error_code(result) + meta = tool.get("_cortex") or {} + if is_denial(code): + denied += 1 + elif meta.get("mutation_permission_required") and not name.startswith("cortex_"): + not_denied_but_mutating.append("%s -> %s" % (name, code or "succeeded")) + + # The two classic bypasses, spelled out. + for label, call in ( + ("batch_run with a write", ("batch_run", {"ops": [{"op": "memory_write", "address": ctx.address, + "type": "u32", "value": 305419896}], + "mutation_permission": True})), + ("batch_run with an unknown op", ("batch_run", {"ops": [{"op": "format_disk"}], + "mutation_permission": True})), + ("batch_run with a computed op name", ("batch_run", {"ops": [{"op": {"$ref": 0}}], + "mutation_permission": True})), + ("semantic batch_run write", ("test_candidate_causality", { + "objective": "x", "execute": True, "mutation_permission": True, + "steps": [{"tool": "batch_run", "arguments": {"transactional": True, "ops": [ + {"op": "memory_write", "address": ctx.address, "type": "u32", "value": 305419896}]}}]})), + ): + result = server.call(*call) + calls += 1 + alive(label) + code = error_code(result) + check(is_denial(code), "%s is refused (%s)" % (label, code)) + + # A raw client message on Cortex's private channel is not forwarded. + raw = server.request("cortex/private/route", {"method": "POST", "path": "/memory/write", + "body": {"address": ctx.address, "type": "u32", "value": 1}}) + alive("cortex/private/route") + check(bool(raw) and "error" in raw and raw["error"].get("code") == -32601, + "cortex/private/route from an MCP client is refused") + + check(not not_denied_but_mutating, + "every tool that needs authority was refused: " + "; ".join(not_denied_but_mutating[:8])) + print("calls made: %d, refused: %d" % (calls, denied)) + + # The hash is read BEFORE the last tool: detaching a debugger is not a + # change to the target's code or data, and on some hosts (Wine) it + # leaves the target stopped. + check(process.poll() is None, "the target is still running") + if process.poll() is None: + after = read_hash(hash_file) + print("state hash after: ", after) + check(before == after, "state hash unchanged after the sweep (%s -> %s)" % (before, after)) + + server.call("cortex_detach", {"_cortex_target": str(ctx.pid)}, timeout=30) + time.sleep(2.5) + check(process.poll() is None, "the target survives cortex_detach") + modified = os.path.getmtime(hash_file) + time.sleep(1.0) + if os.path.getmtime(hash_file) == modified and process.poll() is None: + print("warn the target stopped publishing after cortex_detach (a debugger detach that leaves " + "the target stopped; seen under Wine, not a change to its state)") + finally: + if server: + server.close() + + # Refusals were logged. + logs = [] + for root, _, files in os.walk(bundle): + for name in files: + if name == "cortex_denied_mutations.jsonl": + logs.append(os.path.join(root, name)) + entries = [] + for path in logs: + with open(path) as handle: + entries += [json.loads(line) for line in handle if line.strip()] + check(len(entries) >= 20, "refusals are logged (%d entries in %d file(s))" % (len(entries), len(logs))) + check(all({"ts_ms", "tool", "reason", "source"} <= set(entry) for entry in entries), + "every log entry has tool, reason, source and a timestamp") + check(any(entry["source"] == "runtime" for entry in entries) and + any(entry["source"] == "host" for entry in entries), + "both the runtime and the host record refusals") + + # The debug_* read tools attach a debugger; that is journaled. + phases = [] + for root, _, files in os.walk(bundle): + for name in files: + if name == "cortex_debugger_events.jsonl": + with open(os.path.join(root, name)) as handle: + phases += [json.loads(line).get("phase") for line in handle if line.strip()] + check("attached" in phases and "detached" in phases, + "debugger attach and detach are journaled (%s)" % ", ".join(sorted(set(p for p in phases if p)))) + + # ------------------------------------------------------------ control A: --allow-writes works, and still needs intent + # A fresh target, so nothing left over from the sweep can matter. + process, manifest, manifest_path = start_target(runner, target, workdir, "writes") + try: + ctx = Context(manifest) + hash_file = manifest["hash_file"] + subprocess.run(runner + [cortex, "inject", str(ctx.pid)], cwd=bundle, capture_output=True, + text=True, timeout=120) + server = Mcp(runner + [cortex, "mcp", "--pid", str(ctx.pid), "--tools", "all", "--allow-writes"], bundle) + try: + server.initialize() + baseline = read_hash(hash_file) + result = server.call("memory_write", {"address": ctx.address, "type": "u32", "value": 305419896}) + check(error_code(result) == "mutation_permission_required", + "with --allow-writes a call without mutation_permission is still refused") + check(read_hash(hash_file) == baseline, "a refused write leaves the hash unchanged") + result = server.call("memory_write", {"address": ctx.address, "type": "u32", "value": 305419896, + "mutation_permission": True}) + check(error_code(result) is None, "with --allow-writes and mutation_permission the write is applied") + check(read_hash(hash_file) != baseline, + "control: the state hash does change when a write is allowed (the test can see a change)") + batch = server.call("batch_run", {"ops": [{"op": "memory_write", "address": ctx.address, + "type": "u32", "value": 7}], "mutation_permission": True}) + check(error_code(batch) is None, "with --allow-writes batch_run applies an allowlisted write") + finally: + server.close() + finally: + process.kill() + + # ------------------------------------------------------------ control B: attaching without authority does not inject + process, manifest, manifest_path = start_target(runner, target, workdir, "attach") + try: + ctx = Context(manifest) + server = Mcp(runner + [cortex, "mcp", "--tools", "all"], bundle) + try: + server.initialize() + result = server.call("cortex_attach", {"pid": int(ctx.pid)}, timeout=60) + code = error_code(result) + text = json.dumps(result) + check(code == "write_authority_required" or "write_authority_required" in text, + "cortex_attach without --allow-writes refuses to load the runtime (%s)" % (code,)) + finally: + server.close() + tokens = [n for n in os.listdir(bundle) if n.startswith("cortex.mcp.%s" % ctx.pid)] + runtime_dirs = [] + for root, _, files in os.walk(bundle): + runtime_dirs += [f for f in files if f.startswith("cortex.mcp.%s." % ctx.pid)] + check(not tokens and not runtime_dirs, "no runtime was loaded into the target (no token file)") + finally: + process.kill() + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nsecurity regression passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/semantic_plan_p1_tests.cpp b/tests/semantic_plan_p1_tests.cpp index fa2e1bce..5edc47be 100644 --- a/tests/semantic_plan_p1_tests.cpp +++ b/tests/semantic_plan_p1_tests.cpp @@ -42,19 +42,9 @@ int main() { Check(policy.value("rollback_required_for_mutations", false), "future mutations require rollback"); Check(policy.value("evidence_required_for_confirmation", false), "confirmation requires evidence"); - const auto evidenceModel = first.value("evidence_model", json::object()); - Check(evidenceModel.value("confidence_source", std::string()) == "evidence_only", - "confidence source is explicitly evidence-based"); - - const double emptyConfidence = first.value("evidence_confidence", -1.0); - const json observed = api::semantic::PlanFor( - "capture_runtime_state", - {{"objective", "Observe a labelled transition"}, - {"observations", json::array({{{"label", "before"}}, {{"label", "after"}}})}}); - Check(observed.value("evidence_confidence", -1.0) > emptyConfidence, - "additional observations raise evidence confidence"); - Check(observed.value("evidence_confidence", 2.0) <= 0.90, - "evidence confidence is capped below certainty"); + // Cortex computes no confidence, so none is reported. + Check(!first.contains("confidence") && !first.contains("evidence_confidence"), + "the plan carries no confidence it does not compute"); const json execute = api::semantic::PlanFor( "capture_runtime_state", {{"objective", "Observe"}, {"execute", true}}); diff --git a/tests/semantic_tools_tests.cpp b/tests/semantic_tools_tests.cpp index 85b4c7d6..19e796b8 100644 --- a/tests/semantic_tools_tests.cpp +++ b/tests/semantic_tools_tests.cpp @@ -197,7 +197,15 @@ int main(int argc, char** argv) { Check(arguments == originalArguments, name + ": PlanFor must not mutate input"); Check(plan.value("status", std::string()) == "plan_ready", name + ": planning must return plan_ready"); - Check(plan.value("confidence", 0.0) == 1.0, name + ": planning confidence must be 1.0"); + // Cortex computes no confidence, evidence score or hypothesis list; a constant + // would mislead the agent reading it. + Check(!plan.contains("confidence") && !plan.contains("evidence_confidence"), + name + ": the plan must not carry a confidence it does not compute"); + Check(!plan["result_contract"].contains("confidence") && + !plan["result_contract"].contains("candidates") && + !plan["result_contract"].contains("alternative_hypotheses") && + !plan["result_contract"].contains("tested_hypotheses"), + name + ": the result contract must not promise fields that are never filled"); Check(plan.value("primitive_sequence", json::array()) == tool.at("_primitives"), name + ": primitive sequence differs from catalog declaration"); Check(plan["execution_policy"].value("server_side_execution", false), @@ -207,7 +215,7 @@ int main(int argc, char** argv) { Check(plan["execution_policy"].value("rollback_required_for_mutations", false), name + ": mutation rollback policy missing"); Check(plan["result_contract"].value("status", std::string()) == - "candidate_found|confirmed|not_found|inconclusive|failed|cancelled|timed_out", + "plan_ready|completed|failed|cancelled|timed_out", name + ": result status vocabulary changed unexpectedly"); json executeArguments = arguments; From 935569e959142f96b939414daa86d3bb62c681ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:48:39 +0000 Subject: [PATCH 02/12] test: fix two CI failures caused by the write-authority change - The native authority test removed its log directory while its own ifstream was still open; Windows refuses that. Close the file first. - The GUI prompt test started `cortex.exe mcp` without --allow-writes and called prompt_value_change without mutation_permission. Prompt tools are control operations now, so the suite (and the imgui-ui workflow) pass both, as a person configuring a client would. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/imgui-ui.yml | 3 ++- tests/gui/run_imgui_gui_suite.ps1 | 5 ++++- tests/mcp_authority_tests.cpp | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/imgui-ui.yml b/.github/workflows/imgui-ui.yml index 0de0899e..0eca7ac6 100644 --- a/.github/workflows/imgui-ui.yml +++ b/.github/workflows/imgui-ui.yml @@ -220,7 +220,7 @@ jobs: $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true - foreach ($arg in @("mcp","--transport","native","--tools","all","--token-file",$tokenPath)) { + foreach ($arg in @("mcp","--transport","native","--tools","all","--token-file",$tokenPath,"--allow-writes")) { [void]$psi.ArgumentList.Add($arg) } $mcp = [System.Diagnostics.Process]::Start($psi) @@ -246,6 +246,7 @@ jobs: label = "ImGui prompt E2E" current_value = "before" target_value = "after" + mutation_permission = $true } } } | ConvertTo-Json -Compress -Depth 8 diff --git a/tests/gui/run_imgui_gui_suite.ps1 b/tests/gui/run_imgui_gui_suite.ps1 index 58ec710a..730f60fe 100644 --- a/tests/gui/run_imgui_gui_suite.ps1 +++ b/tests/gui/run_imgui_gui_suite.ps1 @@ -167,7 +167,9 @@ try { $psi.CreateNoWindow = $true foreach ($argument in @( "mcp", "--transport", "native", "--tools", "all", - "--token-file", $tokenPath + "--token-file", $tokenPath, + # A prompt puts a request in front of the person: a control operation. + "--allow-writes" )) { [void]$psi.ArgumentList.Add($argument) } @@ -207,6 +209,7 @@ try { label = "GUI full-suite prompt" current_value = "before" target_value = "after" + mutation_permission = $true } } } | ConvertTo-Json -Compress -Depth 8 diff --git a/tests/mcp_authority_tests.cpp b/tests/mcp_authority_tests.cpp index 45fe4944..9e5ef5d0 100644 --- a/tests/mcp_authority_tests.cpp +++ b/tests/mcp_authority_tests.cpp @@ -133,9 +133,10 @@ void TestDenialLog() { json(std::string(10000, 'a'))); CHECK(cortex::security::DenialCount() == before + 3); + int lines = 0; + { std::ifstream file(directory / cortex::security::DenialLogName()); std::string line; - int lines = 0; bool sawTool = false, sawSemantic = false, sawTruncated = false; while (std::getline(file, line)) { ++lines; @@ -145,6 +146,7 @@ void TestDenialLog() { sawSemantic = sawSemantic || entry.value("semantic_tool", std::string()) == "test_candidate_causality"; sawTruncated = sawTruncated || entry.value("arguments_truncated", false); } + } // close the file before removing it (Windows refuses to delete an open file) CHECK(lines == 3 && sawTool && sawSemantic && sawTruncated); cortex::security::SetDenialDirectory({}); fs::remove_all(directory); From dc86f18b720717dc130bc6857d9fbcf8bdd366fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:00:08 +0000 Subject: [PATCH 03/12] docs, ci: state when --allow-writes is needed; run CI on every pull request - README, docs/mcp.md, docs/getting-started.md and agent/agents.md show the read-only client configuration and the one with --allow-writes, and say that a v1.0.0 client that changed a target must now pass the flag. - CHANGELOG: propose 1.0.1 and mark the change as breaking for writing clients. - docs/mcp.md records why cortex_operation_cancel needs no write authority. - The five Windows workflows run on every pull request (no path filter) and on pushes to main, so a regression of this kind no longer needs a manual run. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/contracts.yml | 69 +-------------------------- .github/workflows/e2e-windows.yml | 15 +----- .github/workflows/imgui-gui-tests.yml | 17 +------ .github/workflows/imgui-ui.yml | 10 +--- .github/workflows/unified-host.yml | 12 +---- CHANGELOG.md | 4 +- README.md | 14 +++++- agent/agents.md | 2 + docs/getting-started.md | 2 +- docs/mcp.md | 18 ++++++- 10 files changed, 43 insertions(+), 120 deletions(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 155f88b6..d847db1c 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -19,75 +19,8 @@ on: - agent/mod-diagnostics-m3 - agent/mod-diagnostics-m4 - agent/mod-diagnostics-m5-m7 + # No path filter: every pull request runs this, whatever its source branch. pull_request: - paths: - - "core/api/request_id.h" - - "core/api/request_limits.h" - - "core/api/pagination.h" - - "core/api/semantic_tools.h" - - "tests/request_id_tests.cpp" - - "tests/pagination_tests.cpp" - - "tests/semantic_plan_p1_tests.cpp" - - ".github/workflows/contracts.yml" - - "core/action/**" - - "core/scripting/**" - - "core/api/**" - - "tests/**" - - "CMakeLists.txt" - - "core/api/mcp_contract.h" - - "core/api/mcp_protocol.h" - - "core/api/mcp_tools.*" - - "core/api/mcp_pipe.*" - - "core/api/mcp_pipe_protocol.h" - - "core/api/native_routes.*" - - "core/api/routes_mcp.cpp" - - "core/api/routes_status.cpp" - - "mcp_bridge/main.cpp" - - "mcp_bridge/policy.h" - - "tests/mcp_protocol_tests.cpp" - - "tests/mcp_pipe_protocol_tests.cpp" - - "tests/mcp_schema_tests.cpp" - - "tests/call_scheduler_tests.cpp" - - "tests/mcp_query_schema_tests.cpp" - - "tests/mcp_bridge_policy_tests.cpp" - - "tests/semantic_tools_tests.cpp" - - "tests/mcp_authority_tests.cpp" - - "tests/fixtures/mcp_tool_risks.json" - - "core/security/**" - - "host/unified_main.cpp" - - "host/diagnostics/**" - - "test_target/main_opengl.cpp" - - "core/target/**" - - "tests/target_model_portable_tests.cpp" - - "tests/capability_model_tests.cpp" - - "core/prompt/**" - - "tests/prompt_queue_contract_tests.cpp" - - "core/diagnostics/**" - - "core/config.*" - - "core/dllmain.cpp" - - "tests/diagnostics_tests.cpp" - - "test_target/main.cpp" - - "sdk/**" - - "tests/diagnostics_m2_tests.cpp" - - "tests/diagnostics_sdk_smoke.cpp" - - "tests/diagnostics_bundle_smoke.cpp" - - "docs/mod-sdk.md" - - "core/symbols/**" - - "core/api/routes_symbols.cpp" - - "tools/symbolize_main.cpp" - - "tests/diagnostics_m3_tests.cpp" - - "tests/diagnostics_m3_bundle_smoke.cpp" - - "tests/dwarf_fixture.cpp" - - "docs/symbols.md" - - "core/diagnostics/hooks.*" - - "sdk/include/cortex/diag*.h" - - "tests/diagnostics_m4_*.cpp" - - "docs/hooks.md" - - "core/diagnostics/shared_channel.*" - - "tools/diagnostics_host/**" - - "tests/diagnostics_m5*.cpp" - - "tests/diagnostics_m5_m7_tests.cpp" - - "docs/external-diagnostics.md" workflow_dispatch: permissions: diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml index 49ea7a4b..1fb766fa 100644 --- a/.github/workflows/e2e-windows.yml +++ b/.github/workflows/e2e-windows.yml @@ -4,20 +4,9 @@ on: push: branches: - agent/automated-e2e-matrix + - main + # No path filter: every pull request runs this, whatever its source branch. pull_request: - paths: - - "CMakeLists.txt" - - "app_imgui/**" - - "runtime_helper/**" - - "core/**" - - "host/**" - - "injector/**" - - "mcp_bridge/**" - - "sdk/**" - - "test_target/**" - - "tests/e2e/**" - - "tools/**" - - ".github/workflows/e2e-windows.yml" workflow_dispatch: concurrency: diff --git a/.github/workflows/imgui-gui-tests.yml b/.github/workflows/imgui-gui-tests.yml index bf085f60..b64512a5 100644 --- a/.github/workflows/imgui-gui-tests.yml +++ b/.github/workflows/imgui-gui-tests.yml @@ -21,23 +21,8 @@ on: - "test_target/**" - "CMakeLists.txt" - ".github/workflows/imgui-gui-tests.yml" + # No path filter: every pull request runs this, whatever its source branch. pull_request: - paths: - - "app_imgui/**" - - "host/debugger/**" - - "core/target/**" - - "core/services/**" - - "core/api/**" - - "core/prompt/**" - - "runtime_helper/**" - - "tests/gui/**" - - "tests/security_no_writes_regression.py" - - "tests/fixtures/mcp_tool_risks.json" - - "core/security/**" - - "mcp_bridge/**" - - "test_target/**" - - "CMakeLists.txt" - - ".github/workflows/imgui-gui-tests.yml" workflow_dispatch: permissions: diff --git a/.github/workflows/imgui-ui.yml b/.github/workflows/imgui-ui.yml index 0eca7ac6..4e594762 100644 --- a/.github/workflows/imgui-ui.yml +++ b/.github/workflows/imgui-ui.yml @@ -5,6 +5,7 @@ on: branches: - next/lightweight-imgui-ui - test/imgui-full-migration + - main paths: - "app_imgui/**" - "core/target/**" @@ -20,15 +21,8 @@ on: - "resources/windows/**" - "CMakeLists.txt" - ".github/workflows/imgui-ui.yml" + # No path filter: every pull request runs this, whatever its source branch. pull_request: - paths: - - "app_imgui/**" - - "core/target/**" - - "core/services/**" - - "core/api/mcp_pipe_protocol.h" - - "runtime_helper/**" - - "CMakeLists.txt" - - ".github/workflows/imgui-ui.yml" workflow_dispatch: concurrency: diff --git a/.github/workflows/unified-host.yml b/.github/workflows/unified-host.yml index 73d3b75a..89f7125f 100644 --- a/.github/workflows/unified-host.yml +++ b/.github/workflows/unified-host.yml @@ -4,17 +4,9 @@ on: push: branches: - agent/unified-cortex-host + - main + # No path filter: every pull request runs this, whatever its source branch. pull_request: - paths: - - "CMakeLists.txt" - - "host/**" - - "injector/**" - - "mcp_bridge/**" - - "tools/**" - - "core/symbols/**" - - "docs/**" - - "README.md" - - ".github/workflows/unified-host.yml" workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d34ef87..05233606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to Cortex are documented in this file. -## [Unreleased] +## [1.0.1] - Unreleased (proposed version) + +Security release. **Breaking for MCP clients that change a target:** they must now be started with `cortex.exe mcp --allow-writes` (see Security below). Read-only clients are unaffected. The version string reported by the server and the release artifacts are not changed by this entry; the number is a proposal for whoever cuts the release. ### Security diff --git a/README.md b/README.md index 0cd319db..3c91ec7c 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,17 @@ MCP is built into `cortex.exe`. Configure your client once; the server starts ta } ``` -That server is read-only and attaches only to a process whose runtime is already loaded (`cortex.exe inject `, or the desktop). To let the agent load the runtime and change the target, add `"--allow-writes"` to `args`; that is your decision, not the agent's. +That server is **read-only**: it attaches only to a process whose runtime is already loaded (`cortex.exe inject `, or the desktop), and every call that would change the target is refused. To let the agent load the runtime and change the target (memory writes, freezes, patches, breakpoints, input, prompts), add `"--allow-writes"` to `args`. That is your decision, not the agent's, and the agent cannot set it: + +```json +{ + "mcpServers": { + "cortex": { "command": "C:/Cortex/cortex.exe", "args": ["mcp", "--allow-writes"] } + } +} +``` + +An MCP client written for v1.0.0 that changed a target must now be started with `--allow-writes`. - **Dynamic targets** — `cortex_processes`, `cortex_attach`, `cortex_targets`, `cortex_detach`, with `tools/list_changed` notifications. - **Many targets, no races** — each attached process has its own runtime connection; tools take a `_cortex_target` selector. @@ -146,7 +156,7 @@ Details: [MCP internals](docs/mcp.md) · [Agent guide](agent/agents.md) · [Sema ```powershell cortex.exe # desktop application -cortex.exe mcp [--pid N | --process name] [--tools compact|all] +cortex.exe mcp [--pid N | --process name] [--tools compact|all] [--allow-writes] cortex.exe probe --pid 1234 # read-only target and runtime health cortex.exe diagnose --pid 1234 # crash and hang watcher cortex.exe analyze # offline crash analysis diff --git a/agent/agents.md b/agent/agents.md index f847c5d8..35ed5ed6 100644 --- a/agent/agents.md +++ b/agent/agents.md @@ -18,6 +18,8 @@ The supported path is Model Context Protocol over stdio from `cortex.exe`: } ``` +This is read-only. To let the agent change the target, the person adds `"--allow-writes"` to `args` (`["mcp", "--allow-writes"]`); an agent cannot add it itself. + ```text AI client -> cortex.exe mcp (stdio) -> authenticated Named Pipe -> cortex_core.dll in the target ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 9c3af345..7b19f86e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -83,7 +83,7 @@ Project names and pointer paths resolve too. A resolved location opens in the Me ## 8. Write permission -**Writes allowed** is an explicit safety permission. The desktop applies it to every state-changing call it makes, and the runtime refuses a state-changing call that arrives without it. An AI client connected through `cortex.exe mcp` has no such switch: only `--allow-writes` on its command line, chosen by you, lets it change the target. +**Writes allowed** is an explicit safety permission. The desktop applies it to every state-changing call it makes, and the runtime refuses a state-changing call that arrives without it. An AI client connected through `cortex.exe mcp` has no such switch: only `--allow-writes` on its command line, chosen by you, lets it change the target. In a client configuration that is `"args": ["mcp"]` for read-only and `"args": ["mcp", "--allow-writes"]` when the agent may write. ### Read-only diff --git a/docs/mcp.md b/docs/mcp.md index e2586edd..0a575fc9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,6 +19,19 @@ The default profile is `compact`, which exposes the semantic Cortex tools rather } ``` +This configuration is **read-only**: calls that change the target are refused with `write_authority_required`. A client that must change the target (writes, freezes, patches, breakpoints, input, prompts, loading the runtime with `cortex_attach`) needs `--allow-writes`, which only the person editing this configuration can add: + +```json +{ + "mcpServers": { + "cortex": { + "command": "C:/path/to/cortex.exe", + "args": ["mcp", "--allow-writes"] + } + } +} +``` + This targetless MCP server remains useful before any process is attached. Its host-control catalog contains: - `cortex_processes` — refresh and filter the local process list; @@ -31,7 +44,8 @@ After attach or detach, Cortex emits `notifications/tools/list_changed` and adve `--pid` and `--process` remain compatible startup auto-attach shortcuts: ```text -cortex.exe mcp --pid 1234 +cortex.exe mcp --pid 1234 # read-only; the runtime must already be loaded +cortex.exe mcp --pid 1234 --allow-writes # may also load the runtime and change the target cortex.exe mcp --process app.exe ``` @@ -253,6 +267,8 @@ A tool with no explicit risk classification is refused (`tool_not_classified`), **"Read-only" means no write to the target's memory, not no effect on the target.** `debug_threads`, `debug_registers`, `debug_breakpoint_list` and `debug_paused` need no write authority, but they use a debugger, and a debugger attached to a process suspends its threads while it handles events. Cortex does not hide this: every debugger attach and detach is published as an AI Activity event (kind `debugger`) and appended to `cortex_debugger_events.jsonl` beside `cortex.exe` and `cortex_core.dll` (phase, pid, backend, epoch `ts_ms`). A separate `--allow-debug` switch is not built. +**`cortex_operation_cancel` needs no write authority, on purpose.** It only sets a cancellation flag on an operation that is already running, and that operation passed the authority and intent checks when it started. A cancelled semantic execution rolls back its own transaction, so cancelling returns the target toward its earlier state; it cannot start a change, widen one, or reach a tool the caller could not already call. Requiring authority would only stop a read-only session from cancelling a long observation. It is classified `host_control` in `tools/list`, and the regression test requires every listed tool to carry a class. + **Known limit.** The runtime's pipe token is a file (`cortex.mcp..token`) readable by the Windows user that runs Cortex. A client that has a shell or file access as that user can read it and speak to the pipe directly, outside every rule above. These controls hold for an agent whose only access to Cortex is its MCP tool calls. ## AI activity in the desktop From 010d9212e42eb5684d436832e597ad5934a83ad5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:50 +0000 Subject: [PATCH 04/12] test: declare the denial-log flags outside the block that closes the file The previous fix scoped them inside the block, so the test no longer compiled. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- tests/mcp_authority_tests.cpp | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/mcp_authority_tests.cpp b/tests/mcp_authority_tests.cpp index 9e5ef5d0..98e16291 100644 --- a/tests/mcp_authority_tests.cpp +++ b/tests/mcp_authority_tests.cpp @@ -134,19 +134,19 @@ void TestDenialLog() { CHECK(cortex::security::DenialCount() == before + 3); int lines = 0; - { - std::ifstream file(directory / cortex::security::DenialLogName()); - std::string line; bool sawTool = false, sawSemantic = false, sawTruncated = false; - while (std::getline(file, line)) { - ++lines; - const auto entry = json::parse(line); - CHECK(entry.contains("ts_ms") && entry.contains("ts_utc") && entry.contains("reason")); - sawTool = sawTool || (entry["tool"] == "memory_write" && entry["arguments"]["address"] == 4096); - sawSemantic = sawSemantic || entry.value("semantic_tool", std::string()) == "test_candidate_causality"; - sawTruncated = sawTruncated || entry.value("arguments_truncated", false); + { // the file is closed before it is removed: Windows refuses to delete an open file + std::ifstream file(directory / cortex::security::DenialLogName()); + std::string line; + while (std::getline(file, line)) { + ++lines; + const auto entry = json::parse(line); + CHECK(entry.contains("ts_ms") && entry.contains("ts_utc") && entry.contains("reason")); + sawTool = sawTool || (entry["tool"] == "memory_write" && entry["arguments"]["address"] == 4096); + sawSemantic = sawSemantic || entry.value("semantic_tool", std::string()) == "test_candidate_causality"; + sawTruncated = sawTruncated || entry.value("arguments_truncated", false); + } } - } // close the file before removing it (Windows refuses to delete an open file) CHECK(lines == 3 && sawTool && sawSemantic && sawTruncated); cortex::security::SetDenialDirectory({}); fs::remove_all(directory); From 8d20d2c85b82aef45d1662136cc60b4b225380e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:27:24 +0000 Subject: [PATCH 05/12] fix: debug_registers left the target's thread suspended ReadTargetThreadRegisters suspended a thread, read its context, then closed the thread handle before the guard that resumes it ran. ResumeThread on a closed handle fails, so the thread stayed suspended: any call to debug_registers froze the target (found by the regression test; reproduced under Wine, seen on Windows CI). Resume explicitly before CloseHandle. The regression test now blames a stall on the exact call (it waits for one state publish after every call), no longer crashes when the target is frozen, runs the x86 target even if x64 fails, and has an --isolate mode, run by CI after a failure, that calls each tool on its own fresh target. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/imgui-gui-tests.yml | 17 +++- core/target/thread_provider.cpp | 7 +- tests/security_no_writes_regression.py | 110 +++++++++++++++++++++---- 3 files changed, 115 insertions(+), 19 deletions(-) diff --git a/.github/workflows/imgui-gui-tests.yml b/.github/workflows/imgui-gui-tests.yml index b64512a5..d0fe0d1a 100644 --- a/.github/workflows/imgui-gui-tests.yml +++ b/.github/workflows/imgui-gui-tests.yml @@ -259,14 +259,27 @@ jobs: # requires the target's state hash to be unchanged. See # tests/security_no_writes_regression.py. $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() python tests/security_no_writes_regression.py ` --cortex "$bundle/cortex.exe" ` --target "$bundle/e2e/cortex_test_target_x64.exe" - if ($LASTEXITCODE -ne 0) { throw "x64 write-authority regression failed" } + if ($LASTEXITCODE -ne 0) { $failed += "x64" } python tests/security_no_writes_regression.py ` --cortex "$bundle/cortex.exe" ` --target "$bundle/e2e/cortex_test_target_x86.exe" - if ($LASTEXITCODE -ne 0) { throw "x86 write-authority regression failed" } + if ($LASTEXITCODE -ne 0) { $failed += "x86" } + if ($failed.Count -gt 0) { throw "write-authority regression failed on: $($failed -join ', ')" } + + - name: Which tool stalls the target? (diagnostic, only after a failure) + if: failure() + continue-on-error: true + shell: pwsh + run: | + # Calls each tool on its own fresh target, so a stall or exit has exactly one suspect. + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/security_no_writes_regression.py --isolate ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_x64.exe" - name: Inject a 32-bit target from the command line shell: pwsh diff --git a/core/target/thread_provider.cpp b/core/target/thread_provider.cpp index 70689d67..e48887ec 100644 --- a/core/target/thread_provider.cpp +++ b/core/target/thread_provider.cpp @@ -25,8 +25,12 @@ class SuspendedThread final { explicit SuspendedThread(HANDLE thread) : thread_(thread) { suspended_ = thread_ && SuspendThread(thread_) != static_cast(-1); } - ~SuspendedThread() { + ~SuspendedThread() { Resume(); } + // Resume now, while the handle is still open. Resuming through a closed handle fails + // silently and leaves the thread suspended for good. + void Resume() { if (suspended_) ResumeThread(thread_); + suspended_ = false; } bool Ok() const { return suspended_; } @@ -189,6 +193,7 @@ bool ReadTargetThreadRegisters(const TargetDescriptor& target, if (!ok && error && error->empty()) *error = "get_thread_context_failed:" + std::to_string(GetLastError()); + suspended.Resume(); // before CloseHandle: the guard's destructor would run too late CloseHandle(thread); return ok; #elif defined(__linux__) diff --git a/tests/security_no_writes_regression.py b/tests/security_no_writes_regression.py index e4a8fab2..fdf2e73c 100644 --- a/tests/security_no_writes_regression.py +++ b/tests/security_no_writes_regression.py @@ -268,6 +268,67 @@ def start_target(runner, target, workdir, tag): raise RuntimeError("target manifest never appeared") +def isolate_each_tool(runner, cortex, target, bundle, workdir): + """Call every listed tool, one per fresh target, and say which ones stall or kill it. + + The sweep above runs all tools against one target, so a stall is blamed on the call + that was running when it was noticed. Here nothing else can be the cause. + """ + process, manifest, _ = start_target(runner, target, workdir, "list") + ctx = Context(manifest) + subprocess.run(runner + [cortex, "inject", str(ctx.pid)], cwd=bundle, capture_output=True, timeout=120) + server = Mcp(runner + [cortex, "mcp", "--pid", str(ctx.pid), "--tools", "all"], bundle) + server.initialize() + tools = server.request("tools/list", {}).get("result", {}).get("tools", []) + try: + server.close() + finally: + process.kill() + names = [t for t in tools if not t.get("_semantic") and t["name"] not in ("cortex_detach", "cortex_attach")] + offenders = [] + for index, tool in enumerate(names): + process, manifest, _ = start_target(runner, target, workdir, "iso%d" % index) + ctx = Context(manifest) + hash_file = manifest["hash_file"] + server = None + verdict = "ok" + try: + subprocess.run(runner + [cortex, "inject", str(ctx.pid)], cwd=bundle, capture_output=True, timeout=120) + server = Mcp(runner + [cortex, "mcp", "--pid", str(ctx.pid), "--tools", "all"], bundle) + server.initialize() + read_hash(hash_file) + started = time.time() + server.call(tool["name"], build_arguments(tool, ctx), timeout=60) + took = time.time() - started + if process.poll() is not None: + verdict = "TARGET EXITED" + else: + last = os.path.getmtime(hash_file) + deadline = time.time() + 8.0 + while time.time() < deadline and os.path.getmtime(hash_file) <= last: + time.sleep(0.05) + if os.path.getmtime(hash_file) <= last: + verdict = "TARGET STOPPED PUBLISHING (call took %.1f s)" % took + except Exception as error: # a tool that wedges the server is also worth naming + verdict = "ERROR %s" % error + finally: + if server: + try: + server.close() + except Exception: + pass + process.kill() + if verdict != "ok": + offenders.append((tool["name"], verdict)) + print("FAIL %s: %s" % (tool["name"], verdict)) + else: + print("ok %s" % tool["name"]) + print("%d tools checked one by one, %d stall or kill the target" % (len(names), len(offenders))) + for name, verdict in offenders: + print(" - %s: %s" % (name, verdict)) + return 1 if offenders else 0 + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--cortex", required=True) @@ -277,6 +338,9 @@ def main(): help="do not call this tool (for hosts whose debugger backend is broken, e.g. Wine)") parser.add_argument("--expect-tools", type=int, default=0, help="fail unless tools/list has exactly this many tools (0 = just report)") + parser.add_argument("--isolate", action="store_true", + help="diagnostic: call every listed tool on its own fresh target and report which ones " + "stop the target from publishing its state") parser.add_argument("--dump-tools", default="", help="write name/method/path/risk of every listed tool to this JSON file, then continue") arguments = parser.parse_args() @@ -293,6 +357,9 @@ def check(condition, message): if not condition: failures.append(message) + if arguments.isolate: + return isolate_each_tool(runner, cortex, target, bundle, workdir) + # Denial logs from earlier runs would hide a missing log. for root, _, files in os.walk(bundle): for name in files: @@ -350,7 +417,12 @@ def check(condition, message): progress = {"mtime": os.path.getmtime(hash_file), "at": time.time(), "recent": []} def alive(label): - """Name the call during which the target stopped or froze, if one did.""" + """Name the call after which the target stopped or froze, if one did. + + The target rewrites its hash file every 200 ms. After every call we give it + 0.45 s to publish once more, so a missing publish is blamed on that call (or + the one before it), not on whatever call happened to run a second later. + """ if died: return False progress["recent"] = (progress["recent"] + [label])[-4:] @@ -358,24 +430,26 @@ def alive(label): if process.poll() is not None: reason = "exited" else: - modified = os.path.getmtime(hash_file) - if modified > progress["mtime"]: - progress["mtime"], progress["at"] = modified, time.time() - elif time.time() - progress["at"] > 1.0: - # A stall is not a change of state; only a target that does not come back is a problem. - deadline = time.time() + 6.0 - while time.time() < deadline and os.path.getmtime(hash_file) <= progress["mtime"]: - time.sleep(0.2) + time.sleep(0.45) + if os.path.getmtime(hash_file) > progress["mtime"]: + progress["mtime"], progress["at"] = os.path.getmtime(hash_file), time.time() + else: + # No publish for 0.45 s. A stall that ends is a warning; a target that + # does not come back is a failure. + started = time.time() + while time.time() - started < 8.0 and os.path.getmtime(hash_file) <= progress["mtime"]: + time.sleep(0.05) if os.path.getmtime(hash_file) > progress["mtime"]: progress["mtime"], progress["at"] = os.path.getmtime(hash_file), time.time() - print("warn the target stalled for over a second near: " + " / ".join(progress["recent"])) + print("warn the target stalled for %.1f s after: %s" + % (time.time() - started + 0.45, " / ".join(progress["recent"]))) else: - reason = "stopped responding" + reason = "stopped responding (no state published for 8 s)" if reason: died.append(label) shown = " / ".join(progress["recent"]) - print("FAIL the target %s during: %s" % (reason, shown)) - failures.append("the target %s during: %s" % (reason, shown)) + print("FAIL the target %s after: %s" % (reason, shown)) + failures.append("the target %s after: %s" % (reason, shown)) return not died semantic = [t for t in tools if t.get("_semantic")] @@ -453,9 +527,13 @@ def alive(label): # leaves the target stopped. check(process.poll() is None, "the target is still running") if process.poll() is None: - after = read_hash(hash_file) - print("state hash after: ", after) - check(before == after, "state hash unchanged after the sweep (%s -> %s)" % (before, after)) + try: + after = read_hash(hash_file) + except RuntimeError as error: + check(False, "the target no longer publishes a state hash (it is frozen): %s" % error) + else: + print("state hash after: ", after) + check(before == after, "state hash unchanged after the sweep (%s -> %s)" % (before, after)) server.call("cortex_detach", {"_cortex_target": str(ctx.pid)}, timeout=30) time.sleep(2.5) From 22a55f6b1d96cda08491dd14fe2d7b5c406c3656 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:58:22 +0000 Subject: [PATCH 06/12] security: --allow-state, a narrower authority for Cortex's own records Saving project notes, addresses, pointer paths, knowledge, facts and the test journals/reports does not touch the target, but the content comes from the client and a later session reads it back. It is now its own risk class, `state`, which needs the caller's mutation_permission AND the person's --allow-state (or --allow-writes, which includes it). --allow-state alone still refuses every write to the target. - runtime: ToolRisk::State, Authority::stateAllowed (envelope field state_allowed), error state_authority_required; 11 project/fact tools move from control to state. - host: --allow-state; the cortex_test_* tools that persist journals, reports or knowledge are checked against it. - cortex_launch(attach) injected with full write authority because of how the merge resolved CreateRuntime; injection and write authority are now separate arguments, and the launch profile grants injection only. - regression test: the sweep checks the refusals, and a new control run with --allow-state proves notes are saved, the host journal tool passes the authority check, the target cannot be written and its state hash is unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- CHANGELOG.md | 2 +- README.md | 4 +- agent/agents.md | 4 ++ core/api/mcp_contract.h | 19 ++++++++- core/api/mcp_pipe.cpp | 2 + core/api/mcp_tools.cpp | 8 ++++ core/api/mcp_tools.h | 4 ++ core/services/payload_client.cpp | 1 + core/services/payload_client.h | 4 ++ core/services/runtime_transport.h | 1 + docs/mcp.md | 14 +++++-- mcp_bridge/agent_guide.h | 15 +++++++ mcp_bridge/host_mode.cpp | 20 +++++---- tests/fixtures/mcp_tool_risks.json | 22 +++++----- tests/mcp_authority_tests.cpp | 7 ++++ tests/security_no_writes_regression.py | 56 +++++++++++++++++++++++++- 16 files changed, 155 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c4cb55..0730e665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The distributed v1.0.0 is affected: through `mutation_permission` (a boolean the - **`scan_new` / `scan_next` with `pause_process`, and all `prompt_*` tools, are now control operations** that need authority. - **A tool that no rule classifies is refused.** Every listed tool now needs an explicit risk class; a tool with none is `unclassified` and is refused with `tool_not_classified` whatever authority the caller holds (before, an unknown name defaulted to `analyze`). `tests/fixtures/mcp_tool_risks.json` records the class of every primitive tool and a test fails if one changes or is missing. - **Debugger attach and detach are journaled.** The `debug_*` read tools stay available without write authority, because they do not write memory, but a debugger attached to a process suspends its threads. Each attach/detach is shown in the AI Activity tab and appended to `cortex_debugger_events.jsonl`. -- **Host tools added after v1.0.0 are classified.** Every `cortex_launch`, `cortex_test_*` and `cortex_knowledge_*` tool is listed as `read_only` or as `host_control` with an `effect`, and the regression test fails on a tool without one. Launch, restart, stop and input stay bounded by the operator-owned launch profile. The tools that persist journals, reports or knowledge still rely on the caller's `mutation_permission` declaration and are not yet behind `--allow-writes` (documented in `docs/mcp.md`). +- **Host tools added after v1.0.0 are classified.** Every `cortex_launch`, `cortex_test_*` and `cortex_knowledge_*` tool is listed as `read_only` or as `host_control` with an `effect`, and the regression test fails on a tool without one. Launch, restart, stop and input stay bounded by the operator-owned launch profile. - **A narrower authority for Cortex's own records: `--allow-state`.** Saving project notes, addresses, pointer paths, knowledge and facts (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) and the test journals and reports (`cortex_test_report`, `cortex_test_step_*`, ...) is a new risk class, `state`. It needs `mutation_permission` and either `--allow-state` or `--allow-writes`; it cannot change the target, and `--allow-state` alone still refuses every write to the target. Without it, an agent cannot plant records for a later session to read. A client that saved knowledge before must now be started with `--allow-state`. - **Refused calls are logged** to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, `runtime` or `host`), beside `cortex_core.dll` and `cortex.exe`, so attempts can be counted. - **Regression test.** `tests/security_no_writes_regression.py` starts the test target, then calls every tool `tools/list` returns, with plausible arguments and `mutation_permission=true`, against a server started without `--allow-writes`, and requires the target's code and data hash to be unchanged. Controls prove the hash does change when writes are allowed. - **Known limit, documented:** the pipe token is a file readable by the same Windows user. A client that also has a shell or file access can read it and bypass these controls. An authority granted by the desktop for a limited time is planned. diff --git a/README.md b/README.md index 3c91ec7c..8a080cac 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ That server is **read-only**: it attaches only to a process whose runtime is alr } ``` -An MCP client written for v1.0.0 that changed a target must now be started with `--allow-writes`. +An MCP client written for v1.0.0 that changed a target must now be started with `--allow-writes`. To let the agent only **save its own records** (notes, knowledge, facts, test journals) without letting it touch the target, use `"--allow-state"` instead; `--allow-writes` includes it. - **Dynamic targets** — `cortex_processes`, `cortex_attach`, `cortex_targets`, `cortex_detach`, with `tools/list_changed` notifications. - **Many targets, no races** — each attached process has its own runtime connection; tools take a `_cortex_target` selector. @@ -156,7 +156,7 @@ Details: [MCP internals](docs/mcp.md) · [Agent guide](agent/agents.md) · [Sema ```powershell cortex.exe # desktop application -cortex.exe mcp [--pid N | --process name] [--tools compact|all] [--allow-writes] +cortex.exe mcp [--pid N | --process name] [--tools compact|all] [--allow-state | --allow-writes] cortex.exe probe --pid 1234 # read-only target and runtime health cortex.exe diagnose --pid 1234 # crash and hang watcher cortex.exe analyze # offline crash analysis diff --git a/agent/agents.md b/agent/agents.md index ac98411e..56fbe24d 100644 --- a/agent/agents.md +++ b/agent/agents.md @@ -93,6 +93,10 @@ on a call only declares that you mean to change something; it is required, and it is not sufficient. Do not retry a refused call with different arguments; tell the person what you needed and why. Every refused attempt is logged. +Saving notes, facts, knowledge or test journals is a separate, narrower permission: it is refused with +`state_authority_required` unless the person started `cortex.exe mcp` with `--allow-state` (or +`--allow-writes`, which includes it). It never lets you change the target. + Without `--allow-writes`, `cortex_attach` only connects to a runtime that is already loaded in the process; it will not load one. Inside `batch_run`, only the operations on its allowlist run. diff --git a/core/api/mcp_contract.h b/core/api/mcp_contract.h index d742b61f..fccbf352 100644 --- a/core/api/mcp_contract.h +++ b/core/api/mcp_contract.h @@ -17,6 +17,9 @@ using json = nlohmann::json; enum class ToolRisk { Observe, Analyze, + // Writes Cortex's own records (project notes, saved addresses and pointer paths, knowledge, facts) and + // nothing in the target. Needs the person's --allow-state (or --allow-writes), not target authority. + State, Control, Mutate, NativeCall, @@ -28,6 +31,7 @@ inline const char* RiskName(ToolRisk risk) { switch (risk) { case ToolRisk::Observe: return "observe"; case ToolRisk::Analyze: return "analyze"; + case ToolRisk::State: return "state"; case ToolRisk::Control: return "control"; case ToolRisk::Mutate: return "mutate"; case ToolRisk::NativeCall: return "native_call"; @@ -37,14 +41,23 @@ inline const char* RiskName(ToolRisk risk) { } inline bool RequiresMutationPermission(ToolRisk risk) { - return risk == ToolRisk::Control || risk == ToolRisk::Mutate || risk == ToolRisk::NativeCall || - risk == ToolRisk::Unclassified; + return risk == ToolRisk::State || risk == ToolRisk::Control || risk == ToolRisk::Mutate || + risk == ToolRisk::NativeCall || risk == ToolRisk::Unclassified; } inline bool StartsWith(const std::string& value, const std::string& prefix) { return value.rfind(prefix, 0) == 0; } +// Non-GET tools that only write Cortex's own records. Anything that can also reach the target stays Control. +inline const std::set& StateTools() { + static const std::set names = { + "project_knowledge_put", "project_knowledge_verify", "project_address_set", "project_address_delete", + "project_pointer_path_set", "project_pointer_path_delete", "project_note_add", "project_note_delete", + "re_session_fact_set", "re_session_fact_delete", "re_object_delete"}; + return names; +} + // Non-GET tools that read or analyze without changing the target. This list is // the only way a POST/DELETE tool reaches `Analyze`: a tool that no rule below // and no entry here names is `Unclassified` and is refused, so a route added @@ -74,6 +87,8 @@ inline ToolRisk ClassifyTool(const std::string& name, if (StartsWith(path, "/call/") || StartsWith(name, "call_")) return ToolRisk::NativeCall; + if (StateTools().count(name)) return ToolRisk::State; + // POST is sometimes used for structured analysis requests. Keep those // callable in inspect mode unless their arguments can change runtime or // persisted Cortex state. diff --git a/core/api/mcp_pipe.cpp b/core/api/mcp_pipe.cpp index 9c4c5934..8f36bf98 100644 --- a/core/api/mcp_pipe.cpp +++ b/core/api/mcp_pipe.cpp @@ -208,6 +208,8 @@ void ServeClient(const std::shared_ptr& worker, const std::string& expec mcp_tools::Authority authority; if (envelope.contains("writes_allowed") && envelope["writes_allowed"].is_boolean()) authority.writesAllowed = envelope["writes_allowed"].get(); + if (envelope.contains("state_allowed") && envelope["state_allowed"].is_boolean()) + authority.stateAllowed = envelope["state_allowed"].get(); const auto result = mcp_tools::Handle( envelope["message"], profile, diff --git a/core/api/mcp_tools.cpp b/core/api/mcp_tools.cpp index 626d359a..af96bb31 100644 --- a/core/api/mcp_tools.cpp +++ b/core/api/mcp_tools.cpp @@ -267,6 +267,10 @@ Gate CheckAuthority(const std::string& name, {"error", code}, {"tool", name}, {"risk", mcp_contract::RiskName(shown)}}; + if (code == "state_authority_required") + gate.error["message"] = + "Saving notes, facts or knowledge needs authority from the person: start cortex.exe mcp " + "with --allow-state (or --allow-writes). mutation_permission alone is not enough."; if (code == "write_authority_required") gate.error["message"] = "Writes are not authorized for this session. The person running Cortex must start " @@ -293,6 +297,10 @@ Gate CheckAuthority(const std::string& name, refuse("mutation_permission_required", effective); return gate; } + if (effective == mcp_contract::ToolRisk::State) { + if (!authority.stateAllowed && !authority.writesAllowed) refuse("state_authority_required", effective); + return gate; + } if (!authority.writesAllowed) refuse("write_authority_required", effective); return gate; } diff --git a/core/api/mcp_tools.h b/core/api/mcp_tools.h index 500fde09..059afc42 100644 --- a/core/api/mcp_tools.h +++ b/core/api/mcp_tools.h @@ -15,8 +15,12 @@ using json = nlohmann::json; // `writesAllowed` comes from outside the model: the human's launch flag // (`cortex.exe mcp --allow-writes`) or the desktop, carried on the // authenticated pipe envelope. It defaults to false. +// +// `stateAllowed` is the narrower authority to save Cortex's own records (notes, knowledge, facts); it +// comes from `--allow-state` and is implied by `writesAllowed`. struct Authority { bool writesAllowed = false; + bool stateAllowed = false; }; mcp_protocol::ToolProfile ParseProfile(const std::string& value, diff --git a/core/services/payload_client.cpp b/core/services/payload_client.cpp index 6965861f..4f42603b 100644 --- a/core/services/payload_client.cpp +++ b/core/services/payload_client.cpp @@ -517,6 +517,7 @@ bool PayloadClient::RoundTrip(const json& message, {"tools", toolProfile}, {"session", sessionId}, {"writes_allowed", WritesAuthority()}, + {"state_allowed", StateAuthority()}, {"message", message} }; if (channel && *channel) envelope["channel"] = channel; diff --git a/core/services/payload_client.h b/core/services/payload_client.h index ff456a5a..797d8eb6 100644 --- a/core/services/payload_client.h +++ b/core/services/payload_client.h @@ -41,6 +41,9 @@ class PayloadClient : public RuntimeTransport { // something a tool call can set. Default: none. void SetWritesAuthority(bool allowed) override { writesAuthority_.store(allowed, std::memory_order_relaxed); } bool WritesAuthority() const { return writesAuthority_.load(std::memory_order_relaxed); } + // The narrower authority to save Cortex's own records (notes, facts, knowledge): --allow-state. + void SetStateAuthority(bool allowed) override { stateAuthority_.store(allowed, std::memory_order_relaxed); } + bool StateAuthority() const { return stateAuthority_.load(std::memory_order_relaxed); } // Loading the runtime puts code in the target. When injection is not // allowed, EnsureReady only connects to a runtime that is already there @@ -193,6 +196,7 @@ class PayloadClient : public RuntimeTransport { std::atomic requestSequence_{0}; std::atomic suspended_{false}; std::atomic writesAuthority_{false}; + std::atomic stateAuthority_{false}; std::atomic injectionAllowed_{true}; }; diff --git a/core/services/runtime_transport.h b/core/services/runtime_transport.h index ff7761fd..41e25ea8 100644 --- a/core/services/runtime_transport.h +++ b/core/services/runtime_transport.h @@ -34,6 +34,7 @@ class RuntimeTransport { // call; the runtime refuses state-changing calls without it, whatever the // call says about itself. The default does nothing (scripted transports). virtual void SetWritesAuthority(bool allowed) { (void)allowed; } + virtual void SetStateAuthority(bool allowed) { (void)allowed; } // Calls a desktop-only private route on an already-connected runtime. virtual bool CallRouteExisting(const std::string& method, diff --git a/docs/mcp.md b/docs/mcp.md index e33b1496..a710eb8a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -252,12 +252,20 @@ Native stdio remains responsive to `notifications/cancelled` because normal requ ## Mutation permission -Two conditions must hold for a call that changes the target (control, mutation and native-call risk): +There are three levels of authority, all given by the person who starts the server and none by the model: + +| flag | lets the agent | tool class | +|------|----------------|------------| +| (none) | observe and analyze | `observe`, `analyze` | +| `--allow-state` | also save Cortex's own records: project notes, saved addresses and pointer paths, knowledge, facts, test journals and reports. It cannot change the target. | `state` | +| `--allow-writes` | also change the target (memory, freezes, patches, breakpoints, input) and load the runtime; it includes `--allow-state` | `control`, `mutate`, `native_call` | + +Two conditions must hold for a call that changes the target (control, mutation and native-call risk), and the same two, with `--allow-state` as the authority, for a call that only saves Cortex's own records (`state` risk): 1. **Write authority**, which comes from outside the model. For `cortex.exe mcp` it is the `--allow-writes` flag the person puts in the client configuration; for the legacy `cortex_host mcp` bridge it is the same flag. The desktop supplies its own: the Read-only / Writes allowed switch. The flag travels to the runtime on the authenticated pipe envelope, never inside a tool call, so an AI client cannot set it. (An authority granted by the desktop for a limited time to an MCP session is planned, not built.) 2. **Declared intent**: `mutation_permission=true` on the call, or on the semantic request that runs the steps. On its own it grants nothing. -Without authority the answer is `write_authority_required`; without the declaration it is `mutation_permission_required`. Every refused call is appended to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, who refused: `runtime` beside `cortex_core.dll`, `host` beside `cortex.exe`), so an attempt that was blocked can still be counted. +Without authority the answer is `write_authority_required` (or `state_authority_required` for a `state` call); without the declaration it is `mutation_permission_required`. Every refused call is appended to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, who refused: `runtime` beside `cortex_core.dll`, `host` beside `cortex.exe`), so an attempt that was blocked can still be counted. Attaching a target does not grant permission to modify it, and loading the runtime counts as a modification: without `--allow-writes`, `cortex_attach` connects only to a runtime that was loaded beforehand (for example with `cortex.exe inject `). The server forwards only `initialize`, `ping`, `server/discover`, `tools/list`, `tools/call` and `notifications/*`; any other method, including the desktop's private `cortex/private/route`, is refused and logged. @@ -269,7 +277,7 @@ A tool with no explicit risk classification is refused (`tool_not_classified`), **`cortex_operation_cancel` needs no write authority, on purpose.** It only sets a cancellation flag on an operation that is already running, and that operation passed the authority and intent checks when it started. A cancelled semantic execution rolls back its own transaction, so cancelling returns the target toward its earlier state; it cannot start a change, widen one, or reach a tool the caller could not already call. Requiring authority would only stop a read-only session from cancelling a long observation. It is classified `host_control` in `tools/list`, and the regression test requires every listed tool to carry a class. -**Host tools of the agent test layer** (`cortex_launch`, `cortex_restart`, `cortex_stop`, `cortex_test_*`, `cortex_knowledge_*`). Each is listed with a class: `read_only`, or `host_control` with an `effect` (`target_input`: sends keys or clicks to the program; `cortex_state`: writes Cortex's own journals, reports or knowledge; `trial`: stops a running trial). Launch, restart, stop and input are bounded by the operator-owned `--launch-config` profile (`allow_attach`, `allow_stop`, `allow_reset`, `allow_input`, `allow_mouse`, `test_keys`, run budgets), which the agent cannot change; that profile is the authority for those actions, and `--allow-writes` is not additionally required. The tools that write journals, reports or knowledge (`cortex_test_report`, `cortex_test_step_*`, `cortex_test_catalog_save`, `cortex_test_link`, `cortex_test_code_link`, `cortex_test_investigation_report`, `cortex_test_generate_prepare`) require `mutation_permission=true`, which is the caller's own declaration: **they are not yet behind `--allow-writes`.** They write only under Cortex's own data directory, not into the target, but the content they store comes from the client. By contrast the runtime primitives `project_knowledge_put` and `project_knowledge_verify` are control operations and do need `--allow-writes`. +**Host tools of the agent test layer** (`cortex_launch`, `cortex_restart`, `cortex_stop`, `cortex_test_*`, `cortex_knowledge_*`). Each is listed with a class: `read_only`, or `host_control` with an `effect` (`target_input`: sends keys or clicks to the program; `cortex_state`: writes Cortex's own journals, reports or knowledge; `trial`: stops a running trial). Launch, restart, stop and input are bounded by the operator-owned `--launch-config` profile (`allow_attach`, `allow_stop`, `allow_reset`, `allow_input`, `allow_mouse`, `test_keys`, run budgets), which the agent cannot change; that profile is the authority for those actions, and `--allow-writes` is not additionally required. The tools that write journals, reports or knowledge (`cortex_test_report`, `cortex_test_step_*`, `cortex_test_catalog_save`, `cortex_test_link`, `cortex_test_code_link`, `cortex_test_investigation_report`, `cortex_test_generate_prepare`) require `mutation_permission=true`, which is the caller's own declaration: **they need `--allow-state`, not `--allow-writes`.** They write only under Cortex's own data directory, not into the target, but the content they store comes from the client, so a session that has not been given that authority cannot plant records for a later session to read. These host tools now need `--allow-state` (or `--allow-writes`) in addition to the declaration; the runtime primitives that do the same for the project (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) are `state` operations with the same authority. **Known limit.** The runtime's pipe token is a file (`cortex.mcp..token`) readable by the Windows user that runs Cortex. A client that has a shell or file access as that user can read it and speak to the pipe directly, outside every rule above. These controls hold for an agent whose only access to Cortex is its MCP tool calls. diff --git a/mcp_bridge/agent_guide.h b/mcp_bridge/agent_guide.h index e9264878..4ab99d4b 100644 --- a/mcp_bridge/agent_guide.h +++ b/mcp_bridge/agent_guide.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include inline const char* CortexAgentGuide() { return R"CORTEXGUIDE( # Testing a program with Cortex @@ -264,6 +265,7 @@ inline nlohmann::json CortexTestTools() { if(declaresPermission) meta={{"host_control",true},{"mutation_permission_required",true}, {"effect",toolName=="cortex_test_run"?"target_input":"cortex_state"}}; + if(declaresPermission&&toolName!="cortex_test_run") meta["authority"]="state"; else if(toolName=="cortex_test_cancel") meta={{"host_control",true},{"effect","trial"}}; else meta={{"read_only",true}}; return json{{"name",name},{"description",description},{"inputSchema",{ @@ -411,3 +413,16 @@ inline nlohmann::json CortexTestTools() { tools.push_back(tool("cortex_test_list","List up to twenty recent trial summaries without loading raw observations.",json::object(),json::array())); return tools; } + +// Host tools that write Cortex's own journals, reports or knowledge. They need the person's --allow-state +// (or --allow-writes) on top of the caller's mutation_permission declaration. +inline bool IsCortexStateTool(const std::string& name) { + static const std::set names=[]{ + std::set result; + for(const auto& tool:CortexTestTools()) + if(tool.contains("_cortex")&&tool["_cortex"].value("effect",std::string())=="cortex_state") + result.insert(tool.value("name",std::string())); + return result; + }(); + return names.count(name)>0; +} diff --git a/mcp_bridge/host_mode.cpp b/mcp_bridge/host_mode.cpp index 2bb29469..7a585705 100644 --- a/mcp_bridge/host_mode.cpp +++ b/mcp_bridge/host_mode.cpp @@ -60,7 +60,7 @@ constexpr std::size_t kMaxStdioMessageBytes = 4u * 1024u * 1024u; constexpr std::size_t kMaxConcurrentRequests = 64; struct TargetSelector { std::optional pid; std::string process; }; -struct Options { std::vector targets; std::string toolProfile = "compact", launchConfigPath, testResults; bool help = false; bool allowWrites = false; }; +struct Options { std::vector targets; std::string toolProfile = "compact", launchConfigPath, testResults; bool help = false; bool allowWrites = false; bool allowState = false; }; struct TargetRuntime { TargetDescriptor target; @@ -91,6 +91,7 @@ struct RunState { cortex::target::Catalog* catalog = nullptr; std::string runtimeDirectory, toolProfile; bool allowWrites = false; + bool allowState = false; std::unique_ptr launcher; std::unique_ptr tests; @@ -123,7 +124,9 @@ void PrintUsage(std::ostream& stream) { << "Without selectors Cortex starts targetless. Use cortex_processes/cortex_attach dynamically.\n\n" << "Read-only by default. --allow-writes lets this server load the runtime into a target and apply\n" << "state-changing calls (a call must still carry mutation_permission). Only the person who starts\n" - << "the server can set it; the AI client cannot. Refused calls are logged to cortex_denied_mutations.jsonl.\n"; + << "the server can set it; the AI client cannot. Refused calls are logged to cortex_denied_mutations.jsonl.\n" + << "--allow-state lets the agent save knowledge, notes and journals (Cortex's own records) without letting it\n" + << "change the target. --allow-writes includes it.\n"; } bool ParseOptions(int argc, char** argv, Options& options, std::string& error) { @@ -131,6 +134,7 @@ bool ParseOptions(int argc, char** argv, Options& options, std::string& error) { const std::string a=argv[i]?argv[i]:""; if (a=="--help"||a=="-h") { options.help=true; continue; } if (a=="--allow-writes") { options.allowWrites=true; continue; } + if (a=="--allow-state") { options.allowState=true; continue; } if (a=="--pid"&&i+1& state, json event) { } std::weak_ptr g_runState; +bool g_allowState = false; // --allow-state: the person lets this server save knowledge, notes and journals // A debugger attached to the target suspends its threads, so tools that look // read-only still have an effect on it. Every attach and detach is journaled. @@ -270,7 +275,7 @@ void RememberInitialized(const std::shared_ptr&s){std::lock_guard&s,const TargetRuntimePtr&r,std::string&error){std::optionalinit;bool initialized=false;{std::lock_guardl(s->protocolMutex);init=s->initializeMessage;initialized=s->initializedNotificationSeen;}if(!init)return true;json rr;bool has=false;if(!r->payload->ForwardMcp(*init,s->toolProfile,rr,has,&error))return false;if(initialized){json n={{"jsonrpc","2.0"},{"method","notifications/initialized"}};if(!r->payload->ForwardMcp(n,s->toolProfile,rr,has,&error))return false;}return true;} -TargetRuntimePtr CreateRuntime(cortex::target::Catalog&catalog,const TargetDescriptor&t,const std::string&dir,bool allowWrites,std::string&code,std::string&msg){auto r=std::make_shared();r->target=t;r->sessions=std::make_unique(catalog);std::string e;if(!r->sessions->Attach(t,&e)){code="target_attach_failed";msg=e;return{};}r->payload=std::make_unique(*r->sessions,dir);r->payload->SetWritesAuthority(allowWrites);r->payload->SetInjectionAllowed(allowWrites);if(!r->payload->EnsureReady(&e)){code="target_runtime_unavailable";msg=e;if(e.rfind("write_authority_required",0)==0){code="write_authority_required";cortex::security::RecordDenial("host","cortex_attach",code,{{"pid",t.processId},{"process",t.name}});}return{};}r->debuggerBackend=ConfiguredDebuggerBackend(dir);r->debugger=CreateDebugProvider(*r,r->debuggerBackend);if(!r->debugger){code="debugger_provider_unavailable";msg="Could not create debugger provider";return{};}return r;} +TargetRuntimePtr CreateRuntime(cortex::target::Catalog&catalog,const TargetDescriptor&t,const std::string&dir,bool allowWrites,bool allowInjection,std::string&code,std::string&msg){auto r=std::make_shared();r->target=t;r->sessions=std::make_unique(catalog);std::string e;if(!r->sessions->Attach(t,&e)){code="target_attach_failed";msg=e;return{};}r->payload=std::make_unique(*r->sessions,dir);r->payload->SetWritesAuthority(allowWrites);r->payload->SetStateAuthority(allowWrites||g_allowState);r->payload->SetInjectionAllowed(allowInjection);if(!r->payload->EnsureReady(&e)){code="target_runtime_unavailable";msg=e;if(e.rfind("write_authority_required",0)==0){code="write_authority_required";cortex::security::RecordDenial("host","cortex_attach",code,{{"pid",t.processId},{"process",t.name}});}return{};}r->debuggerBackend=ConfiguredDebuggerBackend(dir);r->debugger=CreateDebugProvider(*r,r->debuggerBackend);if(!r->debugger){code="debugger_provider_unavailable";msg="Could not create debugger provider";return{};}return r;} json ProcessList(const std::shared_ptr&s,const json&a,std::string&code,std::string&msg){if(!s->catalog){code="target_catalog_unavailable";msg="Local target catalog unavailable";return{};}std::string q;if(a.contains("query")&&a["query"].is_string())q=LowerAscii(a["query"].get());size_t limit=static_cast(std::clamp(UnsignedArg(a,"limit",256),1,2048));auto attached=RuntimeSnapshot(s);json rows=json::array();size_t total=0;for(const auto&t:s->catalog->Targets()){if(!q.empty()&&LowerAscii(t.name+"\n"+t.executablePath+"\n"+t.windowTitle+"\n"+t.id).find(q)==std::string::npos)continue;++total;if(rows.size()>=limit)continue;bool isAttached=false;for(auto&r:attached)if(r->target.id==t.id&&(!r->target.generation||!t.generation||r->target.generation==t.generation))isAttached=true;rows.push_back(TargetJson(t,isAttached));}return{{"ok",true},{"count",rows.size()},{"total_matches",total},{"truncated",total>rows.size()},{"processes",rows}};} @@ -299,6 +304,7 @@ void RegisterOpRoute(const std::shared_ptr&s,uint64_t op,const json&id void ForgetOpRoute(const std::shared_ptr&s,uint64_t op){std::lock_guardl(s->operationRouteMutex);auto it=s->operationRequestIds.find(op);if(it!=s->operationRequestIds.end()){s->requestOperations.erase(RequestKey(it->second));s->operationRequestIds.erase(it);}} bool HandleLocalTool(const std::shared_ptr&s,const json&m,const std::string&name,const json&a,json&response){const bool local=name.rfind("cortex_",0)==0;if(!local)return false;if(PruneDeadRuntimes(s))EmitToolsChanged(s); +if(IsCortexStateTool(name)&&a.contains("mutation_permission")&&a["mutation_permission"].is_boolean()&&a["mutation_permission"].get()&&!(s->allowState||s->allowWrites)){cortex::security::RecordDenial("host",name,"state_authority_required",a);response=LocalToolResponse(MessageId(m),LocalToolFailure("state_authority_required","Saving knowledge, notes or journals needs authority from the person: start cortex.exe mcp with --allow-state (or --allow-writes). mutation_permission alone is not enough."),true);return true;} if(name=="cortex_agent_guide"){response=LocalToolResponse(MessageId(m),{{"ok",true},{"guide",CortexAgentGuide()}});return true;} if(name=="cortex_test_list"){response=LocalToolResponse(MessageId(m),s->tests->List());return true;} if(name=="cortex_test_get"||name=="cortex_test_cancel"){ @@ -1364,7 +1370,7 @@ if(name=="cortex_launch"||name=="cortex_restart"||name=="cortex_stop"){ std::string code,message; if(target&&s->launcher->IsCurrent(pid,generation)&&cortex::test::TestWindow(pid)){ std::lock_guard lock(s->targetMutationMutex); - auto runtime=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,/*allowWrites: the operator-owned launch profile already granted allow_attach (checked above)*/true,code,message); + auto runtime=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,s->allowWrites,/*allowInjection: the operator-owned launch profile already granted allow_attach (checked above); write authority is NOT granted by it*/true,code,message); if(runtime&&PrimeRuntime(s,runtime,message)){ {std::lock_guard lock(s->runtimeMutex);s->runtimes.push_back(runtime);} result["attached"]=true;result["target"]=TargetJson(*target,true); @@ -1387,7 +1393,7 @@ if(name=="cortex_targets"){response=LocalToolResponse(MessageId(m),TargetList(Ru if(name=="cortex_processes"){std::string c,x;auto r=ProcessList(s,a,c,x);response=c.empty()?LocalToolResponse(MessageId(m),r):LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;} if(name=="cortex_operations"){json rows=json::array();for(const auto&o:s->operations.List(static_cast(std::clamp(UnsignedArg(a,"limit",128),1,512))))rows.push_back(OperationJson(o));response=LocalToolResponse(MessageId(m),{{"ok",true},{"count",rows.size()},{"operations",rows}});return true;} if(name=="cortex_operation_cancel"){uint64_t op=UnsignedArg(a,"operation_id");std::string e;if(!op||!s->operations.RequestCancel(op,&e)){response=LocalToolResponse(MessageId(m),LocalToolFailure(e.empty()?"invalid_operation_id":e,e.empty()?"operation_id must identify a running operation":e),true);return true;}json id;{std::lock_guardl(s->operationRouteMutex);auto it=s->operationRequestIds.find(op);if(it!=s->operationRequestIds.end())id=it->second;}if(!id.is_null())BroadcastCancel(s,id);RecordEvent(s,"operation.cancel_requested",nullptr,{{"operation_id",op}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"operation_id",op},{"state","cancel_requested"}});return true;} -if(name=="cortex_attach"){std::lock_guardml(s->targetMutationMutex);bool hp=a.contains("pid"),hn=a.contains("process");if(hp==hn){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_attach_selector","Provide exactly one of pid or process"),true);return true;}TargetSelector sel;if(hp){auto p=UnsignedArg(a,"pid");if(!p){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_pid","pid must be positive"),true);return true;}sel.pid=p;}else{if(!a["process"].is_string()||a["process"].get().empty()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_process","process must be non-empty"),true);return true;}sel.process=a["process"].get();}std::string er;auto target=ResolveUniqueTarget(sel,s->catalog->Targets(),er);if(!target){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_not_found",er),true);return true;}for(auto&r:RuntimeSnapshot(s))if(r->target.id==target->id&&(!r->target.generation||!target->generation||r->target.generation==target->generation)){response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","already_attached"},{"target",TargetJson(r->target,true)}});return true;}std::string c,x;auto r=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,s->allowWrites,c,x);if(!r){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;}if(!PrimeRuntime(s,r,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_protocol_init_failed",x),true);return true;}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&old){return old&&old->target.id==r->target.id&&old->target.generation!=r->target.generation;}),s->runtimes.end());s->runtimes.push_back(r);}RecordEvent(s,"target.attached",r,{{"debugger_backend",r->debuggerBackend}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","attached"},{"target",TargetJson(r->target,true)},{"debugger_backend",r->debuggerBackend},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} +if(name=="cortex_attach"){std::lock_guardml(s->targetMutationMutex);bool hp=a.contains("pid"),hn=a.contains("process");if(hp==hn){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_attach_selector","Provide exactly one of pid or process"),true);return true;}TargetSelector sel;if(hp){auto p=UnsignedArg(a,"pid");if(!p){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_pid","pid must be positive"),true);return true;}sel.pid=p;}else{if(!a["process"].is_string()||a["process"].get().empty()){response=LocalToolResponse(MessageId(m),LocalToolFailure("invalid_process","process must be non-empty"),true);return true;}sel.process=a["process"].get();}std::string er;auto target=ResolveUniqueTarget(sel,s->catalog->Targets(),er);if(!target){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_not_found",er),true);return true;}for(auto&r:RuntimeSnapshot(s))if(r->target.id==target->id&&(!r->target.generation||!target->generation||r->target.generation==target->generation)){response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","already_attached"},{"target",TargetJson(r->target,true)}});return true;}std::string c,x;auto r=CreateRuntime(*s->catalog,*target,s->runtimeDirectory,s->allowWrites,s->allowWrites,c,x);if(!r){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;}if(!PrimeRuntime(s,r,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure("target_protocol_init_failed",x),true);return true;}{std::lock_guardl(s->runtimeMutex);s->runtimes.erase(std::remove_if(s->runtimes.begin(),s->runtimes.end(),[&](auto&old){return old&&old->target.id==r->target.id&&old->target.generation!=r->target.generation;}),s->runtimes.end());s->runtimes.push_back(r);}RecordEvent(s,"target.attached",r,{{"debugger_backend",r->debuggerBackend}});response=LocalToolResponse(MessageId(m),{{"ok",true},{"status","attached"},{"target",TargetJson(r->target,true)},{"debugger_backend",r->debuggerBackend},{"count",RuntimeSnapshot(s).size()}});EmitToolsChanged(s);return true;} std::string c,x;auto r=ResolveRuntime(RuntimeSnapshot(s),a,c,x);if(!r||!ValidateGeneration(r,a,c,x)){response=LocalToolResponse(MessageId(m),LocalToolFailure(c,x),true);return true;} if(name=="cortex_capabilities"){response=LocalToolResponse(MessageId(m),{{"ok",true},{"capabilities",CapabilityDocument(*r)}});return true;} @@ -1412,9 +1418,9 @@ void Watchdog(const std::shared_ptr&s){while(s->watchdogRunning.load() } // namespace -int RunFullMcpHost(int argc,char**argv,const std::string&runtimeDirectory){Options options;std::string error;if(!ParseOptions(argc,argv,options,error)){std::cerr<<"cortex mcp: "<())){std::cerr<<"cortex mcp: local backend unavailable\n";return 3;}auto state=std::make_shared();state->catalog=&catalog;state->runtimeDirectory=runtimeDirectory;state->toolProfile=options.toolProfile;state->allowWrites=options.allowWrites;g_runState=state;cortex::security::SetDenialDirectory(runtimeDirectory);{ std::vector profiles; if(!options.launchConfigPath.empty()){ try{ std::ifstream file(std::filesystem::u8path(options.launchConfigPath)); if(!file){std::cerr<<"cortex mcp: could not read --launch-config\n";return 2;} const json config=json::parse(file); if(!cortex::launch::ParseProfiles(config,profiles,error)){ std::cerr<<"cortex mcp: "<launcher=std::make_unique(std::move(profiles));}{ +int RunFullMcpHost(int argc,char**argv,const std::string&runtimeDirectory){Options options;std::string error;if(!ParseOptions(argc,argv,options,error)){std::cerr<<"cortex mcp: "<())){std::cerr<<"cortex mcp: local backend unavailable\n";return 3;}auto state=std::make_shared();state->catalog=&catalog;state->runtimeDirectory=runtimeDirectory;state->toolProfile=options.toolProfile;state->allowWrites=options.allowWrites;state->allowState=options.allowState;g_allowState=options.allowState;g_runState=state;cortex::security::SetDenialDirectory(runtimeDirectory);{ std::vector profiles; if(!options.launchConfigPath.empty()){ try{ std::ifstream file(std::filesystem::u8path(options.launchConfigPath)); if(!file){std::cerr<<"cortex mcp: could not read --launch-config\n";return 2;} const json config=json::parse(file); if(!cortex::launch::ParseProfiles(config,profiles,error)){ std::cerr<<"cortex mcp: "<launcher=std::make_unique(std::move(profiles));}{ const auto root=options.testResults.empty() ? cortex::services::UserDataDirectory(std::filesystem::u8path(runtimeDirectory))/"tests" : std::filesystem::absolute(std::filesystem::u8path(options.testResults)); state->tests=std::make_unique(root,std::to_string(GetCurrentProcessId())); -}{std::ostringstream activityId;activityId<activitySessionId=activityId.str();}auto available=catalog.Targets();for(const auto&selector:options.targets){auto target=ResolveUniqueTarget(selector,available,error);if(!target){std::cerr<<"cortex mcp: "<runtimes.push_back(runtime);RecordEvent(state,"target.attached",runtime,{{"startup",true},{"debugger_backend",runtime->debuggerBackend}});}PublishActivity(state,{{"kind","session"},{"phase","started"},{"summary","AI/MCP session started"}});state->watchdogRunning=true;state->watchdog=std::thread([state]{Watchdog(state);});std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::string line;while(std::getline(std::cin,line)){if(line.empty())continue;if(line.size()>kMaxStdioMessageBytes){WriteOutput(state,TransportError(nullptr,"message_too_large","MCP stdio message exceeds 4 MiB"));continue;}json m;try{m=json::parse(line);}catch(const std::exception&e){WriteOutput(state,{{"jsonrpc","2.0"},{"id",nullptr},{"error",{{"code",-32700},{"message",e.what()}}}});continue;}if(IsNotification(m)){json r;bool has=false;std::string e;if(!ForwardOne(state,m,r,has,&e))std::cerr<<"cortex mcp: notification forwarding failed: "<activitySessionId=activityId.str();}auto available=catalog.Targets();for(const auto&selector:options.targets){auto target=ResolveUniqueTarget(selector,available,error);if(!target){std::cerr<<"cortex mcp: "<runtimes.push_back(runtime);RecordEvent(state,"target.attached",runtime,{{"startup",true},{"debugger_backend",runtime->debuggerBackend}});}PublishActivity(state,{{"kind","session"},{"phase","started"},{"summary","AI/MCP session started"}});state->watchdogRunning=true;state->watchdog=std::thread([state]{Watchdog(state);});std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::string line;while(std::getline(std::cin,line)){if(line.empty())continue;if(line.size()>kMaxStdioMessageBytes){WriteOutput(state,TransportError(nullptr,"message_too_large","MCP stdio message exceeds 4 MiB"));continue;}json m;try{m=json::parse(line);}catch(const std::exception&e){WriteOutput(state,{{"jsonrpc","2.0"},{"id",nullptr},{"error",{{"code",-32700},{"message",e.what()}}}});continue;}if(IsNotification(m)){json r;bool has=false;std::string e;if(!ForwardOne(state,m,r,has,&e))std::cerr<<"cortex mcp: notification forwarding failed: "<watchdogRunning=false;if(state->watchdog.joinable())state->watchdog.join();for(auto&r:RuntimeSnapshot(state))if(r&&r->debugger){std::lock_guardl(r->debuggerMutex);DetachLogged(r);}PublishActivity(state,{{"kind","session"},{"phase","ended"},{"summary","AI/MCP session ended"}});return 0;} diff --git a/tests/fixtures/mcp_tool_risks.json b/tests/fixtures/mcp_tool_risks.json index 630e111f..ca00fc03 100644 --- a/tests/fixtures/mcp_tool_risks.json +++ b/tests/fixtures/mcp_tool_risks.json @@ -503,13 +503,13 @@ "name": "project_address_delete", "method": "DELETE", "path": "/project/address/{name}", - "risk": "control" + "risk": "state" }, { "name": "project_address_set", "method": "POST", "path": "/project/address", - "risk": "control" + "risk": "state" }, { "name": "project_get", @@ -539,7 +539,7 @@ "name": "project_knowledge_put", "method": "POST", "path": "/project/knowledge", - "risk": "control" + "risk": "state" }, { "name": "project_knowledge_query", @@ -551,25 +551,25 @@ "name": "project_knowledge_verify", "method": "POST", "path": "/project/knowledge/{id}/verify", - "risk": "control" + "risk": "state" }, { "name": "project_note_add", "method": "POST", "path": "/project/note", - "risk": "control" + "risk": "state" }, { "name": "project_note_delete", "method": "DELETE", "path": "/project/note/{id}", - "risk": "control" + "risk": "state" }, { "name": "project_pointer_path_delete", "method": "DELETE", "path": "/project/pointer_path/{name}", - "risk": "control" + "risk": "state" }, { "name": "project_pointer_path_resolve", @@ -581,7 +581,7 @@ "name": "project_pointer_path_set", "method": "POST", "path": "/project/pointer_path", - "risk": "control" + "risk": "state" }, { "name": "prompt_status", @@ -653,7 +653,7 @@ "name": "re_object_delete", "method": "DELETE", "path": "/re/object/{id}", - "risk": "control" + "risk": "state" }, { "name": "re_object_events", @@ -695,13 +695,13 @@ "name": "re_session_fact_delete", "method": "DELETE", "path": "/re/session/fact", - "risk": "control" + "risk": "state" }, { "name": "re_session_fact_set", "method": "POST", "path": "/re/session/fact", - "risk": "control" + "risk": "state" }, { "name": "re_test_run", diff --git a/tests/mcp_authority_tests.cpp b/tests/mcp_authority_tests.cpp index 98e16291..192d0028 100644 --- a/tests/mcp_authority_tests.cpp +++ b/tests/mcp_authority_tests.cpp @@ -90,6 +90,13 @@ void TestPromptTools() { CHECK(ClassifyTool(name, "POST", "/prompt/x") == ToolRisk::Control); CHECK(ClassifyTool(name, "GET", "/prompt/1") == ToolRisk::Control); } + // Cortex's own records are a class of their own: a narrower authority than writing the target. + for (const char* name : {"project_note_add", "project_knowledge_put", "re_session_fact_set"}) { + CHECK(ClassifyTool(name, "POST", "/x") == ToolRisk::State); + CHECK(RequiresMutationPermission(ToolRisk::State)); + } + CHECK(ClassifyTool("project_note_add", "GET", "/project/note") == ToolRisk::Observe); + CHECK(std::string(api::mcp_contract::RiskName(ToolRisk::State)) == "state"); // Reading memory is still observation. CHECK(ClassifyTool("memory_read", "POST", "/memory/read") == ToolRisk::Analyze); CHECK(ClassifyTool("status", "GET", "/status") == ToolRisk::Observe); diff --git a/tests/security_no_writes_regression.py b/tests/security_no_writes_regression.py index 95ecc260..6a79fa2f 100644 --- a/tests/security_no_writes_regression.py +++ b/tests/security_no_writes_regression.py @@ -28,6 +28,7 @@ DENIAL_REASONS_PREFIXES = ( "mutation_permission_required", "write_authority_required", + "state_authority_required", "batch_op_", "batch_ops_required", "method_not_allowed", @@ -394,7 +395,7 @@ def check(condition, message): # Every listed tool must carry a risk class. A tool nobody classified is # refused by the runtime, so it must never appear as a usable tool. - known_risks = ("observe", "analyze", "control", "mutate", "native_call") + known_risks = ("observe", "analyze", "state", "control", "mutate", "native_call") unclassified = [] for tool in tools: meta = tool.get("_cortex") or {} @@ -478,7 +479,7 @@ def alive(label): break code = result.get("error") if isinstance(result, dict) else None risk = (primitives[primitive].get("_cortex") or {}).get("risk") - mutating = risk in ("control", "mutate", "native_call") + mutating = risk in ("state", "control", "mutate", "native_call") if is_denial(code) or (isinstance(code, str) and code.startswith("primitive_lacks_safe_rollback")): denied += 1 elif mutating: @@ -515,6 +516,23 @@ def alive(label): code = error_code(result) check(is_denial(code), "%s is refused (%s)" % (label, code)) + # Saving notes or knowledge is a separate, narrower authority (--allow-state): without it, refused. + by_name = {t["name"]: t for t in tools} + note = by_name.get("project_note_add") + if note: + result = server.call("project_note_add", build_arguments(note, ctx)) + calls += 1 + alive("project_note_add (state check)") + check(error_code(result) == "state_authority_required", + "project_note_add without --allow-state is refused (%s)" % (error_code(result),)) + report = by_name.get("cortex_test_catalog_save") + if report: + result = server.call("cortex_test_catalog_save", {"id": "step-none", "mutation_permission": True}) + calls += 1 + alive("cortex_test_catalog_save (state check)") + check("state_authority_required" in json.dumps(result), + "cortex_test_catalog_save without --allow-state is refused") + # A raw client message on Cortex's private channel is not forwarded. raw = server.request("cortex/private/route", {"method": "POST", "path": "/memory/write", "body": {"address": ctx.address, "type": "u32", "value": 1}}) @@ -607,6 +625,40 @@ def alive(label): finally: process.kill() + # ------------------------------------------------------------ control A2: --allow-state saves records, never writes the target + process, manifest, manifest_path = start_target(runner, target, workdir, "state") + try: + ctx = Context(manifest) + hash_file = manifest["hash_file"] + subprocess.run(runner + [cortex, "inject", str(ctx.pid)], cwd=bundle, capture_output=True, + text=True, timeout=120) + server = Mcp(runner + [cortex, "mcp", "--pid", str(ctx.pid), "--tools", "all", "--allow-state"], bundle) + try: + server.initialize() + tools_now = {t["name"]: t for t in server.request("tools/list", {}).get("result", {}).get("tools", [])} + baseline = read_hash(hash_file) + note = tools_now.get("project_note_add") + if note: + result = server.call("project_note_add", build_arguments(note, ctx)) + check(error_code(result) is None and "state_authority_required" not in json.dumps(result), + "with --allow-state a note is saved (%s)" % (error_code(result),)) + result = server.call("project_note_add", {k: v for k, v in build_arguments(note, ctx).items() + if k != "mutation_permission"}) + check(error_code(result) == "mutation_permission_required", + "with --allow-state a note still needs the caller's mutation_permission") + result = server.call("cortex_test_catalog_save", {"id": "step-none", "mutation_permission": True}) + check("state_authority_required" not in json.dumps(result), + "with --allow-state a host journal tool passes the authority check") + result = server.call("memory_write", {"address": ctx.address, "type": "u32", "value": 305419896, + "mutation_permission": True}) + check(error_code(result) == "write_authority_required", + "with --allow-state the target still cannot be written (%s)" % (error_code(result),)) + check(read_hash(hash_file) == baseline, "--allow-state leaves the target's state hash unchanged") + finally: + server.close() + finally: + process.kill() + # ------------------------------------------------------------ control B: attaching without authority does not inject process, manifest, manifest_path = start_target(runner, target, workdir, "attach") try: From 13e0d749694fd830caa1c79b60d854d0e056a7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:21:18 +0000 Subject: [PATCH 07/12] test: knowledge writes are a state tier now; runner e2e passes --allow-state mcp_schema_tests expected project_knowledge_put/verify to be control after the merge of main; they are state (the person's --allow-state, not --allow-writes). test_runner_e2e.py persists knowledge, the test catalog and reports through project_knowledge_* and cortex_test_*, so both of its MCP clients now start with --allow-state. It still gets no --allow-writes: launching, input and attach stay with the operator-owned launch profile. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- tests/mcp_schema_tests.cpp | 8 ++++---- tests/test_runner_e2e.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/mcp_schema_tests.cpp b/tests/mcp_schema_tests.cpp index a33c9b13..b7cdaeda 100644 --- a/tests/mcp_schema_tests.cpp +++ b/tests/mcp_schema_tests.cpp @@ -151,12 +151,12 @@ int main() { "knowledge reads remain observational"); const auto knowledgePutRisk = api::mcp_contract::ClassifyTool( "project_knowledge_put", "POST", "/project/knowledge"); - check(knowledgePutRisk == api::mcp_contract::ToolRisk::Control, - "knowledge claim persistence requires control permission"); + check(knowledgePutRisk == api::mcp_contract::ToolRisk::State, + "knowledge claim persistence is a state write: it needs the person's --allow-state"); const auto knowledgeVerifyRisk = api::mcp_contract::ClassifyTool( "project_knowledge_verify", "POST", "/project/knowledge/{id}/verify"); - check(knowledgeVerifyRisk == api::mcp_contract::ToolRisk::Control, - "knowledge verification persists an audit result and requires control permission"); + check(knowledgeVerifyRisk == api::mcp_contract::ToolRisk::State, + "knowledge verification persists an audit result: a state write that needs --allow-state"); check(api::mcp_contract::RequiresMutationPermission(knowledgeVerifyRisk), "knowledge verification requires MCP permission"); diff --git a/tests/test_runner_e2e.py b/tests/test_runner_e2e.py index dcee8130..f1078e1f 100644 --- a/tests/test_runner_e2e.py +++ b/tests/test_runner_e2e.py @@ -44,7 +44,7 @@ def main(): tmp=pathlib.Path(temp);manifest=tmp/'target.json';config=tmp/'launch.json' config.write_text(json.dumps({'profiles':[{'name':'fixture','executable':str(target),'arguments':['--e2e-manifest',str(manifest)],'allow_stop':True,'allow_reset':True,'allow_attach':True,'allow_input':True,'allow_mouse':True,'test_keys':[32],'max_runs':2,'max_tests':13}]})) try: - client=Mcp(exe,['--tools','all','--launch-config',str(config),'--test-results',str(evidence)],root) + client=Mcp(exe,['--tools','all','--allow-state','--launch-config',str(config),'--test-results',str(evidence)],root) guide=client.data('cortex_agent_guide')['guide'];assert 'not a universal' in guide;checks.append('built_in_guide') started=client.data('cortex_launch',profile='fixture',attach=True,mutation_permission=True) pid=started['pid'];assert started['attached'];generation=started['generation'];checks.append('launch_auto_attach') @@ -664,7 +664,7 @@ def wait(id): checks.append('restart_respects_run_budget') client.data('cortex_stop',profile='fixture',pid=pid,mutation_permission=True);pid=0 client.close();client=None - client=Mcp(exe,['--test-results',str(evidence)],root) + client=Mcp(exe,['--allow-state','--test-results',str(evidence)],root) archived=client.data('cortex_test_get',id=saved);assert archived['archived'] and archived['run']['outcome']=='passed';checks.append('report_reloaded_new_agent') generated_reopened=client.data('cortex_test_step_get',id=generated_step_id) assert generated_reopened['step']['status']=='awaiting_control' and \ From 29c2ea314b043d6d0317a8de6d6bbbf145df159e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:36:37 +0000 Subject: [PATCH 08/12] test: runner e2e arms breakpoints with --allow-writes and proves the gate test_runner_e2e.py arms hardware-write log breakpoints on the target (debug_breakpoint_add/delete). That changes the target, so the person must grant --allow-writes; with --allow-state alone it is refused, as the Windows CI run of 13e0d74 showed (write_authority_required at the first breakpoint). - the main client starts with --allow-writes (includes --allow-state); - a first client with --allow-state only launches and attaches through the operator-owned profile and asserts that arming a breakpoint is refused, so "a launch profile may attach the runtime but grants no write authority" stays tested (it was broken once by a merge resolution); - the reconnect client keeps --allow-state: it only reads and records. Run end to end under Wine (windows debugger backend): 86 checks pass. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- tests/test_runner_e2e.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_runner_e2e.py b/tests/test_runner_e2e.py index f1078e1f..94e11f89 100644 --- a/tests/test_runner_e2e.py +++ b/tests/test_runner_e2e.py @@ -44,7 +44,26 @@ def main(): tmp=pathlib.Path(temp);manifest=tmp/'target.json';config=tmp/'launch.json' config.write_text(json.dumps({'profiles':[{'name':'fixture','executable':str(target),'arguments':['--e2e-manifest',str(manifest)],'allow_stop':True,'allow_reset':True,'allow_attach':True,'allow_input':True,'allow_mouse':True,'test_keys':[32],'max_runs':2,'max_tests':13}]})) try: - client=Mcp(exe,['--tools','all','--allow-state','--launch-config',str(config),'--test-results',str(evidence)],root) + # A launch profile may attach the runtime, but arming a breakpoint changes the + # target: with --allow-state alone it must be refused (write_authority_required). + gate=Mcp(exe,['--tools','all','--allow-state','--launch-config',str(config),'--test-results',str(evidence)],root) + try: + gate_start=gate.data('cortex_launch',profile='fixture',attach=True,mutation_permission=True) + assert gate_start['attached'];gate_pid=gate_start['pid'] + for _ in range(100): + if manifest.exists():break + time.sleep(.05) + refused=gate.call('debug_breakpoint_add',address=json.loads(manifest.read_text())['test_value'], + kind='hw_write',size=4,action='log',mutation_permission=True) + assert refused['isError'] and refused['structuredContent']['result']['error']=='write_authority_required',refused + gate.data('cortex_stop',profile='fixture',pid=gate_pid,mutation_permission=True) + manifest.unlink(missing_ok=True) + checks.append('launch_attach_without_allow_writes_cannot_arm_breakpoint') + finally: + gate.close() + # This client may also arm breakpoints (it records the hardware-write log below), + # so the person grants --allow-writes, which includes --allow-state. + client=Mcp(exe,['--tools','all','--allow-writes','--launch-config',str(config),'--test-results',str(evidence)],root) guide=client.data('cortex_agent_guide')['guide'];assert 'not a universal' in guide;checks.append('built_in_guide') started=client.data('cortex_launch',profile='fixture',attach=True,mutation_permission=True) pid=started['pid'];assert started['attached'];generation=started['generation'];checks.append('launch_auto_attach') From 0fff3523968718dfc209caaa18d54174bea8fb4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:51:39 +0000 Subject: [PATCH 09/12] fix: tools/list listed cortex_test_run as read_only The host tool that sends keys and mouse clicks to the launched program carried {"read_only": true} in tools/list: an if / else-if / else chain overwrote the target_input metadata set just before it. Only the label was wrong (the mutation_permission check and the operator-owned launch profile apply when the tool is called), but the label is what a client or a reviewer reads. It is now host_control with effect target_input and mutation_permission required. The security regression test also checks the label of five representative host tools (cortex_test_run, cortex_test_cancel, cortex_test_catalog_save, cortex_test_link, cortex_test_report), so a label that is present but wrong fails it; the earlier check only required some label. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- mcp_bridge/agent_guide.h | 7 ++++--- tests/security_no_writes_regression.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mcp_bridge/agent_guide.h b/mcp_bridge/agent_guide.h index 4ab99d4b..558419fd 100644 --- a/mcp_bridge/agent_guide.h +++ b/mcp_bridge/agent_guide.h @@ -262,11 +262,12 @@ inline nlohmann::json CortexTestTools() { const std::string toolName=name; const bool declaresPermission=properties.is_object()&&properties.contains("mutation_permission"); json meta; - if(declaresPermission) + if(toolName=="cortex_test_cancel") meta={{"host_control",true},{"effect","trial"}}; + else if(declaresPermission) { meta={{"host_control",true},{"mutation_permission_required",true}, {"effect",toolName=="cortex_test_run"?"target_input":"cortex_state"}}; - if(declaresPermission&&toolName!="cortex_test_run") meta["authority"]="state"; - else if(toolName=="cortex_test_cancel") meta={{"host_control",true},{"effect","trial"}}; + if(toolName!="cortex_test_run") meta["authority"]="state"; + } else meta={{"read_only",true}}; return json{{"name",name},{"description",description},{"inputSchema",{ {"type","object"},{"properties",properties},{"required",required},{"additionalProperties",false}}}, diff --git a/tests/security_no_writes_regression.py b/tests/security_no_writes_regression.py index 6a79fa2f..369b2956 100644 --- a/tests/security_no_writes_regression.py +++ b/tests/security_no_writes_regression.py @@ -413,6 +413,18 @@ def check(condition, message): unclassified.append("%s (%s)" % (tool.get("name"), meta.get("risk"))) check(not unclassified, "every listed tool has a risk classification" + ("" if not unclassified else ": " + ", ".join(unclassified))) + + # The label must also be the right one: a host tool that sends input to the program, persists + # records or stops a trial must say so, not just carry some label. + expected_effects = {"cortex_test_run": "target_input", "cortex_test_cancel": "trial", + "cortex_test_catalog_save": "cortex_state", "cortex_test_link": "cortex_state", + "cortex_test_report": "cortex_state"} + by_name = {t.get("name"): (t.get("_cortex") or {}) for t in tools} + wrong = ["%s (expected %s, listed %s)" % (n, e, by_name[n].get("effect") or ("read_only" if by_name[n].get("read_only") else None)) + for n, e in expected_effects.items() + if n in by_name and (by_name[n].get("effect") != e or by_name[n].get("read_only"))] + check(not wrong, "host tools that act on the program or on Cortex's records are labelled with their effect" + + ("" if not wrong else ": " + ", ".join(wrong))) tools = [t for t in tools if (t.get("_cortex") or {}).get("risk") != "unclassified"] before = read_hash(hash_file) From 752568e9b3baae196acf57e6832153e228fcf04d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:53:44 +0000 Subject: [PATCH 10/12] Release v1.0.1: write authority comes from the person, plus the agent test layer Security release. A client that changes a target must now start cortex.exe mcp with --allow-writes; a client that only saves Cortex's own records needs the new --allow-state; observing and analyzing clients are unaffected. It also publishes the agent test layer merged into main after v1.0.0 (launch profiles, bounded input trials, a persistent knowledge graph: 37 new tools, 251 on the full profile). - Version 1.0.1 in cortex --version, the About text and the MCP server version. - CHANGELOG: full v1.0.1 section (Security, Added, Changed, Fixed, Tests and CI). - README, README_INSTALL.txt and the docs name cortex-v1.0.1-windows-portable.zip. - publish-release.yml publishes v1.0.1, and when the commit message contains "Release v1.0.1" (a merge commit of a pull request with that title qualifies, not only a direct commit). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/publish-release.yml | 28 ++++++++++----------- CHANGELOG.md | 36 +++++++++++++++++++++++---- README.md | 2 +- README_INSTALL.txt | 6 ++--- app_imgui/PARITY_MATRIX.md | 2 +- app_imgui/cli.cpp | 2 +- app_imgui/desktop_ui.cpp | 2 +- core/api/mcp_protocol.h | 2 +- docs/getting-started.md | 2 +- docs/mcp.md | 4 +-- 10 files changed, 56 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 104d11b6..f6bff9f2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -43,15 +43,15 @@ permissions: contents: write concurrency: - group: publish-v1.0.0-${{ github.ref }} + group: publish-v1.0.1-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - RELEASE_TAG: v1.0.0 + RELEASE_TAG: v1.0.1 jobs: windows-x64-stage: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'Release v1.0.0') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') runs-on: windows-2025 timeout-minutes: 50 steps: @@ -130,7 +130,7 @@ jobs: retention-days: 2 windows-x86-stage: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'Release v1.0.0') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') runs-on: windows-2025 timeout-minutes: 45 steps: @@ -180,7 +180,7 @@ jobs: retention-days: 2 package: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'Release v1.0.0') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') needs: [windows-x64-stage, windows-x86-stage] runs-on: windows-2025 timeout-minutes: 15 @@ -236,8 +236,8 @@ jobs: $versionError = $versionProcess.StandardError.ReadToEnd() $versionProcess.WaitForExit() Write-Host ("release version output: {0}" -f $versionText.Trim()) - if ($versionProcess.ExitCode -ne 0 -or $versionText -notmatch "1\.0\.0") { - throw "release binary does not report v1.0.0 (exit=$($versionProcess.ExitCode), stderr=$versionError)" + if ($versionProcess.ExitCode -ne 0 -or $versionText -notmatch "1\.0\.1") { + throw "release binary does not report v1.0.1 (exit=$($versionProcess.ExitCode), stderr=$versionError)" } $oldPath = $env:PATH @@ -268,18 +268,18 @@ jobs: } New-Item -ItemType Directory -Force dist | Out-Null - Compress-Archive -Path "$bundle/*" -DestinationPath "dist/cortex-v1.0.0-windows-portable.zip" -CompressionLevel Optimal + Compress-Archive -Path "$bundle/*" -DestinationPath "dist/cortex-v1.0.1-windows-portable.zip" -CompressionLevel Optimal - name: Upload release archive uses: actions/upload-artifact@v4 with: - name: cortex-v1.0.0-windows-portable - path: dist/cortex-v1.0.0-windows-portable.zip + name: cortex-v1.0.1-windows-portable + path: dist/cortex-v1.0.1-windows-portable.zip if-no-files-found: error retention-days: 14 release: - if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'Release v1.0.0') + if: github.event_name == 'push' && contains(github.event.head_commit.message, 'Release v1.0.1') needs: package runs-on: ubuntu-latest steps: @@ -289,13 +289,13 @@ jobs: shell: bash run: | set -euo pipefail - awk '/^## \[v1.0.0\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md > release-notes.md + awk '/^## \[v1.0.1\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md > release-notes.md test -s release-notes.md - name: Download release archive uses: actions/download-artifact@v4 with: - name: cortex-v1.0.0-windows-portable + name: cortex-v1.0.1-windows-portable path: dist - name: Publish GitHub release @@ -312,4 +312,4 @@ jobs: --target "$GITHUB_SHA" \ --title "$RELEASE_TAG" \ --notes-file release-notes.md \ - dist/cortex-v1.0.0-windows-portable.zip + dist/cortex-v1.0.1-windows-portable.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 0730e665..04d4d4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,31 +2,57 @@ All notable changes to Cortex are documented in this file. -## [1.0.1] - Unreleased (proposed version) +## [v1.0.1] - 2026-10-16 -Security release. **Breaking for MCP clients that change a target:** they must now be started with `cortex.exe mcp --allow-writes` (see Security below). Read-only clients are unaffected. The version string reported by the server and the release artifacts are not changed by this entry; the number is a proposal for whoever cuts the release. +Security release. It also publishes the agent test layer that was merged into `main` after v1.0.0 (launch profiles, bounded input trials, a persistent knowledge graph; pull requests #17 to #39). + +**Breaking for MCP clients that write.** A client that changes a target (memory writes, breakpoints, patches, scripts, native calls, prompts) must now be started with `cortex.exe mcp --allow-writes`. A client that only saves Cortex's own records (project notes, addresses, pointer paths, knowledge, session facts, the `cortex_test_*` journals, catalog and reports) needs `--allow-state`, which cannot change the target; `--allow-writes` includes it. Clients that only observe and analyze are unaffected. See `README.md` and `docs/mcp.md` for the three configurations. ### Security The distributed v1.0.0 is affected: through `mutation_permission` (a boolean the client wrote itself), `batch_run`, `cortex_attach` and `cortex/private/route`, an AI client could change a target, or load the runtime into it, without the person having allowed writes. This was found by auditing the MCP path and reproduced against the real `cortex.exe mcp`. It is fixed as follows. -- **Write authority now comes from the person, not the model.** `cortex.exe mcp` (and the `cortex_host mcp` bridge) take `--allow-writes`; without it every state-changing call is refused with `write_authority_required`. The flag reaches the runtime on the authenticated pipe envelope, never inside a tool call. `mutation_permission=true` on the call is still required, as a declaration of intent, and no longer sufficient. The desktop supplies its own authority from the Writes allowed switch. **This is a behaviour change:** an MCP client that wrote before must now be started with `--allow-writes`. +- **Write authority now comes from the person, not the model.** `cortex.exe mcp` (and the `cortex_host mcp` bridge) take `--allow-writes`; without it every call that changes the target is refused with `write_authority_required`. The flag reaches the runtime on the authenticated pipe envelope, never inside a tool call. `mutation_permission=true` on the call is still required, as a declaration of intent, and no longer sufficient. The desktop supplies its own authority from the Read-only / Writes allowed switch. **This is a behaviour change:** an MCP client that wrote before must now be started with `--allow-writes`. +- **A narrower authority for Cortex's own records: `--allow-state`.** Saving project notes, addresses, pointer paths, knowledge and facts (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) and the test journals, catalog and reports (`cortex_test_report`, `cortex_test_step_*`, `cortex_test_catalog_save`, ...) is a new risk class, `state`. It needs `mutation_permission` and either `--allow-state` or `--allow-writes`, and is refused with `state_authority_required` otherwise. It cannot change the target: `--allow-state` alone still refuses every write to the target. Without it, an agent cannot plant records for a later session to read. A client that saved knowledge before must now be started with `--allow-state`. - **`batch_run` no longer bypasses the permission.** It was classed as an analysis although its `ops` write memory, freeze values and patch code. It is now classified from its operations against an allowlist, an unknown or computed operation is refused, and the same classification is re-checked after `$from_step` references are resolved so a reference cannot make an authorized step riskier. - **`cortex_attach` (and `--pid` / `--process`) no longer inject without authority.** Loading the runtime puts code in the target; without `--allow-writes` the server connects only to a runtime that is already there (for example loaded with `cortex.exe inject `). - **The private desktop channel is closed to MCP clients.** A raw `cortex/private/route` message sent by an MCP client was forwarded to the runtime, which ran any native route with no check. The server now forwards only `initialize`, `ping`, `server/discover`, `tools/list`, `tools/call` and `notifications/*`, and the runtime accepts a private route only from the desktop's own adapter. - **`scan_new` / `scan_next` with `pause_process`, and all `prompt_*` tools, are now control operations** that need authority. - **A tool that no rule classifies is refused.** Every listed tool now needs an explicit risk class; a tool with none is `unclassified` and is refused with `tool_not_classified` whatever authority the caller holds (before, an unknown name defaulted to `analyze`). `tests/fixtures/mcp_tool_risks.json` records the class of every primitive tool and a test fails if one changes or is missing. +- **Host tools added after v1.0.0 are classified.** Every `cortex_launch`, `cortex_test_*` and `cortex_knowledge_*` tool is listed in `tools/list` as `read_only` or as `host_control`; the ones that send input, persist records or stop a trial also name an `effect` (`target_input`, `cortex_state` or `trial`). The regression test fails on a tool without a label, or with the wrong one. Launch, restart, stop, attach and input stay bounded by the operator-owned launch profile, and a launch profile that attaches the runtime grants no write authority. - **Debugger attach and detach are journaled.** The `debug_*` read tools stay available without write authority, because they do not write memory, but a debugger attached to a process suspends its threads. Each attach/detach is shown in the AI Activity tab and appended to `cortex_debugger_events.jsonl`. -- **Host tools added after v1.0.0 are classified.** Every `cortex_launch`, `cortex_test_*` and `cortex_knowledge_*` tool is listed as `read_only` or as `host_control` with an `effect`, and the regression test fails on a tool without one. Launch, restart, stop and input stay bounded by the operator-owned launch profile. - **A narrower authority for Cortex's own records: `--allow-state`.** Saving project notes, addresses, pointer paths, knowledge and facts (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) and the test journals and reports (`cortex_test_report`, `cortex_test_step_*`, ...) is a new risk class, `state`. It needs `mutation_permission` and either `--allow-state` or `--allow-writes`; it cannot change the target, and `--allow-state` alone still refuses every write to the target. Without it, an agent cannot plant records for a later session to read. A client that saved knowledge before must now be started with `--allow-state`. - **Refused calls are logged** to `cortex_denied_mutations.jsonl` (tool, arguments, UTC time, `runtime` or `host`), beside `cortex_core.dll` and `cortex.exe`, so attempts can be counted. -- **Regression test.** `tests/security_no_writes_regression.py` starts the test target, then calls every tool `tools/list` returns, with plausible arguments and `mutation_permission=true`, against a server started without `--allow-writes`, and requires the target's code and data hash to be unchanged. Controls prove the hash does change when writes are allowed. +- **Regression test.** `tests/security_no_writes_regression.py` starts the test target, loads the runtime beforehand, then calls every tool `tools/list` returns (251 on the `--tools all` profile), with plausible arguments and `mutation_permission=true`, against a server started without `--allow-writes`, and requires the target's code and data hash to be unchanged. Controls prove the hash does change when writes are allowed, and that `--allow-state` saves a note but still cannot write the target. It runs on x64 and x86 in CI. - **Known limit, documented:** the pipe token is a file readable by the same Windows user. A client that also has a shell or file access can read it and bypass these controls. An authority granted by the desktop for a limited time is planned. +- **Left as is, on purpose:** `cortex_operation_cancel` and `cortex_test_cancel` need no authority (they only cancel something already authorized), and the `debug_*` read tools stay outside the control class: they write no memory, but a debugger attached to a process suspends its threads, which is why attach and detach are journaled. + +### Added + +- **Launching and testing programs.** The operator lists the programs an agent may start in a launch profile file (`cortex.exe mcp --launch-config `); the agent selects a profile by name and cannot supply its own executable, arguments or working directory. `cortex_launch_status`, `cortex_launch`, `cortex_restart` and `cortex_stop` start, restart and stop the program, with run limits, one running child per profile, and optional attach of the runtime. See `docs/automated-tests.md`. +- **Bounded input trials.** `cortex_test_run` posts allowlisted input to the launched program, reads values before and after, evaluates expectations (`increased`, `unchanged`, ...), waits on conditions, and writes JSON and Markdown reports under `--test-results`. `cortex_test_get`, `cortex_test_list` and `cortex_test_cancel` read and stop trials; `cortex_test_compare` compares repeatable trials. Input never uses `SendInput`; only the allowlisted program receives window messages. +- **Persistent typed knowledge graph.** `project_knowledge_put`, `project_knowledge_get`, `project_knowledge_list`, `project_knowledge_query`, `project_knowledge_verify` and `project_knowledge_graph` keep claims, revisions and verifications in the project; `cortex_knowledge_graph` and `cortex_knowledge_code_check` read them and revalidate code references after a restart or a new generation of the program. +- **Evidence and code observations.** `cortex_test_link` links a recorded trial to a knowledge record; `cortex_test_code_context`, `cortex_test_code_candidates` and `cortex_test_code_link` resolve the module and symbol of code observed during a trial (x64 PE unwind ranges included) and retain the location with the binary hash. `cortex_test_evidence_graph` shows the evidence behind a claim. +- **Investigations.** Predictions are evaluated against what a trial observed; `cortex_test_choose` picks a discriminating next experiment; `cortex_test_step_prepare`, `cortex_test_step_continue`, `cortex_test_step_record`, `cortex_test_step_next` and `cortex_test_step_get` run it as a supervised step that survives a reconnection; `cortex_test_generate` and `cortex_test_generate_prepare` propose bounded experiments (experimental). `cortex_test_report`, `cortex_test_report_get`, `cortex_test_investigation_report`, `cortex_test_investigation_summary`, `cortex_test_catalog_list` and `cortex_test_catalog_save` keep the reports and a catalog of tested scenarios. +- **`cortex_agent_guide`** returns the built-in guidance for an agent using these tools; `AGENTS.md`, `agent/testing.md` and `agent/investigation-template.md` document the workflow. +- 37 tools in total (31 host tools and the 6 `project_knowledge_*` primitives): the `--tools all` profile now lists 251 tools and the compact profile 72. ### Changed - **Semantic tool results no longer carry `confidence`, `evidence_confidence`, `candidates`, `alternative_hypotheses` or `tested_hypotheses`.** Cortex never computed them (`confidence` was a constant 1.0), and a figure that looks like a measure misleads the agent reading it. `status` is now documented as what it is: `plan_ready`, `completed`, `failed`, `cancelled` or `timed_out`. - The documentation and README no longer say that the application enforces writes for every call where that was only true of the desktop. +### Fixed + +- **`debug_registers` froze the target.** `ReadTargetThreadRegisters` suspended a thread, read its context, then closed the thread handle before the guard that resumes it ran; `ResumeThread` on a closed handle fails, so the thread stayed suspended. Found by the new regression test; fixed by resuming explicitly before closing the handle. +- **`cortex_test_run` was listed as `read_only`.** `tools/list` described the tool that sends keys and mouse clicks to the launched program as read-only. It is now `host_control` with `effect: target_input` and requires `mutation_permission`, like the other host tools that act. Only the label was wrong: the permission and the operator-owned launch profile were already enforced when the tool is called. The regression test now checks the label of five representative host tools. + +### Tests and CI + +- The five Windows workflows `imgui-gui-tests`, `contracts`, `imgui-ui`, `unified-host` and `e2e-windows` run on every pull request and on pushes to `main`, with no path filter, so a change outside the usual paths cannot skip them. +- `tests/fixtures/mcp_tool_risks.json` freezes the risk class of every one of the 179 primitive tools; `tests/mcp_authority_tests.cpp` covers classification, the three authority levels and the denial log. +- `tests/test_runner_e2e.py` arms hardware-write log breakpoints, so its main client is started with `--allow-writes`; a first client with `--allow-state` only asserts that a launch-profile attach cannot arm one. +- `publish-release.yml` publishes when the commit message contains `Release v1.0.1`, so a merge commit whose pull request is titled that way publishes as well as a direct commit. + ## [v1.0.0] - 2026-09-30 First stable release. It is the Dear ImGui desktop that was prepared as the unpublished 0.8 candidate, validated against a real game (AssaultCube) in addition to the automated suites. Versions 0.7.0 and 0.8.0 were not published; their changes are all below. diff --git a/README.md b/README.md index 8a080cac..e6824cc7 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ## Quick start -1. Download **`cortex-v1.0.0-windows-portable.zip`** from the [latest release](https://github.com/PdB333/cortex/releases) and extract the whole archive. +1. Download **`cortex-v1.0.1-windows-portable.zip`** from the [latest release](https://github.com/PdB333/cortex/releases) and extract the whole archive. 2. Run **`cortex.exe`**, click **Select process** and attach to your target. 3. Find a value in **Memory › Value scan** (*First scan*, change it in the target, *Next scan*). 4. Double-click a result to keep it in **Addresses**, then right-click it to open it in the Memory viewer, Disassembler, Debugger or RE. diff --git a/README_INSTALL.txt b/README_INSTALL.txt index a2b06a78..41252cc5 100644 --- a/README_INSTALL.txt +++ b/README_INSTALL.txt @@ -1,7 +1,7 @@ -CORTEX v1.0.0 - INSTALLATION AND QUICK START +CORTEX v1.0.1 - INSTALLATION AND QUICK START ============================================ -Cortex v1.0.0 ships as a single portable Windows application: cortex.exe. +Cortex v1.0.1 ships as a single portable Windows application: cortex.exe. The x64 and x86 instrumentation runtimes live in the runtime folder and are selected automatically for each target. Nothing else has to be installed (no Qt, no Visual C++ runtime). @@ -11,7 +11,7 @@ INSTALLATION 1. Download the archive: - cortex-v1.0.0-windows-portable.zip + cortex-v1.0.1-windows-portable.zip 2. Extract the whole archive into a normal, writable folder, for example: diff --git a/app_imgui/PARITY_MATRIX.md b/app_imgui/PARITY_MATRIX.md index 6fd3546b..98ed3476 100644 --- a/app_imgui/PARITY_MATRIX.md +++ b/app_imgui/PARITY_MATRIX.md @@ -78,7 +78,7 @@ Qt invokable remains without an explicit migration outcome. | deterministic GUI smoke tests | green in CI (headless, native window, workspace, attached, multi-session, cross-bitness suites) | | portable dependency-closure validation | green in CI (`imgui-ui.yml`) | | performance, memory and startup comparison versus Qt | runs in CI (`ui-migration-benchmarks.yml`) | -| Windows packaging/release workflow | `publish-release.yml` (publishes only on a `Release v1.0.0` commit) | +| Windows packaging/release workflow | `publish-release.yml` (publishes only on a `Release vX.Y.Z` commit that matches the version written in the workflow) | | Linux renderer/runtime decision | deferred, see [`PLATFORM_SCOPE.md`](PLATFORM_SCOPE.md) | | manual validation on real authorized targets | pending | diff --git a/app_imgui/cli.cpp b/app_imgui/cli.cpp index 13c05c1e..7b2be92c 100644 --- a/app_imgui/cli.cpp +++ b/app_imgui/cli.cpp @@ -78,7 +78,7 @@ static std::optional RunCliMode(std::vector& args) { return 0; } if (command == "--version" || command == "version") { - std::puts("cortex 1.0.0"); + std::puts("cortex 1.0.1"); return 0; } #if CORTEX_WITH_TEST_MODES diff --git a/app_imgui/desktop_ui.cpp b/app_imgui/desktop_ui.cpp index 9fc8810a..b1c934cf 100644 --- a/app_imgui/desktop_ui.cpp +++ b/app_imgui/desktop_ui.cpp @@ -1188,7 +1188,7 @@ void DrawApp(AppState& app) { ImGui::EndMenu(); } if (ImGui::BeginMenu("Help")) { - ImGui::TextDisabled("Cortex v1.0.0"); + ImGui::TextDisabled("Cortex v1.0.1"); ImGui::Separator(); if (ImGui::MenuItem("Diagnostics")) app.workspaces.Select("diagnostics"); diff --git a/core/api/mcp_protocol.h b/core/api/mcp_protocol.h index ea2d7271..7aa8e486 100644 --- a/core/api/mcp_protocol.h +++ b/core/api/mcp_protocol.h @@ -14,7 +14,7 @@ using json = nlohmann::json; inline constexpr const char* kModernProtocolVersion = "2026-07-28"; inline constexpr const char* kLatestLegacyProtocolVersion = "2025-11-25"; inline constexpr const char* kServerName = "cortex"; -inline constexpr const char* kServerVersion = "1.0.0"; +inline constexpr const char* kServerVersion = "1.0.1"; inline constexpr long long kCatalogTtlMs = 30000; enum class ToolProfile { diff --git a/docs/getting-started.md b/docs/getting-started.md index 7b19f86e..481d841a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,7 +6,7 @@ For a screenshot-based walkthrough, see [Illustrated UI walkthrough](ui-walkthro ## 1. Get a testable build -Use the `cortex-v1.0.0-windows-portable.zip` release asset or, for a branch build, the `cortex-imgui-preview-windows-portable` artifact of the latest green **Lightweight ImGui UI** workflow run. +Use the `cortex-v1.0.1-windows-portable.zip` release asset or, for a branch build, the `cortex-imgui-preview-windows-portable` artifact of the latest green **Lightweight ImGui UI** workflow run. Extract the entire archive and keep its directory structure intact. Cortex needs the `runtime/x64` and `runtime/x86` folders beside `cortex.exe`: one runtime per architecture, because Windows cannot load a 64-bit DLL into a 32-bit game or the reverse. Cortex picks the right one for each target. No Qt or other runtime has to be installed. diff --git a/docs/mcp.md b/docs/mcp.md index a710eb8a..a68e6031 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -277,7 +277,7 @@ A tool with no explicit risk classification is refused (`tool_not_classified`), **`cortex_operation_cancel` needs no write authority, on purpose.** It only sets a cancellation flag on an operation that is already running, and that operation passed the authority and intent checks when it started. A cancelled semantic execution rolls back its own transaction, so cancelling returns the target toward its earlier state; it cannot start a change, widen one, or reach a tool the caller could not already call. Requiring authority would only stop a read-only session from cancelling a long observation. It is classified `host_control` in `tools/list`, and the regression test requires every listed tool to carry a class. -**Host tools of the agent test layer** (`cortex_launch`, `cortex_restart`, `cortex_stop`, `cortex_test_*`, `cortex_knowledge_*`). Each is listed with a class: `read_only`, or `host_control` with an `effect` (`target_input`: sends keys or clicks to the program; `cortex_state`: writes Cortex's own journals, reports or knowledge; `trial`: stops a running trial). Launch, restart, stop and input are bounded by the operator-owned `--launch-config` profile (`allow_attach`, `allow_stop`, `allow_reset`, `allow_input`, `allow_mouse`, `test_keys`, run budgets), which the agent cannot change; that profile is the authority for those actions, and `--allow-writes` is not additionally required. The tools that write journals, reports or knowledge (`cortex_test_report`, `cortex_test_step_*`, `cortex_test_catalog_save`, `cortex_test_link`, `cortex_test_code_link`, `cortex_test_investigation_report`, `cortex_test_generate_prepare`) require `mutation_permission=true`, which is the caller's own declaration: **they need `--allow-state`, not `--allow-writes`.** They write only under Cortex's own data directory, not into the target, but the content they store comes from the client, so a session that has not been given that authority cannot plant records for a later session to read. These host tools now need `--allow-state` (or `--allow-writes`) in addition to the declaration; the runtime primitives that do the same for the project (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) are `state` operations with the same authority. +**Host tools of the agent test layer** (`cortex_launch`, `cortex_restart`, `cortex_stop`, `cortex_test_*`, `cortex_knowledge_*`). Each is listed with a class: `read_only`, or `host_control`. The host tools that act on the program or on Cortex's records also name an `effect` (`target_input`: sends keys or clicks to the program; `cortex_state`: writes Cortex's own journals, reports or knowledge; `trial`: stops a running trial); `cortex_launch`, `cortex_restart` and `cortex_stop` are `host_control` bounded by the launch profile. Launch, restart, stop and input are bounded by the operator-owned `--launch-config` profile (`allow_attach`, `allow_stop`, `allow_reset`, `allow_input`, `allow_mouse`, `test_keys`, run budgets), which the agent cannot change; that profile is the authority for those actions, and `--allow-writes` is not additionally required. The tools that write journals, reports or knowledge (`cortex_test_report`, `cortex_test_step_*`, `cortex_test_catalog_save`, `cortex_test_link`, `cortex_test_code_link`, `cortex_test_investigation_report`, `cortex_test_generate_prepare`) require `mutation_permission=true`, which is the caller's own declaration: **they need `--allow-state`, not `--allow-writes`.** They write only under Cortex's own data directory, not into the target, but the content they store comes from the client, so a session that has not been given that authority cannot plant records for a later session to read. These host tools now need `--allow-state` (or `--allow-writes`) in addition to the declaration; the runtime primitives that do the same for the project (`project_note_add`, `project_knowledge_put`, `re_session_fact_set`, ...) are `state` operations with the same authority. **Known limit.** The runtime's pipe token is a file (`cortex.mcp..token`) readable by the Windows user that runs Cortex. A client that has a shell or file access as that user can read it and speak to the pipe directly, outside every rule above. These controls hold for an agent whose only access to Cortex is its MCP tool calls. @@ -287,7 +287,7 @@ A tool with no explicit risk classification is refused (`tool_not_classified`), ## Server metadata -`initialize` and `server/discover` report the server name and version `1.0.0`. `/schema/validate` on the runtime checks the tool manifest and MCP schemas and is exercised by the integrated E2E, which also calls every read-only tool of the manifest. +`initialize` and `server/discover` report the server name and version `1.0.1`. `/schema/validate` on the runtime checks the tool manifest and MCP schemas and is exercised by the integrated E2E, which also calls every read-only tool of the manifest. ## Compatibility From fee9c60500b2cde82a99932325a2b3f3cf42f10a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:47:52 +0000 Subject: [PATCH 11/12] Release v1.0.1: the CLI compatibility smoke expects 1.0.1 imgui-ui.yml still matched "1\.0\.0" against `cortex --version`, which now answers "cortex 1.0.1"; the CLI compatibility smoke of the windows-x64 job failed on it (run 38883616202). The binary was right, the check was stale. My first search for version strings missed it because the dots are escaped in the regex; a search for the escaped form finds no other occurrence. This commit message starts with the release marker on purpose: publish-release reads the message of the head commit of the push to main. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- .github/workflows/imgui-ui.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/imgui-ui.yml b/.github/workflows/imgui-ui.yml index 4e594762..f5444b4c 100644 --- a/.github/workflows/imgui-ui.yml +++ b/.github/workflows/imgui-ui.yml @@ -81,7 +81,7 @@ jobs: } $version = Invoke-CortexCli @("--version") - if ($version -notmatch "1\.0\.0") { throw "Unexpected version output: $version" } + if ($version -notmatch "1\.0\.1") { throw "Unexpected version output: $version" } $help = Invoke-CortexCli @("--help") if ($help -notmatch "cortex mcp") { throw "MCP command missing from help" } From 34ce4dd48301571ab13bdadb54a259891f161e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:47:27 +0000 Subject: [PATCH 12/12] Release v1.0.1: a read-only call no longer revokes the desktop's write authority The desktop keeps the person's Writes allowed state as one shared flag on the payload client. It is set once per frame (desktop_ui.cpp), and my authority change also set it inside RuntimeModelBase::Call and ReModel::Call from the `mutationAllowed` argument. The read-only overload passes mutationAllowed=false whatever the switch says, so every read-only model call lowered the flag. The debugger's own breakpoint calls (DebuggerModel goes to the payload directly) then reached the runtime with writes_allowed=false and were refused: with Writes allowed on, "find what accesses" could record nothing. Windows CI caught it once (windows-release-full, portable pass, test access_finder_recorded_no_write, PR run 38905331962). It did not show on the earlier runs of the same commit because it depends on whether a read-only model call runs between the frame start and the breakpoint call. Reproduced under Wine with the build of fee9c60: the GUI attached suite fails 4 of 4 runs with access_finder_recorded_no_write. With this change it passes 3 of 3 (frame_writes=16). Only the per-frame sync lowers the authority now; a mutating call, which got past the switch check, raises it. app_models_tests encoded the old behaviour (a read-only call was expected to drop the authority); it now asserts the reverse, and fails on the previous code. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u --- app_imgui/application/re_model.cpp | 4 ++- app_imgui/application/runtime_model_base.cpp | 12 +++++--- tests/app_models_tests.cpp | 30 +++++++++++++++++--- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app_imgui/application/re_model.cpp b/app_imgui/application/re_model.cpp index 32d734a1..7c359f96 100644 --- a/app_imgui/application/re_model.cpp +++ b/app_imgui/application/re_model.cpp @@ -56,7 +56,9 @@ bool ReModel::Call(const std::string& tool, json arguments, if (error) *error = "mutation_permission_required"; return false; } - payload_.SetWritesAuthority(mutationAllowed); + // See RuntimeModelBase::Call: only a mutating call (switch on) raises the + // shared authority; the per-frame sync in desktop_ui.cpp is what lowers it. + if (mutation) payload_.SetWritesAuthority(true); if (!EnsureRuntime(mutation, mutationAllowed, error)) return false; if (mutation) arguments["mutation_permission"] = true; diff --git a/app_imgui/application/runtime_model_base.cpp b/app_imgui/application/runtime_model_base.cpp index ae86753e..e6ea3f0e 100644 --- a/app_imgui/application/runtime_model_base.cpp +++ b/app_imgui/application/runtime_model_base.cpp @@ -39,10 +39,14 @@ bool RuntimeModelBase::Call(const std::string& tool, json arguments, json& resul if (error) *error = "mutation_permission_required"; return false; } - // The runtime enforces the same switch on its side: authority is the - // person's Writes allowed state, and `mutation_permission` below only says - // this call means to change something. - payload_.SetWritesAuthority(mutationAllowed); + // The runtime enforces the same switch on its side. The person's Writes + // allowed state reaches the payload once per frame (desktop_ui.cpp), and + // that is the only place that lowers it: a read-only call passes + // mutationAllowed = false whatever the switch says, so it must not touch the + // shared authority, or it would revoke it for the debugger's direct calls + // made later in the same frame. A mutating call got here with the switch on, + // so raising is consistent; `mutation_permission` below only declares intent. + if (mutation) payload_.SetWritesAuthority(true); if (!EnsureRuntime(mutation, mutationAllowed, error)) return false; if (mutation) arguments["mutation_permission"] = true; diff --git a/tests/app_models_tests.cpp b/tests/app_models_tests.cpp index 51f52d29..2fb63199 100644 --- a/tests/app_models_tests.cpp +++ b/tests/app_models_tests.cpp @@ -393,13 +393,35 @@ nop 5 check(call && call->arguments.value("mutation_permission", false), "the same call also declares mutation_permission"); check(watches.Refresh(&error), "a read-only refresh works"); + } + + // The person's switch reaches the payload once per frame (desktop_ui.cpp) and + // that is where it is lowered. A read-only call passes mutationAllowed = false + // whatever the switch says, so it must leave the authority alone: lowering it + // revoked the debugger's own breakpoint calls made later in the same frame + // ("find what accesses" recorded nothing). + { + FakeTransport transport; + transport.loaded = true; transport.writesAuthority = true; - transport.calls.clear(); - transport.authorityPerCall.clear(); WatchesModel readOnly(transport); + std::string error; readOnly.Refresh(&error); - const bool refreshAuthority = !transport.authorityPerCall.empty() && transport.authorityPerCall.back(); - check(!refreshAuthority, "a call made with writes not allowed carries no write authority"); + check(transport.writesAuthority, "a read-only call does not lower the authority the person set"); + check(!transport.authorityPerCall.empty() && transport.authorityPerCall.back(), + "a read-only call runs with the authority the person set"); + } + + // With the switch off nothing raises it: a mutating call is refused before it + // reaches the payload, and reads leave the default (no authority) as it is. + { + FakeTransport transport; + transport.loaded = true; + WatchesModel watches(transport); + std::string error; + check(!watches.AddWatch("0x1000", "i32", "hp", false, &error), "AddWatch with writes off is refused"); + watches.Refresh(&error); + check(!transport.writesAuthority, "with the switch off no call raises the authority"); } // Read-only refresh never injects: it only connects to a loaded runtime.