Skip to content

Release v1.0.1: MCP write authority comes from the person, plus the agent test layer - #42

Merged
PdB333 merged 13 commits into
mainfrom
security/mcp-write-authority
Oct 16, 2026
Merged

PdB333 merged 13 commits into
mainfrom
security/mcp-write-authority

Conversation

@PdB333

@PdB333 PdB333 commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

How this publishes

publish-release.yml runs on the push to main and publishes when the head commit message contains Release v1.0.1. A merge commit of a pull request carries the PR title in its message, so "Create a merge commit" (the usual way in this repository) publishes as long as the title above is kept; squash does too; "Rebase and merge" also does, because the last commit of the branch starts with Release v1.0.1.

It then builds x64 and x86, assembles cortex-v1.0.1-windows-portable.zip (README, README_INSTALL.txt, CHANGELOG.md, LICENSE), checks that cortex --version answers 1.0.1, takes the release notes from the ## [v1.0.1] section of CHANGELOG.md, and creates the tag and the GitHub release. It refuses to overwrite an existing v1.0.1 release.

The same workflow ran on this PR's final head (its pull_request run builds the archive and skips the publish step): run 38910602137.

Summary

Security release. 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. This moves the decision outside the model, adds a narrower tier for Cortex's own records, and adds a regression test that fails if any listed tool can change a target without that decision. It also publishes the agent test layer merged into main after v1.0.0 (pull requests #17 to #39: launch profiles, bounded input trials, a persistent knowledge graph; 37 new tools).

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/reports/links) needs --allow-state, which cannot change the target; --allow-writes includes it;
  • read-only and analysis clients are unaffected.

README, docs/mcp.md, docs/getting-started.md and agent/agents.md show the three configurations. The version is 1.0.1 (a minor number such as 1.1.0 would also fit 37 new tools; it is a text replacement if preferred).

