Skip to content

[cli/json] run --json drops the block reason on stderr and reports phase=resolve (breaks the phase=admission predicate from #81) #96

Description

@jamiesun

Summary

#81 added a stderr mirror for policy rejections so callers that only read stdout/stderr can see why a run was refused. The mirror works in compatibility mode, but sshx run --json — the canonical agent contract — only prints the generic line: the block reason (which command, which rule, what to do instead) never reaches stderr, and phase is reported as resolve instead of the admission predicate #81 documented.

Repro (built from origin/main @ 1be5f1d, v0.19.0+1)

Compatibility mode — correct:

$ sshx -h=prod-web --json "docker exec db psql -U app -c 'SELECT 1'" 2>&1 >/dev/null
sshx: blocked by safety policy (phase=admission, error_kind=blocked, executed=false, exit_code=-1); no remote command ran
sshx: block reason: ⚠️  Dangerous command blocked | Command: docker exec db psql … | Reason: Direct PostgreSQL client execution ("psql") bypasses the guarded SQL pipeline. Use: sshx sql -h=<host> … | If you are sure, use --force or -f flag

run --json — reason missing, phase different:

$ sshx run --target=prod-web --json -- "docker exec db psql -U app -c 'SELECT 1'" 2>&1 >/dev/null
sshx: blocked by safety policy (phase=resolve, error_kind=blocked, executed=false, exit_code=-1); no remote command ran
# no "sshx: block reason:" line

The stdout document does contain the reason (error.message), so nothing is lost for a full-document reader — but the stderr mirror, which exists precisely for callers that surface only stdout/stderr, is empty in the mode the docs recommend.

Root cause

Two independent defects in the same path:

  1. reportPolicyRejection (internal/app/lifecycle.go) reads document["error"] as a JSON string. In compat mode the envelope's error is a string, so documentString() works. In run --json the envelope's error is an object {"kind":"blocked","message":"…"}; json.Unmarshal into string fails silently, documentString returns "", and flattenPolicyReason is never called.
  2. run reports blocked plan validation as PhaseResolve, compat reports PhaseAdmission. reportRunRequestFailure hardcodes Phase: execution.PhaseResolve (internal/app/run.go), while the executor sets PhaseAdmission for the identical SafetyCheck failure. fix: policy-block visibility, sshx text read pipelining, and the non-leading sudo boundary #81's own doc comment defines the predicate as phase=admission, so a caller that filters on it silently misses every run --json block.

Expected

  • run --json emits the same sshx: block reason: … stderr line as compat mode (read error.message when error is an object).
  • One phase for one decision: either both paths report admission, or the documented predicate covers both values.
  • A regression test that runs the block through both entry points and asserts stderr content + phase parity (lifecycle_policy_test.go currently only asserts the generic line for one shape).

Environment

  • sshx: built from origin/main 1be5f1d (v0.19.0-1-g1be5f1d); reproduced on installed v0.17.0 as well.
  • Observed while triaging agent-facing diagnostics (RAM-TA3 Tools/sshx-experience-log.md, entry 2026-09-27).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions