Skip to content

fix(cli): bind loopback, keep secrets off argv, escape markup, add tasks show (#935) - #1049

Open
frankbria wants to merge 5 commits into
mainfrom
fix/935-cli-hardening
Open

fix(cli): bind loopback, keep secrets off argv, escape markup, add tasks show (#935)#1049
frankbria wants to merge 5 commits into
mainfrom
fix/935-cli-hardening

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #935.

1. The server was on the LAN by default

cf serve --host and run_server both defaulted to 0.0.0.0 while the printed hints said localhost. That process exposes SQLite state, workspace file access and agent-execution endpoints, so the default put all of it on the local network.

Both now default to 127.0.0.1, and .env.example no longer suggests 0.0.0.0. Choosing a wildcard bind warns:

WARNING: binding 0.0.0.0 accepts connections from other machines on your network.

and warns much harder when auth is off — the combination the issue calls out, and one the dev docs actively suggest:

CODEFRAME_AUTH_REQUIRED is disabled. The API — including workspace file access
and agent execution — is reachable with no credentials. Do not use this
combination outside a trusted network.

2. Credentials in argv

auth setup --value/-v put the secret in the process command line — readable by any local process via /proc/<pid>/cmdline for the lifetime of the call, and written to shell history. The docstring taught it, with real-looking keys:

codeframe auth setup --provider anthropic --value sk-ant-...
codeframe auth setup -p github -v ghp_...

--value is now hidden=True, marked DEPRECATED in its help, and prints a warning when used. The supported non-interactive paths are:

echo "$MY_KEY" | codeframe auth setup -p github --value-stdin
codeframe auth setup -p github --value-file ~/.secrets/gh-token

Kept rather than removed outright so existing scripts do not break silently — they get a warning instead. --value-stdin uses readline(), so a trailing newline from echo is not stored as part of the secret.

3. A task title could crash the CLI

Titles and blocker text were interpolated into Rich-rendered output with markup enabled:

>>> console.print(f"[cyan]Title:[/cyan] Fix the [/b] parser")
MarkupError: closing tag '[/b]' at position 28 doesn't match any open tag

cf tasks list and the TUI blocker log crashed on a perfectly legitimate title. Every user-controlled interpolation in cli/app.py and tui/app.py is now escape()d.

Tested by rendering five hostile-but-legitimate titles through the real CliRunner — including Array indexing a[0] and b[1], which is ordinary prose that Rich also chokes on. One extra test asserts Rich would raise on those titles unescaped, so the suite cannot pass vacuously.

4. cf tasks show did not exist

Advertised in both README.md ("Task details with dependencies") and CLAUDE.md. Implemented rather than deleting a documented feature: details, dependencies with resolved titles, accepts a unique ID prefix, and exits non-zero with a helpful message on an unknown or ambiguous id.

Acceptance criteria

  • cf serve and the server default to 127.0.0.1; 0.0.0.0 requires an explicit flag and warns when auth is disabled
  • Credential values are prompted or read from stdin/file; option-passing deprecated and docstring examples updated
  • Task/blocker text is escaped in CLI tables and TUI logs; tests assert a title containing [/b] renders without raising
  • cf tasks show <id> exists

Tests

29 tests, 18 verified RED against main. ruff and mypy codeframe/ (197 files) clean.

Known limitations

  • --value is deprecated, not removed. Removing it outright would break existing automation with no migration window; it is hidden from help and warns on use.
  • The escaping is applied at the interpolation sites in cli/app.py and tui/app.py. A console.print added elsewhere later could reintroduce the bug — a lint for "f-string with user data into console.print" would generalize this, but is beyond this issue.
  • typer.confirm output is not Rich-rendered, so those call sites are intentionally left unescaped (noted inline).

…sks show (#935)

1. `cf serve --host` and the server both defaulted to 0.0.0.0 while the printed
   hints said localhost — so a beta server exposing SQLite state, workspace file
   access and agent-execution endpoints sat on the LAN by default. Both now
   default to 127.0.0.1; .env.example no longer suggests 0.0.0.0. Binding a
   wildcard address warns, and warns much harder when
   CODEFRAME_AUTH_REQUIRED is disabled — that combination is an
   unauthenticated remote shell, and the dev docs suggest the flag.

2. `auth setup --value/-v` put the credential in argv, readable by any local
   process via /proc/<pid>/cmdline and written to shell history — and the
   docstring taught it with real-looking keys. The option is now hidden and
   warns when used; --value-stdin and --value-file are the supported
   non-interactive paths, and the docstring examples show those.

3. Task titles and blocker text went into Rich-rendered output unescaped, so a
   title containing '[/b]' raised MarkupError and crashed `cf tasks list` and
   the TUI blocker log. Escaped at every user-controlled interpolation in
   cli/app.py and tui/app.py. Tests render five hostile-but-legitimate titles
   through the real CliRunner, plus one that proves the titles are hostile so
   the suite cannot pass vacuously.

4. `cf tasks show <id>` is advertised in README.md and CLAUDE.md and did not
   exist. Implemented: details, dependencies (with resolved titles), accepts a
   unique ID prefix, exits non-zero with a helpful message on an ambiguous or
   unknown id.

29 tests.
A module-level import turned 'the command is missing' into a collection error
against main, which hid the other 17 RED tests behind one ImportError.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 45s —— View job


GLM precision review — CLI hardening (#935)

Bug-hunting review (logic errors, security, data loss, crashes only). Style/architecture/coverage handled by a separate review.

  • Gather PR context (gh pr view, gh pr diff) and existing comments
  • Read all 6 changed files in full
  • Read surrounding code / callers to confirm each suspected defect
  • Post inline comments for confirmed findings
  • Post summary comment via gh pr comment

Result — 1 defect found

major — secret exposure in the new --value-stdin path (codeframe/cli/auth_commands.py:621)
echo "$TOKEN" | cf auth setup --value-stdin (without -p) leaks the credential to the terminal: the interactive provider prompt reads stdin before the credential read, so it consumes the piped secret as the provider name; resolve_provider_name then raises a ValueError that echoes the name verbatim, and the except ValueError prints it in plaintext. This is exactly the exposure the PR aims to prevent. Posted as an inline comment with a drop-in fix (require --provider when --value-stdin/--value-file is used).

Checked and clean: _warn_if_exposed's auth-off set matches the canonical _AUTH_FALSY; tasks_show resolution, field accesses, and exit handling are correct; Rich escape() covers every user-controlled interpolation in the changed hunks; --value-file error paths all exit non-zero. One non-blocking note: run_server (python -m codeframe.ui.server) doesn't call _warn_if_exposed, so an explicit HOST=0.0.0.0 there warns only on the cf serve path — default is loopback in both, so it's a warning-coverage gap, not a hole.

Comment on lines +617 to +621
# stdin/file take precedence over the deprecated option.
if value_stdin:
# .readline() not .read(): a trailing newline from `echo` is not part of
# the secret, and a here-doc may carry more than one line.
value = sys.stdin.readline().strip()

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.

[major] --value-stdin without --provider leaks the credential to the terminal — the very exposure this PR is meant to prevent.

The interactive provider prompt at line 604 (typer.prompt(...)) reads from stdin and runs before this read, so it consumes the piped secret as the provider choice. choice_map.get(choice, choice) then assigns the secret to provider, and resolve_provider_name raises ValueError(f"Unknown provider: '{name}' ...") — echoing the name verbatim. The except ValueError at line 613 prints that message via console.print(f"[red]Error:[/red] {e}"), so the secret is written to stdout in plaintext. (Independent of the leak, --value-stdin also simply cannot work without -p, because the provider prompt eats the first stdin line.)

Failure scenario: echo "$MY_TOKEN" | codeframe auth setup --value-stdin (no -p) → terminal/CI log shows Error: Unknown provider: 'ghp_xxxx...'. Valid providers: ..., and no credential is stored.

Suggested change
# stdin/file take precedence over the deprecated option.
if value_stdin:
# .readline() not .read(): a trailing newline from `echo` is not part of
# the secret, and a here-doc may carry more than one line.
value = sys.stdin.readline().strip()
# stdin/file take precedence over the deprecated option. The value must be
# read (or the option rejected) before the interactive provider prompt:
# typer.prompt() reads stdin too, so `echo $KEY | cf auth setup --value-stdin`
# with no -p would consume the secret as the provider name and echo it back
# in the ValueError raised below (#935).
if (value_stdin or value_file is not None) and not provider:
console.print(
"[red]Error:[/red] --value-stdin/--value-file require --provider/-p "
"(provider selection also reads stdin)."
)
raise typer.Exit(1)
if value_stdin:
value = sys.stdin.readline().strip()

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — scanned for concrete defects only (logic errors, crashes, secret exposure, data loss, race conditions). Style/architecture/coverage are out of scope and handled separately.

severity file:line finding
major codeframe/cli/auth_commands.py:621 --value-stdin without --provider leaks the credential to the terminal: the provider prompt consumes the piped secret as the provider name, and resolve_provider_name echoes it back in the printed ValueError. (Posted as inline comment with a fix.)

Checked and clean:

  • _warn_if_exposed auth-off set ({"0","false","no","off"}) matches the canonical _AUTH_FALSY in auth/dependencies.py:40 exactly — no false negative/positive vs. real enforcement.
  • tasks_show: tasks.get (exact) → find_by_prefix (escaped SQL LIKE) → ambiguity/exit handling is correct; Task field accesses (created_at/updated_at datetime formatting, estimated_hours, complexity_score, github_issue_number) match the model and _row_to_task, no None-derefs.
  • Rich escape() applied at every user-controlled interpolation site in the changed hunks of cli/app.py and tui/app.py; typer.confirm call sites correctly left unescaped.
  • --value-file error paths (OSError/IndexError/UnicodeDecodeError) are handled and exit non-zero.

Minor non-defect noted (not blocking): run_server (python -m codeframe.ui.server) does not call _warn_if_exposed, so an explicit HOST=0.0.0.0 there warns only on the cf serve path. The default is loopback in both, so this is a warning-coverage gap, not a security hole.

CI caught tests/cli/test_serve_command.py asserting the OLD 0.0.0.0 default —
the suite was pinning the vulnerability in place. Updated with a comment saying
why, the same way #929 handled tests that cemented a bug.

That surfaced a third default the issue did not name: GlobalConfig.api_host
(core/config.py) also defaulted to 0.0.0.0, so API_HOST-driven deployments kept
binding every interface even after `cf serve` and run_server were fixed.

tests/cli + tests/config: 557 passed.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — CLI hardening (#935)

Bug-hunting review (logic errors, security, data loss, crashes only). Style/architecture/coverage handled by a separate review.

  • Gather PR context (gh pr view, gh pr diff) and existing comments
  • Identify what changed since the prior review (synchronize)
  • Read changed files in full + surrounding callers
  • Confirm/verify the previously-reported --value-stdin finding against current code
  • Hunt for new defects in the new/changed hunks
  • Post inline comments for confirmed findings + summary

View job run

security-headers.js, its test and proxy.ts belong to #936 (CSP nonce) and were
swept in here by a `git add -A`. They are unrelated to the CLI hardening this
PR is for; re-applied on the #936 branch.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — CLI hardening (#935)

Bug-hunting review (logic errors, security, data loss, crashes only). Style/architecture/coverage handled by a separate review.

  • Gather PR context (gh pr view, gh pr diff) and existing comments
  • Identify what changed since the prior review (synchronize)
  • Read changed files in full + surrounding callers
  • Confirm/verify the previously-reported --value-stdin finding against current code
  • Hunt for new defects in the new/changed hunks
  • Post inline comments for confirmed findings + summary

View job run

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 8m 49s —— View job


GLM precision review — CLI hardening (#935)

Re-review on synchronize. Bug-hunting (logic errors, security, data loss, crashes only); style/architecture/coverage handled separately.

  • Gather PR context (gh pr view, gh pr diff) and existing comments
  • Identify what changed since the prior review (synchronize)
  • Read changed files in full + surrounding callers
  • Confirm/verify the previously-reported --value-stdin finding against current code
  • Hunt for new defects in the new/changed hunks
  • Post inline comment for the new finding + summary

Result — 1 new defect + 1 prior finding still open

severity file:line finding
major codeframe/cli/auth_commands.py:604/614/621 Previously reported — still present (unfixed). --value-stdin without -p leaks the credential to the terminal (provider prompt consumes the piped secret; ValueError echoes it; except prints it). None of the new commits touch this path.
minor codeframe/cli/app.py:3399 New. work_update_description echoes description unescaped → MarkupError crash on legitimate input, after the description is already persisted. The line above was escaped in this PR; this one was missed. Inline comment + fix posted.

Verified clean: _warn_if_exposed auth-off set matches canonical _AUTH_FALSY; tasks_show field accesses and SQL are correct; api_host has no consumers (loopback default harmless); changed-hunk escapes in app.py/tui/app.py complete; --value-file error paths exit non-zero.

Summary posted via gh pr comment; inline comment posted on app.py:3399. No files modified, no commits pushed.

Comment thread codeframe/cli/app.py
console.print("[green]Task description updated[/green]")
console.print(f" Task: {task.title}")
console.print(f" Task: {escape(task.title)}")
console.print(f" New description: {description[:100]}{'...' if len(description) > 100 else ''}")

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.

[minor] The description argument is echoed into console.print unescaped — a MarkupError crash on legitimate input, the same bug class this PR fixes for titles/blockers elsewhere.

Failure scenario: codeframe work update-description <id> "Fix the [/b] parser" (or ordinary prose like "Array indexing a[0] and b[1]", which the PR's own test asserts Rich chokes on). The line above (3398) was escaped in this PR and tasks_show escapes task.description — so descriptions are treated as needing escape elsewhere — but this echo was missed. The crash also fires after tasks_module.update(...) (line 3395) already persisted the new description, so the operator sees an error while the update silently succeeded.

Suggested change
console.print(f" New description: {description[:100]}{'...' if len(description) > 100 else ''}")
console.print(f" New description: {escape(description[:100])}{'...' if len(description) > 100 else ''}")

Same crash class nearby in this function's branches (not changed by the PR, but reachable with LLM-generated titles / user-typed ids): {t.title} at line 3389 and {task_id} at lines 3383/3387 are also interpolated unescaped.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — CLI hardening (#935)

Re-review on synchronize. Scanned for concrete defects only (logic errors, crashes, secret exposure, data loss, race conditions). Style/architecture/coverage are out of scope and handled separately.

severity file:line finding
major codeframe/cli/auth_commands.py:604/614/621 Previously reported — still present (unfixed). --value-stdin/--value-file without -p leaks the credential to the terminal: the interactive provider typer.prompt (L604) reads stdin before the credential read (L621), consuming the piped secret as the provider name; resolve_provider_name echoes it verbatim in the ValueError, which except ValueError (L614) prints in plaintext. None of the new commits (CSP removal, api_host loopback, test import) touch this path. Drop-in fix from the prior inline (require --provider when stdin/file is used) still applies.
minor codeframe/cli/app.py:3399 New. work_update_description echoes the description arg unescaped → MarkupError crash on legitimate input (e.g. a description containing [/b] or a[0]), fired after the description is already persisted. The PR escaped the title echo on the line directly above and escapes descriptions in tasks_show, but missed this one. (Inline comment posted with a fix.)

Checked and clean:

  • _warn_if_exposed auth-off set {"0","false","no","off"} matches the canonical _AUTH_FALSY (auth/dependencies.py:40) exactly — no false negative/positive vs real enforcement.
  • tasks_show: tasks.getfind_by_prefix (parameterized + LIKE-escaped) → ambiguity/exit handling correct; created_at/updated_at are non-Optional datetime (_row_to_task always parses them), optionals guarded, no None-derefs.
  • GlobalConfig.api_host → loopback default is harmless: the field has no consumers anywhere in the tree (vestigial).
  • Rich escape() covers every user-controlled interpolation in the changed hunks of cli/app.py and tui/app.py; typer.confirm call sites correctly left unescaped.
  • --value-file error paths (OSError/IndexError/UnicodeDecodeError) handled and exit non-zero.

Non-blocking note (not a defect): run_server (python -m codeframe.ui.server) does not call _warn_if_exposed, so an explicit HOST=0.0.0.0 there warns only on the cf serve path. Default is loopback in both, so this is a warning-coverage gap, not a security hole.

View job run

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.

[P1.17] Harden CLI defaults and output: bind localhost, keep secrets off argv, escape Rich markup, add cf tasks show

1 participant