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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ breaking changes may land in a minor release.

### Fixed

- Report stale or unverifiable Codex hook trust in `validate` and `probe-adapter`
before a live probe launches; check both relay events against Codex's read-only
hook discovery for the operation's directory and executable (#461).

- Distinguish confirmed missing tmux-family sessions from failed window listings;
raise on unproven liveness failures and warn when metadata uses a sentinel (#525).

Expand Down
10 changes: 10 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,16 @@ persisted artifacts.
- Add a CLI without touching Python: drop a TOML profile in `.bmad-loop/profiles/<name>.toml` (binary, prompt template, bypass flags, hook dialect, native→canonical event map). A CLI that needs its own adapter _class_ still needs Python — but not a core edit: the profile's `adapter` field names a kind resolved against the registry, which a co-installed package extends.
- `bmad-loop probe-adapter` collects + sanitizes the data needed to finalize/add a profile (hook payload shape, transcript location/format, token schema): a zero-launch scan by default, opt-in `--probe` for live capture. See the [adapter authoring guide](adapter-authoring-guide.md).

For Codex, `validate` and `probe-adapter` ask Codex's read-only `hooks/list` API
whether the configured SessionStart and Stop relays are enabled and trusted.
Stale, missing, or unverifiable hook trust is a failing result. A worktree run
uses a different directory, so `validate` cannot certify its future trust from
the main checkout. A live probe checks its temporary hook directory before
launch; a fresh directory without a Codex trust grant stops with a hook-trust
diagnostic.
Profile or stage arguments that can change Codex hook discovery make the trust
verdict unverifiable rather than certifying a different launch configuration.

### Budgeting & cost tracking

- Mid-session per-session token budget (`max_tokens_per_session`, default 4M weighted): both adapter wait loops sample cumulative usage on the ~30s heartbeat and trip once on crossing, per `session_budget_mode` — `warn` (default) raises an ATTENTION + lifecycle breadcrumb only; `enforce` also sends a wrap-up nudge, grants `session_budget_grace_s` (default 240s) to finish, then terminates the session `over_budget` (ordinary retry→defer routing; an artifact flushed at kill time is still honored). Sampling is live-verified on `claude` and best-effort on other transcript-reading profiles (two independent unknowns there: whether the CLI delivers the transcript path early — until a hook event carries it the guard is inert — and whether it flushes usage mid-turn); the nudge into a busy pane is best-effort everywhere (the termination is the guarantee), and adapters with no mid-session usage signal (`usage_parser = "none"`, Copilot's shutdown-only flush) leave the guard inert.
Expand Down
7 changes: 6 additions & 1 deletion src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,8 +726,13 @@ def interactive_argv(self, spec: SessionSpec) -> list[str]:
extra = self.extra_args
if extra is None:
extra = self.profile.bypass_args
binary = self.binary
if self.profile.hooks.dialect == "codex-hooks-json":
from ..codex_trust import resolved_codex_binary

binary = resolved_codex_binary(binary, self.profile.env) or binary
argv = [
self.binary,
binary,
*self.profile.launch_args,
self.profile.render_prompt(spec.prompt),
*extra,
Expand Down
1 change: 1 addition & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"git.version",
"hooks.config-parse",
"hooks.registered",
"hooks.trust",
"hooks.relay-present",
"hooks.relay-stale",
"mux.backend",
Expand Down
80 changes: 69 additions & 11 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,49 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"profile": profile.name, "config_path": str(hook_config)},
)

if profile.hooks.dialect == "codex-hooks-json":
from .codex_trust import hook_discovery_args_safe, project_hook_trust

unsafe_roles = []
if pol is not None:
for role in ROLES:
cfg = pol.adapter.resolved(role)
if cfg.name == profile.name and not hook_discovery_args_safe(cfg.extra_args):
unsafe_roles.append(role)

if not profile.packaged:
trust_message = (
"hook trust unverifiable: project-owned Codex profile may name an "
"untrusted executable; validation will not launch it"
)
elif pol is not None and pol.scm.isolation == "worktree":
trust_message = (
"hook trust unverifiable for future worktree sessions: each isolated "
"directory needs its own Codex trust grant"
)
elif not hooks_ok:
trust_message = "hook trust cannot pass: Codex relay hooks are not registered"
elif unsafe_roles:
trust_message = (
"hook trust unverifiable: adapter.extra_args may change Codex hook "
f"discovery for {', '.join(unsafe_roles)}"
)
else:
trust = project_hook_trust(project, profile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve the executable before querying Codex

On native Windows with the npm-installed codex.cmd shim, this passes the bare profile name into Popen, which searches only executable extensions rather than honoring PATHEXT; the repository already handles this exact case by passing shutil.which()'s resolved .cmd path in opencode_http.py. As a result, adapter.binary can report Codex as found while every hooks.trust query becomes unverifiable, making validate fail for an otherwise runnable supported Windows setup. Pass the resolved executable into project_hook_trust rather than reusing profile.binary.

AGENTS.md reference: AGENTS.md:L1-L3

Useful? React with 👍 / 👎.

trust_message = None if trust.status == "trusted" else trust.reason
if trust_message is None:
report.ok(
"hooks.trust",
f"Codex hook trust current for {profile.name} in {project}",
{"profile": profile.name, "project": str(project), "binary": profile.binary},
)
else:
report.fail(
"hooks.trust",
f"{profile.name}: {trust_message}",
{"profile": profile.name, "project": str(project), "binary": profile.binary},
)

# #461: `hooks.registered` above is a substring match on the config JSON — it
# never touches the artifact the registered command points AT. A branch switch
# (or a deleted .bmad-loop/) leaves the registration green while every hook
Expand Down Expand Up @@ -3106,8 +3149,7 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int:
# instead of reloading the predecessor's old paused state and double-driving.
if runs.engine_liveness(run_dir) == "alive":
print(
f"run {run_dir.name} is still live — resuming would double-drive it; "
"stop it first",
f"run {run_dir.name} is still live — resuming would double-drive it; stop it first",
file=sys.stderr,
)
return 1
Expand Down Expand Up @@ -5010,17 +5052,22 @@ def cmd_probe(args: argparse.Namespace) -> int:
)

profile = None
codex_profile_error = False
try:
profile = get_profile(args.cli, project)
except ProfileError as e:
if args.cli == "codex":
codex_profile_error = True
if not args.binary:
print(f"FAIL: {e}", file=sys.stderr)
prefix = "Codex hook trust unverifiable: " if codex_profile_error else ""
print(f"FAIL: {prefix}{e}", file=sys.stderr)
return 1
# Human-facing notice — stderr in JSON mode, where stdout is the document.
print(
f" ok: unknown profile {args.cli!r}; reduced {noun} from --binary {args.binary}",
file=sys.stderr if args.json else sys.stdout,
)
if not codex_profile_error:
print(
f" ok: unknown profile {args.cli!r}; reduced {noun} from --binary {args.binary}",
file=sys.stderr if args.json else sys.stdout,
)

if profile is not None and profile.hookless:
print(
Expand Down Expand Up @@ -5048,7 +5095,11 @@ def cmd_probe(args: argparse.Namespace) -> int:

if args.probe:
if profile is None:
print("FAIL: --probe needs a known profile (its hook dialect/events)", file=sys.stderr)
prefix = "Codex hook trust unverifiable: " if codex_profile_error else ""
print(
f"FAIL: {prefix}--probe needs a known profile (its hook dialect/events)",
file=sys.stderr,
)
return 1
finding = probe_mod.probe(
cli=args.cli,
Expand All @@ -5063,6 +5114,10 @@ def cmd_probe(args: argparse.Namespace) -> int:
finding = probe_mod.scan(
cli=args.cli, profile=profile, project=project, hints=hints, pseudo=pseudo
)
if codex_profile_error:
finding.hook_trust = "unverifiable"
finding.warnings.append("Codex hook trust unverifiable: profile cannot be loaded")
finding.next_steps.append("Repair the Codex profile, then re-run the probe")

# One or the other, never both: --json selects the pure JSON document
# (machine.py contract), otherwise the human-readable markdown report.
Expand Down Expand Up @@ -5104,14 +5159,16 @@ def cmd_probe(args: argparse.Namespace) -> int:
# 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.
trailers = sys.stderr if args.json else sys.stdout
trust_ok = finding.hook_trust is None or finding.hook_trust == "trusted"
trailer_prefix = "ok" if trust_ok else "FAIL"
if args.out:
out_path = Path(args.out)
if args.json:
machine.write_document(out_path, report)
else:
out_path.write_text(report, encoding="utf-8")
print(
f" ok: {noun} written to {out_path} ({len(finding.warnings)} warning(s))",
f" {trailer_prefix}: {noun} written to {out_path} ({len(finding.warnings)} warning(s))",
file=trailers,
)
else:
Expand All @@ -5120,10 +5177,11 @@ def cmd_probe(args: argparse.Namespace) -> int:
else:
print(report)
print(
f" ok: {finding.mode} {noun} for {args.cli} ({len(finding.warnings)} warning(s))",
f" {trailer_prefix}: {finding.mode} {noun} for {args.cli} "
f"({len(finding.warnings)} warning(s))",
file=trailers,
)
return 0
return 0 if trust_ok else 1


def cmd_diagnose(args: argparse.Namespace) -> int:
Expand Down
Loading
Loading