Skip to content

feat(052): a command typed with no prefix in front of it (US1) - #276

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

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

Conversation

@guan4tou2

@guan4tou2 guan4tou2 commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

What changed

Spec 052's US1, the MVP: a command typed into an enrolled terminal is recorded
with its output, with nothing typed in front of it.

RedLog could already capture a command's output. redlog-run nmap -sV host has
worked for releases, and it is used a handful of times per engagement, because
an operator mid-engagement does not remember a prefix and the commands worth
having are the ones typed without thinking about the logger. This removes the
prefix.

Builds on #268 (the relay, the correlation key, the classifier, the state
machine), which is now in main and merged into this branch.

  • hooks/shell-zsh-hook.zsh diverts the shell's own stdout and stderr
    through two redlog-relay.py pipe processes in preexec and puts them back
    in precmd.
  • hooks/command-class.json is the one home for the class policy, read by
    the adapter and by RedLog.
  • Output over the inline threshold is kept whole in the body store and
    referenced from the event, instead of being truncated.
  • The capture card tells "installed" from "working", and offers the install
    as its one action.
  • Uninstall leaves .zshrc byte-identical, which it did not.

Why

The command itself is launched by nothing here

preexec moves the shell's descriptors; it does not wrap, subshell or PTY the
command. The command keeps the terminal, its stdin and its job control, which
is what lets nc be suspended and vim be used (FR-006). O1 measured stty -g
byte-identical across the diversion, so the line discipline is untouched.

