Conversation
d1593db to
dfc6bb5
Compare
A read-only flight recorder for local MiniMax Code sessions, backed by the runtime's own SQLite projection: seven MCP tools plus one Skill for analysis, and a local interactive Studio panel for reading a session by eye. What it answers, without opening a single session file: turns and steps, LLM and tool wall-clock time, decode time, token and context breakdown, tool arguments and results, which calls failed and why, when compaction ran, and which sub-agents were dispatched. Time-to-first-token is reported as unavailable rather than estimated, because the runtime does not persist it. Package layout — the same shape the rest of this repository uses: plugin.json portable Agent Plugins registry manifest .claude-plugin/plugin.json mcode 0.4.0+ layout (Skill path + MCP server) mcp.json stdio MCP descriptor skills/mcode-trajectory-studio/SKILL.md exactly one Skill README.md / README.zh-CN.md / LICENSE Apache-2.0 Platforms: verified on Linux (x86_64, Node.js 24.19.0, mcode 0.4.12) with the full test suite, the MCP handshake and the Studio panel, and verified again by installing and running the Plugin by hand on macOS and Windows. No platform-specific handling is needed: paths go through node:path, the home directory resolves from HOME or USERPROFILE, git is invoked with execFile and an argument array rather than a shell, and the SQLite driver is Node's built-in node:sqlite. Node 22+ is required on every platform. Paths are resolved, never assumed. The data directory comes from MINIMAX_DATA_DIR or MAVIS_DATA_DIR, then the platform's home variable, so nothing is tied to one machine. Inside it the canonical layout (v2/sqlite/runtime-state.sqlite, v2/sessions) is tried first, then a short list of alternatives, and a hit outside the canonical layout is reported as a warning instead of passing silently; --doctor prints the path actually opened. A test sweeps every shipped file for a hardcoded machine path and fails on one. Runtime and data behavior: opens the projection strictly read-only (mode=ro) and never writes, moves or deletes session storage; falls back to the session's own messages.jsonl when the projection is missing. No network access at runtime, no telemetry, no credentials, no installers and no native binaries. git is optional and only groups a project's worktrees. Structure: no god files and no import cycles. The server is layered (config/json/sqlite/fsutil -> redact/git -> sessions/stats/tasks/events/search/ jsonl -> store facade -> mcp/http/main) with domain modules taking the Store facade as their first argument, so the layer graph stays a DAG. The client is ES modules under web/js/, where surfaces announce intents on a leaf bus instead of importing the actions, so its graph is acyclic too — both properties are enforced by tests. Hardening: the panel binds 127.0.0.1 only and fences every API call with an authority check, an origin check and a required custom header; static assets ship a deny-by-default CSP and nosniff. summary detail returns no message text, tool arguments or results; full detail is opt-in and passes through a secret redactor. Client-facing errors are fixed codes from a closed set — an unexpected error's message goes to stderr, and no request-derived value is reflected back. Tests: 51 Plugin tests and the repository suite (408 tests) via `npm run check`, covering the SQLite reads, the JSONL fallback, git grouping with a real worktree merge, input provenance, the tool-call/task join, the agent-definition lookup, redaction, the MCP protocol surface, client formatting, the panel's request fences and error contract, path portability, and the package declarations. The panel was also driven end to end in a real browser with zero console messages, and an MCP handshake returns protocol 2025-06-18 with live tool output.
dfc6bb5 to
8b7cd94
Compare
hetaoBackend
left a comment
There was a problem hiding this comment.
Request changes for exact current head 8b7cd943719dd3ca6c103208d3b6269d609e26b9.
The read-only SQLite access and parameterized queries are good, but several confidentiality/containment contracts are not yet safe:
- Symlink root escapes.
server/tasks.mjs:93-113validates the lexical task path/final file but not a symlinked task directory;server/jsonl.mjs:19-53traverses session directories and then usesstat()before checkingisSymbolicLink(), so the check observes the resolved target. I reproduced both paths reading externaloutput.log/messages.jsonlthrough parent-directory symlinks. Canonicalize every traversed component beneath the approved roots and add real escape canaries. - Secret redaction misses common stored forms.
server/redact.mjs:9-44,75-89leaves JSON strings such as{"api_key":"..."}and{"env":{"TOKEN":"..."}}unchanged, and turnsAuthorization: Bearer tokenintoAuthorization=[redacted] token. Task descriptions/commands atserver/tasks.mjs:52-60also bypass redaction. Full-detail and task surfaces must redact these realistic encodings before returning them. - Studio checks are CSRF fences, not authorization.
server/http.mjs:205-225checks Host/Origin/fixedx-trajectory-client/Fetch metadata, but any local process can send the fixed header; the URL minted atserver/http.mjs:97-128carries no capability. There is no binding between the caller and requested session/task IDs. Add a per-process capability token or enforce/document an equivalent single-user authorization boundary. - The MCP
readOnlyHintis false fortrajectory_studio.server/mcp.mjs:35-45,124-133marks every tool read-only, but starting Studio binds a listener and writesstudio-port.json(server/http.mjs:172-190). The write also follows a pre-existing symlink. Correct the annotation and make persistence symlink-safe. - Node compatibility is overstated. The server imports
node:sqlite(server/sqlite.mjs:10-13) and launches plainnode, but the manifests/package say Node>=22. Without--experimental-sqlite, the supported minimum is Node 22.13.0. Raise the minimum and test that exact version.
Local repository checks passed and substantive GitHub checks are green, but current tests do not cover the reproduced symlink/redaction cases. [code]smith is skipped and was not used as evidence.
…ntial forms, gate the panel on a capability Addresses the review on MiniMax-AI#56. Every finding was reproduced before the change and re-checked after it; the new suites are written so that "refuse everything" cannot pass them. Symlink root escapes (tasks.mjs, jsonl.mjs). Every file read now goes through `containedRealPath` / `openContainedRead` in fsutil.mjs and has to canonicalize inside the data directory, with a non-symlink final component opened under O_NOFOLLOW so the containment decision and the read describe one inode. Three separate defects were reproduced: a symlinked task directory, a symlinked task root, and a symlinked session directory. The session level of the JSONL walk had no `isDirectory()` guard at all, and the `stat()` before `isSymbolicLink()` was dead code, because stat resolves the link it was asked to detect. Redaction missed the encodings credentials take on disk. The key/value rule required the key and its separator to be adjacent, so `{"api_key":"…"}` never matched, and the Authorization rule ran after the key/value rule, which consumed `Authorization` and left the token of `Authorization: Bearer …` in the clear. The rules are now ordered most-specific-first and shape-preserving, task descriptions and commands are redacted at the data source, a session title has only its credential substrings replaced, and every outbound payload is swept once more at the boundary so a field added later cannot escape. The browser run caught one more defect: key-name redaction matched `sessionId`, so `/api/sessions` returned `"[redacted]"` and every following request 404'd. The panel's fences were CSRF defences, not authorization. Any local process could send the fixed header and read every session's tool arguments. A per-process 256-bit capability is now minted on each start, carried in the URL fragment (so it is never sent to the server, logged, or leaked through Referer) and required, constant-time, on every API route. mcode spawns one MCP server per session, so two sessions get two panels with two capabilities and neither URL opens the other. `trajectory_studio` declared `readOnlyHint: true` while opening a listener. The annotation is corrected, and the port file is gone rather than hardened: once the capability is per process, reusing a previous process's port is semantically wrong (the old token is gone and the new one was never sent to the old page), and removing the write removes the symlink hazard. The Plugin now creates no files. The Node range was overstated. Measured across nine releases: `node:sqlite` needs 22.13.0, and FTS5 is not monotonic in the Node version - absent in 22.13.0 through 22.18.x and throughout 23.x, present from 22.19.0 and 24.0.0. The floor is now enforced with a readable message instead of a module-resolution crash, the verified range mirrors mcode's own `engines` field (`>=22.19 <23 || >=24 <27`), FTS5 is probed rather than inferred, and search degrades with a warning where it is absent. Found while measuring: SQLite needs a writable *directory* even for a read-only open, because WAL needs its `-shm` file - now documented as a limitation. Also, from the same review points: the panel binds `127.0.0.1` as a non-overridable constant and refuses a non-loopback peer address; the client builds every node with `createElement` and writes only `textContent`, guarded by a mutation-checked scan for markup injection sinks; and the Node minimum is stated identically in both manifests and both READMEs. Tests: 105 in the Plugin suite, 462 repo-wide, 0 failures. The Plugin suite passes 105/0/0 on 22.19.0, 22.21.1, 24.0.0, 24.16.0 and 24.19.0, and 104/0/1 skipped on 22.13.0, 22.15.0, 23.4.0 and 23.11.0, the single skip being the FTS5 search test. Browser evidence for the render layer is in `tools/panel-e2e.mjs`.
…ntial forms, gate the panel on a capability Addresses the review on MiniMax-AI#56. Every finding was reproduced before the change and re-checked after it, and the new suites are written so that "refuse everything" cannot pass them. **Symlink root escapes** (`tasks.mjs`, `jsonl.mjs`). Every file read now goes through `containedRealPath` / `openContainedRead` in `fsutil.mjs` and has to canonicalize inside the data directory, with a non-symlink final component opened under `O_NOFOLLOW` so the containment decision and the read describe one inode. Three separate defects were reproduced: a symlinked task directory, a symlinked task root, and a symlinked session directory. The session level of the JSONL walk had no `isDirectory()` guard at all, and the `stat()` before `isSymbolicLink()` was dead code, because `stat` resolves the link it was asked to detect. **Redaction missed the encodings credentials take on disk.** The key/value rule required the key and its separator to be adjacent, so `{"api_key":"…"}` never matched, and the Authorization rule ran after the key/value rule, which consumed `Authorization` and left the token of `Authorization: Bearer …` in the clear. The rules are now ordered most-specific-first and shape-preserving, task descriptions and commands are redacted at the data source, a session title has only its credential substrings replaced, and every outbound payload is swept once more at the boundary so a field added later cannot escape. One defect the browser run caught and no unit test would have: key-name redaction matched `sessionId`, so `/api/sessions` returned `"[redacted]"` and every following request 404'd. **The panel's fences were CSRF defences, not authorization.** Any local process could send the fixed header and read every session's tool arguments. A per-process 256-bit capability is now minted on each start, carried in the URL fragment (so it is never sent to the server, logged, or leaked through `Referer`) and required, constant-time, on every API route. mcode spawns one MCP server per session, so two sessions get two panels with two capabilities and neither URL opens the other's panel. **`trajectory_studio` declared `readOnlyHint: true` while opening a listener.** The annotation is corrected, and the port file is gone rather than hardened: once the capability is per process, reusing a previous process's port is semantically wrong (the old token is gone and the new one was never sent to the old page), and removing the write removes the symlink hazard. The Plugin now creates no files. **The Node range was overstated.** Measured across nine releases: `node:sqlite` needs 22.13.0, and FTS5 is not monotonic in the Node version — absent in 22.13.0 through 22.18.x and throughout 23.x, present from 22.19.0 and 24.0.0. The floor is enforced with a readable message instead of a module-resolution crash, the verified range mirrors mcode's own `engines` (`>=22.19 <23 || >=24 <27`), FTS5 is probed rather than inferred, and search degrades with a warning where it is absent. Found while measuring: SQLite needs a writable *directory* even for a read-only open, because WAL needs its `-shm` file, now documented as a limitation. **Render layer, unchanged but now guarded.** The client already built every node with `createElement` and wrote only `textContent`; a mutation-checked scan now fails the suite if that ever stops being true, and the panel binds `127.0.0.1` as a non-overridable constant and refuses a non-loopback peer address. **New evidence.** `test/protocol.test.mjs` spawns the real MCP server over stdio the way mcode does and drives it as a client: version negotiation, the seven declared tools and their annotations, a call against a fixture projection, full detail redacted on the wire while the payload survives, an unknown tool and an unknown method, a notification left unanswered, `--doctor`, and the lifecycle — closing stdin has to end the process *with the panel running*, because a listener left behind leaks a port on every session. Every wait is bounded, so a server that stops answering fails the suite instead of hanging it. `tools/fixture.mjs` now owns the runtime-shaped projection that the domain tests, the protocol suite and the E2E harness share, and `tools/compat-matrix.mjs` asserts the compatibility table rather than printing it (fail === 0; skip count matching whether this runtime's SQLite has FTS5; every skip being the FTS5 search test). It is mutation-checked: an injected failure and an injected unrelated skip both make it exit 1. **Cross-platform.** This suite had never run off Linux. Its first run on Windows and macOS found three platform assumptions, all of them in the tests rather than the Plugin: a temporary path compared before canonicalization (`/var` on macOS, a short `RUNNER~1` path on Windows), a POSIX-only assertion that a resolved data directory comes back verbatim, and a temporary directory removed while SQLite still held the file open (`EBUSY` on Windows, harmless on POSIX — it cascaded through 24 tests). All three are fixed; both platforms now report 115 tests, 0 failures. No workflow is added to this repository: what runs on the project's runners is the maintainers' call. The job definitions are written out in the pull request instead. Tests: 115 in the Plugin suite, 472 repo-wide, 0 failures. Verified on Node 22.12.0 (refused with a reason), 22.13.0, 22.15.0, 22.19.0, 22.21.1, 22.23.2, 23.4.0, 23.11.0, 24.0.0, 24.16.0, 24.19.0 and 24.20.0, with the outcome matching the documented range on each. Version 0.1.1.
6aed44f to
9d5b6a6
Compare
|
Thanks for the review, and for doing the reproduction work rather than just naming the files — the symlink cases in particular were reproduced exactly as you described, and the two All five findings are fixed in a single follow-up commit, 1. Symlink root escapes. Every read now goes through 2. Redaction. The root cause was two bugs, not one. The key/value rule required the key and its separator to be adjacent, so 3. Panel authorization — you were right that this was the most serious one. The Host/Origin/ 4. 5. Node compatibility. Measured rather than reasoned about, and it is more interesting than "22 or newer". Extra tests. Two things worth flagging while you re-review:
Happy to split the commit, rename anything, or drop |
hetaoBackend
left a comment
There was a problem hiding this comment.
Request changes for exact current head 9d5b6a6a8da7ae32f2cbdf5eb32d9ef308fcf627.
The capability token, loopback bind, readOnlyHint correction and Node SQLite floor are substantive improvements, but confidentiality/resource blockers remain:
plugins/weekbin/mcode-trajectory-studio/server/redact.mjs:135,167only recognizes a narrow exact key set. Structured payloads containingclientSecret,refreshToken,accessToken,authToken,privateKey,apiSecretorxApiKeycan pass through because value-only redaction has no key context. Add canary tests through both HTTP and MCP full-detail egress./api/overviewreturnsstats.workspaceDirfromserver/stats.mjs:34-41throughserver/http.mjs:399-415without theredactPath()applied to other workspace fields, leaking the absolute user path. Redact it and add an HTTP regression.- Full event responses have only per-string limits;
http.mjs:370-392can return up to 1000 events andredact.mjs:181-183uses an effectively unlimited entry budget, with no total byte/depth bound. Add a bounded aggregate response contract for HTTP and MCP. server/jsonl.mjs:79-91appends chunks tobufferbefore checking the 2 MiB line limit. A single unterminated line can grow without bound until EOF. Enforce an incremental buffer cap and test a large no-newline record.server/fsutil.mjs:77-92realpaths before opening;O_NOFOLLOWonly protects the final component, leaving an intermediate-directory replacement race despite the no-window claim. Either implement directory-fd/openat-style containment or narrow the documented guarantee and test the accepted threat boundary.
These are not closed by the currently green repository/CodeQL checks. [code]smith is skipped and is not evidence.
|
Thanks for the second round — the File reads. Every read canonicalizes each traversed component, has to land inside the canonical data directory, may not have a symlinked final component, is opened under Redaction. Rules run most-specific-first and preserve shape, and they now cover the form credentials actually take on disk. The runtime stores tool results as JSON text: in a real projection, 109,462 of 118,109 message rows carry E-mail addresses and phone numbers are masked on the MCP egress only — that output reaches a model context, while the panel is the reader's own screen. The home directory, the data directory and operator-configured roots fold to Bounding. Per string, per response, and per row. Per response: an event page is trimmed to an aggregate byte budget and reports The JSONL line cap. Enforced per chunk rather than after appending. Measured inside a copy of the module with three lines of instrumentation: the pending buffer peaks at 2.06 MiB — the cap plus one chunk — for a 10, 100 and 400 MiB stream, so retention no longer tracks the file. Re-verifying that turned up one remaining defect, in that same code: an oversized line whose newline arrived in the same chunk as the byte that crossed the cap was discarded without being counted, because only the pending-buffer path incremented The panel. A 256-bit capability per process, carried in the URL fragment (never sent to the server, so never logged and never in a Node compatibility. Documented limits, in Tests: 166 plugin, 523 repo-wide, 0 failures; |
f946793 to
ef28dc3
Compare
… JSONL-reporting defects
One commit for the work on this branch since the last reviewed head. Nothing outside
`plugins/weekbin/mcode-trajectory-studio/` is touched.
Redaction, tuned to the form credentials actually take on disk
- The redactor could not see that form. The runtime stores tool results as JSON *text*:
in a real projection, 109,462 of 118,109 message rows carry `tool_call_result_data` as
a string, so a credential arrives escaped — `{\"api_key\":\"…\"}` — and a key/value rule
that requires the key and its separator to be adjacent never fires. A string value that
parses as JSON is now walked as structured data (rewritten only when a redaction
actually fired, so JSON tool results are never reformatted to protect nothing), and the
key/value rule tolerates and re-emits any backslash run on either delimiter. Measured
through the shipped MCP path: the escaped pairs whose key names a credential go from
none redacted to 54 pairs, 0 surviving.
- A permissive value pattern let the *outer* pair of an escaped document match first, so
the scanner stepped past the sensitive pair nested inside it. There is a regression test
for that shape.
- Rules were not idempotent: the unquoted value class stops at `]`, so `alreadyRedacted`
never saw a complete marker and every sweep added a bracket; and a marker a small
`maxLength` had cut short was re-redacted on the next pass, shifting the omitted count.
Both fixed at the cause, and idempotence is asserted as a property over the corpus ×
four option sets rather than by inspection.
- Structured keys are judged by word-boundary containment instead of an exact-name set, so
`clientSecret`, `refreshToken`, `accessToken`, `authToken`, `privateKey`, `apiSecret` and
`xApiKey` are covered, while counters (`inputTokens`, `total_tokens`) and the addressing
identifier (`sessionId`) are not — treating a session id as a secret returns
`"[redacted]"` from `/api/sessions` and breaks every following request. A task's
description and command are redacted at the data source, so no consumer can forget.
- Added coverage for `github_pat_`, `npm_`, `hf_`, Google `AIza`/`ya29.`, Azure
`AccountKey=` and an unlabelled JWT, and a connection string whose password itself
contains `@` no longer stops at the first `@` and leaves the tail in the clear. Over-
redaction is pinned from the other side: `npm_config_registry` and `HF_HOME` survive
intact and a 13-digit epoch is not read as a phone number.
- E-mail addresses and phone numbers are masked on the MCP egress only, because that
output reaches a model context while the panel is the reader's own screen. The home
directory, the data directory and operator-configured roots fold to `~` wherever they
appear — including the doubled-backslash form a Windows path takes inside a JSON column
— and another account's home keeps its shape and loses the name.
- `/api/overview` returned `stats.workspaceDir` raw beside a `session.workspaceDir` that
was already folded; both are folded now, with an HTTP regression.
Bounding what can be reached
- Per-string limits were not a response limit: 1000 full records serialised to ~20 MB. An
aggregate byte budget plus depth and entry ceilings were added; the page is trimmed in
order and reports `truncated`/`omitted` so the client pages on `nextOffset`. MCP emits
the payload twice (text and `structuredContent`), so its record-list budget is half.
- The SQLite read had no per-row ceiling, so one multi-megabyte row was materialised and
parsed whole. The size test now runs inside SQL and an oversized row is returned as
`oversized` with its byte count rather than silently dropped.
- The `git rev-parse` probe inherited the whole `process.env` on a directory that came out
of session data. It now receives an exact allowlist plus fixed `-c core.fsmonitor=false
-c credential.helper=`. The user's `~/.gitconfig` is deliberately kept (`safe.directory`
lives there); an `alias.rev-parse` in a repository's own config cannot shadow the
builtin, which was measured.
- Diagnostics were unbounded — one warning per failed read, echoed in full on every
`trajectory_list`. The list is capped and reports its dropped count.
The JSONL line cap, and its reporting
- The cap is enforced per chunk rather than after appending, so a single unterminated line
no longer grows the buffer to the size of the file. Measured inside an instrumented copy
of the module: the pending buffer peaks at 2.06 MiB (the cap plus one chunk) for a 10,
100 and 400 MiB stream.
- An oversized line whose newline arrived in the same chunk as the byte that crossed the
cap was discarded *without being counted*: only the pending-buffer path incremented
`droppedOversized`, so which path a line took — counted or silent — depended on where the
chunk boundary fell. Both paths count now, they cannot double count (the discarding
branch consumes the overflowing line's newline and returns before the per-line test),
and a test asserts the two deliveries agree.
Containment, git's environment, and the panel
- On Linux the opened descriptor is re-verified through `/proc/self/fd`, so an intermediate
directory swapped between canonicalization and open is refused; where `/proc` is absent
that residual race is the documented boundary instead of an unstated one.
- The panel opens in `summary` detail. The markup shipped the toggle `checked` while the
state defaulted to `full`, so the panel fetched content the control said it was not
fetching; the control is derived from the state now so the two cannot drift apart.
Docs
- README (both languages) carries the confidentiality contract and its known limits;
DESIGN.md gains the threat model and the accepted boundaries, and the validator section
is renumbered 6.6 → 6.7 because the inserted section took 6.6.
- An unused export that returned an unredacted, unbounded object was removed rather than
left as a channel that would be unredacted the day someone wired it up.
Tests: 166 plugin (was 138 at the reviewed head), 523 repo-wide, 0 failures.
tools/compat-matrix.mjs asserts 166 pass / 0 skip on this runtime.
ef28dc3 to
4dd8dd4
Compare

What changes
Adds one new Plugin:
plugins/weekbin/mcode-trajectory-studio. It exposes seven MCP tools plus one Skill for read-only session-trajectory inspection of local MiniMax Code sessions, backed by the runtime's own SQLite projection, and ships a local Studio panel. Nothing outsideplugins/weekbin/mcode-trajectory-studio/is touched.The Plugin reads the runtime's projection with the built-in
node:sqlite, so it adds no native module and no ABI constraint of its own — mcode writes that file withbetter-sqlite3, and only SQLite's on-disk format is shared.The confidentiality contract
This Plugin's whole risk surface is "what can leave this process", because it reads session data that routinely quotes credentials. The contract it implements:
Access is read-only, and it writes nothing. The projection is opened with
readOnly: true. No session storage is written, moved or deleted, and the Plugin creates no files at all — not even a port file, which is why reusing a previous process's port is not attempted. When the projection is missing or unreadable it falls back to the session's ownmessages.jsonland says so.There is no network access and no telemetry. The only listener is the optional panel, bound to
127.0.0.1as a non-overridable constant, and it refuses a non-loopback peer address.summarydetail returns no message text, tool arguments or tool results.fulldetail is opt-in per call.fulldetail is redacted in two tiers, because a credential does not arrive in the shape one would expect. The runtime stores tool results as JSON text, not as objects: in a real projection of 525 sessions / 118,109 message rows, 109,462 rows carrytool_call_result_dataas a string, so a credential reaches the redactor escaped —{\"api_key\":\"…\"}. A key/value rule that requires the key and its separator to be adjacent never fires on that. So: a string value that parses as JSON is walked again as structured data (and rewritten only when a redaction actually fired, so JSON tool results are never reformatted to protect nothing), and the key/value rule tolerates and re-emits any backslash run on either delimiter so text that is not valid JSON at all is still covered. Measured back through the shipped MCP path on that same projection: 54 escaped pairs whose key names a credential, 0 surviving.Credential keys are judged by word boundary, not by an exact list.
clientSecret,refreshToken,accessToken,authToken,privateKey,apiSecretandxApiKeyare covered. Counters (inputTokens,total_tokens) and addressing identifiers (sessionId) deliberately survive: a session id is how the API addresses everything, and treating it as a secret returned"[redacted]"from/api/sessionsand broke every following request.Coverage also includes private key blocks, credentials inline in a connection string (including a password that itself contains
@, which an earlier rule truncated and left half in the clear), the wholeAuthorizationheader with its scheme, a bare scheme and token, provider-prefixed keys (sk-,ghp_,glpat-,xox*…), length-anchored tokens (github_pat_,npm_,hf_, GoogleAIza/ya29., AzureAccountKey=), AWS key ids, and an unlabelled JWT. Rules are ordered most-specific-first and preserve shape, so the result stays valid JSON. Task descriptions and commands are redacted at the data source; a session title has only its credential substrings replaced. Every outbound payload is swept once more at the boundary — including the MCP failure branch, since an egress swept only when it succeeds is not a boundary.The rules are idempotent, and that is asserted as a property. Text is swept at the data source, per record and again at the boundary, so a rule that re-redacts its own output compounds.
redact.test.mjsassertsredact(redact(x)) === redact(x)over the corpus × four option sets, that no sweep grows a marker a bracket at a time, that a truncation marker is stable across sweeps, and that a JSON string with nothing to redact comes back byte-identical. Over-redaction is pinned from the other side too:npm_config_registryandHF_HOMEsurvive intact, and a 13-digit epoch is not read as a phone number.Personal data is masked on the egress that leaves the machine, and nowhere else. E-mail addresses and phone numbers are masked on the MCP path, whose output reaches a model context. The panel is the reader's own screen, where masking a customer's address would destroy the answer they opened it for; that difference is one explicit argument, not an implicit behaviour.
Paths are folded, not returned verbatim. The home directory, the data directory (including a deployment that places it outside the home directory) and any root named in
MCODE_TRAJECTORY_REDACT_ROOTScollapse to~wherever they appear — inside a warning string, in a workspace field, in a tool argument, and in the doubled-backslash form a Windows path takes inside a JSON column. Another account's home keeps its shape and loses the name (/home/<user>/…). That setting can only fold more, never less, so unlike the listen address it is not a knob that can weaken anything.Everything reachable has a ceiling, and truncation is reported rather than implied. Per string, per response (an event page is trimmed to a byte budget and reports
truncated/omittedso the client pages onnextOffset), and per row — the SQLite read has an 8 MiBdata_jsonceiling whose test runs inside SQL, so an oversized row is returned asoversizedwith its byte count instead of being parsed into the process or silently dropped. A JSONL record with no newline cannot grow the read buffer past the 2 MiB line cap, and the lines it drops are counted indroppedOversized. Diagnostics are bounded too:warningskeeps the most recent 64 entries and reports the dropped count.File reads are contained. Every read canonicalizes each traversed component, must land inside the canonical data directory, may not have a symlinked final component, is opened under
O_NOFOLLOW, and on Linux has its opened descriptor re-verified through/proc/self/fd, so the containment decision and the read describe one inode. Where/procis unavailable (macOS, Windows) that residual race is a documented, accepted boundary rather than an unstated one.The panel is authorized, not merely fenced. A 256-bit capability is minted per process, carried in the URL fragment (so it never reaches the server, never lands in a request log, and never appears in a
Referer), and required with a constant-time comparison on every/api/*route. Because one MCP server runs per session, two sessions get two panels with two capabilities and neither URL opens the other's. Host,OriginandSec-Fetch-Sitechecks are kept as defence in depth. Assets are served under a deny-by-default CSP (default-src 'none') withno-referrerandno-store, and every node is rendered withcreateElement/textContent, so a session body is text and never markup — with a mutation-checked scan that fails the suite if a markup sink is ever introduced. The panel opens insummarydetail, so no content is fetched until the reader ticks 显示正文, and the control is derived from the state so the two cannot drift apart.The one place an external command runs gets an allowlisted environment.
git rev-parsefolds a project's worktrees into one sidebar group, and it runs on a directory that came out of session data. It receives a fixed allowlist of environment variables rather than the wholeprocess.env, so no credential the host exports is inherited, plus-c core.fsmonitor=false -c credential.helper=and no askpass helper. The user's own~/.gitconfigis deliberately kept, becausesafe.directorylives there and the probe reads no value from it; analias.rev-parseplanted in a repository's own.git/configcannot shadow the builtin, which was measured rather than assumed.Documented limits (see
README.md"Known limits" andDESIGN.md§6.6): the redactor recognises credential shapes and credential-named keys, so a high-entropy string with no label and no provider shape is not a credential to it; PII masking covers e-mail addresses and phone numbers only, and only on the MCP egress; an absolute path under a root the operator has not configured is returned verbatim; and the panel capability does travel into the session transcript with the tool result, because the runtime persists tool results and sends the context on later turns — it is contained by being process-scoped and loopback-only, andserver/main.mjs --serveis the way to keep a capability out of a model context entirely.--doctorprints real paths on purpose: it is the diagnostic a user reads and decides whether to share.Compatibility
The Plugin's floor and range are tied to the host's Node, not to a second copy of a native driver, because it uses the built-in
node:sqlite:node:sqliteabsent entirelytrajectory_searchdegrades with a warning and the FTS5 test reports itself skippedThe two numbers that matter are therefore separate: the
node:sqlitefloor is 22.13.0, and FTS5 is not monotonic in the Node version — absent from 22.13.0 through 22.18.x and throughout 23.x, present from 22.19.0 and 24.0.0. The floor is enforced with a readable sentence rather than a module-resolution crash, the verified range mirrors mcode's ownengines(>=22.19 <23 || >=24 <27), FTS5 is probed rather than inferred, and search degrades with an explicit warning where it is absent. Declaring these two numbers once is not enough on its own — the Node minimum is stated identically in both manifests and both READMEs, andnode-version.test.mjsasserts that.The suite executed green on every release in that table; on the FTS5-absent releases its single skip was the search test reporting itself skipped rather than failing.
tools/compat-matrix.mjsships with the Plugin so the table can be re-checked rather than trusted: it runs the suite under whatever Node executes it and fails unlessfail === 0, unless the skip count matches whether that runtime's SQLite has FTS5, and unless every skip is the FTS5 search test. It is mutation-checked — an injected failure and an injected unrelated skip both make it exit 1.The suite runs on Linux, macOS and Windows. The off-Linux runs are what surfaced three platform assumptions in the tests — a temporary path compared before canonicalization (
/varon macOS, a shortRUNNER~1path on Windows), a POSIX-only assertion that a resolved data directory comes back verbatim, and a temporary directory removed while SQLite still held the file open (EBUSYon Windows) — all three fixed.User value
After installing the Plugin, a user can ask what a session actually did and where its time went, without opening any session file by hand.
Example prompt:
Expected result: the agent calls
trajectory_summaryand reports the folded totals — turns, steps,llmMs,toolMs,decodeMs, token counts, tool calls and failures, compactions, sub-agent dispatches — and marks TTFT as unavailable rather than estimating it, because the runtime does not persist time-to-first-token.Example prompt:
Expected result: the agent calls
trajectory_studioand returns a127.0.0.1URL whose fragment carries a per-process capability. Opening it verbatim including the#t=…fragment shows a three-lane timeline, a scannable record stream, a per-record inspector, and a background-task list, insummarydetail — message text, tool arguments and results are fetched only once the reader ticks 显示正文, since the timeline, timings, token usage and tool names need none of it. A URL with the fragment stripped loads the page shell and no data, and the page says so.Plugin submission checklist
plugins/<github-owner>/<plugin-name>.plugin.jsonname matches the Plugin directory.README.mdincludes a real example prompt and expected result.LICENSEandplugin.jsondeclare an open-source license (Apache-2.0).TODOhas been replaced.npm run checkpasses.Required executables:
nodeonPATH, 22.13.0 or newer — the hard floor for the built-innode:sqlitewithout--experimental-sqlite. Below it the Plugin exits with that sentence rather than a module-resolution error. The verified range is mcode's own:>=22.19 <23 || >=24 <27.gitis optional and only used to fold a project's worktrees into one sidebar group. No accounts, no paid services, no npm dependencies, no native module and no build step.Network and data behavior: no network access at runtime and no telemetry, and the Plugin creates no files. It reads the runtime's SQLite projection strictly read-only and falls back to the session's own
messages.jsonlwhen the projection is missing. The optional Studio panel binds127.0.0.1only and requires a per-process capability on every route. What leaves afullpayload is described under "The confidentiality contract" above.Known limitation: SQLite needs a writable directory even for a read-only open, because a WAL database needs its
-shmfile. On a read-only mount, or when the data directory belongs to another user, the Plugin reportssqlite_unavailable:attempt to write a readonly databaseand falls back tomessages.jsonlwith the timing fields absent. Reading a live WAL database that another process is writing works normally.Evidence
npm run check— the same command CI runs (validatethen the repository suite):The Plugin's own suite is 166 tests / 0 failures.
tools/compat-matrix.mjsasserts the current run on whatever Node executes it — on Node 24.19.0 / SQLite 3.53.3 that is 166 pass, 0 fail, 0 skipped.containment.test.mjs— a canary planted outside the data directory, reached for through a symlinked task directory, a two-hop link, a relative link, a symlinkedoutput.log, a symlinked task root, a symlinked session directory and a symlinkedmessages.jsonl. Each case asserts the canary is absent and that the read is reported unavailable, with a positive control that must still succeed. It also pins the resource behaviour: an unterminated JSONL line takes the incremental cap's discard path and a line after it still folds.redact.test.mjs— JSON object forms, nested envelopes, the escaped form the runtime actually stores ({\"api_key\":\"…\"}) and double escaping,Authorization: Bearer …,Basic,proxy-authorization, a header inside a shell command, a tool-call description, the provider and length-anchored token shapes, and the identifiers the API is keyed on. Each case asserts the secret is gone and that the surrounding payload survives, plus the four properties described above and the over-redaction pins.panel-security.test.mjs— the capability (the old fixed header alone is 403; a wrong-length value is refused without throwing; a repeated header is refused; the right one is accepted and not echoed), cross-process isolation, the loopback bind plus unreachability on every non-loopback interface and a peer-address fence, the mutation-checked render-sink scan, and the privacy default (the toggle must not shipchecked, the state default must besummary, and the control must be derived from the state).protocol.test.mjs— the real MCP server over stdio, driven as a client the way mcode does: version negotiation, the seven tools and their annotations, a call against a fixture projection, full detail redacted on the wire (including a credential hidden inside a tool result, stored as the runtime stores it), the failure branch swept through the same path as the success branch, a reply trimmed to its byte budget withtruncated/omittedreported, unknown tool, unknown method, an unanswered notification,--doctor, and the lifecycle — closing stdin ends the process with the panel running. Every wait is bounded, so a server that stops answering fails the suite instead of hanging it.node-version.test.mjs— the floor, the verified range, the FTS5 claim against an actualCREATE VIRTUAL TABLE … USING fts5on the running runtime, and that all four declarations state the same numbers.store.test.mjs— the SQLite reads, the JSONL fallback, repository grouping (including a real worktree merge), provenance, the tool-call/task join, the agent-definition lookup, and the bounds: a row past the per-record byte cap is reported asoversizedwith the index andtotalstill aligned and its siblings intact, the warning list is bounded with its dropped count accounted for, the folded-root list always includes the data directory and refuses a root that would rewrite every separator, and the environment handed togitis matched against an exact allowlist so...process.envcannot come back — with the real-worktree test as the positive control that the restricted environment still runs git.Two claims in the contract above were measured against real data rather than reasoned about. Redaction: over the projection of 525 sessions / 118,109 message rows described earlier, the escaped credential pairs whose key names a credential went from none redacted to 54 pairs, 0 surviving when read back through the shipped MCP path. Rendering, in a real browser: at the default
summarydetail the first paint fetches no message text at all, and after enabling 显示正文 three planted payloads (<img src=x onerror=…>,<svg onload=…>,"><script>…</script>) render as literal text with zeroon*inline handlers, zerojavascript:URLs, no<img>and a clean console; a credential in the session title arrives as[redacted]; no token inlocalStorageand no cookie; and the response headers aredefault-src 'none'plusno-referrer.tests/plugins/mcode-trajectory-studio/smoke.test.mjsaudits the package against the repository layout (registry manifest, MCP descriptor, exactly one Skill, version coherence, package hygiene and size limits) and runs deliberate mutations through its manifest checkers, so a passing audit cannot be a false green.The two CodeQL findings from the first round are addressed in the current tree: the client only ever receives a code from a closed set, the request is never interpolated into a response, and an unexpected error's detail goes to stderr. No new alerts are expected; the scan re-runs on push.