Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ breaking changes may land in a minor release.

### Changed

- **BREAKING: `probe-adapter` now runs `diagnose`'s egress leak self-check, and captured hook
payloads ship as a schema instead of scrubbed values (#199).** The rendered report re-scans
itself before emitting (the guard moved to `sanitize.guard`, one audited implementation for
both commands): an email / secret / home path / username in the final bytes makes the command
refuse to emit — message on stderr, empty stdout, exit ≠ 0, no `--out` file — and a stray
occurrence of the pseudonymized project directory name is repaired to its alias and disclosed.
Each captured event now reports dotted key paths with leaf types (`tool_input.command:str`),
never payload values, so the `--json` document's `schema_version` bumps to 2
(`captured_events[].payload` removed, `payload_schema` added; `payload_keys` stays, now
identifier-gated). Collection hardening rides along: transcript-location components that embed
the username are redacted, the project dir name is aliased in locations, a home-rooted
`--binary` hint renders `~`-relative, and a credential-shaped dict key can no longer surface
in the token key paths.
- **BREAKING: `diagnose --json` and `probe-adapter --json` now emit a pure JSON document
(#195).** Both used to print their human-readable report with a fenced ` ```json ` block
appended, so a consumer had to scrape the fence out of prose. `--json` now emits the
Expand Down
30 changes: 19 additions & 11 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,23 @@ If `tmux` or the binary is missing, probe degrades gracefully to a scan.
The report is built to be **safe to paste into an issue or PR**. A single audited
sanitizer (`src/bmad_loop/sanitize.py`) is the only chokepoint:

- **numbers, booleans, and `null` pass through** — token _counts_ are not PII;
- **dict keys are kept verbatim** — field names and casing are the whole point of
a payload probe;
- every **leaf string** is `$HOME`→`~` redacted and then kept **only if** it looks
like a short machine identifier (e.g. `claude-opus-4-8`, `session-abc_123`);
anything else — prose, code, paths, emails — becomes `<redacted:str>`;
- **list lengths are preserved**, contents are scrubbed element by element;
- **captured hook payloads ship as a schema, never as values** — per event you
get the field names and the dotted key paths with leaf _types_
(`tool_input.command:str`); no payload value of any kind reaches the report,
and a dynamic or credential-shaped key collapses to `<key>`;
- **numbers, booleans, and `null` pass through** elsewhere — token _counts_ are
not PII;
- **transcript locations are redacted per component** — home → `~`, anything
that isn't a plain machine identifier (or that embeds your username) →
`<redacted>`, and your project directory name → a salted alias
(`project-3f2a9c…`), the same pseudonymization `diagnose` uses;
- `--help` / `--version` text and log tails have the home dir and any emails
redacted, with a line cap.
redacted, with a line cap;
- **the rendered report re-scans itself before emitting** — the same
`sanitize.guard` egress backstop as `diagnose`. A stray occurrence of the
aliased project name is repaired and disclosed; an email / secret / home path /
username in the final bytes makes the command **refuse to emit** (message on
stderr, empty stdout, exit ≠ 0, no `--out` file) rather than ship it.

In PROBE mode the raw capture exists **only transiently** inside the temp dir,
which is `rmtree`'d in a `finally` (even on exception or Ctrl-C). The CLI's own
Expand Down Expand Up @@ -247,9 +255,9 @@ bmad-loop probe-adapter <cli> --probe --project /tmp/scratch
```

The **Hook payload shape** section now shows, per captured event, the native→
canonical pairing, the payload keys, and the scrubbed payload — so you can confirm
`session_id` / `transcript_path` casing and that the CLI accepted the hook config
for your dialect. If the CLI rejects the config or never fires a hook, the report
canonical pairing, the payload keys, and the payload **schema** (key paths + leaf
types, never values) — so you can confirm `session_id` / `transcript_path` casing
and that the CLI accepted the hook config for your dialect. If the CLI rejects the config or never fires a hook, the report
says so (with a scrubbed log tail) instead of silently producing nothing.

### 4. Write the `usage_parser`
Expand Down
58 changes: 53 additions & 5 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1929,6 +1929,7 @@ def cmd_tui(args: argparse.Namespace) -> int:

def cmd_probe(args: argparse.Namespace) -> int:
from . import probe as probe_mod
from . import sanitize
from .adapters.profile import ProfileError, get_profile

project = _project(args)
Expand Down Expand Up @@ -1965,6 +1966,20 @@ def cmd_probe(args: argparse.Namespace) -> int:
)
return 1

# Pseudonymize the one identifier-shaped value the probe KNOWS is the
# user's (diagnose aliases the same value, ns="project"): the project
# directory name, which would otherwise pass the location redaction's
# identifier gate verbatim. Registration is skipped when the name collides
# with a token that legitimately appears in the report (the CLI or binary
# name) — otherwise the guard's repair pass would rewrite every occurrence
# of that legitimate token into the alias and mangle the report.
pseudo = sanitize.Pseudonymizer()
legit_tokens = {args.cli, Path(args.binary).name if args.binary else ""}
if profile is not None:
legit_tokens.add(Path(profile.binary).name)
if project.name not in legit_tokens:
pseudo.alias(project.name, ns="project")

if args.probe:
if profile is None:
print("FAIL: --probe needs a known profile (its hook dialect/events)", file=sys.stderr)
Expand All @@ -1976,16 +1991,49 @@ def cmd_probe(args: argparse.Namespace) -> int:
hints=hints,
timeout_s=args.timeout,
keep_temp=args.keep_temp,
pseudo=pseudo,
)
else:
finding = probe_mod.scan(cli=args.cli, profile=profile, project=project, hints=hints)
finding = probe_mod.scan(
cli=args.cli, profile=profile, project=project, hints=hints, pseudo=pseudo
)

# One or the other, never both: --json selects the pure JSON document
# (machine.py contract), otherwise the human-readable markdown report.
if args.json:
report = probe_mod.render_json(finding)
else:
report = probe_mod.render_markdown(finding)
# Either way the renderer runs the egress self-check over its own rendered
# bytes and raises instead of returning tainted output.
repairs: list[tuple[str, int]] = []
try:
if args.json:
report = probe_mod.render_json(finding, pseudo=pseudo, repairs=repairs)
else:
report = probe_mod.render_markdown(finding, pseudo=pseudo, repairs=repairs)
except probe_mod.LeakDetected as e:
# Fail closed BEFORE any egress: message → stderr, stdout stays empty,
# no partial --out file, exit != 0 (the machine.py error shape).
print(
f"FAIL: refusing to emit — leak self-check fired: {', '.join(e.rules)}",
file=sys.stderr,
)
print(
"hint: sensitive[project:<alias>] is your project directory name; any "
"other rule means PII/secret-shaped content reached the report — please "
"report the rule names above as a bmad-loop bug",
file=sys.stderr,
)
return 1

if repairs:
merged: dict[str, int] = {}
for label, count in repairs:
merged[label] = merged.get(label, 0) + count
summary = ", ".join(f"{label} x{count}" for label, count in sorted(merged.items()))
print(
f"warning: leak backstop pseudonymized {sum(merged.values())} stray "
f"occurrence(s) ({summary}) — a per-field routing gap in bmad-loop; "
"please include this line in your bug report",
file=sys.stderr,
)

# Every `ok:` trailer is human-facing chatter, so in JSON mode it goes to
# stderr — stdout is the document alone, or empty when --out took it.
Expand Down
107 changes: 14 additions & 93 deletions src/bmad_loop/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
**pseudonymized** (story keys/branches/SHAs are identifier-shaped and would
otherwise survive verbatim), or scrubbed. Unknown/future fields default to a
``scrub_json`` pass, never raw. As a final backstop the rendered bytes are run
through :func:`sanitize.assert_no_leak`. A stray pseudonymized original (a
through :func:`sanitize.guard` — the fail-closed egress self-check shared with
``probe-adapter`` since #199. A stray pseudonymized original (a
per-field routing gap — the value is in the legend, so its safe alias is known)
is **repaired** by substituting the alias, re-verified, and disclosed in the
dump itself — a "Backstop repairs" section in the markdown report, an optional
Expand All @@ -43,6 +44,14 @@
from .journal import Journal, load_state
from .model import RunState, StoryTask

# The guard machinery (fail-closed egress self-check + alias-substitution
# repair) moved to sanitize.py so probe-adapter shares the single audited
# implementation (#199). LeakDetected stays importable from here — cli.py and
# external callers resolve `diagnostics.LeakDetected` — so it carries a noqa:
# without it ruff F401 autofixes the re-export away and the except clause in
# cmd_diagnose breaks.
from .sanitize import LeakDetected # noqa: F401 — re-export

# Deliberately NOT bumped when `--json` stopped being a fenced ```json block
# inside the markdown report and became a pure document (machine.py contract):
# this number versions the *document*, and the payload did not change. Bumping it
Expand Down Expand Up @@ -525,7 +534,7 @@ def render_json(
# real non-ASCII is safe — and it must be identical in both dumps below, or
# the bytes we verified would not be the bytes we emit.
rendered = json.dumps(_to_jsonable(d), indent=2, sort_keys=True, ensure_ascii=False)
rendered, reps = _guard(rendered, pseudo)
rendered, reps = sanitize.guard(rendered, pseudo)
if reps:
# Disclose the repair in the dump itself so the routing gap surfaces as
# a reportable bug. Substitution preserved JSON validity — a leaked
Expand All @@ -535,7 +544,7 @@ def render_json(
data = json.loads(rendered)
data["backstop_repairs"] = dict(reps)
rendered = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)
_assert_clean(rendered, pseudo)
sanitize.assert_clean(rendered, pseudo)
if repairs is not None:
repairs.extend(reps)
return rendered
Expand Down Expand Up @@ -637,7 +646,7 @@ def render_markdown(
out.append("")

rendered = "\n".join(out)
rendered, reps = _guard(rendered, pseudo)
rendered, reps = sanitize.guard(rendered, pseudo)
if reps:
note = [
"",
Expand All @@ -654,7 +663,7 @@ def render_markdown(
rendered += "\n".join(note)
# The note is appended after the repair loop verified the body, so
# re-check the whole thing: the note must sit inside the verified bytes.
_assert_clean(rendered, pseudo)
sanitize.assert_clean(rendered, pseudo)
if repairs is not None:
repairs.extend(reps)
return rendered
Expand All @@ -664,91 +673,3 @@ def _dict_inline(d: dict) -> str:
if not d:
return "—"
return ", ".join(f"{k}={v}" for k, v in sorted(d.items()))


_MAX_REPAIR_PASSES = 3


def _repair_candidates(pseudo: sanitize.Pseudonymizer | None) -> list[tuple[str, str, str]]:
"""``(original, alias, label)`` triples for the leak check and repair.

Filtered to assert_no_leak's ≥4-char detection threshold (repair must never
rewrite an occurrence detection would not fire on) and deduped by original
(a value aliased under two namespaces gets one deterministic label — the
first insertion's). Labels are ``ns:alias`` — safe to print by construction,
never the original."""
if pseudo is None:
return []
candidates: list[tuple[str, str, str]] = []
seen: set[str] = set()
for ns, original, alias in pseudo.entries():
if len(original) >= 4 and original not in seen:
seen.add(original)
candidates.append((original, alias, f"{ns}:{alias}"))
return candidates


def _assert_clean(rendered: str, pseudo: sanitize.Pseudonymizer | None) -> None:
"""One plain, no-repair re-check — run after a repair note is appended so
the note itself sits inside the verified bytes."""
extras = [(orig, label) for orig, _alias, label in _repair_candidates(pseudo)]
fired = sanitize.assert_no_leak(rendered, extra=extras)
if fired:
raise LeakDetected(fired)


def _guard(
rendered: str, pseudo: sanitize.Pseudonymizer | None
) -> tuple[str, list[tuple[str, int]]]:
"""Verify the rendered bytes; repair stray pseudonymized originals; fail closed.

When the pseudonymizer is supplied, its legend's original values (the real
story keys/branches/SHAs) are fed into the self-check too, so any that
slipped through the per-field routing are caught here in the final bytes.
Unlike a hard-rule hit, such a miss is repairable: the backstop knows the
original's safe alias, so it substitutes it and re-verifies instead of
refusing outright. Returns ``(text, [(label, count), ...])`` of applied
repairs. Raises :class:`LeakDetected` on any hard rule (email / secret /
home-path / url-creds / username — genuine PII never auto-repairs) or if
repair does not converge within the pass bound."""
candidates = _repair_candidates(pseudo)
extras = [(orig, label) for orig, _alias, label in candidates]
# Longest-first: a branch embedding a story slug at a "-" boundary must be
# replaced whole, not spliced into a half-alias mongrel by the inner slug.
by_length = sorted(candidates, key=lambda c: len(c[0]), reverse=True)

tally: dict[str, int] = {}
for _ in range(_MAX_REPAIR_PASSES):
fired = sanitize.assert_no_leak(rendered, extra=extras)
if not fired:
return rendered, sorted(tally.items())
if any(not rule.startswith("sensitive[") for rule in fired):
raise LeakDetected(fired)
# A repair pass replaces every standalone occurrence of every candidate,
# so pass 2 is reachable only if a substitution manufactured a NEW
# standalone occurrence of a different original — a hash-output
# coincidence (the alias alphabet is [A-Za-z0-9-] and "-" is itself a
# boundary char). The bound turns a pathological substitution cycle
# into a fail-closed refusal instead of a loop.
for original, alias, label in by_length:
rendered, n = sanitize.replace_standalone(rendered, original, alias)
if n:
tally[label] = tally.get(label, 0) + n
fired = sanitize.assert_no_leak(rendered, extra=extras)
if fired:
raise LeakDetected(fired)
return rendered, sorted(tally.items())


class LeakDetected(Exception):
"""The rendered dump tripped sanitize.assert_no_leak — emission is refused.

Raised only for hard rules (email/secret/home-path/url-creds/username) or a
``sensitive[*]`` repair that did not converge; a plain stray-original hit is
repaired by alias substitution instead. ``rules`` carries the fired rule
names — ``sensitive[<ns>:<alias>]`` for pseudonymizer originals, printable
because the label never contains the original value."""

def __init__(self, rules: list[str]):
self.rules = rules
super().__init__("diagnostic dump tripped leak self-check: " + ", ".join(rules))
26 changes: 13 additions & 13 deletions src/bmad_loop/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,19 @@

- :func:`emit` takes a ``dict`` and serializes it — ``status`` and ``list``.
- :func:`emit_document` takes an already-serialized ``str`` — ``diagnose`` and
``probe-adapter``, whose renderers return text. Their reasons differ, and only
one is a constraint: ``probe.render_json`` simply *returns a string*, so there
is nothing to re-encode, whereas ``diagnostics.render_json`` serializes
*before* running its leak self-check, making those exact bytes the ones the
check verified — re-encoding them here would emit bytes nothing verified.
``probe-adapter``, whose renderers return text. For both this is a hard
constraint: each serializes *before* running the shared leak self-check
(``sanitize.guard``), making those exact bytes the ones the check verified —
re-encoding them here would emit bytes nothing verified.

:func:`write_document` is the ``--out`` sibling of :func:`emit_document`, taking
the same already-serialized string to a file instead of stdout.

The serializer flags differ by family on purpose and are not to be unified: the
renderers behind :func:`emit_document` sort their keys (a diff-stable dump is
worth more than field order there) while :func:`emit`'s dicts are built in the
order they are meant to be read, and ``diagnostics.render_json`` alone passes
``ensure_ascii=False`` because its leak guard has to scan the values unescaped.
order they are meant to be read, and the two guarded renderers pass
``ensure_ascii=False`` because the leak guard has to scan the values unescaped.
"""

from __future__ import annotations
Expand All @@ -77,8 +76,9 @@ def _validated(rendered: str) -> str:
"""Return ``rendered`` unchanged, or raise if it is not a whole JSON document.

Parse to assert, never to re-serialize: the caller's exact bytes are what gets
written. ``diagnostics.render_json`` runs its leak self-check *before* handing
the string over, so re-encoding here would ship bytes nothing checked.
written. The ``diagnose``/``probe-adapter`` renderers run their leak self-check
*before* handing the string over, so re-encoding here would ship bytes nothing
checked.
"""
try:
json.loads(rendered)
Expand All @@ -93,16 +93,16 @@ def emit_document(rendered: str) -> None:
Verifies well-formedness before writing, then writes the ORIGINAL string —
never a re-serialization of the parsed result. Half the commands reach stdout
through here rather than through :func:`emit`, so without this the contract
would hold for them by convention only; but ``diagnostics.render_json``
validated these exact bytes with its leak self-check, so emitting anything
re-derived would ship bytes nothing checked. Parse to assert, print verbatim.
would hold for them by convention only; but the guarded renderers validated
these exact bytes with their leak self-check, so emitting anything re-derived
would ship bytes nothing checked. Parse to assert, print verbatim.

Raises :class:`ValueError` on a malformed document — a bug in the caller's
renderer, and far better surfaced as a crash with empty stdout (which the
contract permits) than as a half-parsable stream a consumer has to diagnose.

stdout is switched to UTF-8 first, because a document is not necessarily
ASCII — ``diagnostics.render_json`` dumps with ``ensure_ascii=False`` so its
ASCII — the guarded renderers dump with ``ensure_ascii=False`` so the
leak guard can scan the values unescaped, which lets a non-sensitive
non-ASCII field (a localized ``platform.release()``, say) through to here
verbatim. Encoding it for a legacy non-UTF-8 console then raised
Expand Down
Loading