command_end is emitted after the component that owns the bytes has drained
(O2's contract), by redlog-relay.py finish waiting on two part files — each
written whole and renamed into place — rather than by precmd racing them. A
part that never arrives is reported truncated with limit_hit: drain-timeout,
not as empty output.

Nothing in the output decides anything

The bytes are the output of a command run against something hostile. If
anything in them is read to decide where a record begins, ends or belongs, the
target writes RedLog's evidence. So everything structural is an identifier the
adapter minted out of band (FR-002). The pty test runs a command whose output is
a plausible RedLog event, a plausible correlation key and a plausible
end-of-command marker at once, and asserts one command_start, one
command_end, the bytes recorded verbatim, the key still the adapter's own, and
neither command's output dragged into the other's.

Three ways a command has no body, and they are not the same

Principle VI: an empty stdout with completeness: complete says "this command
printed nothing", and a reader believes it.

Situation What the record says
nmap -oN scan.txt redirected / metadata-only (FR-008)
vim /etc/shadow interactive / metadata-only (FR-025, FR-026)
relay could not run not-captured / metadata-only

looks_redirected() reads the command LINE — what the operator typed, never the
output — and only reports redirected when no stdout arrived either.
relay-contract.test.ts carries the table, because each distinction is silent
when wrong: 2> leaves stdout alone, >&2 is a dup onto a descriptor the relay
still holds, &> takes both, | tee f is not a redirection, echo 2 > out.txt
is one, and a > inside either kind of quote is not.

The bound stopped being a truncation limit

100 KB per stream was chosen for a wrapper used a few times per engagement. Every
command is relayed now, so nmap -A and ffuf hit it routinely, and a truncated
scan is the evidence the operator most wanted. Over the inline threshold the body
is kept whole in the body store and referenced; what remains in the relay is a
memory bound (8 MiB) so a runaway yes is stopped rather than growing until
something else fails (research.md T006).

An install that proves itself

A copied file and an appended rc line prove a setup flow ran, not that anything
is captured: the rc has to be re-read, the adapter has to load, the transport has
to reach RedLog. The card's terminal row has three states now, and the third is
evidence — a command that arrived from a terminal that is not one of RedLog's own
panes (FR-015).

Deliberate calls worth a reviewer's attention

The PTY class uses shell functions, which research.md D3 rejected. zsh gives
preexec no way to replace the command about to run, and the PTY class is the
one case that needs replacing rather than diverting. So one function per program
in the policy's pty list. D3 rejected that for the general case — it misses
builtins, pipelines and anything invoked by path — and for a short explicit list
it is the right tool. The limitation is recorded rather than hidden: sudo ssh
and /usr/bin/ssh miss the wrapper and land as interactive.

redlog-session.py gained --best-effort, and it is the difference between
a logger and a gate. The recorder refuses to start when capture is paused, which
is right when an operator asked to record; under the automatic wrapper this
path is reached by an ssh the operator typed for their own reasons, and
refusing to run it would be the audit tool deciding what the engagement may do.

The body-ref fields were the real work in T021. A body-store reference has to
be known to four things that are nowhere near each other: the FTS index, the
retention sweep (a file nothing pins is evicted out from under its event), the
export plan and the attachment manifest. Each carried its own literal list. They
now read one BODY_REF_FIELDS. That also closed a gap already there: both
export sites listed only request_body_ref and response_body_ref, so ws and
tcp bodies have never been in a bundle.

Two contracts were replaced, not left for someone to find.
zsh-pty-harness.test.ts asserted that a hook-recorded command_end carried no
stdout — "the gap this spec exists to close". And
capture.terminalOwnShellIncluded said "commands only, redlog-run adds
stdout/stderr". Both are inverted here.

Three allowlisted exports from #268 are still allowlisted — their callers are
redlog status / redlog stop / the project-switch stop, which are US2
(T029-T035).

Found while building, fixed here

  • The classifier agreement test caught a real bug on its first run.
    redlog-relay.py classify was reading its own -- separator as the program
    name, so every command classified as relayed — nc and vim included,
    which is FR-025 and FR-026 exactly inverted. All 25 defaults now go through
    both the Python and the TypeScript walk and are compared.
  • Uninstall was not an inverse. Install appended a block beginning with a
    newline; uninstall replaced that whole run, leading newline included, with a
    single newline. Every cycle left one more blank line in the operator's
    .zshrc, and a file that had not ended with a newline came back with one.
  • The WSL harness hit its own limit. Five test files drive a shell now, and
    on Windows each is wsl.exe; the interop service begins refusing sessions and
    returns Wsl/Service/0x8007274c on stdout in the console code page, which
    lands spliced into the next command line as mojibake. Worse, a refused
    capability probe made findShellTarget return null and the whole file SKIP,
    which reads as green. wslpath is gone (the translation is C:\x →
    /mnt/c/x), the probe is one retried call, and the remaining WSL work is
    serialised by a lock directory. No lock is taken on Linux, so CI is unchanged.

Testing notes

The pty suite drives a real interactive zsh: preexec and precmd do not fire
under zsh -c, and a piped stdout hides the line discipline the relay has to
leave intact. On CI's ubuntu leg it runs against local zsh; on Windows it goes
through wsl -d kali-linux, which is the delivery target, locally; anywhere else
it skips with its reason.

hooks-manager's rc edit is tested as a pure inverse property rather than
through installHook, which refuses outright on win32 — otherwise FR-016 would
have been exercised for the first time on CI.

Verification

  • npm run typecheck
  • npm run verify:specs
  • npm run verify:architecture
  • npm run verify:i18n
  • npm test — 3106 passed, 24 skipped (after merging current main)
  • npm run build
  • full e2e on this branch after the merge — 100 passed, 4 skipped (5.6 min).
    Run locally because CI skips e2e when an earlier gate fails, and this
    branch changes hooks, ingest and the capture card.

Tasks T018–T028 of 47; Phase 3 (US1) complete. Next is Phase 4 (US2,
T029–T035): redlog status|start|stop|mode|class, the durable stop, and the
pause gap.

🤖 Generated with Claude Code

guan4tou2 and others added 6 commits October 7, 2026 14:37
RedLog could already capture a command's output. `redlog-run nmap -sV host`
has worked for releases, and it is used a handful of times per engagement,
because an operator mid-engagement does not remember a prefix and the commands
worth having are the ones typed without thinking about the logger. This
removes the prefix.

preexec saves fds 1 and 2 and points them at two `redlog-relay.py pipe`
processes; precmd puts them back, which is what gives the relays their EOF.
The relays start before the diversion, so what they pass through lands on the
real terminal. The command itself is launched by nothing here: it keeps the
terminal, its stdin and its job control, which is what lets `nc` be suspended
and `vim` be used (FR-006, and O1 measured `stty -g` byte-identical across the
diversion).

O2's contract -- command_end is emitted after the component that owns the
bytes has drained -- is honoured by `redlog-relay.py finish`, not by precmd
racing it. It waits, bounded, for both part files; each is written whole and
renamed into place, so a part that is present is complete, and one that never
arrives is reported truncated with limit_hit: drain-timeout rather than as
empty output.

The class policy got one home: hooks/command-class.json. terminal-class.ts
imports it, `redlog-relay.py classify` reads it beside itself, and the adapter
splits the line with zsh's own ${(z)...} so the classifier sees the argv zsh
will run. Two copies of those names would drift silently until the day an
operator's reverse shell went through a relay.

The walks over wrappers are still two implementations, and the agreement test
written for exactly that caught a real one on its first run: classify was
reading its own `--` separator as the program name, so EVERY command
classified as relayed -- `nc` and `vim` included, which is FR-025 and FR-026
inverted. All 25 defaults now go through both sides and are compared.

Contract replaced, not follow-up work: test/zsh-pty-harness.test.ts asserted
that a hook-recorded command_end carried NO stdout -- "the gap this spec
exists to close". The gap is closed, so the assertion is inverted here.

Noted, not fixed: a relayed command now costs four python3 spawns plus the
existing payload build and curl. Cheap on a native Kali; ~3s per command
through WSL from Windows, which is also why the shell-driven tests now carry
explicit budgets -- under full-suite load they were failing as bare timeouts,
the shape CLAUDE.md warns names nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty stdout with `completeness: complete` says "this command printed
nothing", and a reader believes it. For `nmap -oN scan.txt` that is false --
the output went where the operator sent it -- and for `vim /etc/shadow` it is
false twice over, because what was not recorded is the one thing anyone would
want to know about that command. Principle VI: no-event, not-captured,
stopped and failed stay distinct all the way to the timeline.

Redirected (FR-008): `looks_redirected()` reads the command LINE -- what the
operator typed, never the command's output, which is what FR-002 forbids --
and only reports `redirected` when no stdout arrived either. The distinctions
are fiddly and each one is silent when wrong, so relay-contract.test.ts
carries the table: `2>` and `2>>` leave stdout alone, `>&2` is a dup onto a
descriptor the relay still holds, `&>` takes both, `| tee f` is not a
redirection, `echo 2 > out.txt` is one (that `2` is a word, not a descriptor),
and a `>` inside either kind of quote is not. The pty test also reads the file
back: a capture tool that swallowed the operator's redirection would be worse
than one that mislabelled it.

Interactive (FR-025/FR-026): a native-class command was never going to be
relayed, and that is a decision, not a failure. `interactive` and
`not-captured` stay different words -- collapsing them would make every
deliberate silence look like a broken capture and every broken capture look
deliberate.

The harness hit its own limit here and had to be fixed before the result
could be trusted. Five files now drive a shell, vitest starts them in
parallel, and on Windows each is `wsl.exe`; the interop service begins
refusing sessions and returns `Wsl/Service/0x8007274c` on stdout in the
console code page, which lands spliced into the next command line as
mojibake. Worse, a refused capability probe made findShellTarget return null
and the whole file SKIP, which reads as green. So: `wslpath` is gone (the
translation is C:\x -> /mnt/c/x, and spawning a Linux process to do it was
most of the storm), the probe is one retried call instead of two unretried,
and the remaining WSL work is serialised by a lock directory. No lock is
taken on Linux, so CI is unchanged -- and the files got faster, with
relay-contract down from 22s to 7s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…T024)