What changes

  • Authority comes from outside the model, in three levels. None = observe and analyze. --allow-state = also save Cortex's records (state_authority_required). --allow-writes = also change the target or load the runtime (write_authority_required; includes state). Both flags are set by the person and reach the runtime on the authenticated pipe envelope (writes_allowed, state_allowed), never inside a tool call. The desktop supplies its own authority from the Read-only / Writes allowed switch (synced once per frame). mutation_permission=true stays as the caller's declared intent: required, no longer sufficient.
  • batch_run is classified from its operations against an allowlist; unknown or computed operations are refused, re-checked after $from_step resolution.
  • cortex_attach no longer injects the runtime without authority; the server forwards only initialize, ping, server/discover, tools/list, tools/call and notifications/*; the private desktop route is accepted only from the desktop adapter.
  • Unclassified tools are refused by default (tool_not_classified). tests/fixtures/mcp_tool_risks.json records the class of all 179 primitives. scan_* with pause_process and prompt_* are control operations; 11 project/fact tools are the state class.
  • Host tools of the agent test layer carry a label in tools/list (read_only, or host_control with an effect of target_input, cortex_state or trial for the ones that act). The operator-owned launch profile (--launch-config) stays the authority for launch, restart, stop, attach and test input; a launch profile that attaches the runtime grants no write authority (tested).
  • Refused calls are logged to cortex_denied_mutations.jsonl; debugger attach/detach are journaled (AI Activity tab and cortex_debugger_events.jsonl).
  • Semantic results no longer carry confidence, evidence_confidence or hypothesis fields that Cortex never computed.
  • Release mechanics: version 1.0.1 in cortex --version, the About text and the MCP server version; README, README_INSTALL.txt and the docs name cortex-v1.0.1-windows-portable.zip; publish-release.yml and the CLI smoke in imgui-ui.yml expect 1.0.1; CHANGELOG has a full ## [v1.0.1] section.

Three bugs found by the work on this branch

  • debug_registers froze the target. ReadTargetThreadRegisters closed the thread handle before the guard that resumes it ran, so the thread stayed suspended. Fixed in core/target/thread_provider.cpp; found by the regression test.
  • cortex_test_run was listed as read_only in tools/list (an if / else-if / else chain overwrote its target_input label). Only the label was wrong: the permission and the launch profile were enforced at call time. Fixed; the regression test now checks the label of five representative host tools.
  • A read-only call revoked the desktop's write authority (introduced by this PR, caught by its own CI before merge). The desktop keeps the person's Writes allowed state as one shared flag on the payload client, synced once per frame; this PR also set it inside RuntimeModelBase::Call / ReModel::Call, and the read-only overload passes mutationAllowed = false, so every read-only model call lowered it. The debugger's direct breakpoint calls then reached the runtime with writes_allowed=false and were refused: with Writes allowed on, "find what accesses" could record nothing (GUI suite test access_finder_recorded_no_write, failed once in the PR's run 38905331962 and not on 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 (4 of 4 failures on the build of fee9c60, 3 of 3 passes with the fix). Fixed in 34ce4dd: only the per-frame sync lowers the authority; a mutating call raises it. tests/app_models_tests.cpp had encoded the old behaviour and now asserts the reverse (it fails on the previous code).

Evidence (Windows CI, head 34ce4dd48301571ab13bdadb54a259891f161e6c, eight workflows green on the pull request)

tests/security_no_writes_regression.py, with no --skip-tool, starts the test target, loads the runtime beforehand, starts cortex.exe mcp without --allow-writes, calls every tool tools/list returns (251) with mutation_permission=true, and compares the target's code-and-data hash before and after. Step "Without --allow-writes no MCP call changes a target", job windows-release-full of run 38910602459:

arch calls refused state hash before state hash after
x64 348 113 00fe1d6cc8b090dd 00fe1d6cc8b090dd
x86 348 113 6151f471602f88dc 6151f471602f88dc

Controls in the same run: with --allow-writes and mutation_permission the write is applied and the hash changes; with --allow-writes but no mutation_permission it is refused; with --allow-state a note is saved, a host journal tool passes the authority check, the target still cannot be written (write_authority_required) and its hash is unchanged; batch_run writes, unknown ops, computed op names, semantic batch_run and a raw cortex/private/route are refused; cortex_attach without authority loads nothing (no token file); the target survives cortex_detach. No stall warning in this run. The GUI suite passes in both of its passes in the same job (the access finder records its writes: frame_writes=5 and 7).

Runs on the head: imgui-gui-tests 38910602459, contracts 38910602153, imgui-ui 38910602164, unified-host 38910602279, e2e-windows 38910602150, full-windows-build 38910602249 (x64 and x86, including "Test bounded input trials and reports"), publish-release 38910602137, ui-migration-benchmarks 38910602201.

History, so the evidence is not read as a single pass: 8d20d2c was green on five workflows before main was merged; 22a55f6 (merge + --allow-state) had contracts red (a stale test expecting control for knowledge writes); 13e0d74 had full-windows-build red (the runner e2e arms breakpoints, which correctly needs --allow-writes); 29c2ea3 was green on six; 752568e (release commit) had imgui-ui red because its CLI smoke still expected 1.0.0; fee9c60 was green on seven when run by hand and then failed once on the PR (the authority regression above); 34ce4dd fixes it and is green on all eight.

Known limits (not fixed here)

  • The pipe token is a file readable by the same Windows user. An agent that also has a shell or file access as that user can read it and bypass these controls. Documented in docs/mcp.md. An authority granted by the desktop for a limited time is planned, not built.
  • debug_* read tools stay outside the control class, on purpose. They write no memory, but a debugger attached to a process suspends its threads; "read-only" means "no write to memory", not "no effect on the target". Attach/detach are journaled and counted. A separate --allow-debug is not built.
  • cortex_operation_cancel and cortex_test_cancel need no authority (they only cancel something already authorized). docs/mcp.md documents this for cortex_operation_cancel; cortex_test_cancel is labelled trial in tools/list and tested, but not yet named in the docs.
  • The desktop's authority is still one shared flag. It is now lowered only by the per-frame sync, but a flag shared between threads is a weaker design than passing the authority with each call; that refactor is not part of this release.
  • Stall warnings. The regression test warns when the target is unresponsive after a call. Earlier runs warned once (1.6 s after lua_scripts_* / ocr, capture_runtime_state, 2.6 s after watch_allocations_*); the target recovered each time and its hash is unchanged, so they read as tool latency, not a freeze; they were not isolated with --isolate. The last two runs had none.
  • Wine. Development runs used Wine; the evidence above is Windows CI only.
  • tests/semantic_plan_p1_tests.cpp is stale (it describes the plan-only phase) and is run by no workflow; it is untouched.

For the open pull requests

New host or primitive tools added by other branches (for example #40 and #41) must carry a label in tools/list and, for primitives, a class in tests/fixtures/mcp_tool_risks.json, or security_no_writes_regression.py and tool_not_classified will reject them; the tools that save Cortex's records belong to the state class.

🤖 Generated with Claude Code

https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u

claude added 12 commits October 7, 2026 13:08
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
- 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
…equest

- 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
…file

The previous fix scoped them inside the block, so the test no longer compiled.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
main gained the agent test layer (launch profiles, cortex_test_*, persistent
knowledge graph, 37 new tools). Conflicts were in mcp_bridge/host_mode.cpp and
agent/agents.md. Resolution:
- both option sets kept (--allow-writes and --launch-config/--test-results);
- cortex_launch(attach) may load the runtime because the operator-owned
  profile already granted allow_attach;
- the new host tools carry a class in tools/list (read_only, or host_control
  with an effect), the regression test requires it, and the fixture lists the
  six new project_knowledge_* primitives;
- docs/CHANGELOG state that the journal/knowledge-writing host tools are not
  yet behind --allow-writes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
…w-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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
…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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
… 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
PdB333 pushed a commit that referenced this pull request Oct 16, 2026
…een)

- PREREGISTRATION: the pin moves from 29c2ea3 to fee9c60, the head of the release
  pull request (#42), with the run ids of the seven green workflows and the state hashes of the no-write regression
  (x64 422b2c886ff27cfd, x86 848f9ca4848caa2e, unchanged).
- cortex_tool_lists.json regenerated at that commit: identical in names, counts and classes to the lists at
  29c2ea3 (251 tools; C 137, D0 72, D0' 93). Between the two commits only the version string and the tools/list
  label of cortex_test_run changed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u

PdB333 commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

CI: windows-release-full failed once on this head (run 38905331962); re-run once, not merging until green.

  • Failing step: "Validate portable GUI with clean PATH" (the second pass of the GUI suite), test attached-x64: memory tools failed: access_finder_recorded_no_write. That check arms a hardware-write log breakpoint on the target's frame counter and waits up to ~12 s for at least one hit; it saw none.
  • Why I don't read it as this PR's regression: the first pass of the same job ("Run full GUI integration suite", same test) passed seconds earlier; the same job passed both passes on this exact commit in run 38889604204; the --isolate diagnostic that CI ran after the failure checked 219 tools on a fresh target each and found none that stalls or kills the target. The change under review touches version strings, the CHANGELOG, a tools/list label and authority checks, none of it on the access-finder path.
  • That is an argument, not a root cause: the test is timing-based. If the re-run fails again I will treat it as a real bug and investigate before merging.

Generated by Claude Code

…e 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u

PdB333 commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

Correction to my previous comment: the windows-release-full failure was this PR's, and it is fixed in 34ce4dd.

I wrote that nothing in this change was on the access-finder path. That was wrong: the desktop's write authority is on it.

  • Cause. The desktop keeps the person's Writes allowed state as one shared flag on the payload client, set once per frame (desktop_ui.cpp). This PR also set it inside RuntimeModelBase::Call and ReModel::Call from the mutationAllowed argument, and the read-only overload passes mutationAllowed = false whatever the switch says. So any read-only model call lowered the flag, and the debugger's own breakpoint call (DebuggerModel goes to the payload directly) reached the runtime with writes_allowed=false and was refused. With Writes allowed on, "find what accesses" could record nothing. It depends on whether a read-only model call runs between the frame start and the breakpoint call, which is why the same commit passed on earlier runs.
  • Reproduced. Under Wine, the GUI attached suite built from fee9c60 fails 4 of 4 runs with access_finder_recorded_no_write; built from 34ce4dd it passes 3 of 3 (frame_writes=16).
  • Fix. Only the per-frame sync lowers the authority; a mutating call, which already passed the switch check, raises it. tests/app_models_tests.cpp had encoded the old behaviour (it expected a read-only call to drop the authority); it now asserts the reverse and fails on the previous code.
  • Not merging fee9c60. The checks are running again on 34ce4dd; the release is merged only when they are green on that head.

Generated by Claude Code

@PdB333
PdB333 merged commit 91f5a22 into main Oct 16, 2026
42 checks passed
PdB333 pushed a commit that referenced this pull request Oct 16, 2026
- PREREGISTRATION: the pin is the release commit 91f5a22 (pull request #42), whose tree is
  identical to the validated head 34ce4dd. Eight Windows workflows green on that head; state hashes of the no-write
  regression unchanged (x64 00fe1d6cc8b090dd, x86 6151f471602f88dc).
- cortex_tool_lists.json regenerated at that commit: identical in names, counts and classes to the lists at fee9c60
  (251 tools; C 137, D0 72, D0' 93).
- main is merged into this branch, so it now contains the released code.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018noSWjr7FkDDFzzYZ5XS9u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants