Skip to content

Terminal degradation ladder + redacted forensic crash log (#144) - #215

Open
OsherElhadad wants to merge 4 commits into
mainfrom
feat/issue-144-tty-ladder
Open

Terminal degradation ladder + redacted forensic crash log (#144)#215
OsherElhadad wants to merge 4 commits into
mainfrom
feat/issue-144-tty-ladder

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #144.

Builds directly on #191 (issue #116), which landed the shared event tail. This is
the robustness half: the ladder as an explicit contract, non-UTF-8 survival, and a
forensic crash log.

What #191 already covered vs what this adds

#191 already did This PR adds
use_color(stream) — the colour seam capability(stream)five named rungs; use_color is now exactly capability == "full"
plain text on non-TTY, NO_COLOR honoured the ladder as a tested contract: only full may emit an escape byte, byte-verified per rung
progress on stderr; stdout stays JSON unchanged (re-verified per rung)
guard for closed stderr (2>&-) that guard is now the ladder's none rung, named and testable
sanitize() — escape-injection defence applied to crash-log payloads and to the user-visible crash line
"the follower must not die quietly" (one stderr line) that path now writes a forensic log and points at it
PYTHONIOENCODING=ascii / LC_ALL=C survival (assigned here by #191's review)
cap-evolve tail --ladder scriptable rung read-out

The ladder

Rung Detected by Output style Transcript
full TTY, TERM not dumb/unknown, no NO_COLOR ANSI colour + Unicode ✅ below
plain TTY + NO_COLOR same text, 0 escape bytes ✅ below
dumb TTY + TERM=dumb/unknown no colour, append-only ✅ below
pipe not a TTY (redirect, CI) plain lines, grep-clean ✅ below + od -c
none stream missing/closed (2>&-) nothing; following disables itself ✅ below

An unset TERM is deliberately not demoted — normal on a real TTY outside a
shell profile; dumb is the explicit opt-out.

On height-budgeting (#144 item 2): nothing at any rung repaints the screen, moves
the cursor, or allocates a layout — output is append-only at every rung. There is no
live layout to overflow or double-render on a resize, which is the cheapest way to be
correct about resizes. No curses, no rich, zero new deps.

Non-UTF-8 locales

emit() checks the stream's encoding up front and transliterates (±+/-,
Δd, -). Pre-checking rather than catching matters: CPython opens stderr
with errors="backslashreplace", so under PYTHONIOENCODING=ascii the write does not
raise — it silently prints \xb1. Technically ASCII, unreadable in practice.

The crash log

Contents (crash-<stamp>.json): cap_evolve_version, when, argv, python,
platform, cwd, terminal (both rungs, TERM, NO_COLOR, encoding,
PYTHONIOENCODING, locale), context (which code path), exception, traceback,
recent_events (last 25). Lands next to the run, else
${XDG_CACHE_HOME:-~/.cache}/cap-evolve/crashes/. One stderr line points at it; exit
code is non-zero. Follows #193's precedent of offloading verbose detail to a local
file rather than stdout.

No-leak proof. The payload and the crash line both go through the existing
dashboard.redactnot a new scrubber. If redact is unavailable the log is not
written at all (no log beats a leaked key). Two real leaks were found and fixed
while testing:

  1. main's redact lacked Add cap-evolve doctor install/health diagnostic #193's ghp_/github_pat_/UUID shapes and its
    shape-independent pass over this process's env values, so a bare high-entropy
    watsonx-style key survived.

    Correction (review): this PR originally claimed that hunk was ported
    byte-identical to Add cap-evolve doctor install/health diagnostic #193's. That was false as stated — there were three
    comment/whitespace deltas. Precisely: it was AST-identical ignoring docstrings, and
    dashboard.py auto-merged clean either way, so the "either merge order is a no-op"
    effect held while the literal claim did not.

    It is now moot: review found that Add cap-evolve doctor install/health diagnostic #193's env pass only admitted values under a
    secret-looking key, so a bare credential exported as MODEL_ENDPOINT_SUFFIX
    leaked into the crash log and onto stderr. This PR therefore hardens redact
    past Add cap-evolve doctor install/health diagnostic #193 — admission is now OR-ed with a value-based rule that never consults the
    key name — so the hunk is deliberately no longer a copy. Terminal degradation ladder + redacted forensic crash log (#144) #215 must land after
    Add cap-evolve doctor install/health diagnostic #193
    , because the leak fix lives here.

  2. The crash line printed at the user echoed the raw exception, leaking exactly what
    the log had masked. Every CLI crash line now routes through one _safe_exc()
    (redact + sanitize), so no call site can forget either half.

Multi-shape canary test asserts all four shapes: bare high-entropy, UUID, ghp_,
watsonx-style — not just an sk- prefix. Correction (review): it planted every
canary under a key name the regex already matched, so it only exercised the case that
already worked — the same defect pattern as #193's sk--prefixed canary. It now plants
canaries under innocent key names too (MODEL_ENDPOINT_SUFFIX, DEPLOYMENT_ID,
AUTH_HEADER_VALUE), and asserts benign env values (PATH, TERM, LANG) are not
over-redacted.

Scope note (review): issue #144's items 2 and 4 (height budget, width detection) are
a deliberate scope reduction, not a satisfied requirement — both presuppose output
that takes over the screen, and every rung here is append-only. Stated in
GETTING_STARTED.md; a future repainting view needs dashboard._term_width and that
item re-opened.

Expected merge order

#191#118#193#215#214. #191 first (this branch is based on it; merge
probe: 0 conflicts). After #193, because this PR hardens the shared redact past
it.

Correction (review): this PR previously said no feat/issue-137-* branch existed.
It does — origin/feat/issue-137-cli-ergonomics, PR #214. Re-ran the probe: the
only textual conflict is cli.py, and the resolution is take #214's side, since
#214 deleted the one-line usage: literal in favour of a generated listing.

But the textual resolution is not sufficient, and this needs its own issue: #214
adds _harden_utf8(), which reconfigure(encoding="utf-8")s stdout/stderr at the top
of main(). That makes this PR's _encodable() see a UTF-8 stream under
PYTHONIOENCODING=ascii, so transliteration never fires and ± reaches an ASCII
terminal (test_run_follow_survives_ascii_io_encoding fails in the merge). Two PRs
solve the same problem incompatibly: #214 reconfigures the stream to accept the glyph,
#144 transliterates the glyph to fit the stream. Verified this pre-exists the review
fixes (identical failure against this PR's original head), so it is a design conflict,
not a regression. #214's own docstring says ponytail: the CLI-level guard only; the TUI ladder is #144's job, which suggests _harden_utf8 should yield to the ladder — but
that is for whoever rebases second to decide, not to silently pick here.

Verification

Full suite (baseline 179; #191 added ~37, this PR 17):

$ PYTHONPATH=core python -m pytest core/tests -q
233 passed in 73.21s (0:01:13)
$ python -m compileall -q core skills
COMPILE_OK

Rung 4 — pipe (real cap-evolve run --follow on examples/toy_calc, mock optimizer, zero API)

$ python -m cap_evolve.cli run --spec ... --follow --dashboard off > stdout.pipe 2> stderr.pipe
exit=0
[02:26:15] splits frozen  train=4 val=2 test=2 (test sealed)
[02:26:15] baseline  val=0.0000 ±0.0000
[02:26:15] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  — paired Δ̄=+1.0000 > 0 (…)
[02:26:16] reject  cand_0002  val=1.0000 (parent 1.0000)  — paired Δ̄=+0.0000 <= 0 (…)
[02:26:17] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=cand_0001
$ od -c stderr.pipe | grep -o esc | wc -l
0

Zero escape bytes. stdout still parses: test_reward: 1.0.

Rungs 1–3 (real pty) and 5

=== RUNG 1 full (TTY, TERM=xterm-256color) — cat -v
^[[36m[02:26:43] baseline  val=0.0000 M-BM-10.0000^[[0m
^[[32m[02:26:44] ACCEPT  cand_0001  val=1.0000 (parent 0.0000) …^[[0m
=== RUNG 2 plain (TTY + NO_COLOR=1)
[02:27:02] baseline  val=0.0000 M-BM-10.0000        esc bytes: 0
=== RUNG 3 dumb (TTY + TERM=dumb)
[02:27:06] baseline  val=0.0000 M-BM-10.0000        esc bytes: 0
=== RUNG 5 none (2>&-)  exit=0
progress leaked into stdout? 0     (stdout still valid JSON)

Ladder read-out per rung:

{"stdout": "full",  "stderr": "full",  …}   # pty, TERM=xterm
{"stdout": "plain", "stderr": "plain", …}   # pty, NO_COLOR=1
{"stdout": "dumb",  "stderr": "dumb",  …}   # pty, TERM=dumb
{"stdout": "pipe",  "stderr": "pipe",  …}   # piped

PYTHONIOENCODING=ascii + LC_ALL=C

$ PYTHONIOENCODING=ascii LC_ALL=C LANG=C python -m cap_evolve.cli run … --follow
exit=0
[02:28:53] baseline  val=0.0000 +/-0.0000
[02:28:54] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  - paired d?=+1.0000 > 0 (SE=0 -> STRICT fallback…)
[02:28:53] FINALIZE  test=1.0000 (baseline 0.0000, d+1.0000)  best=cand_0001
non-ascii bytes: 0        backslash-escapes left: 0

Crash log + no-leak (4 planted canaries)

$ ... crash with canaries in argv, exception text, env, and an event payload
exit=1
cap-evolve run crashed: RuntimeError: optimizer died]0;PWNED: OPENAI_API_KEY=«redacted» session=«redacted» key=«redacted»
forensic log (redacted, safe to attach to a bug report): /tmp/lad/cache/cap-evolve/crashes/crash-20260730-023049.json

--- canary grep -c over BOTH the log and stderr ---
log:0 stderr:0  <- sk-proj-CANARY1abcdefghijklmnopqrstuv0123
log:0 stderr:0  <- ghp_CANARY2ABCDEFGHIJKLMNOP0123456789
log:0 stderr:0  <- 3f2504e0-4f89-11d3-9a0c-0305e82c3301
log:0 stderr:0  <- hIQ7bLpZ2mNvXk3TuWq9            (bare, shapeless)
escape bytes in stderr: 0

Zero canaries leak. The OSC attack embedded in the crash message is inert.

Dying follower mid-flight (run continues, evidence on disk):

[follow] live progress stopped: ValueError: renderer blew up mid-run OPENAI_API_KEY=«redacted» — the run continues; use `cap-evolve tail` or the dashboard to watch it (details: /…/run_fc/crash-20260730-023106.json)

with "context": {"where": "follow-thread", "terminal_rung": "pipe"}, the traceback,
and the last 3 events (splits, evaluate, baseline) — diagnosable without a repro.
The run still exited 0 with valid JSON.

Escape injection still inert (#191's three attacks, color=False and color=True)

OSC title        -> '[02:36:49] OPTIMIZER ERROR  c1: boom]0;PWNED'
screen clear     -> '[02:36:49] brand_new payload=[2J[Hcleared'
forged FINALIZE  -> '[02:36:49] brand_new payload=a⏎FINALIZE  test=1.0000 … best=FAKE'
all three attacks inert, one line each, under color=False AND color=True

Full commands + untruncated output in the ## 🔬 Evidence comment below.

Osher Elhadad added 3 commits July 30, 2026 00:34
…ve tail

A classic `cap-evolve run` was completely silent for its whole duration: every
phase ran under subprocess.run(capture_output=True), so a multi-hour hill-climb
showed a blank terminal until it returned one JSON blob — and a hung run was
indistinguishable from a working one. The only live view was the web dashboard,
which leaves CI/ssh/headless/air-gapped users with nothing.

- New core module `cap_evolve.eventstream` (stdlib only, zero new deps): the ONE
  place that reads a run's events.jsonl. read_new_events() (byte-offset
  incremental read, partial trailing line left unconsumed), follow_events()
  (blocking generator that waits for the file, stops on finalize/idle/signal),
  and format_event()/render_line() (one human-readable line per event, ANSI
  applied only by render_line).
- `cap-evolve run --follow`: prints stage transitions, baseline, per-candidate
  accept/reject with candidate id + val + reason, budget warnings, optimizer
  errors, finalize, plus a cumulative cost/token meter. Progress goes to STDERR
  so stdout stays the machine-readable final JSON scripts parse. Runs on a
  daemon thread started before baseline creates the run dir, so the first
  events are never missed; it can never raise into the run.
- `cap-evolve tail [run_dir]`: attaches to an existing or ongoing run (default:
  newest run_* under --base). Waits for the run dir to appear, so you can attach
  before the run creates it. --from-start replays history.
- The dashboard's SSE route now imports read_new_events from the shared core
  helper instead of owning its own copy, so terminal and web read the same typed
  event stream and can never disagree. #118 (stall detection), #122 (replay),
  #138 and #144 build on this module.
- Degrades cleanly with no TTY: plain text when piped, in CI, or under NO_COLOR.

Closes #116
…ion, honest cost, stderr safety

Review fixes for #191 (hub PR for #118/#122/#138/#144).

- A malformed event no longer kills the follower thread. format_event is total
  (non-dict / bad `t` degrade to None or `--:--:--`), and cli.py reports on stderr
  instead of swallowing, so a dead follower is never mistaken for a silent run —
  the exact bug #116 exists to fix.
- read_new_events returns only JSON objects, so no consumer (CLI or dashboard SSE)
  receives a bare 42/null/[1,2]; unreadable records surface as a log_corruption
  event instead of vanishing.
- All rendered text is sanitised: C0/C1 controls and ESC sequences are stripped and
  newlines collapse, so an optimizer's stderr cannot set the window title, clear the
  screen, or forge a fake FINALIZE line.
- The cost meter no longer double-counts: runner spend from `evaluate`, optimizer
  spend from `step`-likes, intake from `intake` — matching Spent.total_usd. Exposed
  as public accrue_totals so #138 does not fork the arithmetic.
- --follow disables itself when stderr is unusable (`2>&-`) rather than letting
  progress corrupt the stdout JSON contract.
- Hub API: follow_events yields a typed _follow_end sentinel naming its exit reason
  (stop_kind/idle/should_stop) for #118, should_stop now receives the last event, the
  300s idle_timeout module default is gone, a shrunk file re-reads from 0 for #122,
  and format_event(skip_kinds=()) exposes bookkeeping kinds for #138.
- tail exits 2 on an impossible run dir and 3 on an idle timeout with no events;
  --resume --follow no longer replays the whole prior log; use_color requires its
  stream; negative --idle-timeout is rejected.
…crash log

Closes #144. Builds directly on #191 (issue #116), which landed the shared event
tail, sanitize(), and the plain-on-non-TTY endpoints.

The ladder is now an explicit contract, not two endpoints. eventstream.capability()
names five rungs — full (TTY+colour), plain (NO_COLOR), dumb (TERM=dumb/unknown),
pipe (non-TTY/CI), none (2>&- / closed) — each one detected by a single signal and
each one tested. Only `full` may put an escape byte on the wire; the other four are
byte-verified plain. use_color() is now exactly `capability() == "full"`, so there
is one decision rather than two that can drift. `cap-evolve tail --ladder` prints
the rung, so a CI job can assert its own output mode. Nothing at any rung repaints
or addresses the terminal, which is how #144's height-budget concern is answered:
append-only output has no layout to overflow on a resize.

Non-UTF-8 robustness (assigned here by #191's review): emit() checks the stream's
encoding up front and transliterates (± -> +/-, Δ -> d, — -> -) rather than
raising UnicodeEncodeError inside the follower thread, which would take the live
view dark. Pre-checking matters because CPython opens stderr with
errors="backslashreplace": under PYTHONIOENCODING=ascii the write does NOT raise,
it silently prints "\xb1". Technically ASCII, unreadable in practice.

Forensic crash log: an unhandled exception in a subcommand or in the follow thread
writes version, argv, python/platform, the terminal rung + encoding, the traceback
and the last 25 events to the run dir (else ${XDG_CACHE_HOME:-~/.cache}/
cap-evolve/crashes/) and prints ONE line pointing at it. #191's review found the
follower could die silently and take the run dark; that path now leaves evidence.
Event payloads in the log are sanitised too — a crash log is `cat`ed as often as
it is read.

No secrets: the payload AND the user-visible crash line both go through the
existing dashboard.redact, not a new scrubber. Writing the log is skipped entirely
if redact is unavailable — no log beats a leaked key. Two real leaks were found
and fixed while testing this: (1) main's redact lacked #193's ghp_/UUID shapes and
its shape-independent pass over this process's secret-looking env values, so a
bare high-entropy watsonx-style key survived — that hunk is ported byte-identical,
so merging #193 in either order is a no-op; (2) the crash line printed at the user
echoed the raw exception, leaking what the log had masked. Every CLI crash line
now routes through one _safe_exc() helper (redact + sanitize), so no call site can
forget either half.

Left to #137 (CLI ergonomics): argument-parsing-level UTF-8 handling. This change
owns the rendering/TUI half only.
Copilot AI review requested due to automatic review settings July 29, 2026 23:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread core/cap_evolve/cli.py
return fn(argv[1:])
except (KeyboardInterrupt, SystemExit):
raise # Ctrl-C / an explicit exit code is not a crash
except BaseException as e: # noqa: BLE001 — #144: never exit with a bare traceback
for usd_key, tok_key in pairs:
try:
totals["usd"] = totals.get("usd", 0.0) + float(ev.get(usd_key) or 0.0)
except (TypeError, ValueError):
pass
try:
totals["tokens"] = totals.get("tokens", 0) + int(ev.get(tok_key) or 0)
except (TypeError, ValueError):
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command run verbatim, with full output. Zero-API throughout (mock optimizer on examples/toy_calc).

Environment

$ git log --format="%an <%ae> | %s" -1
Osher Elhadad <Osher.Elhadad@ibm.com> | feat(observability): terminal degradation ladder + redacted forensic crash log
$ python -V
Python 3.14.2

Test suite + compileall

$ python -m compileall -q core skills; echo exit=$?
exit=0
$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 30%]
........................................................................ [ 61%]
........................................................................ [ 92%]
.................                                                        [100%]
233 passed in 70.50s (0:01:10)

New tests added by this PR

$ PYTHONPATH=core python -m pytest core/tests/test_eventstream.py -v -k "ladder or rung or crash or ascii or emit or capability or colour or interrupt or user_visible"
core/tests/test_eventstream.py::test_format_event_never_emits_ansi PASSED [  5%]
core/tests/test_eventstream.py::test_capability_names_every_rung PASSED  [ 11%]
core/tests/test_eventstream.py::test_only_the_full_rung_gets_colour PASSED [ 17%]
core/tests/test_eventstream.py::test_every_rung_below_full_emits_zero_escape_bytes PASSED [ 23%]
core/tests/test_eventstream.py::test_emit_returns_false_on_the_none_rung PASSED [ 29%]
core/tests/test_eventstream.py::test_emit_transliterates_when_the_stream_cannot_encode PASSED [ 35%]
core/tests/test_eventstream.py::test_ascii_fallback_never_raises_on_arbitrary_unicode PASSED [ 41%]
core/tests/test_eventstream.py::test_run_follow_survives_ascii_io_encoding PASSED [ 47%]
core/tests/test_eventstream.py::test_crash_log_has_what_a_bug_report_needs PASSED [ 52%]
core/tests/test_eventstream.py::test_crash_log_keeps_only_the_recent_tail PASSED [ 58%]
core/tests/test_eventstream.py::test_crash_log_leaks_no_secret_of_any_shape PASSED [ 64%]
core/tests/test_eventstream.py::test_crash_log_falls_back_to_the_cache_dir PASSED [ 70%]
core/tests/test_eventstream.py::test_crash_log_event_payloads_stay_terminal_safe PASSED [ 76%]
core/tests/test_eventstream.py::test_cli_crash_writes_a_forensic_log_and_exits_nonzero PASSED [ 82%]
core/tests/test_eventstream.py::test_keyboard_interrupt_is_not_treated_as_a_crash PASSED [ 88%]
core/tests/test_eventstream.py::test_tail_ladder_flag_reports_the_rung PASSED [ 94%]
core/tests/test_eventstream.py::test_the_user_visible_crash_line_is_also_redacted_and_inert PASSED [100%]
====================== 17 passed, 37 deselected in 3.40s =======================

Rung 4 — pipe (piped stdout+stderr, i.e. CI)

$ python -m cap_evolve.cli run --spec $S --project $P --run-ts pipe --follow --dashboard off > stdout.pipe 2> stderr.pipe
exit=0
--- stderr.pipe (progress) ---
[02:41:42] splits frozen  train=4 val=2 test=2 (test sealed)
[02:41:42] eval val/seed  reward=0.0000 ±0.0000  0.0s
[02:41:42] baseline  val=0.0000 ±0.0000
[02:41:43] eval val/cand_0001  reward=1.0000 ±0.0000  0.0s
[02:41:43] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:41:43] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  — paired Δ̄=+1.0000 > 0 (SE=0 → STRICT fallback, warned; n=2)
[02:41:43] eval val/cand_0002  reward=1.0000 ±0.0000  0.0s
[02:41:43] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:41:43] reject  cand_0002  val=1.0000 (parent 1.0000)  — paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)
[02:41:44] eval val/cand_0003  reward=1.0000 ±0.0000  0.0s
[02:41:44] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:41:44] reject  cand_0003  val=1.0000 (parent 1.0000)  — paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)
[02:41:44] eval test/FINAL  reward=1.0000 ±0.0000  0.0s
[02:41:44] eval test/FINAL_seed  reward=0.0000 ±0.0000  0.0s
[02:41:44] FINALIZE  test=1.0000 (baseline 0.0000, Δ+1.0000)  best=cand_0001
--- stdout.pipe (machine contract) ---
{
  "run_dir": ".capevolve/run_pipe",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_pipe/dashboard.html"
}

$ od -c stderr.pipe | grep -o esc | wc -l     # escape bytes
0
$ od -c stderr.pipe | head -6
0000000    [   0   2   :   4   1   :   4   2   ]       s   p   l   i   t
0000020    s       f   r   o   z   e   n           t   r   a   i   n   =
0000040    4       v   a   l   =   2       t   e   s   t   =   2       (
0000060    t   e   s   t       s   e   a   l   e   d   )  \n   [   0   2
0000100    :   4   1   :   4   2   ]       e   v   a   l       v   a   l
0000120    /   s   e   e   d           r   e   w   a   r   d   =   0   .

Rungs 1–3 — real ptys (pty.openpty(), not a mock)

Harness (/tmp/ptyrun.py) runs the CLI with stdout+stderr on real ptys and dumps raw bytes.

Rung 1 full — TTY, TERM=xterm-256color

$ TERM=xterm-256color python /tmp/ptyrun.py full python -m cap_evolve.cli run … --follow
exit=0
$ cat -v full.stderr
[02:42:06] splits frozen  train=4 val=2 test=2 (test sealed)^M
[02:42:06] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
^[[36m[02:42:06] baseline  val=0.0000 M-BM-10.0000^[[0m^M
[02:42:07] eval val/cand_0001  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:07] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
^[[32m[02:42:07] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+1.0000 > 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^[[0m^M
[02:42:08] eval val/cand_0002  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:08] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
^[[2m[02:42:08] reject  cand_0002  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^[[0m^M
[02:42:08] eval val/cand_0003  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:08] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
^[[2m[02:42:08] reject  cand_0003  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^[[0m^M
[02:42:09] eval test/FINAL  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:09] eval test/FINAL_seed  reward=0.0000 M-BM-10.0000  0.0s^M
^[[1m[02:42:09] FINALIZE  test=1.0000 (baseline 0.0000, M-NM-^T+1.0000)  best=cand_0001^[[0m^M
$ od -c full.stderr | grep -o esc | wc -l
0

Rung 2 plain — TTY + NO_COLOR=1

exit=0
$ cat -v plain.stderr
[02:42:09] splits frozen  train=4 val=2 test=2 (test sealed)^M
[02:42:09] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
[02:42:09] baseline  val=0.0000 M-BM-10.0000^M
[02:42:10] eval val/cand_0001  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:10] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:10] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+1.0000 > 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:11] eval val/cand_0002  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:11] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:11] reject  cand_0002  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:11] eval val/cand_0003  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:11] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:11] reject  cand_0003  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:12] eval test/FINAL  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:12] eval test/FINAL_seed  reward=0.0000 M-BM-10.0000  0.0s^M
[02:42:12] FINALIZE  test=1.0000 (baseline 0.0000, M-NM-^T+1.0000)  best=cand_0001^M
$ od -c plain.stderr | grep -o esc | wc -l
0

Rung 3 dumb — TTY + TERM=dumb

exit=0
$ cat -v dumb.stderr
[02:42:12] splits frozen  train=4 val=2 test=2 (test sealed)^M
[02:42:12] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
[02:42:12] baseline  val=0.0000 M-BM-10.0000^M
[02:42:13] eval val/cand_0001  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:13] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:13] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+1.0000 > 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:14] eval val/cand_0002  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:14] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:14] reject  cand_0002  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:15] eval val/cand_0003  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:15] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)^M
[02:42:15] reject  cand_0003  val=1.0000 (parent 1.0000)  M-bM-^@M-^T paired M-NM-^TM-LM-^D=+0.0000 <= 0 (SE=0 M-bM-^FM-^R STRICT fallback, warned; n=2)^M
[02:42:15] eval test/FINAL  reward=1.0000 M-BM-10.0000  0.0s^M
[02:42:15] eval test/FINAL_seed  reward=0.0000 M-BM-10.0000  0.0s^M
[02:42:15] FINALIZE  test=1.0000 (baseline 0.0000, M-NM-^T+1.0000)  best=cand_0001^M
$ od -c dumb.stderr | grep -o esc | wc -l
0

Rung 5 none2>&- (stderr closed before exec)

$ /bin/sh -c "exec 2>&-; python -m cap_evolve.cli run … --follow > none.out"
exit=0
$ cat none.out
{
  "run_dir": ".capevolve/run_none",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_none/dashboard.html"
}

$ grep -c "baseline  val=" none.out    # progress leaked into stdout?
0

cap-evolve tail --ladder per rung

$ python -m cap_evolve.cli tail --ladder                          # piped
{"stdout": "pipe", "stderr": "pipe", "ladder": ["full", "plain", "dumb", "pipe", "none"]}
$ TERM=xterm    (on a real pty)
{"stdout": "full", "stderr": "full", "ladder": ["full", "plain", "dumb", "pipe", "none"]}^M
$ NO_COLOR=1 TERM=xterm (pty)
{"stdout": "plain", "stderr": "plain", "ladder": ["full", "plain", "dumb", "pipe", "none"]}^M
$ TERM=dumb (pty)
{"stdout": "dumb", "stderr": "dumb", "ladder": ["full", "plain", "dumb", "pipe", "none"]}^M

PYTHONIOENCODING=ascii + LC_ALL=C

$ PYTHONIOENCODING=ascii LC_ALL=C LANG=C python -m cap_evolve.cli run … --follow > a.out 2> a.err
exit=0
[02:42:41] splits frozen  train=4 val=2 test=2 (test sealed)
[02:42:41] eval val/seed  reward=0.0000 +/-0.0000  0.0s
[02:42:41] baseline  val=0.0000 +/-0.0000
[02:42:41] eval val/cand_0001  reward=1.0000 +/-0.0000  0.0s
[02:42:41] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:42:41] ACCEPT  cand_0001  val=1.0000 (parent 0.0000)  - paired d?=+1.0000 > 0 (SE=0 -> STRICT fallback, warned; n=2)
[02:42:42] eval val/cand_0002  reward=1.0000 +/-0.0000  0.0s
[02:42:42] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:42:42] reject  cand_0002  val=1.0000 (parent 1.0000)  - paired d?=+0.0000 <= 0 (SE=0 -> STRICT fallback, warned; n=2)
[02:42:42] eval val/cand_0003  reward=1.0000 +/-0.0000  0.0s
[02:42:42] gate warning (paired): combined/paired SE is 0 (likely n_trials=1 or identical trials)
[02:42:42] reject  cand_0003  val=1.0000 (parent 1.0000)  - paired d?=+0.0000 <= 0 (SE=0 -> STRICT fallback, warned; n=2)
[02:42:43] eval test/FINAL  reward=1.0000 +/-0.0000  0.0s
[02:42:43] eval test/FINAL_seed  reward=0.0000 +/-0.0000  0.0s
[02:42:43] FINALIZE  test=1.0000 (baseline 0.0000, d+1.0000)  best=cand_0001
--- stdout ---
{
  "run_dir": ".capevolve/run_ascii",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_ascii/dashboard.html"
}

$ python -c "print(sum(1 for b in open(\"a.err\",\"rb\").read() if b>127))"   # non-ascii bytes
0
$ grep -c "\\x\|\\u" a.err     # CPython backslashreplace mojibake left?
0
$ grep -c UnicodeEncodeError a.err
0

Crash log — 4 multi-shape canaries planted in argv, exception text, env, and event payloads

Canaries: sk-proj-… (prefix), ghp_… (PAT), a UUID session id, and hIQ7bLpZ2mNvXk3TuWq9
(bare high-entropy, watsonx-style — no recognisable shape, the one that defeats shape-matching).
The exception also carries an OSC-title escape attack.

$ python /tmp/crashme.py 2> crash.err ; echo exit=$?
exit=1
$ cat -v crash.err     # the ONE user-visible line
cap-evolve run crashed: RuntimeError: optimizer died]0;PWNED: OPENAI_API_KEY=M-BM-+redactedM-BM-; session=M-BM-+redactedM-BM-; key=M-BM-+redactedM-BM-;
forensic log (redacted, safe to attach to a bug report): /tmp/ev/cache/cap-evolve/crashes/crash-20260730-024307.json
$ cat $LOG
{
  "cap_evolve_version": "0.1.0",
  "when": "2026-07-30T02:43:07+0300",
  "argv": [
    "cap-evolve",
    "run",
    "--spec",
    "x",
    "--token=\u00abredacted\u00bb"
  ],
  "python": "3.14.2",
  "platform": "macOS-15.7.4-arm64-arm-64bit-Mach-O",
  "cwd": "/private/tmp/ev",
  "terminal": {
    "stdout": "pipe",
    "stderr": "pipe",
    "TERM": "",
    "NO_COLOR": false,
    "stdout_encoding": "utf-8",
    "PYTHONIOENCODING": "",
    "locale": ""
  },
  "context": {
    "where": "cap-evolve run"
  },
  "exception": "RuntimeError('optimizer died\\x1b]0;PWNED\\x07: OPENAI_API_KEY=\u00abredacted\u00bb session=\u00abredacted\u00bb key=\u00abredacted\u00bb",
  "traceback": "Traceback (most recent call last):\n  File \"/tmp/wt-144/core/cap_evolve/cli.py\", line 860, in main\n    return fn(argv[1:])\n  File \"/tmp/crashme.py\", line 4, in <lambda>\n    cli.COMMANDS[\"run\"] = lambda a: (_ for _ in ()).throw(\n                                    ~~~~~~~~~~~~~~~~~~~~~^\n        RuntimeError(\"optimizer died\\033]0;PWNED\\007: OPENAI_API_KEY=\u00abredacted\u00bb session=%s key=\u00abredacted\u00bb\n        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n                     % (os.environ[\"OPENAI_API_KEY\"], os.environ[\"SOME_SESSION_ID\"], os.environ[\"WATSONX_APIKEY\"])))\n                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/tmp/crashme.py\", line 4, in <genexpr>\n    cli.COMMANDS[\"run\"] = lambda a: (_ for _ in ()).throw(\nRuntimeError: optimizer died\u001b]0;PWNED\u0007: OPENAI_API_KEY=\u00abredacted\u00bb session=\u00abredacted\u00bb key=\u00abredacted\u00bb\n",
  "recent_events": []
}

--- canary grep -c over BOTH the log and the user-visible stderr ---
log:0 stderr:0   <- sk-proj-CANARY1abcdefghijklmnopqrstuv0123
log:0 stderr:0   <- ghp_CANARY2ABCDEFGHIJKLMNOP0123456789
log:0 stderr:0   <- 3f2504e0-4f89-11d3-9a0c-0305e82c3301
log:0 stderr:0   <- hIQ7bLpZ2mNvXk3TuWq9
$ od -c crash.err | grep -o esc | wc -l    # escape bytes in the crash line
0

Every canary: 0 in the log, 0 on stderr. Zero escape bytes. The OSC attack survives as inert text (]0;PWNED).

The live view dying mid-flight (#191's "the follower could die silently" bug)

Forced by making render_line raise on the 3rd event, with a credential in the message.

$ python /tmp/killfollow.py run … --run-ts fc --follow > fc.out 2> fc.err
exit=0
$ cat fc.err
[02:43:32] splits frozen  train=4 val=2 test=2 (test sealed)
[02:43:32] eval val/seed  reward=0.0000 ±0.0000  0.0s
[follow] live progress stopped: ValueError: renderer blew up mid-run OPENAI_API_KEY=«redacted» — the run continues; use `cap-evolve tail` or the dashboard to watch it (details: /private/tmp/ev/.capevolve/run_fc/crash-20260730-024332.json)
$ cat fc.out       # the run CONTINUED and still produced valid JSON
{
  "run_dir": ".capevolve/run_fc",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_fc/dashboard.html"
}

$ cat .capevolve/run_fc/crash-*.json
{
  "cap_evolve_version": "0.1.0",
  "when": "2026-07-30T02:43:32+0300",
  "argv": [
    "/tmp/killfollow.py",
    "run",
    "--spec",
    ".capevolve/project/capevolve.yaml",
    "--project",
    ".capevolve/project",
    "--run-ts",
    "fc",
    "--follow",
    "--dashboard",
    "off"
  ],
  "python": "3.14.2",
  "platform": "macOS-15.7.4-arm64-arm-64bit-Mach-O",
  "cwd": "/private/tmp/ev",
  "terminal": {
    "stdout": "pipe",
    "stderr": "pipe",
    "TERM": "",
    "NO_COLOR": false,
    "stdout_encoding": "utf-8",
    "PYTHONIOENCODING": "",
    "locale": ""
  },
  "context": {
    "where": "follow-thread",
    "terminal_rung": "pipe"
  },
  "exception": "ValueError('renderer blew up mid-run OPENAI_API_KEY=\u00abredacted\u00bb",
  "traceback": "Traceback (most recent call last):\n  File \"/tmp/wt-144/core/cap_evolve/cli.py\", line 162, in worker\n    line = eventstream.render_line(ev, totals, color=color)\n  File \"/tmp/killfollow.py\", line 7, in boom\n    raise ValueError(\"renderer blew up mid-run OPENAI_API_KEY=\u00abredacted\u00bb + os.environ[\"OPENAI_API_KEY\"])\nValueError: renderer blew up mid-run OPENAI_API_KEY=\u00abredacted\u00bb\n",
  "recent_events": [
    {
      "t": 1785368612.048394,
      "kind": "splits",
      "train": 4,
      "val": 2,
      "test": 2,
      "seed": 0
    },
    {
      "t": 1785368612.0507898,
      "kind": "evaluate",
      "split": "val",
      "tag": "seed",
      "reward": 0.0,
      "stderr": 0.0,
      "cost_usd": 0.0,
      "tokens": 0,
      "seconds": 0.0
    },
    {
      "t": 1785368612.0509849,
      "kind": "baseline",
      "val": 0.0,
      "stderr": 0.0
    }
  ]
}
$ grep -c "sk-proj-CANARY1abcdefghijklmnopqrstuv0123" fc.err .capevolve/run_fc/crash-*.json
fc.err:0
.capevolve/run_fc/crash-20260730-024332.json:0

Escape injection still inert after these changes (#191's three attacks)

$ python - <<EOF   (render_line under color=False AND color=True)
OSC title        -> '[02:43:34] OPTIMIZER ERROR  c1: boom]0;PWNED'
screen clear     -> '[02:43:34] brand_new payload=[2J[Hcleared'
forged FINALIZE  -> '[02:43:34] brand_new payload=a⏎FINALIZE  test=1.0000 (baseline 0.0000)  best=FAKE'
all three attacks inert, exactly one line each, under color=False AND color=True

Files touched

 CHANGELOG.md                   |  24 +++++
 core/cap_evolve/cli.py         |  88 +++++++++++++---
 core/cap_evolve/dashboard.py   |  25 +++++
 core/cap_evolve/eventstream.py | 214 +++++++++++++++++++++++++++++++++++--
 core/tests/test_eventstream.py | 234 +++++++++++++++++++++++++++++++++++++++++
 docs/GETTING_STARTED.md        |  37 +++++++
 6 files changed, 600 insertions(+), 22 deletions(-)

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #215

CHANGES REQUESTED — one real credential leak reproduced end-to-end in the crash log AND on stderr (finding 1), plus a NO_COLOR spec deviation and a crash-log filename collision that silently destroys evidence. The ladder itself, the escape-byte contract, the UTF-8 pre-check and the injection hardening all hold up under attack; the redact port is semantically identical but not byte-identical.


Blocking

1. dashboard.py:120-129 — the shape-independent env pass only covers env vars whose KEY looks secret, so a bare high-entropy credential under an innocent-looking key leaks into the crash log and onto stderr.

The PR's own leak test (test_eventstream.py:630-636) plants every canary under OPENAI_API_KEY / WATSONX_APIKEY / RITS_API_KEY — keys that _key_is_secret matches. It never tries the case that actually breaks: the same shapeless value exported under a name the key-regex misses. _env_secret_values() filters on _key_is_secret(k), so such a value is in neither the shape list nor the env list, and redact is a no-op on it.

Reproduced in the real --follow path (not a unit stub), MODEL_ENDPOINT_SUFFIX=hIQ7bLpZ2mNvXk3TuWq9:

$ cat fc.err
[follow] live progress stopped: ValueError: renderer blew up OPENAI_API_KEY=«redacted» bare=hIQ7bLpZ2mNvXk3TuWq9 — the run continues; …
$ grep -c "hIQ7bLpZ2mNvXk3TuWq9" fc.err .capevolve/run_fc/crash-*.json
fc.err:1
.capevolve/run_fc/crash-20260730-030018.json:2

Consequence: the file the docs call "redacted, safe to attach to a bug report" (GETTING_STARTED.md:117) is not safe for exactly the class of key this PR was written to catch — a bare watsonx/RITS-style secret. Users will paste it into a public issue on the strength of that sentence. _safe_exc has the same hole, so the user-visible line leaks it too.

Fix: widen the env-value pass to all env values that look like credential material, not just those under a secret-looking key — e.g. drop _key_is_secret(k) in favour of a length+entropy floor (len(v) >= 16 and mixed-case-alnum with no whitespace), or scrub every env value that is also long enough to be a token. Add the missing test case: same canary, key MODEL_ENDPOINT_SUFFIX.

2. eventstream.py:557argv is redacted element-wise, so --api-key VALUE (space-separated, the normal CLI form) is never masked; only --api-key=VALUE is.

redact walks the list and scrubs each string in isolation, so the --flag and its value are two separate strings and _INLINE_KV_RE (which needs [:=]) can never fire:

>>> redact(["--api-key", "hIQ7bLpZ2mNvXk3TuWq9"])
['--api-key', 'hIQ7bLpZ2mNvXk3TuWq9']          # leaked
>>> redact(["--api-key=hIQ7bLpZ2mNvXk3TuWq9"])
['--api-key=«redacted»']                        # masked

Confirmed in a real crash log:

"argv": ["cap-evolve","run","--api-key","hIQ7bLpZ2mNvXk3TuWq9","--token=«redacted»", …]

Note _scrub_value("--api-key hIQ…") is also unredacted, so joining is not the fix — the space form is unhandled in both shapes. Consequence: any credential passed as a separate argv element lands verbatim in the forensic log. Independent of finding 1 (this one leaks even for shapeless values under no env var at all).

Fix: in write_crash_log, scan sys.argv pairwise before handing it to redact — if _key_is_secret(argv[i].lstrip("-")), replace argv[i+1] with _REDACTED. Cheaper than teaching redact about list adjacency, and keeps redact pure.

3. eventstream.py:568,585 — the crash filename has second resolution and write_text truncates, so two crashes in the same second silently overwrite each other.

paths: [.../crash-20260730-030403.json, .../crash-20260730-030403.json]
same file? True   -> second crash OVERWRITES first
content: "exception": "RuntimeError('secret-B')"

Consequence: the main() handler and the follow-thread handler both fire on the same failure (a crash that kills the run usually kills the follower too) — the interesting first traceback is destroyed by the second, and the log line points at a file describing a different exception. This is a forensic tool losing forensics. Fix: add os.getpid() and a counter or %f to stamp, or open with "x" and retry with a suffix.


Non-blocking

**4. eventstream.py:232 — NO_COLOR=(set but empty) does not demote.**os.environ.get("NO_COLOR")is falsy on"", so an empty-but-set value stays on full`:

TERM=xterm NO_COLOR=1      -> full? no  -> plain   ✅
TERM=xterm NO_COLOR=0      -> plain               ✅ (presence honoured)
TERM=xterm NO_COLOR=       -> full                ❌

The NO_COLOR spec is presence-based ("when present, regardless of its value") but explicitly excludes the empty string, so this is arguably conformant — however NO_COLOR= is what you get from export NO_COLOR with no value, and a user who typed that expects no colour. NO_COLOR=0 → plain is already presence-based, so the two cases are inconsistent with each other. Fix: "NO_COLOR" in os.environ and document the choice, or add a one-line comment stating empty-is-ignored is deliberate per spec.

5. eventstream.py:225-236 — no FORCE_COLOR, and capability() reads global os.environ rather than taking it as an argument. FORCE_COLOR=1 TERM=dumb stays dumb, so a user on a colour-capable terminal that reports TERM=dumb (some CI runners, emacs -nw) has no override at all. Not a defect against the stated contract, but the ladder is documented as a contract (GETTING_STARTED.md:85-100) and the escape hatch is missing in one direction only (you can force off, never on). CI=true correctly does not demote — right call, isatty already covers real CI and demoting on CI would break docker run -t in CI.

6. Issue #144 item 4 asks to "detect width (…reuse dashboard.py:_term_width)" — no width detection exists. grep -c "width\|COLUMNS\|get_terminal_size" core/cap_evolve/eventstream.py0. The docstring argues width is moot because output is append-only, which is a defensible answer to items 2 and 4 — but it is a scope reduction against the issue, not a satisfied requirement. Say so in the PR body so the issue can be closed honestly (or file the remainder).

7. eventstream.py:568,585 — crash logs are world-readable (0644) and never pruned. ~/.cache/cap-evolve/crashes/ had 9 files after my probing with no retention logic anywhere (grep prune/unlink → none). Even fully redacted these carry argv, cwd and platform. Fix: os.umask-independent 0o600 via open(..., opener=) or chmod after write, and drop the oldest beyond ~50.

8. cli.py:861KeyboardInterrupt writes no forensic log, but issue #144 item 3 says "on an unhandled crash/interrupt". Re-raising Ctrl-C is the right exit behaviour, but an interrupt during a long run is exactly when a user wants the recent-event trace. Low value; note the deliberate divergence in the PR body or add a log-without-changing-exit-code.

9. eventstream.py:301 — the for text in (line, ascii_fallback(line)) retry loop is dead code after the _encodable pre-check. If _encodable returned False, line is already ascii_fallback(line), so both tuple elements are identical and the second iteration can only re-fail. Harmless, but it advertises a fallback that does not exist. Delete the loop; a single try is the same behaviour in fewer lines.


Nits

10. dashboard.py:83,99 — three comment/whitespace deltas from #193's identical hunk (see the byte-identity section). Zero functional effect, but it defeats the stated "either merge order is a no-op" property at the text level. Copy #193's hunk verbatim.

11. GETTING_STARTED.md:111 — the example crash path is dated crash-20260130-140455.json (January), inconsistent with every other timestamp in this PR. Cosmetic.

12. eventstream.py:558"NO_COLOR": bool(...) loses the distinction finding 4 is about; recording the raw string would make a future ladder bug report self-diagnosing.


Crash-log leak audit

My own canaries, planted via four vectors simultaneously (argv space-separated, argv =-joined, exception message, event payload, context), then grep -c over both the log and the user-visible stderr line.

Shape Canary Vector In log? On stderr?
bare high-entropy (watsonx-style) hIQ7bLpZ2mNvXk3TuWq9 env under innocent key MODEL_ENDPOINT_SUFFIX + argv space-sep + exc msg + event payload LEAKED ×4 LEAKED ×1
bare high-entropy same value, env under WATSONX_APIKEY env + exc msg ✅ 0 ✅ 0
UUID 3f2504e0-4f89-11d3-9a0c-0305e82c3301 env + argv + exc msg + payload ✅ 0 ✅ 0
ghp_ PAT ghp_CANARY2ABCDEF…0123456789 env + argv = + exc msg ✅ 0 ✅ 0
sk-proj- sk-proj-CANARY1abcdef…0123 env + exc msg ✅ 0 ✅ 0
short shapeless WxKeyCANARY9zz3QQ env under WATSONX_APIKEY + argv ✅ 0 ✅ 0
JWT eyJhbGciOiJIUzI1NiI… env + exc msg ✅ 0 ✅ 0

Verdict: YES, one canary leaks — the bare high-entropy value, in both the log and on stderr (findings 1 and 2). Every shaped credential is masked; the shape-independent pass has an env-key blind spot.

Also checked:

  • Traceback locals: traceback.format_exception without capture_locals does not include frame locals. Planted secret_local = "LOCALCANARY_abcdefghij" in the raising frame with no mention in the message → 0 occurrences in the log. ✅ Correct as written; note that any future switch to traceback.TracebackException(capture_locals=True) would leak wholesale.
  • World-readable: 0644 file, 0755 dir. ⚠️ finding 7.
  • Unbounded append: files never pruned (⚠️ finding 7); individual files are not appended (write_text), which is what enables finding 3.
  • Read-only run dir → cache fallback:write_crash_log(run_dir="/tmp/ro/nope")~/.cache/cap-evolve/crashes/… .

Ladder detection matrix

capability() per rung, plus a real-pty.openpty() end-to-end run --follow for the TTY rungs with od -c byte counting.

Rung Probe Result Escape bytes Correct?
full real pty, TERM=xterm-256color full 10 (\e[36m, \e[0m, \e[32m, \e[2m, \e[1m) ✅ only rung that colours
plain real pty, TERM=xterm NO_COLOR=1 plain 0
dumb real pty, TERM=dumb dumb 0
dumb real pty, TERM=unknown dumb 0
dumb real pty, TERM=DUMB (uppercase) dumb 0 .lower() handles it
pipe > f 2> f pipe 0
none exec 2>&- none, emit → False 0, JSON on stdout intact, grep -c "baseline val=" n.out = 0 ✅ no leak into stdout
none stream=None / .closed=True none, emit → False 0
edge TERM unset real pty, TERM popped full 0 (no events) agree with the author — unset TERM is normal on a real pty outside a shell profile (ssh -T, some IDE terminals); dumb is the documented opt-out. Demoting would break more than it fixes.
edge TERM=xterm-mono real pty full ✅ correct not to special-case; there is no portable "no colour" TERM list, and NO_COLOR/TERM=dumb are the right escapes. Avoiding a denylist here is the right instinct given #192/#209.
edge NO_COLOR=0 real pty plain 0 ✅ presence-based
edge NO_COLOR= (empty) real pty full ⚠️ finding 4
edge FORCE_COLOR=1 TERM=dumb real pty dumb 0 ⚠️ finding 5 (no force-on override)
edge CI=true TERM=xterm real pty full correct not to demoteisatty already catches real CI; demoting on CI would break docker run -t/act
edge stream lies: isatty() raises stub pipe 0 ✅ fails safe
edge stream lies: isatty()"yes" (truthy non-bool) stub full, 2 escapes 2 ⚠️ trusts truthiness; theoretical only (bool() of a truthy string)
edge no isatty attribute stub pipe 0
edge stderr → ENOSPC (/dev/full equivalent; darwin has none, simulated OSError(ENOSPC)) stub emit → False, no raise, run unaffected
edge BrokenPipeError (| head) stub emit → False_cmd_tail returns 0 ✅ no traceback at the user

Only full puts an escape byte on the wire — reproduced.


Is the redact port byte-identical to #193?

NO. Three deltas — all comments/whitespace, zero functional:

--- dashboard.py @ origin/feat/issue-121-doctor   (#193)
+++ dashboard.py @ origin/feat/issue-144-tty-ladder (#215)
@@ -81,7 +81,7 @@
-    # credential shapes that the length-based rules above miss.
+    # credential shapes the length-based rules above miss. (Also landing via #193.)
@@ -95,13 +95,14 @@
-    that literally — the only shape-independent defense. Longest first so a value
-    that contains another isn't half-masked.
+    that literally — the only shape-independent defence. Longest first so a value
+    that contains another isn't half-masked. (Also landing via #193; identical hunk.)
     vals = {v for k, v in os.environ.items()
             if v and len(v) >= 6 and _key_is_secret(k)}
     return sorted(vals, key=len, reverse=True)
 
+
 # KEY=secret / KEY: secret inside prose …

(defensedefence, two added parentheticals, one blank line for PEP8 E302.)

Semantically identical: YES — verified by AST comparison with docstrings normalised:

$ python -c "<ast.dump with docstrings stripped>"
AST identical ignoring docstrings: True

Practical merge consequence: none for dashboard.py — I merged both branches and dashboard.py auto-merged clean (git resolves comment-only deltas on non-adjacent lines):

$ git checkout -B m193 origin/feat/issue-121-doctor && git merge origin/feat/issue-144-tty-ladder
Auto-merging core/cap_evolve/cli.py
CONFLICT (content): Merge conflict in core/cap_evolve/cli.py     <- usage string only
Auto-merging docs/GETTING_STARTED.md

So the "either merge order is a no-op" claim is true in effect, false as literally stated. No divergent redactor results either way. Downgraded to a nit (10) rather than blocking — but please make it actually byte-identical so the claim in the PR body is checkable.


Injection

render_line under color=False and color=True, plus inside a crash message via _safe_exc. Counting newlines and control/BiDi survivors.

Attack Newlines Ctrl/BiDi survivors Result
\033]0;PWNED\007 (OSC title) 0 0 ✅ inert → payload=]0;PWNED
\033[2J\033[H (clear+home) 0 0 ✅ inert → payload=[2J[H
newline-forged FINALIZE 0 0 ✅ one line, marker
\r carriage-return overwrite 0 0 inertreal⏎FAKE ACCEPT … — CR maps to , cannot overwrite the line
\b ×12 backspace 0 0 inertsafePWNED, both visible
DEL \x7f 0 0 ✅ stripped
\x00 NUL 0 0 ✅ stripped
\x9b (8-bit CSI) 0 0 ✅ stripped (C1 range covered)
\x9d…\x9c (8-bit OSC) 0 0 ✅ stripped
\x85 NEL 0 0 ✅ stripped
\x0b/\x0c VT/FF 0 0 ✅ stripped
BiDi override (Trojan Source) 0 1 survives ⚠️ payload=admin\u202egnp.txtrenders as admin+reversed
BiDi full set \u202d \u202e \u2066 \u2069 \u200b 0 5 survive ⚠️ all pass through
\u2028/\u2029 LS/PS 0 2 survive ⚠️ line separators, harmless in a terminal, but JSON/HTML-hostile downstream
very long line (200 000 × A) 0 0 inert — passes through at len=200029, no truncation, no crash, no wrap exploit
all of the above inside a crash message 0 1 (BiDi) ⚠️ same as above

sanitize (eventstream.py:322-325, unchanged by this PR) is an allowlist over C0/C1/DEL{c: None for c in range(0x20) if c != 0x09} plus 0x7F plus 0x80..0x9F, with \n/\r. That is the right shape, and it is why \r, \b, DEL and 8-bit CSI are all inert without anyone having enumerated them: the correct instinct after #192/#209/#197.

The gap is that the allowlist covers control bytes and not control code pointsU+202E, U+2066, U+200B, U+2028 are all Cf/Zl/Zp category and survive. Not blocking for this PR (they cannot drive the terminal, forge a line, or move the cursor — the stated threat model), but a payload=admin‮gnp.txt that displays as admin + reversed text is a plausible spoof of a candidate id or file path in a progress line, and it is one line to close:

_CTRL.update({c: None for c in (0x200B, 0x200E, 0x200F, *range(0x202A, 0x2030),
                                *range(0x2066, 0x206A), 0x2028, 0x2029, 0xFEFF)})

Suggest filing it rather than blocking — but please don't leave it undocumented, since "sanitize handles untrusted text" is now asserted in three places.


Merge-order note

Merged this branch into each sibling; all conflicts are one-line usage: string collisions in cli.py:859, trivially resolved by unioning the subcommand lists.

Base Conflicts Where
origin/feat/issue-116-follow-tail (#191, its base) 0
origin/feat/issue-118-stall-detection (#118) 0 — fast-forward
origin/feat/issue-121-doctor (#193) 1 cli.py:859 usage string (doctor vs tail)
origin/feat/issue-137-cli-ergonomics (#137) 1 cli.py

Two corrections to the PR body: origin/feat/issue-137-cli-ergonomics does exist (the PR says no branch was found) — please re-check and re-run the merge probe. And #193's dashboard.py hunk auto-merges cleanly despite not being byte-identical, so the risk there is lower than the PR implies.

Recommendation: #191#118#193#215#137. #215 last among the eventstream group means the ladder/crash-log APIs land on top of a settled follow_events; putting #193 before it means the hardened redactor is what write_crash_log calls, so if the author prefers, the duplicated hunk can be dropped from #215 entirely at rebase time — which is the cleanest resolution of nit 10. #137 last because it is pure cli.py ergonomics and will conflict with whatever lands before it regardless of order.


Verification I re-ran

$ cd /tmp/rv-215 && PYTHONPATH=/tmp/rv-215/core python -m pytest core/tests -q
........................................................................ [ 30%]
........................................................................ [ 61%]
........................................................................ [ 92%]
.................                                                        [100%]
233 passed in 74.81s (0:01:14)

$ python -m compileall -q core skills; echo exit=$?
exit=0

233 passed, matching the claim (no #200 flake this run).

#191's tests unmodified: confirmed — git diff …-- core/tests/ has zero deletion lines, so every #191 assertion still passes verbatim against the redefined use_color.
use_color callers: only cli.py:229 (_cmd_tail) remains; cli.py:141 moved to capability() directly. Old semantics were isatty and not NO_COLOR; new is == "full", which additionally excludes TERM=dumb. No caller relied on colouring a dumb terminal, and no test asserted it. ✅ safe redefinition.

Rung transcripts, real pty.openpty():

full:  exit=0 stderr_ESC=10 stdout_ESC=0
plain: exit=0 stderr_ESC=0  stdout_ESC=0
dumb:  exit=0 stderr_ESC=0  stdout_ESC=0
$ cat -v full.stderr | head -3
[03:01:51] splits frozen  train=4 val=2 test=2 (test sealed)^M
[03:01:51] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
^[[36m[03:01:51] baseline  val=0.0000 M-BM-10.0000^[[0m^M
$ cat -v plain.stderr | head -3
[03:01:55] splits frozen  train=4 val=2 test=2 (test sealed)^M
[03:01:55] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
[03:01:55] baseline  val=0.0000 M-BM-10.0000^M

pipe rung, real end-to-end run:

$ python -m cap_evolve.cli run … --run-ts pipe --follow --dashboard off > stdout.pipe 2> stderr.pipe
exit=0
$ od -An -c stderr.pipe | grep -c 033
0
$ cat stdout.pipe
{"run_dir": ".capevolve/run_pipe", "best_id": "cand_0001", …, "test_delta": 1.0, "iterations": 3}

tail --ladder per rung, on real ptys:

TERM unset                       -> {"stdout": "full",  "stderr": "full",  …}  | ESC=0
TERM=xterm-256color              -> {"stdout": "full",  "stderr": "full",  …}  | ESC=0
TERM=dumb                        -> {"stdout": "dumb",  "stderr": "dumb",  …}  | ESC=0
TERM=unknown                     -> {"stdout": "dumb",  "stderr": "dumb",  …}  | ESC=0
TERM=DUMB                        -> {"stdout": "dumb",  "stderr": "dumb",  …}  | ESC=0
TERM=xterm-mono                  -> {"stdout": "full",  "stderr": "full",  …}  | ESC=0
TERM=xterm NO_COLOR=1            -> {"stdout": "plain", "stderr": "plain", …}  | ESC=0
TERM=xterm NO_COLOR=0            -> {"stdout": "plain", "stderr": "plain", …}  | ESC=0
TERM=xterm NO_COLOR=  (empty)    -> {"stdout": "full",  "stderr": "full",  …}  | ESC=0   <- finding 4
TERM=dumb FORCE_COLOR=1          -> {"stdout": "dumb",  "stderr": "dumb",  …}  | ESC=0   <- finding 5
TERM=xterm CI=true               -> {"stdout": "full",  "stderr": "full",  …}  | ESC=0

PYTHONIOENCODING=ascii LC_ALL=C LANG=C, real run:

exit=0
$ python -c "print(sum(1 for b in open('a.err','rb').read() if b>127))"   # non-ascii bytes
0
$ grep -c '\\x\|\\u' a.err        # backslashreplace mojibake
0
$ grep -c UnicodeEncodeError a.err
0
$ head -3 a.err
[03:02:18] splits frozen  train=4 val=2 test=2 (test sealed)
[03:02:18] eval val/seed  reward=0.0000 +/-0.0000  0.0s
[03:02:18] baseline  val=0.0000 +/-0.0000

The pre-check-vs-catch claim — VERIFIED, and it matters. CPython opens stderr with errors="backslashreplace" but stdout with errors="strict":

$ PYTHONIOENCODING=ascii python -c "…"
stderr.encoding= ascii errors= backslashreplace
stdout.encoding= ascii errors= strict
stderr write did NOT raise
stdout write RAISED
--- stderr bytes ---
MOJI \xb1\u0394\u2014

So a try/except UnicodeEncodeError around the stderr write would never fire and would have shipped literal \xb1\u0394\u2014 to the user. _encodable() is the correct approach and the author's reasoning is right. Per-encoding behaviour:

enc=ascii    ok=True -> '… val=0.5000 +/-0.1000 d->...<<x>> ok ?? ?'
enc=utf-8    ok=True -> '… val=0.5000 ±0.1000 Δ→…«x» ✓ 中文 🎉'   <- no degradation ✅
enc=cp1252   ok=True -> '… +/-0.1000 d->...<<x>> ok ?? ?'
enc=latin-1  ok=True -> '… +/-0.1000 d->...<<x>> ok ?? ?'
enc=None     ok=True -> '… ±0.1000 Δ→…«x» ✓ 中文 🎉'              <- no attr → assume capable ✅

ASCII fallback is readable, not garbage±+/-, Δd, ->, «»<<>>; only genuinely untransliterable CJK/emoji become ?. UTF-8 streams and attribute-less streams correctly skip degradation.

Follower-death path — reproduced (forced render_line to raise on the 3rd event):

$ python /tmp/killfollow.py run … --run-ts fc --follow > fc.out 2> fc.err ; echo EXIT=$?
EXIT=0
$ cat fc.err
[03:00:18] splits frozen  train=4 val=2 test=2 (test sealed)
[03:00:18] eval val/seed  reward=0.0000 ±0.0000  0.0s
[follow] live progress stopped: ValueError: renderer blew up OPENAI_API_KEY=«redacted» bare=hIQ7… — the run continues; use `cap-evolve tail` or the dashboard to watch it (details: …/run_fc/crash-20260730-030018.json)
$ head -3 fc.out
{ "run_dir": ".capevolve/run_fc", "best_id": "cand_0001", …

Run continued, valid JSON on stdout, reason on disk — as claimed (and it surfaced finding 1).

Exit 0 after follower death — I agree with the author, keep 0. The exit code answers "did the optimization succeed", and it did: the test number is real, the JSON contract is intact, and run_dir is complete. Failing the run because the cosmetic live view died would turn an observability bug into a CI outage and violate --follow's own docstring ("observability must never break the run"). Crucially this is not the silent death #191's review blocked on — the reason is on stderr and on disk and named in the JSON's sibling run dir, so nobody mistakes silence for progress. The one thing missing is machine-detectability: a script reading only stdout cannot tell the follower died. Suggest a "follow": "stopped" key in the stdout JSON (non-blocking, and cheap) so automation has the signal without an exit-code change.

Second exception-print path — none found. Every exception that reaches a user in cli.py goes through _safe_exc (lines 173, 254, 868); grep -n "except\|{e\b\|{e!r}" shows no print/write of a raw exception anywhere in cli.py or eventstream.py. Elsewhere str(e) appears in harness.py/check.py/gepa.py, but those go into log_event/report structures, not the terminal — out of scope here, though harness.py:835 et al. writing error=str(e)[:300] into events.jsonl is worth a look in whichever PR owns event logging, since render_line will later print it (sanitized, but not redacted).

Docs — the ladder is documented as a five-rung contract table with the detection signal per rung (GETTING_STARTED.md:85-100), which is what a user can rely on. No markdown links in the new section, so nothing to resolve; CHANGELOG.md:29-33 matches. Two accuracy problems: the "safe to attach to a bug report" claim is currently false for finding 1's shape, and the January date at line 111. Fix the first with finding 1, and consider documenting that empty NO_COLOR is ignored.

@skillberry-bot

Copy link
Copy Markdown
Contributor

Automatic Labeling Failed

An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually.

…both crash records

Review fixes for #215.

Blocking:
- dashboard.py: the shape-independent env pass only admitted values under a
  secret-LOOKING key, so a bare high-entropy credential exported as
  MODEL_ENDPOINT_SUFFIX was in neither the shape list nor the env list and reached
  the crash log (x4) and stderr (x1) verbatim. Admission is now OR-ed with a
  value-based rule (_looks_like_credential: length + no whitespace/separator +
  mixed character classes) that never consults the key name. Fifth denylist-shaped
  defence in this epic to fail; this one is value-based by design.
- dashboard.py: `--api-key VALUE` (space-separated, the normal CLI form) was never
  masked, because redact walked argv element-wise and the separator is list
  adjacency, not a character. Handled inside redact's list branch so every list
  reaching an artifact is covered, plus a --flag-prefixed rule for prose.
- eventstream.py: the crash filename was second-resolution and write_text truncated,
  so main()'s handler and the follow thread's -- which fire on the SAME failure --
  silently destroyed each other's evidence. The name now carries pid+thread id and
  the file is created with O_EXCL.

Non-blocking:
- NO_COLOR is presence-based per no-color.org (empty-but-set now demotes).
- FORCE_COLOR overrides TERM=dumb; NO_COLOR still wins over it.
- sanitize also strips Unicode Cf/Zl/Zp (BiDi overrides, zero-widths, LS/PS): the
  allowlist covered control bytes, not control code points, so U+202E survived and
  could spoof a candidate id.
- crash logs are 0600 and pruned to the newest 50.
- KeyboardInterrupt writes a forensic log; its exit behaviour is unchanged.
- deleted the dead retry loop in emit (unreachable after the _encodable pre-check).
- record NO_COLOR/FORCE_COLOR as raw strings, not bool.
- a dead follower now reports "follow": "stopped" inside the run's EXISTING final
  JSON object -- never a second document on stdout (#217).
- docs: state the width/height scope reduction against #144 items 2 and 4 outright.

Tests: the leak canaries now include innocent key names (MODEL_ENDPOINT_SUFFIX,
DEPLOYMENT_ID, MY_FAVOURITE_STRING). The old test planted every canary under a key
the regex already matched, so it only exercised the case that already worked -- the
same defect pattern as #193's sk--prefixed canary. Also asserts benign env values
(PATH, TERM, LANG) are not over-redacted, and that BiDi code points are stripped
(written as escapes, so the test file is not itself a Trojan Source finding).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


def _scrub_value(val: str) -> str:
out = _INLINE_KV_RE.sub(lambda m: m.group(1) + _REDACTED, val)
out = _FLAG_VALUE_RE.sub(lambda m: m.group(1) + _REDACTED, val)
def _scrub_value(val: str) -> str:
out = _INLINE_KV_RE.sub(lambda m: m.group(1) + _REDACTED, val)
out = _FLAG_VALUE_RE.sub(lambda m: m.group(1) + _REDACTED, val)
out = _INLINE_KV_RE.sub(lambda m: m.group(1) + _REDACTED, out)
try:
for stale in sorted(directory.glob("crash-*.json"))[:-keep]:
stale.unlink(missing_ok=True)
except OSError:
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

All 3 blocking, 6 non-blocking and 3 nits addressed or explicitly declined. Commit 405a2d3. 242 passed (233 baseline + 9 new), compileall clean.

The headline finding was right and it was worse than a style problem: a bare credential under an innocent key name reached a file the docs call "safe to attach to a bug report", so the docs were actively instructing users to leak it. Fixed at the root, and the test methodology that let it through is fixed too.


Leak audit

Re-ran your exact probe (MODEL_ENDPOINT_SUFFIX=hIQ7bLpZ2mNvXk3TuWq9x) plus every original shaped canary, through the real cli.main crash path — env var and exception text and argv (both =-joined and space-separated) simultaneously, then grep -c over the crash log and the user-visible stderr line.

Shape Key name Vectors In log? On stderr?
bare high-entropy MODEL_ENDPOINT_SUFFIX (innocent) env + exc msg + argv space-sep 0 (was ×4) 0 (was ×1)
bare high-entropy DEPLOYMENT_ID (innocent) env + exc msg + argv space-sep ✅ 0 ✅ 0
JWT AUTH_HEADER_VALUE (innocent) env + exc msg ✅ 0 ✅ 0
bare high-entropy WATSONX_APIKEY (secret-looking) env + exc msg ✅ 0 ✅ 0
sk-proj- OPENAI_API_KEY (secret-looking) env + exc msg ✅ 0 ✅ 0
ghp_ PAT GITHUB_TOKEN (secret-looking) env + exc msg + argv = ✅ 0 ✅ 0
UUID SOME_SESSION_ID (secret-looking) env + exc msg ✅ 0 ✅ 0
| Shape | Key name | Vectors | In log? | On stderr? |
| bare high-entropy | `MODEL_ENDPOINT_SUFFIX` (innocent) | env + exc msg + argv space-sep | 0 | 0 |
| bare high-entropy | `DEPLOYMENT_ID` (innocent)         | env + exc msg | 0 | 0 |
| bare high-entropy | `WATSONX_APIKEY` (secret-looking)  | env + exc msg | 0 | 0 |
| sk-proj-          | `OPENAI_API_KEY` (secret-looking)  | env + exc msg | 0 | 0 |
| ghp_ PAT          | `GITHUB_TOKEN` (secret-looking)    | env + exc msg + argv `=` | 0 | 0 |
| UUID              | `SOME_SESSION_ID` (secret-looking) | env + exc msg | 0 | 0 |
| JWT               | `AUTH_HEADER_VALUE` (innocent)     | env + exc msg | 0 | 0 |

stderr line: cap-evolve run crashed: RuntimeError: boom «redacted» «redacted» «redacted» «redacted» «redacted» «redacted» «redacted»
argv in log:  ['cap-evolve', 'run', '--api-key', '«redacted»', '--token=«redacted»', '--project', 'my-proj']

Note --project my-proj survives unredacted — the fix is not "mask everything".


Why a value-based rule, not a key-name heuristic

You named the pattern precisely, so I took the option that doesn't guess at names. Weighing the three you listed:

Scrub every env value present in the text (no filter). Rejected. PATH, PWD, HOME and VIRTUAL_ENV all appear inside a traceback's file paths, so this masks the tracebacks — it destroys the artifact to protect it.

Allowlist the crash-log fields. Right shape of defence, wrong layer. The payload is already effectively an allowlist (nine named fields, no free-form env dump). The leak was not an unexpected field; it was a credential inside exception, traceback and argv — fields a forensic log cannot drop and still be forensic. An allowlist here would have to reject the traceback itself. So: allowlist at the field layer (already true), value-rule at the string layer.

Scrub all env values above an entropy/length threshold. ← chosen. The admission test is now OR-ed and the second arm never looks at the key name:

def _looks_like_credential(value: str) -> bool:
    if not 16 <= len(value) <= 512:            return False
    if any(c.isspace() or c in "/\\" for c in value): return False
    return (any(c.islower() for c in value) and any(c.isupper() for c in value)
            and any(c.isdigit() for c in value))

A credential is an opaque single token: long, unbroken, mixed character classes. That is a property of the value, so MODEL_ENDPOINT_SUFFIX and WATSONX_APIKEY are indistinguishable to it — which is the entire point. The key-name arm is kept only because it admits values too short for any value floor to accept safely (RITS_API_KEY=Zx91Kk22PpQq, 12 chars); dropping the floor to 12 globally would start eating build hashes.

Over-redaction is the safe direction here, so I checked the cost explicitly rather than asserting it — test_env_values_that_are_not_credentials_survive_redaction pins that PATH, xterm-256color, en_US.UTF-8, a home directory, C, dumb and true all survive. Separators exclude paths; the digit requirement excludes LSCOLORS; the length floor excludes LANG.

Marked with a # ponytail: comment naming the ceiling: it is character-class mixing, not real Shannon entropy, so an all-lowercase 32-hex key not already caught by the [0-9a-fA-F]{40,} shape rule would still slip. Upgrade path is stated in the comment.


1. Innocently-named credential leak — FIXED (dashboard.py)

Root cause as you diagnosed: _env_secret_values() filtered on _key_is_secret(k), so the value was in neither list and redact was a no-op. Reproduced first, then fixed:

BEFORE: boom bare=hIQ7bLpZ2mNvXk3TuWq9x         <- leaked
AFTER : boom bare=«redacted»

Also fixed the docs claim that made this dangerous: GETTING_STARTED.md now states what the redactor actually covers (secret-looking keys, secret-shaped values, and any env value that looks like credential material whatever it was named) instead of an unqualified "safe to attach".

2. Space-separated secret flags — FIXED (dashboard.py)

Two independent holes, both closed. Your suggestion was to scan sys.argv pairwise at the call site; I put it one level lower instead — inside redact's list branch — because write_crash_log is not the only thing that hands a list to redact, and a guard at the shared function is a smaller diff than a guard per caller. Plus a --flag-anchored rule for the prose form:

space-sep argv          : ['--api-key', '«redacted»']
space-sep in prose      : ran with --api-key «redacted» now
inline argv (regression): ['--api-key=«redacted»']

Deliberately anchored on --flag: widening _INLINE_KV_RE to accept whitespace after a bare word would mask the next word of ordinary prose ("the api key is wrong" → "the api key «redacted» wrong"). Guarded against the obvious over-reach, and the caveat from finding 1 applies here too — this arm is a key-name heuristic, which is why it is additive to the value rule rather than a replacement for it:

following FLAG is not a value : ['--api-key', '--verbose']
innocent flag's value kept    : ['--project', 'my-proj']
`tokens` cost metric kept     : ['--tokens', '4200']

3. Crash handlers overwriting each other — FIXED (eventstream.py)

Filename now carries pid + thread id, and the file is created with "x" (O_EXCL) so a surviving collision is impossible rather than merely unlikely. Both handlers firing on one failure, same second:

crash-20260730-033244-86700-59648.json -> follow-thread | RuntimeError('evidence-follow-thread')
crash-20260730-033244-86700-91488.json -> main          | RuntimeError('evidence-main')
distinct files: 2 | files on disk: 2

Pinned by test_two_crash_handlers_do_not_overwrite_each_other, which drives a real thread rather than stubbing the clock.

4. NO_COLOR= (empty) — FIXED

Presence-based now ("NO_COLOR" in os.environ). I went with the fix rather than the "it's arguably spec-conformant" defence, because your inconsistency argument settles it: NO_COLOR=0 already demoted, so treating "" as colour please while "0" means no colour was the bug, not the spec. On real ptys:

TERM=xterm NO_COLOR=1              -> {"stdout": "plain", ...}
TERM=xterm NO_COLOR=               -> {"stdout": "plain", ...}   <- was full
TERM=xterm NO_COLOR=0              -> {"stdout": "plain", ...}

5. FORCE_COLOR — ADDED

The ladder could only ever be forced down. Now:

TERM=dumb                          -> {"stdout": "dumb", ...}
TERM=dumb FORCE_COLOR=1            -> {"stdout": "full", ...}
TERM=dumb FORCE_COLOR=1 NO_COLOR=  -> {"stdout": "dumb", ...}   <- NO_COLOR wins

NO_COLOR beats FORCE_COLOR deliberately: "no colour" is a stronger request than "colour is possible". Left capability() reading global os.environ — threading an env argument through every call site is a testability change with no caller, and monkeypatch.setenv already covers the tests.

6. Width detection (#144 item 4) — DECLINED, now stated in the docs

Agreed this was a scope reduction dressed as a satisfied requirement. GETTING_STARTED.md now says so outright: items 2 and 4 both presuppose output that takes over the screen, this is append-only at every rung, and a future repainting view needs dashboard._term_width and that item re-opened. Honest close rather than a silent one.

7. Crash log permissions + retention — FIXED

0o600 via an opener (set before any byte lands, so umask cannot widen it), pruned to the newest 50. Both pinned by test_crash_logs_are_owner_only_and_pruned.

mode=-rw-------

8. KeyboardInterrupt writes no log — FIXED

Logs, then re-raises unchanged. Ctrl-C's exit behaviour is not mine to redefine, and the log write is inside its own try so a failure there cannot turn a clean interrupt into a traceback. context.interrupted = true distinguishes it from a crash.

9. Dead retry loop in emit — DELETED

You were right that both tuple elements are identical after the _encodable pre-check. Now a single try; the ASCII path is unchanged (re-verified end-to-end below).

10. Byte-identity with #193 — restated precisely, and now moot

My claim was false as stated and I should not have written it that way. Precisely: the hunk was not byte-identical (defensedefence, two parentheticals, one blank line), it was AST-identical ignoring docstrings, and dashboard.py auto-merged clean either way — so the effect claim held while the literal one did not.

It is now moot in the better direction: this PR genuinely hardens redact past #193 (the value-based env rule and the list-adjacency rule are new), so it is no longer a near-copy and AST identical ignoring docstrings is now False by design. On your suggestion that one PR defer to the other — that's the right call and it now points the other way: #215 must land after #193, because the leak fix lives here. I reverted the two gratuitous comment deltas (defencedefense, dropped the "Also landing via #193" parentheticals) so the remaining diff is only the substantive change.

11. January date in the docs — FIXED

Now crash-20260730-140455-8134-41207.json, which also shows the new pid+thread suffix.

12. "NO_COLOR": bool(...) — FIXED

Raw strings for both, so a future ladder report is self-diagnosing:

"NO_COLOR": null,
"FORCE_COLOR": null,

BiDi — FIXED, took your one-liner

sanitize now covers Unicode Cf/Zl/Zp alongside the control bytes. Your diagnosis of why it was missed (allowlist over control bytes, not control code points) is the useful part and it's now in the comment.

sanitize('admin<U+202E>gnp.txt<U+200B>x<U+2028>y') -> 'admingnp.txtxy'

One deviation from your snippet: I wrote the test canaries as chr(cp) and \u202e escapes rather than literals. A first attempt with literals was blocked by our commit scanner as a Trojan Source finding — correctly, since that is exactly what they are. A test file full of real BiDi overrides is also unreviewable. Verified no literal Cf code point remains in either file.

"follow": "stopped" — ADDED, inside the single object

Judged worth it: it closes the one real gap in the follower-death story (machine-detectability) at near-zero cost, without touching the exit code. Respects #217 — folded into the run's existing final object, never a second document, and _with_follow_status returns stdout untouched if it isn't a single JSON object:

$ python killfollow.py run … --follow > fc.out 2> fc.err ; echo EXIT=$?
EXIT=0
$ cat fc.err
[follow] live progress stopped: ValueError: renderer blew up mid-run OPENAI_API_KEY=«redacted» bare=«redacted» — the run continues; …
$ cat fc.out
{ … "dashboard": ".capevolve/run_fc/dashboard.html", "follow": "stopped" }
$ python -c "import json; d=json.load(open('fc.out')); print(d['follow'], d['test_reward'])"
stopped 1.0

Note the same line that leaked bare=hIQ7… in your repro is now bare=«redacted».


#214 correction — and a merge conflict you'll want to know about

You were right, origin/feat/issue-137-cli-ergonomics does exist (PR #214); the PR body's claim that no branch was found was wrong and is corrected. Re-ran the probe:

=== base: feat/issue-121-doctor (#193) ===
  CONFLICT: CHANGELOG.md, core/cap_evolve/cli.py, core/cap_evolve/dashboard.py
=== base: feat/issue-137-cli-ergonomics (#214) ===
  CONFLICT: core/cap_evolve/cli.py
=== base: feat/issue-116-follow-tail (#191, its base) ===
  (no conflicts)
=== base: feat/issue-118-stall-detection ===
  CONFLICT: core/cap_evolve/cli.py, core/cap_evolve/eventstream.py

Textual resolution against #214 is exactly as you said — take #214's side, since it deleted the usage: literal in favour of a generated listing (grep -c 'usage: cap-evolve {version' on #2140).

But the textual resolution is not sufficient, and this is worth its own issue. #214 adds _harden_utf8(), which reconfigure(encoding="utf-8")s stdout/stderr at the top of main(). That makes _encodable() see a UTF-8 stream under PYTHONIOENCODING=ascii, so #144's transliteration never fires and ± reaches an ASCII terminal:

$ pytest core/tests/test_eventstream.py::test_run_follow_survives_ascii_io_encoding
UnicodeEncodeError: 'ascii' codec can't encode character '\xb1' in position 101
1 failed, 249 passed

Two PRs solving the same problem two incompatible ways: #214 reconfigures the stream to accept the glyph, #144 transliterates the glyph to fit the stream. I verified this pre-exists my fixes — the identical failure reproduces merging #214 with #215's original head d71f3ff, so it is a design conflict, not a regression from this commit. My branch standalone is 242/242. #214's own docstring already says ponytail: the CLI-level guard only; the TUI ladder is #144's job, which suggests the intended resolution is for _harden_utf8 to yield to the ladder — but that is a decision for whoever rebases second, not something to silently pick here. Flagging rather than fixing across a PR boundary.

Sequence recommendation unchanged, and I agree with yours: #191#118#193#215#214.


Verification

Full suite + compileall

$ PYTHONPATH=/tmp/fx-215/core python -m pytest core/tests -q
........................................................................ [ 29%]
........................................................................ [ 59%]
........................................................................ [ 89%]
..........................                                               [100%]
242 passed in 71.20s (0:01:11)

$ python -m compileall -q core skills; echo exit=$?
exit=0

233 → 242: nine new tests. No existing test modified except the leak test, whose methodology was the defect.

Redaction unit probes (all seven canaries exported)

1 innocent-key env value  : boom bare=«redacted»
2 space-sep argv          : ['--api-key', '«redacted»']
3 space-sep in prose      : ran with --api-key «redacted» now
4 inline argv (regression): ['--api-key=«redacted»']
5 innocent #2             : ['--token', '«redacted»']
6 benign PATH untouched   : /usr/bin:/bin
7 tokens count untouched  : ['--tokens', '4200']
8 project name untouched  : ['--project', 'my-proj']
9 BiDi stripped           : 'admingnp.txtxy'

End-to-end crash, real cli.main — OSC attack + 4 canaries via argv/exc/env

$ python crashme.py 2> crash.err ; echo exit=$?
exit=1
$ cat -v crash.err
cap-evolve run crashed: RuntimeError: optimizer died]0;PWNED: OPENAI_API_KEY=M-BM-+redactedM-BM-; session=M-BM-+redactedM-BM-; bare=M-BM-+redactedM-BM-; wx=M-BM-+redactedM-BM-;
forensic log (redacted, safe to attach to a bug report): /tmp/fxv/cache/cap-evolve/crashes/crash-20260730-033158-80673-91488.json

$ cat $LOG | head -20
  "argv": ["cap-evolve","run","--api-key","«redacted»","--token=«redacted»","--project","my-proj"],
  "terminal": { "stdout": "pipe", "stderr": "pipe", "TERM": "", "NO_COLOR": null, "FORCE_COLOR": null, … }

--- canary grep -c over BOTH log and stderr ---
log:0 stderr:0  <- hIQ7bLpZ2mNvXk3TuWq9x                      (innocent key)
log:0 stderr:0  <- sk-proj-CANARY1abcdefghijklmnopqrstuv0123
log:0 stderr:0  <- ghp_CANARY2ABCDEFGHIJKLMNOP0123456789
log:0 stderr:0  <- 3f2504e0-4f89-11d3-9a0c-0305e82c3301
log:0 stderr:0  <- WxKeyCANARY9zz3QQ
$ od -c crash.err | grep -c 033      # escape bytes
0
$ stat -f "mode=%Sp" $LOG
mode=-rw-------
$ grep -c "my-proj\|optimizer died" $LOG   # still diagnosable
3

Both handlers on one failure

crash-20260730-033244-86700-59648.json -> follow-thread | RuntimeError('evidence-follow-thread')
crash-20260730-033244-86700-91488.json -> main          | RuntimeError('evidence-main')
distinct files: 2 | files on disk: 2

Ladder — capability() + escape count per rung

{'TERM': 'xterm'}                                     -> rung=full   ESC=2
{'TERM': 'xterm', 'NO_COLOR': '1'}                    -> rung=plain  ESC=0
{'TERM': 'xterm', 'NO_COLOR': ''}                     -> rung=plain  ESC=0   <- finding 4
{'TERM': 'xterm', 'NO_COLOR': '0'}                    -> rung=plain  ESC=0
{'TERM': 'dumb'}                                      -> rung=dumb   ESC=0
{'TERM': 'dumb', 'FORCE_COLOR': '1'}                  -> rung=full   ESC=2   <- finding 5
{'TERM': 'dumb', 'FORCE_COLOR': '1', 'NO_COLOR': ''}  -> rung=dumb   ESC=0
piped StringIO                                        -> rung=pipe   ESC=0
stream=None                                           -> rung=none   emit=False

tail --ladder on real pty.openpty()

TERM=xterm-256color                {"stdout": "full",  "stderr": "full",  …}
TERM=xterm NO_COLOR=1              {"stdout": "plain", "stderr": "plain", …}
TERM=xterm NO_COLOR=               {"stdout": "plain", "stderr": "plain", …}   <- was full
TERM=xterm NO_COLOR=0              {"stdout": "plain", "stderr": "plain", …}
TERM=dumb                          {"stdout": "dumb",  "stderr": "dumb",  …}
TERM=unknown                       {"stdout": "dumb",  "stderr": "dumb",  …}
TERM=DUMB                          {"stdout": "dumb",  "stderr": "dumb",  …}
TERM=dumb FORCE_COLOR=1            {"stdout": "full",  "stderr": "full",  …}
TERM=dumb FORCE_COLOR=1 NO_COLOR=  {"stdout": "dumb",  "stderr": "dumb",  …}
TERM=xterm CI=true                 {"stdout": "full",  "stderr": "full",  …}

Full run --follow per rung on real ptys, od -c escape bytes

full2: exit=0 stderr_ESC=10 stdout_ESC=0
plain: exit=0 stderr_ESC=0  stdout_ESC=0
plainempty (NO_COLOR=): exit=0 stderr_ESC=0 stdout_ESC=0
dumb:  exit=0 stderr_ESC=0  stdout_ESC=0

$ cat -v full2.stderr | head -4
[03:45:23] splits frozen  train=4 val=2 test=2 (test sealed)^M
[03:45:23] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
^[[36m[03:45:23] baseline  val=0.0000 M-BM-10.0000^[[0m^M
[03:45:23] eval val/cand_0001  reward=1.0000 M-BM-10.0000  0.0s^M

$ cat -v plainempty.stderr | head -4
[03:44:17] splits frozen  train=4 val=2 test=2 (test sealed)^M
[03:44:17] eval val/seed  reward=0.0000 M-BM-10.0000  0.0s^M
[03:44:17] baseline  val=0.0000 M-BM-10.0000^M
[03:44:17] eval val/cand_0001  reward=1.0000 M-BM-10.0000  0.0s^M

$ for t in full2 plain plainempty dumb; do od -c $t.stderr | grep -o 033 | wc -l; done
full2        ESC=10
plain        ESC=0
plainempty   ESC=0
dumb         ESC=0

Only full (and an explicitly FORCE_COLOR-ed dumb) puts an escape byte on the wire — 10, matching your count.

pipe rung end-to-end (no "follow" key when the follower lives)

$ python -m cap_evolve.cli run … --run-ts pipe --follow --dashboard off > stdout.pipe 2> stderr.pipe
exit=0
$ od -c stderr.pipe | grep -c 033
0
$ cat stdout.pipe
{ "run_dir": ".capevolve/run_pipe", "best_id": "cand_0001", "test_reward": 1.0, "test_delta": 1.0, "iterations": 3, "dashboard": "…" }

ASCII path unchanged after deleting the retry loop

$ PYTHONIOENCODING=ascii LC_ALL=C LANG=C python -m cap_evolve.cli run … --follow > a.out 2> a.err
exit=0
[03:46:45] splits frozen  train=4 val=2 test=2 (test sealed)
[03:46:45] eval val/seed  reward=0.0000 +/-0.0000  0.0s
[03:46:45] baseline  val=0.0000 +/-0.0000
$ python -c "print(sum(1 for b in open('a.err','rb').read() if b>127))"   # non-ascii bytes
0
$ grep -c '\\x\|\\u' a.err        # backslashreplace mojibake
0
$ grep -c UnicodeEncodeError a.err
0

Follower death + "follow": "stopped" — see finding-follow block above; EXIT=0, one JSON object, canary redacted, crash log 0600, log:0 stderr:0.


Files touched

 core/cap_evolve/cli.py         |  55 ++++++++++++-
 core/cap_evolve/dashboard.py   |  84 +++++++++++++++++---
 core/cap_evolve/eventstream.py | 102 ++++++++++++++++++------
 core/tests/test_eventstream.py | 175 +++++++++++++++++++++++++++++++++++++++--
 docs/GETTING_STARTED.md        |  36 ++++++---
 5 files changed, 398 insertions(+), 54 deletions(-)

Thanks for the probe that found this one — planting the canary under a name the filter wasn't looking for is the test that mattered, and it's now the test that ships.

OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
… to stdout

Review fixes for #137 (PR #214).

B1 — the `cap-evolve <phase>` redirect printed a fixed `--run-dir <dir>` template
that was wrong for 5 of the 8 phases: finalize also needs --project, and
intake/implement-and-check/gate reject --run-dir entirely. A confidently wrong
remediation at the SEAL step is worse than a bare 'unknown command'. _PHASE_SCRIPTS
is now a name -> required-flags map derived from each script's real argparse, and the
message also points at `--help` and the phase's SKILL.md. New
test_phase_redirect_commands_are_runnable executes every rendered command and fails
on any structural argparse rejection.

B2 — _harden_utf8 reconfigured stderr, which defeated #215's eventstream._encodable()
pre-check (it reads stderr.encoding to decide whether to transliterate +/-), silently
shipping mojibake to an ASCII terminal on the merged tree. stderr is already opened
errors=backslashreplace by CPython and belongs to #144's TUI ladder; stdout is opened
strict and IS load-bearing (ASCII --help carries a right-arrow). Narrowed to
(sys.stdout,).

N1 — a whitespace-only docstring is truthy but strips to '', so .splitlines()[0]
raised IndexError and took down every invocation including --help on 3.10-3.12
(requires-python >= 3.10). Uses next(iter(...), fallback).

N2 — --dashboard-port had no validation and reached bind() as an uncaught
OverflowError; because the launch precedes the --plan-only return, even a
spend-nothing preview crashed. Now a 1-65535 check in the same pre-spend block.

N3 — the documented-CLI absence-exclusion was line-wide, silently skipping 19 of the
scanned sites (including four real `cap-evolve finalize` instructions) and disabling
itself for any line containing a stray 'not'. Anchored to the text immediately before
the backtick: 3 skips now, all genuine absence statements, and the bypass shape is
caught.

N4 — the e2e stdout-contract test spawned a real uvicorn and opened a browser,
importing #200's port-contention flake. Passes --dashboard off.

N5 — CONTRIBUTING.md now states the contract explicitly (stdout is exactly one JSON
object on success AND failure) and carves out the two audited exceptions:
`report --terminal` and finalize's TestSealError on an already-sealed run.

Also re-homes #116/#118's `cap-evolve tail` exit-code prose into the module docstring
as a merge note, so it is not lost when the deleted subcommand list is resolved.
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.

Terminal/TUI robustness: no-TTY/CI degradation ladder + crash/forensic log

4 participants