Skip to content

feat(052): the relay, the correlation key, the classifier and the state machine - #268

Merged
guan4tou2 merged 6 commits into
mainfrom
feat/052-terminal-auto-capture
Oct 7, 2026
Merged

guan4tou2 merged 6 commits into
mainfrom
feat/052-terminal-auto-capture

Conversation

@guan4tou2

Copy link
Copy Markdown
Owner

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-run is the only path touched, and it keeps its contract.

  • hooks/redlog-relay.py — the output relay, lifted out of redlog-run.
    It was two named pipes, two tees and an inline python heredoc inside
    shell-common.sh; spec 052 needs the same capture from preexec, for every
    command, and two implementations of "record a command's output" drift
    (research.md D2, Principle III).
  • One correlation key per command. command_start, command_end and
    every future output chunk carry a command_id the adapter minted, and it is
    the only thing ingest consults for a chunk.
  • src/core/terminal-class.ts — relayed | pty | native over argv.
  • src/core/terminal-enrollment.ts — whether a terminal is recording, in
    what mode, for which project, in a file both sides read.
  • A bounded health probe (T002a), which is a live bug fix and the one
    change here an existing user feels.

Why

The probe could wedge the operator's shell (T002a)

The two health probes in hooks/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 exactly
like this from the shell's side.

Found by accident while building the pty harness — spawnSync had blocked the
test 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-alive after 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
unattributed rather than dropped or attached to whatever happened to be
open; 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 nc in the pty
class would silently break the Ctrl-Z / stty raw -echo / fg upgrade in the
middle of an engagement (FR-025); relaying vim would fill the evidence with
cursor movements and record none of the file (FR-026). Reading through sudo,
env and proxychains is the same point once removed — classifying
sudo nc on 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 /tmp through it would change a directory that exits a
millisecond later. Builtins and functions run in the shell as they always did,
and the record says metadata-only / not-captured rather than reporting
empty 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 its
caller gets written twice. The ingest contract is complete and tested without
it.

The architecture gate was right about terminal-enrollment.ts. Most of
the 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.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 — quiet enough that
only a packaged build would show it.

Testing notes

test/shell-redlog-run.test.ts is 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.ts runs the same
scenario through a WSL target, so it is proven before it is pushed. The pty
harness (test/helpers/zsh-pty.*) drives a real interactive zsh: preexec
does not fire under zsh -c, and a piped stdout hides the line discipline.

Verification

  • npm run typecheck
  • npm run verify:specs
  • npm run verify:architecture
  • npm run verify:i18n
  • npm test — 3052 passed, 25 skipped
  • npm run build
  • full e2e — 100 passed, 4 skipped (5.1 min), run locally because this
    branch 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.zsh and the capture card and belongs in its own branch.

🤖 Generated with Claude Code

guan4tou2 and others added 6 commits October 7, 2026 11:06
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>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a4cc9da1-c5e7-4768-afc4-27765d09e684
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@guan4tou2
guan4tou2 merged commit 5002948 into main Oct 7, 2026
5 checks passed
@guan4tou2
guan4tou2 deleted the feat/052-terminal-auto-capture branch October 7, 2026 10:00
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
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.

1 participant