[GH-ISSUE #3182] [Bug] Bare --json leaves Cutter command-specific JSON disabled #1121

Closed
opened 2026-10-15 18:31:15 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3182

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

CLI

Observed behavior

The real CLI executable advertises cutter-command --json as "Return command-specific JSON output when available", but bare --json sent json:false to an authenticated loopback bridge fixture. It selected JSON for the CLI result representation while leaving the command's JSON mode disabled. All five flag variants exited 0. The explicit spelling --json=true sent json:true; combining it with --format json also produced parseable JSON stdout.

The fixture returned text for a false JSON request and an object for a true request. Those responses establish the production CLI's parsing and authenticated transport behavior. No real Cutter session or engine result was exercised.

Reproduction

  1. Start from a clean checkout at 58300885207133422af235e0f2b10658cf958b03, use the repository's pinned toolchain, and prepare the runtime:
npm ci
npm run build:cached

These preparation commands were not rerun by the independent verifier; verification used an already prepared checkout containing installed dependencies and the built runtime.

  1. Create this fixture in the checkout root. It creates a private synthetic session descriptor, validates authenticated CLI requests, and records the transmitted JSON mode without running Rizin commands:
cat > repro.mjs <<'EOF'
import { createServer } from 'node:net';
import { createHmac, randomBytes } from 'node:crypto';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { spawn } from 'node:child_process';
import assert from 'node:assert/strict';

const root = process.cwd();
const state = resolve(process.argv[2] ?? 'repro-state');
const bridge = join(state, 'bridge');
const home = join(state, 'home');
for (const dir of [state, bridge, home, ...['config', 'cache', 'data'].map(x => join(home, x))])
  await mkdir(dir, { recursive: true, mode: 0o700 });
const session = '00000000-0000-4000-8000-000000000001';
const token = randomBytes(32).toString('hex');
const hmac = text => createHmac('sha256', token).update(text).digest('hex');
const requests = [];
let ordinal = 0;
const server = createServer(socket => {
  const number = ++ordinal;
  let buffer = '', clientNonce = '', serverNonce = '';
  socket.setEncoding('utf8');
  socket.on('data', chunk => {
    buffer += chunk;
    while (buffer.includes('\n')) {
      const end = buffer.indexOf('\n');
      const frame = JSON.parse(buffer.slice(0, end));
      buffer = buffer.slice(end + 1);
      if (frame.kind === 'rea-auth-v2') {
        clientNonce = frame.nonce;
        serverNonce = number.toString(16).padStart(16, '0') + 'b'.repeat(48);
        socket.write(JSON.stringify({session_id: session, nonce: serverNonce,
          proof: hmac(`rea-cutter-server-v2:${session}:${clientNonce}:${serverNonce}`)}) + '\n');
        continue;
      }
      assert.equal(frame.proof, hmac(`rea-cutter-client-v2:${session}:${clientNonce}:${serverNonce}:${frame.request}`));
      const request = JSON.parse(frame.request);
      if (request.kind === 'command') requests.push(request);
      const value = {ok: true, session_id: session, current_file: null,
        document_generation: 0, cutter_version: 'Local fixture', identity_status: 'partial'};
      if (request.kind === 'status') Object.assign(value, {
        completed_connection: (number - 1).toString(16).padStart(16, '0'), completed_commands: []});
      else if (request.kind === 'ack') Object.assign(value, {acknowledged: true});
      else Object.assign(value, {output: request.json ? {answer: 42} : 'text-output',
        execution_state: 'complete', output_truncated: false});
      const response = JSON.stringify(value);
      socket.end(JSON.stringify({response,
        proof: hmac(`rea-cutter-response-v2:${session}:${clientNonce}:${serverNonce}:${response}`)}) + '\n');
    }
  });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const descriptor = join(bridge, `cutter-${process.pid}-${session}.json`);
await writeFile(descriptor, JSON.stringify({session_id: session, pid: process.pid,
  host: '127.0.0.1', port: server.address().port, token, document_generation: 0,
  current_file: null, cutter_version: 'Local fixture', identity_status: 'partial'}), {mode: 0o600});
try {
  for (const flags of [[], ['--json'], ['--json=true'], ['--format', 'json'], ['--json=true', '--format', 'json']]) {
    const before = requests.length;
    const child = spawn(process.execPath, [join(root, 'scripts/rea.mjs'), 'cutter-command', session, '0', 'iI', ...flags], {
      cwd: root, env: {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, 'config'),
        XDG_CACHE_HOME: join(home, 'cache'), XDG_DATA_HOME: join(home, 'data'),
        REA_CUTTER_BRIDGE_DIR: bridge, REA_LOG_LEVEL: 'silent'}});
    let stdout = '', stderr = '';
    child.stdout.on('data', data => {stdout += data;});
    child.stderr.on('data', data => {stderr += data;});
    const exit = await new Promise(resolve => child.on('close', resolve));
    assert.equal(exit, 0, stderr);
    assert.equal(requests.length, before + 1, stdout);
    const request = requests.at(-1);
    let jsonOutput;
    try { JSON.parse(stdout); jsonOutput = true; } catch { jsonOutput = false; }
    console.log(JSON.stringify({flags, exit, wireJson: request.json, jsonOutput,
      containsText: stdout.includes('text-output'), containsAnswer: stdout.includes('42')}));
  }
} finally {
  await new Promise(resolve => server.close(resolve));
  await rm(descriptor);
}
EOF
  1. Run the fixture with isolated home and configuration directories:
mkdir -p repro-home/config repro-home/cache repro-home/data
HOME="$PWD/repro-home" XDG_CONFIG_HOME="$PWD/repro-home/config" XDG_CACHE_HOME="$PWD/repro-home/cache" XDG_DATA_HOME="$PWD/repro-home/data" node repro.mjs
  1. Compare wireJson for bare --json with --json=true. Each call uses the same synthetic session, generation 0, and iI command. The omitted-option and --format json controls send false. Bare --json also sends false, while both explicit-true variants send true. The fixture closes its receiver and removes its session descriptor before returning.

  2. Remove the generated reproduction files:

rm -rf repro.mjs repro-state repro-home

Expected behavior

node scripts/rea.mjs cutter-command --help documents bare --json as "Return command-specific JSON output when available". The command-local boolean declaration and forwarding are in src/cli/cutterCommands.ts:47-61, and the shared input contract accepts it in src/contracts/cutterToolContracts.ts:12-24. Supplying the documented command option should transmit json:true to the selected bridge. The separate global --format json option should select the CLI result representation without enabling command-specific JSON by itself. docs/cli.md:302-308 documents global JSON result formatting and the default TOON representation.

Impact and scope

Callers following help silently request the wrong command output mode. The demonstrated scope is the bare --json spelling on cutter-command; the command remains usable. The observed workaround is --json=true, with --format json added when the caller also requires JSON stdout. No data loss or security boundary violation was established. Real Cutter's final engine call was inspected in source and was not executed.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node v26.10.0; Darwin 27.0.0; locked Incur 0.6.1. Architecture was not stated in the verifier's environment record.

Target and artifact context

A local authenticated loopback bridge fixture with a synthetic session UUID, generation 0, and single-line iI command. The fixture supplies distinguishable text and object responses. No real binary, running Cutter engine, or external service is required for this reproduction.

Interface

CLI

Earliest failure stage

Request parsing or validation

Relevant output or evidence

{"flags":[],"exit":0,"wireJson":false,"jsonOutput":false,"containsText":true,"containsAnswer":false}
{"flags":["--json"],"exit":0,"wireJson":false,"jsonOutput":true,"containsText":true,"containsAnswer":false}
{"flags":["--json=true"],"exit":0,"wireJson":true,"jsonOutput":false,"containsText":false,"containsAnswer":true}
{"flags":["--format","json"],"exit":0,"wireJson":false,"jsonOutput":true,"containsText":true,"containsAnswer":false}
{"flags":["--json=true","--format","json"],"exit":0,"wireJson":true,"jsonOutput":true,"containsText":false,"containsAnswer":true}

Typed outcome or recovery expected

Repeat the bare --json case with the same valid command, session, and generation. It should transmit json:true and receive the fixture's object response. Omission and --format json alone should continue to transmit json:false. Preserve the separate global formatting contract. Until restoration, the independently demonstrated workaround is --json=true --format json when both command-specific JSON and JSON stdout are needed.

Analysis or suspected cause

Source-derived explanation at the reported baseline: src/cli.ts:101 registers Cutter commands, and the dispatcher loads dist/cli-runtime/cli.js. In locked Incur 0.6.1, node_modules/incur/src/Cli.ts:801 extracts builtin flags before command parsing. Lines 2806-2809 consume bare --json as output formatting; line 2849 preserves unmatched flags such as --json=true. Line 1916 passes the remaining arguments to command execution.

REA forwards the parsed boolean through src/cli/cutterCommands.ts:47-61 and src/cutter/CutterBridgeService.ts:93-97. src/cutter/CutterBridgeClient.ts:357-362 serializes it into the command request. bridge/cutter/rea_bridge.py:419 selects cutter.cmdj only when the request's JSON field is true and otherwise selects cutter.cmd. That final engine selection is source-derived, rather than a real Cutter observation.

Additional context

P2 is supported because the documented option silently selects the wrong command output mode, but the command remains usable and the explicit-true spelling is a demonstrated practical workaround.

Related: #3183 reports a separate failure in the same area; the two have independent fixes.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3182 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area CLI ### Observed behavior The real CLI executable advertises `cutter-command --json` as "Return command-specific JSON output when available", but bare `--json` sent `json:false` to an authenticated loopback bridge fixture. It selected JSON for the CLI result representation while leaving the command's JSON mode disabled. All five flag variants exited 0. The explicit spelling `--json=true` sent `json:true`; combining it with `--format json` also produced parseable JSON stdout. The fixture returned text for a false JSON request and an object for a true request. Those responses establish the production CLI's parsing and authenticated transport behavior. No real Cutter session or engine result was exercised. ### Reproduction 1. Start from a clean checkout at `58300885207133422af235e0f2b10658cf958b03`, use the repository's pinned toolchain, and prepare the runtime: ```sh npm ci npm run build:cached ``` These preparation commands were not rerun by the independent verifier; verification used an already prepared checkout containing installed dependencies and the built runtime. 2. Create this fixture in the checkout root. It creates a private synthetic session descriptor, validates authenticated CLI requests, and records the transmitted JSON mode without running Rizin commands: ```sh cat > repro.mjs <<'EOF' import { createServer } from 'node:net'; import { createHmac, randomBytes } from 'node:crypto'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { spawn } from 'node:child_process'; import assert from 'node:assert/strict'; const root = process.cwd(); const state = resolve(process.argv[2] ?? 'repro-state'); const bridge = join(state, 'bridge'); const home = join(state, 'home'); for (const dir of [state, bridge, home, ...['config', 'cache', 'data'].map(x => join(home, x))]) await mkdir(dir, { recursive: true, mode: 0o700 }); const session = '00000000-0000-4000-8000-000000000001'; const token = randomBytes(32).toString('hex'); const hmac = text => createHmac('sha256', token).update(text).digest('hex'); const requests = []; let ordinal = 0; const server = createServer(socket => { const number = ++ordinal; let buffer = '', clientNonce = '', serverNonce = ''; socket.setEncoding('utf8'); socket.on('data', chunk => { buffer += chunk; while (buffer.includes('\n')) { const end = buffer.indexOf('\n'); const frame = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1); if (frame.kind === 'rea-auth-v2') { clientNonce = frame.nonce; serverNonce = number.toString(16).padStart(16, '0') + 'b'.repeat(48); socket.write(JSON.stringify({session_id: session, nonce: serverNonce, proof: hmac(`rea-cutter-server-v2:${session}:${clientNonce}:${serverNonce}`)}) + '\n'); continue; } assert.equal(frame.proof, hmac(`rea-cutter-client-v2:${session}:${clientNonce}:${serverNonce}:${frame.request}`)); const request = JSON.parse(frame.request); if (request.kind === 'command') requests.push(request); const value = {ok: true, session_id: session, current_file: null, document_generation: 0, cutter_version: 'Local fixture', identity_status: 'partial'}; if (request.kind === 'status') Object.assign(value, { completed_connection: (number - 1).toString(16).padStart(16, '0'), completed_commands: []}); else if (request.kind === 'ack') Object.assign(value, {acknowledged: true}); else Object.assign(value, {output: request.json ? {answer: 42} : 'text-output', execution_state: 'complete', output_truncated: false}); const response = JSON.stringify(value); socket.end(JSON.stringify({response, proof: hmac(`rea-cutter-response-v2:${session}:${clientNonce}:${serverNonce}:${response}`)}) + '\n'); } }); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const descriptor = join(bridge, `cutter-${process.pid}-${session}.json`); await writeFile(descriptor, JSON.stringify({session_id: session, pid: process.pid, host: '127.0.0.1', port: server.address().port, token, document_generation: 0, current_file: null, cutter_version: 'Local fixture', identity_status: 'partial'}), {mode: 0o600}); try { for (const flags of [[], ['--json'], ['--json=true'], ['--format', 'json'], ['--json=true', '--format', 'json']]) { const before = requests.length; const child = spawn(process.execPath, [join(root, 'scripts/rea.mjs'), 'cutter-command', session, '0', 'iI', ...flags], { cwd: root, env: {...process.env, HOME: home, XDG_CONFIG_HOME: join(home, 'config'), XDG_CACHE_HOME: join(home, 'cache'), XDG_DATA_HOME: join(home, 'data'), REA_CUTTER_BRIDGE_DIR: bridge, REA_LOG_LEVEL: 'silent'}}); let stdout = '', stderr = ''; child.stdout.on('data', data => {stdout += data;}); child.stderr.on('data', data => {stderr += data;}); const exit = await new Promise(resolve => child.on('close', resolve)); assert.equal(exit, 0, stderr); assert.equal(requests.length, before + 1, stdout); const request = requests.at(-1); let jsonOutput; try { JSON.parse(stdout); jsonOutput = true; } catch { jsonOutput = false; } console.log(JSON.stringify({flags, exit, wireJson: request.json, jsonOutput, containsText: stdout.includes('text-output'), containsAnswer: stdout.includes('42')})); } } finally { await new Promise(resolve => server.close(resolve)); await rm(descriptor); } EOF ``` 3. Run the fixture with isolated home and configuration directories: ```sh mkdir -p repro-home/config repro-home/cache repro-home/data HOME="$PWD/repro-home" XDG_CONFIG_HOME="$PWD/repro-home/config" XDG_CACHE_HOME="$PWD/repro-home/cache" XDG_DATA_HOME="$PWD/repro-home/data" node repro.mjs ``` 4. Compare `wireJson` for bare `--json` with `--json=true`. Each call uses the same synthetic session, generation 0, and `iI` command. The omitted-option and `--format json` controls send false. Bare `--json` also sends false, while both explicit-true variants send true. The fixture closes its receiver and removes its session descriptor before returning. 5. Remove the generated reproduction files: ```sh rm -rf repro.mjs repro-state repro-home ``` ### Expected behavior `node scripts/rea.mjs cutter-command --help` documents bare `--json` as "Return command-specific JSON output when available". The command-local boolean declaration and forwarding are in `src/cli/cutterCommands.ts:47-61`, and the shared input contract accepts it in `src/contracts/cutterToolContracts.ts:12-24`. Supplying the documented command option should transmit `json:true` to the selected bridge. The separate global `--format json` option should select the CLI result representation without enabling command-specific JSON by itself. `docs/cli.md:302-308` documents global JSON result formatting and the default TOON representation. ### Impact and scope Callers following help silently request the wrong command output mode. The demonstrated scope is the bare `--json` spelling on `cutter-command`; the command remains usable. The observed workaround is `--json=true`, with `--format json` added when the caller also requires JSON stdout. No data loss or security boundary violation was established. Real Cutter's final engine call was inspected in source and was not executed. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node v26.10.0; Darwin 27.0.0; locked Incur 0.6.1. Architecture was not stated in the verifier's environment record. ### Target and artifact context A local authenticated loopback bridge fixture with a synthetic session UUID, generation 0, and single-line `iI` command. The fixture supplies distinguishable text and object responses. No real binary, running Cutter engine, or external service is required for this reproduction. ### Interface CLI ### Earliest failure stage Request parsing or validation ### Relevant output or evidence ```text {"flags":[],"exit":0,"wireJson":false,"jsonOutput":false,"containsText":true,"containsAnswer":false} {"flags":["--json"],"exit":0,"wireJson":false,"jsonOutput":true,"containsText":true,"containsAnswer":false} {"flags":["--json=true"],"exit":0,"wireJson":true,"jsonOutput":false,"containsText":false,"containsAnswer":true} {"flags":["--format","json"],"exit":0,"wireJson":false,"jsonOutput":true,"containsText":true,"containsAnswer":false} {"flags":["--json=true","--format","json"],"exit":0,"wireJson":true,"jsonOutput":true,"containsText":false,"containsAnswer":true} ``` ### Typed outcome or recovery expected Repeat the bare `--json` case with the same valid command, session, and generation. It should transmit `json:true` and receive the fixture's object response. Omission and `--format json` alone should continue to transmit `json:false`. Preserve the separate global formatting contract. Until restoration, the independently demonstrated workaround is `--json=true --format json` when both command-specific JSON and JSON stdout are needed. ### Analysis or suspected cause Source-derived explanation at the reported baseline: `src/cli.ts:101` registers Cutter commands, and the dispatcher loads `dist/cli-runtime/cli.js`. In locked Incur 0.6.1, `node_modules/incur/src/Cli.ts:801` extracts builtin flags before command parsing. Lines 2806-2809 consume bare `--json` as output formatting; line 2849 preserves unmatched flags such as `--json=true`. Line 1916 passes the remaining arguments to command execution. REA forwards the parsed boolean through `src/cli/cutterCommands.ts:47-61` and `src/cutter/CutterBridgeService.ts:93-97`. `src/cutter/CutterBridgeClient.ts:357-362` serializes it into the command request. `bridge/cutter/rea_bridge.py:419` selects `cutter.cmdj` only when the request's JSON field is true and otherwise selects `cutter.cmd`. That final engine selection is source-derived, rather than a real Cutter observation. ### Additional context P2 is supported because the documented option silently selects the wrong command output mode, but the command remains usable and the explicit-true spelling is a demonstrated practical workaround. Related: #3183 reports a separate failure in the same area; the two have independent fixes.
bsakaguchi 2026-10-15 18:31:15 +00:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Delivered on main in fffb07419.

The CLI framework consumed bare --json as its global formatter option. Cutter now exposes unambiguous --json-output while preserving explicitly valued --json=true/false with deterministic precedence.

Validation: Actual pinned CLI parser regressions and Cutter command boundary tests. Full exact-revision qualification passed.

<!-- gh-comment-id:6177619911 --> @morluto commented on GitHub (Oct 14, 2026): Delivered on main in [fffb07419](https://github.com/morluto/rea/commit/fffb0741936067530d1ce9777461cd53e87398c2). The CLI framework consumed bare --json as its global formatter option. Cutter now exposes unambiguous --json-output while preserving explicitly valued --json=true/false with deterministic precedence. Validation: Actual pinned CLI parser regressions and Cutter command boundary tests. Full exact-revision [qualification](https://github.com/morluto/rea/actions/runs/38629313022) passed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1121
No description provided.