Repository navigation
feat(052): the relay, the correlation key, the classifier and the state machine - #268
Merged
Merged
Conversation
Phase 1 of spec 052: the decisions that change the shape of everything after them, and the harness the rest of the feature is measured against. The adapter under test is zsh `preexec`/`precmd`. Neither fires under `zsh -c`, and a piped stdout hides the line discipline the relay has to leave intact, so the suite needs a pty with an interactive shell on the far end. The driver is Python — `pty` is standard library, and node-pty is a native module this repository already fights with. On Linux it runs directly; on the Windows box it runs through `wsl -d kali-linux`, which is Kali Rolling with zsh 5.9 and python3: the delivery target, locally. Anywhere else the suite skips, which is honest rather than a gap — the adapter is POSIX-only by FR-019. Four tests. Three prove the harness; the fourth proves the gap the feature exists to close. The one that matters is RED: with no adapter installed, commands run and RedLog hears nothing. A harness that cannot fail cannot witness anything. Two things cost an hour each and are written down where the next person will meet them: - The throwaway HOME is created by the driver, on the shell's own filesystem. Handed across `wsl` as a Windows path it lands on the 9p mount, and the rc, `.redlog`, zsh's compdump and the adapter's spool all become network reads: thirty seconds for one command against six. - The shell runs asynchronously. `spawnSync` blocks the process the collector lives in, so the adapter's POST connects and then waits for a reply that cannot come until the shell exits. Chasing the second one surfaced a live defect in `shell-common.sh`, not in anything 052 adds: the health probe passes `--connect-timeout 1` with no `--max-time`, which bounds the handshake and not the wait. A RedLog that accepts the connection and never answers hangs the operator's prompt on every command, forever. The send is bounded; only the probe is not. Recorded as T002a and in research.md, because it is an FR-009 violation in the code this feature builds on. Also settles T001 (where the pty suite runs), T006 (output bound: reuse the body store's externalisation rather than pick a bigger truncation number) and T007 (a nested shell gets its own session, with the nesting recorded). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1's three experiments, run against a toy relay — D1's shape in six lines of zsh, redirecting the shell's own descriptors in preexec and restoring them in precmd — driven by the T002 harness on Kali/zsh 5.9. Two of the three answers moved. O1, terminal modes: diverting descriptors costs nothing. `stty -g` before and after is byte-identical, and `sleep 30 &` / `jobs` / `kill %1` all behave, which is the evidence D1 wanted for refusing a PTY on the ordinary path. The trap is elsewhere: `stty raw -echo; stty sane` leaves settings that differ from the original, so `sane` is a known-good default and not a restore. That belongs to the PTY class, which has to save and put back `stty -g` itself. O2, ordering: a 300 KB burst is fully in the log — 300,000 bytes exactly — by the time the next command reads it, which rules out the cheap failure where a body lands under the following command. It does not prove the bytes are there at precmd, where a naive implementation would send command_end, and the experiment cannot tell the two apart. So the contract is the stronger one: command_end is emitted by whatever owns the bytes, after it drains, never by precmd racing the relay. O3, background output: a job started with `&` inherits the relay's descriptor at fork time, so its output lands in whichever command happens to be running when it writes. Measured twice, both times inside an unrelated `sleep`. There is no fd-level signal that separates it from the foreground command's own bytes — so FR-007 cannot be met by attribution logic in the relay, and the data model's `unattributed: true` chunk stops being a defensive extra. It is the only honest representation, and the UI has to be able to show output that belongs to the terminal rather than to a command. Phase 1 is complete: every open question that would have changed the adapter's shape now has an answer, and each answer has a measurement behind it rather than a preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…02a) The two health probes in shell-common.sh carried --connect-timeout and no --max-time. A connect timeout expires when nothing accepts; a process that accepts the socket and then never answers satisfies it and is then waited on forever. This runs from preexec, so "forever" is the operator's shell, wedged by its own audit logger, on every command. RedLog mid-crash, swapped out, or a port inherited by something else all look like this from the shell's side. Both probes are now bounded at --max-time 2. Worst case is 127.0.0.1 and the WSL gateway in turn, once per shell: _REDLOG_HOST is exported after the first resolve. Pinned by test/zsh-hook-unresponsive-redlog.test.ts, which drives the real hook on a pty against startBlackHole() -- a port that accepts and says nothing. Unbounded it fails with "no prompt after echo still-alive" after the full 45s driver budget; bounded, the prompt returns in ~10s and the command's own output is intact, which is what FR-009 asks for. Found by T002, not by review: spawnSync had blocked the harness's collector in exactly this shape, and the hang read as a broken adapter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`redlog-run` carried its own capture loop: two named pipes, two tees and an inline python heredoc. Spec 052 needs the same capture from preexec, for every command, and two implementations of "record a command's output" drift. It moves to hooks/redlog-relay.py and both callers use it (research.md D2). The contract comes first, in test/relay-contract.test.ts: bytes reach the terminal while the command runs and not at exit; the caller sees the command's own status, 127/126/128+N as the shell reports them; byte counts are of everything that passed, not of what survived the cap; and output that is not text becomes U+FFFD instead of an exception, because the command returning something strange is the one worth having recorded. Two deliberate changes fell out of it: - The relay launches a process, so a builtin or function cannot go through it -- `redlog-run cd /tmp` through a relay would change a directory that exits a millisecond later. It runs in the shell as before, and the record says metadata-only / not-captured rather than reporting empty output as if it had been captured. Both words are already in contracts/events.md. - redlog-relay.py is a support file. It is in all three supportFiles lists, plugin.json and verify-packaged-resources.mjs; missing, every install would fall through to running commands unrecorded, which is quiet enough that only a packaged build would show it. test/shell-redlog-run.test.ts is unchanged, which is the point of T010 -- but it is POSIX-only and skipped on win32, so the same scenario now also runs through the WSL target and asserts that command_end field for field. Finding an extraction regression from CI's ubuntu leg is the round trip CLAUDE.md is about. runInShell sends its script as a file: between Node's Windows argument quoting and wsl.exe re-parsing the command line, `bash -c <string>` arrived with `$?` and `"$var"` emptied, which reads as a shell that returns 0 for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…013) Output is about to arrive as its own rows, one per chunk, and something has to say which command a chunk belongs to. The only safe answer is an id the adapter minted: the bytes are the output of a command run against something hostile, so anything read out of them is attacker-controlled (FR-002). Shell side: _redlog_new_command_id mints one key per command and command_start and command_end both carry it. The relay fills completeness, output_disposition and limit_hit from what it actually held, so "produced no output" and "nothing was ever held" stay different words all the way to the record. Ingest side: command_id maps to the command_start event id in causes-resolver.ts. It is consulted first for command_end -- the old terminal|pid|command key only correlates while the text is byte-identical, and zsh hands preexec the line as typed while a later row may report the expanded form -- and consulted ONLY for a chunk. A chunk that resolves to nothing is marked unattributed rather than dropped or attached to whatever happened to be open: background output lands inside the foreground command's descriptors (research.md O3), and a hole in the evidence and a wrong attribution are both worse than saying which it was. The chunk producer is deliberately absent. Nothing produces chunks until output is routed from preexec (T020), and a producer written before its caller gets written twice. The ingest contract is complete and tested without it. One test changed shape while being written: insertEvent's 2s dedup window means two identical command_starts cannot both land, so the ambiguity the id removes is pinned where it is real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two foundations, both decided before any shell routes anything through them. terminal-class.ts classifies a command's argv as relayed, pty or native. It reads as an implementation detail and is not one: a PTY-captured command cannot be locally suspended, so putting `nc` in the pty class would silently break the Ctrl-Z / stty raw -echo / fg upgrade in the middle of an engagement (FR-025), and relaying `vim` would fill the evidence with cursor movements and record none of the file (FR-026). The wrapper walk is the same point once removed: classifying `sudo nc` on its first word breaks the upgrade just as surely (FR-027). An empty argv is native, not relayed, so a bare Enter does not route the prompt through a relay. terminal-enrollment.ts holds whether a terminal is recording, in what mode, and for which project -- in a file, because the next prompt is a new preexec that remembers nothing and RedLog has to read the same answer (FR-022). Every transition is one function, because the rules only hold together: a project switch outranks both `start` and `mode auto`, or FR-010's pin is one keystroke deep and a command from engagement A can be filed under B. The architecture gate was right about this module. Most of it had no production caller, because in the finished design the shell is what reads and writes the state. Two real callers exist today and are wired rather than deferred: RedLog's own panes enroll like any other terminal, and the capture card counts them -- which is what lets the card tell "RedLog's panes only" from "this machine's terminals too" instead of inferring both from silence. Three exports are allowlisted, each naming the task that consumes it; the gate fails when an entry stops matching, so each one removes itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This was referenced Oct 7, 2026
guan4tou2
added a commit
that referenced
this pull request
Oct 8, 2026
feat(052): the relay, the correlation key, the classifier and the state machine
guan4tou2
added a commit
that referenced
this pull request
Oct 8, 2026
feat(052): the relay, the correlation key, the classifier and the state machine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Spec 052's foundations: the pieces every user story needs, with nothing yet
routed through them. No shell behaviour changes for an operator who has not
opted in —
redlog-runis the only path touched, and it keeps its contract.hooks/redlog-relay.py— the output relay, lifted out ofredlog-run.It was two named pipes, two
tees and an inline python heredoc insideshell-common.sh; spec 052 needs the same capture frompreexec, for everycommand, and two implementations of "record a command's output" drift
(research.md D2, Principle III).
command_start,command_endandevery future output chunk carry a
command_idthe adapter minted, and it isthe only thing ingest consults for a chunk.
src/core/terminal-class.ts—relayed | pty | nativeover argv.src/core/terminal-enrollment.ts— whether a terminal is recording, inwhat mode, for which project, in a file both sides read.
change here an existing user feels.
Why
The probe could wedge the operator's shell (T002a)
The two health probes in
hooks/shell-common.shcarried--connect-timeoutand no
--max-time. A connect timeout expires when nothing accepts; aprocess that accepts the socket and then never answers satisfies it and is
then waited on forever. This runs from
preexec, so "forever" is theoperator's shell, wedged by its own audit logger, on every command. RedLog
mid-crash, swapped out, or a port inherited by something else all look exactly
like this from the shell's side.
Found by accident while building the pty harness —
spawnSynchad blocked thetest collector's event loop, which is that shape precisely.
startBlackHole()is that accident, on purpose: unbounded, the test fails with
no prompt after echo still-aliveafter the full 45 s driver budget; bounded,the prompt returns in ~10 s with the command's own output intact (FR-009).
Correlation cannot read the bytes
Output is about to arrive as its own rows. The bytes are the output of a
command run against something hostile, so anything read out of them is
attacker-controlled — FR-002, and the single most important divergence from
the tool this spec takes its shell lessons from. So: an id, minted by the
adapter, and nothing else. A chunk that resolves to nothing is marked
unattributedrather than dropped or attached to whatever happened to beopen; background output inherits the relay's descriptor at fork time and lands
inside the foreground command (measured, research.md O3), and a hole in the
evidence and a wrong attribution are both worse than saying which it was.
The classifier is a product decision, not an implementation detail
A PTY-captured command cannot be locally suspended. Putting
ncin theptyclass would silently break the Ctrl-Z /
stty raw -echo/fgupgrade in themiddle of an engagement (FR-025); relaying
vimwould fill the evidence withcursor movements and record none of the file (FR-026). Reading through
sudo,envandproxychainsis the same point once removed — classifyingsudo ncon its first word breaks the upgrade just as surely (FR-027).Deliberate calls worth a reviewer's attention
A builtin cannot go through a relay. The relay launches a process, so
redlog-run cd /tmpthrough it would change a directory that exits amillisecond later. Builtins and functions run in the shell as they always did,
and the record says
metadata-only/not-capturedrather than reportingempty output as if it had been captured. Both words were already in
contracts/events.md.The chunk producer is absent on purpose. Nothing produces chunks until
output is routed from
preexec(T020), and a producer written before itscaller gets written twice. The ingest contract is complete and tested without
it.
The architecture gate was right about
terminal-enrollment.ts. Most ofthe module had no production caller, because in the finished design the shell
is what reads and writes the state. Rather than allowlist the file, two real
callers are wired now: RedLog's own panes enroll like any other terminal, and
the capture card counts them — which is what lets the card tell "RedLog's
panes only" from "this machine's terminals too" instead of inferring both from
silence. Three exports remain allowlisted, each naming the task that consumes
it; the gate fails when an entry stops matching, so each one removes itself.
redlog-relay.pyis a support file. It is in all threesupportFileslists,
plugin.jsonandverify-packaged-resources.mjs. Missing, everyinstall would fall through to running commands unrecorded — quiet enough that
only a packaged build would show it.
Testing notes
test/shell-redlog-run.test.tsis unchanged, which is the point of T010 —but it is POSIX-only and skipped on win32, so an extraction regression would
first appear on CI's ubuntu leg.
test/relay-contract.test.tsruns the samescenario through a WSL target, so it is proven before it is pushed. The pty
harness (
test/helpers/zsh-pty.*) drives a real interactive zsh:preexecdoes not fire under
zsh -c, and a piped stdout hides the line discipline.Verification
npm run typechecknpm run verify:specsnpm run verify:architecturenpm run verify:i18nnpm test— 3052 passed, 25 skippednpm run buildbranch changes capture contracts and CI skips e2e when an earlier gate
fails
Tasks T001–T017 of 47. Next is Phase 3 (US1 MVP, T018–T028), which touches
shell-zsh-hook.zshand the capture card and belongs in its own branch.🤖 Generated with Claude Code