Skip to content

[temp] write-permission probe - #1

Closed
ra-co88 wants to merge 15 commits into
masterfrom
fix/apodex-audit-findings
Closed

[temp] write-permission probe#1
ra-co88 wants to merge 15 commits into
masterfrom
fix/apodex-audit-findings

Conversation

@ra-co88

@ra-co88 ra-co88 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

test

pt-act added 15 commits August 27, 2026 11:12
The Claude login flow passed the PKCE `code_verifier` as the OAuth `state`
parameter, embedding a client secret verbatim in the authorization URL
(browser history, provider access logs, QR/manual-paste, terminal
scrollback). Anyone observing the URL obtained the verifier, defeating the
core guarantee of PKCE S256.

Generate an independent `state` via `generate_state()` and thread an
`expected_state` through `login_claude`, `exchange_claude_code[_at_url]`,
the TUI login path, and the scriptable CLI. The verifier now stays
client-side, only ever sent in the TLS-protected token-exchange body.

Regression test: assert the authorization URL's `state != verifier` and
that the verifier never appears in the URL (replaces the prior test that
pinned the buggy `state == verifier` behavior).
…ive gate (SEC-02)

`macos_computer_use`'s run_applescript/run_jxa executed model-supplied
scripts via osascript with no destructive-command gate, while the bash
tool's 1jehuang#604 gate held identical `rm -rf $HOME` payloads. A prompt-injected
or mistaken agent could destroy files or exfiltrate secrets through the
scripting actions, re-creating the 1jehuang#604 data-loss class via a neighboring
tool.

Promote the gate from bash-private to a shared `tool::destructive_gate`
module (rename bash_destructive_gate.rs -> destructive_gate.rs, declared in
tool/mod.rs; functions widened to pub(crate); add a caller-labeled variant).
Add `applescript_destructive_refusal`: it extracts embedded shell payloads
(`do shell script`, JXA `doShellScript`) and routes literals through the
exact shipped 1jehuang#604 policy, holds computed/dynamic shell arguments for
justification, and flags native permanent-destruction verbs
(NSFileManager removeItem*, NSTask); Finder Trash is intentionally allowed.
Wire it into the run_applescript/run_jxa dispatch arms, thread working_dir
through execute->run->dispatch, and add a `justification` field so a refused
script can be re-issued like bash.

Honest defense-in-depth (cf. SEC-05): a static scan cannot catch every
interpreter obfuscation; catastrophic targets stay blocked even with
justification. 8 unit tests.
webfetch validated only the URL scheme; it had no allow/blocklist for
private ranges, and the shared HTTP client followed redirects by default.
An agent could be steered to read cloud instance metadata
(169.254.169.254), scan the LAN, or hit loopback dev services — and a
public URL could 30x-redirect into that space after any pre-flight check.

Add a shared `tool::ssrf` guard: parse+require http(s), reject
localhost/.local, resolve the host and refuse if ANY resolved IP is
loopback/private/link-local (incl. 169.254.169.254 metadata)/unspecified/
broadcast/multicast/CGNAT/IETF-reserved, plus IPv6 ULA/link-local and
IPv4-mapped forms (defeats DNS-rebinding via a single private A record).
webfetch now uses a no-auto-redirect client and follows redirects manually
(bounded, MAX_REDIRECTS=5), re-running the guard on every hop.

Scope: the strict guard applies to the model-supplied webfetch URL. The
websearch SearXNG endpoint is left unguarded because it is operator-
configured (config/env) and commonly self-hosted on localhost/LAN — the
operator config is the trust boundary, not a model-influenced input. 3 tests.
Config mutation helpers did `load -> mutate -> save` with no locking
(RC-01: two concurrent writers to disjoint fields silently lost one
update), and `save()` used a bare `std::fs::write` with default perms
(SEC-04: config.toml can hold provider api_keys yet was world-readable on
inherited/shared directories, and a crash mid-write could truncate it).

RC-01: add a process-wide CONFIG_WRITE_LOCK and a `Config::mutate()` /
`mutate_if()` that hold the lock across the WHOLE load->modify->save cycle;
route all ~19 `set_*` and external-auth-trust helpers through them. (Locking
only the write was insufficient — the added concurrency test caught that
disjoint writes still raced.)

SEC-04 + durability: `save()` now writes to a temp file in the same
directory, hardens it to 0o600 before the secret bytes land, fsyncs, then
atomically renames over the target, and hardens the file+dir via
`jcode-storage::harden_secret_file_permissions` (0o600/0o700, Windows-aware).