`ssh`, `socat` and `pwncat-cs` bring their own TTY, so diverting the shell's
descriptors records nothing worth having -- the bytes are drawn inside a
terminal the relay never sees. They get hooks/redlog-session.py, verified
under spec 022, with bounded output, identity pinned at session start and
pause honoured at receipt. `script(1)` would be a second recorder with
different semantics and no identity pinning (research.md D7).

The mechanism had to be decided here, and it is not preexec: zsh gives it no
way to replace the command that is about to run, and the PTY class is the one
case that needs replacing rather than diverting. So one shell function per
program in the policy's `pty` list, installed at startup from a new
`redlog-relay.py policy --field pty`.

That is exactly what research.md D3 rejected for the GENERAL case -- a
function misses builtins, pipelines and anything invoked by path -- and for a
short explicit list it is the right tool. The limitation is recorded rather
than hidden: `sudo ssh` and `/usr/bin/ssh` miss the wrapper, classify as
`pty`, and land as `interactive` like any other command whose body was never
held. Wrappers are installed only for programs actually present, because a
function named `ssh` on a box without ssh turns "command not found" into a
python traceback.

redlog-session.py gained --best-effort, and it is the difference between a
logger and a gate. The recorder refuses to start when capture is paused or
RedLog is unreachable, which is right when an operator ASKED to record. Under
the automatic wrapper this path is reached by an ssh the operator typed for
their own reasons, and refusing to run it would be the audit tool deciding
what the engagement may do. With the flag it prints why and execvp's the
command, so the exit status is the command's own; the same flag turns
"already inside a redlog-session" from an error into the ordinary case it now
is.

Proved red before green by commenting out the wrapper install: without it the
collector sees command_start/command_end and no session_start at all.

