Repository navigation
feat(052): a command typed with no prefix in front of it (US1) - #276
Merged
Merged
Conversation
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>
|
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 |
7 tasks done
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)
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 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 hosthasworked 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
mainand merged into this branch.hooks/shell-zsh-hook.zshdiverts the shell's own stdout and stderrthrough two
redlog-relay.py pipeprocesses inpreexecand puts them backin
precmd.hooks/command-class.jsonis the one home for the class policy, read bythe adapter and by RedLog.
referenced from the event, instead of being truncated.
as its one action.
.zshrcbyte-identical, which it did not.Why
The command itself is launched by nothing here
preexecmoves the shell's descriptors; it does not wrap, subshell or PTY thecommand. The command keeps the terminal, its stdin and its job control, which
is what lets
ncbe suspended andvimbe used (FR-006). O1 measuredstty -gbyte-identical across the diversion, so the line discipline is untouched.
command_endis emitted after the component that owns the bytes has drained(O2's contract), by
redlog-relay.py finishwaiting on two part files — eachwritten whole and renamed into place — rather than by
precmdracing them. Apart that never arrives is reported
truncatedwithlimit_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, onecommand_end, the bytes recorded verbatim, the key still the adapter's own, andneither 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
stdoutwithcompleteness: completesays "this commandprinted nothing", and a reader believes it.
nmap -oN scan.txtredirected/metadata-only(FR-008)vim /etc/shadowinteractive/metadata-only(FR-025, FR-026)not-captured/metadata-onlylooks_redirected()reads the command LINE — what the operator typed, never theoutput — and only reports
redirectedwhen no stdout arrived either.relay-contract.test.tscarries the table, because each distinction is silentwhen wrong:
2>leaves stdout alone,>&2is a dup onto a descriptor the relaystill holds,
&>takes both,| tee fis not a redirection,echo 2 > out.txtis 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 -Aandffufhit it routinely, and a truncatedscan 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
yesis stopped rather than growing untilsomething 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
preexecno way to replace the command about to run, and the PTY class is theone case that needs replacing rather than diverting. So one function per program
in the policy's
ptylist. D3 rejected that for the general case — it missesbuiltins, 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 sshand
/usr/bin/sshmiss the wrapper and land asinteractive.redlog-session.pygained--best-effort, and it is the difference betweena 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
sshthe operator typed for their own reasons, andrefusing 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: bothexport sites listed only
request_body_refandresponse_body_ref, so ws andtcp bodies have never been in a bundle.
Two contracts were replaced, not left for someone to find.
zsh-pty-harness.test.tsasserted that a hook-recordedcommand_endcarried nostdout — "the gap this spec exists to close". And
capture.terminalOwnShellIncludedsaid "commands only, redlog-run addsstdout/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
redlog-relay.py classifywas reading its own--separator as the programname, so every command classified as
relayed—ncandvimincluded,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.
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.on Windows each is
wsl.exe; the interop service begins refusing sessions andreturns
Wsl/Service/0x8007274con stdout in the console code page, whichlands spliced into the next command line as mojibake. Worse, a refused
capability probe made
findShellTargetreturn null and the whole file SKIP,which reads as green.
wslpathis gone (the translation isC:\x→/mnt/c/x), the probe is one retried call, and the remaining WSL work isserialised by a lock directory. No lock is taken on Linux, so CI is unchanged.
Testing notes
The pty suite drives a real interactive zsh:
preexecandprecmddo not fireunder
zsh -c, and a piped stdout hides the line discipline the relay has toleave 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 elseit skips with its reason.
hooks-manager's rc edit is tested as a pure inverse property rather thanthrough
installHook, which refuses outright on win32 — otherwise FR-016 wouldhave been exercised for the first time on CI.
Verification
npm run typechecknpm run verify:specsnpm run verify:architecturenpm run verify:i18nnpm test— 3106 passed, 24 skipped (after merging currentmain)npm run buildRun 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 thepause gap.
🤖 Generated with Claude Code