Tests: owner-only permissions after save, and 8-thread concurrent disjoint
writes preserve both updates and leave the file parseable.
…REL-01)

`wait_until_probably_online()` looped forever with exponential backoff and
no ceiling. A permanent offline state (broken VPN profile, captive portal
that never satisfies the probe) wedged all four turn.rs recovery call sites
indefinitely, with a spinner but no elapsed bound and no escape.

Bound it: the function now returns `ReconnectOutcome { Online, GaveUp }` and
delegates to `wait_until_probably_online_bounded(max_total)` with a default
300s ceiling; the loop checks elapsed time each iteration and never sleeps
past the remaining budget. All four turn.rs call sites now check
`.is_online()` — on GaveUp they surface a clear "still offline after
waiting several minutes" message and stop (return/break) instead of looping.

Test asserts a bounded wait terminates within its ceiling regardless of
network state.
Ignore `.agents/` (Orion-OS local memory bank / working context, per its
ops manual — travels with the machine, not the repo), alongside the
pre-existing `.personal/`, and `.DS_Store` (macOS Finder metadata).
…tting (REL-02)

A single TOML syntax error (a hand edit or a model config-edit slip) made
`load_from_file()` log and return None, so `load()` fell through to
`Config::default()` — silently wiping every user setting, including
security opt-outs like telemetry/discovery, on the next reload.

Now, on a parse/read error, `load_from_file()` (1) backs up the corrupt
file to `config.toml.corrupt` (owner-only, idempotent per corrupt version)
so it is recoverable and never silently overwritten by the next save, and
(2) returns the last config that parsed successfully in this process
(new LAST_GOOD_CONFIG snapshot) instead of defaults. Interactive callers
still surface the error via the existing `load_strict()` path
(config_edit_notice).

Test: after corrupting the file, load() keeps the prior centered=true and
writes a .toml.corrupt backup containing the bad bytes.
…Y-01, VC-01)

A11Y-01: add docs/ACCESSIBILITY.md — an honest account of the TUI's
screen-reader story: what works (keyboard-only, NO_COLOR, glyph-based
status, measured theme contrast), the known gap (no announcement channel /
no --json-events stream yet), and the OKLab-vs-WCAG contrast caveat. Linked
from the README UI section.

VC-01 (narrow residual): the meta-audit confirmed contrast IS already
computed and asserted (jcode-tui-style/harmony.rs) and NO_COLOR IS honored
in the CLI — but the TUI renderer ignored it. Add
`palette::strip_colors_for_no_color()` and call it once per frame in the
render loop (ui.rs) so NO_COLOR/JCODE_NO_COLOR drops all fg/bg/underline to
the terminal default while preserving text modifiers, matching the CLI.
Kept as a buffer-level pass at the existing palette chokepoint rather than
adding a ColorCapability variant (which would ripple across two crates).

Tests: NO_COLOR strips every cell to Reset; disabled leaves colors intact.
Add SECURITY.md: an honest account of what the installer and in-app updater
verify (SHA-256 checksums over HTTPS, multi-source), the residual risk
(checksums share a trust root with the binary, so they prove integrity not
authenticity; curl|bash is trust-on-first-use; binaries are unsigned),
cautious-install guidance, and a concrete hardening roadmap (detached
SHA256SUMS signatures verified in jcode-update-core, multi-channel
publication, platform notarization, pinned installer artifact hash).

Signature verification itself requires a maintainer decision on signing-key
custody, so it is documented as a tracked roadmap item rather than
half-implemented. Linked from the README install section. Informational
finding SEC-07.
Address three gaps found reviewing the first pass:

RC-01 (audit #1): the write lock was process-local only, so two separate
jcode processes could still lose updates. Add a cross-process advisory lock
(ConfigFileLock: flock(LOCK_EX) on a 0o600 config.toml.lock on Unix; a
documented no-op elsewhere where only the in-process mutex applies) held
across the whole read-modify-write in mutate_if.

REL-02 (audit #2): last-good was in-process only, so a fresh process with a
corrupt config.toml still fell back to Config::default(). Persist a
byte-for-byte last-good snapshot to config.toml.last-good on every good load
(atomic, 0o600) and restore from it when the in-process snapshot is empty,
so known-good settings survive restarts.

SEC-04 (audit #3): back_up_corrupt_config used std::fs::write then hardened
afterward — a window where key-bearing bytes sat at default perms. Route it
through the existing write_atomic_hardened (temp created 0o600 before the
secret bytes, then atomic rename).

Tests: extend the malformed-config test to assert across-restart recovery
from disk and 0o600 on both the corrupt backup and the last-good snapshot;
add an inter-process lock-file test (created, reusable, owner-only).
…inding (SEC-03 #4)

The prior SSRF guard resolved+checked the host, but reqwest re-resolved at
connect time — a hostile DNS could return a public IP during validation and
an internal IP during connection (TOCTOU). The code documented this gap; the
audit follow-up asked to close it.

Add `guard_public_url_pinned` which returns the validated `SocketAddr`, and
in webfetch build a per-hop no-redirect client with reqwest `.resolve(host,
addr)` so the connection uses exactly the IP that was validated. Applied on
every redirect hop, so both the initial fetch and any 30x land on a checked
address. Literal-IP URLs need no pinning (no name to re-resolve) and return
None. `guard_public_url` is kept as a thin wrapper.

Updated the module doc to state the TOCTOU gap is now closed for the pinned
client (residual: proxies / non-pinned code paths, and still no internal-host
allowlist). Test: pinned guard rejects internal targets and returns no pin for
a literal public IP; existing webfetch tests still pass.
A follow-up review correctly flagged that an earlier summary over-claimed a
few fixes. Add docs/AUDIT_REMEDIATION.md stating, per finding, the exact
mechanism and its scope — including the deliberately-scoped ones:

- SEC-02 is heuristic defense-in-depth, not a scripting sandbox; a justified
  native-verb call is a reflection gate, not prevention (full enforcement =
  SEC-05 OS sandbox).
- SEC-03 pinning closes the common DNS-rebinding TOCTOU but not proxy/non-
  pinned paths; SearXNG is intentionally operator-trusted.
- RC-01 cross-process lock is Unix-only (in-process mutex only elsewhere).
- REL-02 recovers last-good across restarts via an on-disk snapshot, bounded
  by whether a valid config was ever loaded on the machine.
- SEC-07 and A11Y-01 `--json-events` are documented roadmap, not implemented.
- Verification uses `--ignore-rust-version` (1.94.0 vs cosmetic 1.94.1 MSRV).

This is the authoritative, accurately-scoped record addressing the review's
recommendation to correct the summary.
The inter-process config write lock was Unix-only (flock); on Windows two
jcode processes could still lose one another's config updates. Wire the
Windows path so RC-01 is closed on both platforms jcode ships on.

- ConfigFileLock now holds the lock file on Unix and Windows. Acquire uses
  flock(LOCK_EX) on Unix and LockFileEx(LOCKFILE_EXCLUSIVE_LOCK) on Windows;
  Drop releases via flock(LOCK_UN) / UnlockFileEx. Non-unix/non-windows
  targets keep the explicit in-process-mutex-only fallback.
- Uses windows-sys (already a jcode-base Windows dep); adds the
  Win32_Storage_FileSystem + Win32_System_IO features. Cargo.lock unchanged.
- The lock-file open/harden is refactored into open_lock_file() shared by
  both platforms.

Docs: docs/AUDIT_REMEDIATION.md RC-01 section updated to state the lock is
now cross-platform (Unix + Windows), with the honest caveat that the Windows
path is API-verified against windows-sys 0.59 but runtime-tested only on
macOS (needs a Windows CI leg).

Changelog: add changelog/v0.81.0.json ("Security & reliability hardening")
summarizing the user-visible effect of the audit fixes (SEC-01/02/03/04/07,
RC-01, REL-01/02, A11Y-01, VC-01) and index it.

Verified: config:: tests still pass on macOS (4/4 in atomic_save_tests,
including the inter-process lock-file test); config_file.rs compiles clean.
@ra-co88 ra-co88 closed this Aug 27, 2026
ra-co88 pushed a commit that referenced this pull request Aug 30, 2026
Completes the client side of verify-then-commit (docs/VERIFY_THEN_COMMIT.md
gap #1): the remote TUI can now answer interactive stdin requests instead
of only noticing them.

- ServerEvent::StdinRequest arms a pending-answer state and shows the
  prompt in the transcript (empty prompts get a status notice)
- Enter sends the composer line as Request::StdinResponse - the first
  caller of the previously dead send_stdin_response path - so verify-
  then-commit approvals and interactive bash stdin both work remotely
- Esc declines with an empty reply (the edit gate treats it as a
  rejection) while preserving the draft for reuse
- other keys fall through so the user can scroll/edit while waiting
- replies are excluded from cross-session prompt history
- 4 wire-level tests using the dummy socketpair idiom assert the actual
  StdinResponse frames; in-workspace edit_approval suite now 13/13

Default remains off: local sessions have no stdin channel today, so the
gate fails closed there (documented).
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.

2 participants