Also: the T002a ceiling now reads runZsh's own elapsed time instead of a
clock around the call. The harness serialises WSL work behind a lock, so
timing from outside included the queue -- it read 41.6s under a full-suite run
and failed, having measured four other test files rather than the hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 KB per stream was chosen for a wrapper used on purpose a few times per
engagement. Every command is relayed now, so `nmap -A`, `ffuf` and `gobuster`
hit that routinely -- and a truncated scan is the evidence the operator most
wanted. T006 decided not to pick a bigger number but to externalise, so the
bound changes meaning rather than value.

Over the inline threshold the body is kept WHOLE on ingest, in the body store
HTTP bodies already use, with {sha256, size, file, encoding} on the event.
What is left in the relay is a MEMORY bound -- 8 MiB, two of them resident
while a command runs -- so a runaway `yes` is stopped rather than growing
until something else fails. When it fires the event still says `truncated` and
names the bound (FR-004); REDLOG_MAX_BYTES makes it reachable from a test
without pushing eight megabytes through a pty.

The ref fields were the real work. A body-store reference has to be known to
four things that are nowhere near each other: the FTS index, the retention
sweep (a file nothing pins is evicted out from under its event), the export
plan and the attachment manifest. Each carried its own literal list, so adding
two fields meant editing four lists correctly or losing evidence in silence.
They now read one BODY_REF_FIELDS from http-body-store.ts.

That also closed a gap that was already there: both export sites listed only
request_body_ref and response_body_ref, so ws and tcp bodies have never been
in a bundle. The new test asserts the command-body case directly, because a
ref the export does not know about is a body on the operator's disk and not in
the bundle the client reads -- the quietest evidence loss available, since
every count still adds up.

The renderer reads the refs too. Without that, everything over 4 KB would
simply vanish from the detail pane, which is a worse bug than the one being
fixed. RefBackedStream is at module scope: a component declared inside another
component's body is a new type on every render.

The store directory is still called http-bodies although it now holds command
output as well. Renaming it would orphan every existing project's files for a
cosmetic gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…T025-T028)

Four things the card and the installer were getting wrong now that the shell
hook is what brings the operator's own terminals in, with their output.

**The card said "installed" and meant it as an answer.** It is not one. A
copied file and an appended rc line prove that a setup flow ran, not that
anything is being captured: the rc has to be re-read, the adapter has to load,
and the transport has to reach RedLog. The row has three states now, and the
third is evidence -- a command that arrived from a terminal that is not one of
RedLog's own panes (FR-015). The test is "not builtin-terminal" rather than
"is auto-relay", because `source` is absent on rows from adapters installed
before this release and reading that as RedLog's pane would tell an operator
whose hook has worked for months that their setup is unfinished.

**The install was not the card's action.** That was right when it was written
-- RedLog's own pane records with nothing installed, so the hook was a
second-order concern behind a "manage sources" click. Spec 052 changed what
the install buys, and an operator who works in their own shell would otherwise
read "healthy" on a machine whose real work is unrecorded. It is now the
card's one action, once there is a record at all (FR-014). "Nothing has been
recorded yet" still outranks it.

**Uninstall was not byte-identical, and FR-016 says it has to be.** Install
appended a block beginning with a newline; uninstall replaced that whole run,
leading newline included, with a single newline. Every cycle left one more
blank line in the operator's .zshrc, and a file that had not ended with a
newline came back with one. rcWithHook/rcWithoutHook are pure and exact, and
the test is the inverse property over five starting files and five repeated
cycles. Pure on purpose: installHook refuses outright on win32, so this would
otherwise have been exercised for the first time on CI's ubuntu leg. An rc
holding nothing but our block is removed entirely, the way the PowerShell
branch already refuses to leave a 0-byte file behind.

**The row could not tell RedLog's panes from this machine's terminals.** Two
fields, because they answer different questions: `enrolled` counts terminals
that have a state file and how many are recording, and `ownShellLastEventAt`
is the FR-015 proof.

Also: `terminalOwnShellIncluded` said "commands only, redlog-run adds
stdout/stderr". That stopped being true at T020, so the string is part of this
change rather than a stale line someone finds later.

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: 052ac674-cd7c-4c56-87b2-2e6ec950c825
  • 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 89b6e10 into main Oct 7, 2026
5 checks passed
@guan4tou2
guan4tou2 deleted the feat/052-us1-auto-capture branch October 7, 2026 10:00
guan4tou2 added a commit that referenced this pull request Oct 8, 2026
feat(052): a command typed with no prefix in front of it (US1)
guan4tou2 added a commit that referenced this pull request Oct 8, 2026
feat(052): a command typed with no prefix in front of it (US1)
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