Skip to content

Harden reliability and security across all top-level commands - #49

Merged
burakdede merged 1 commit into
mainfrom
audit/reliability-security-hardening
Aug 1, 2026
Merged

Harden reliability and security across all top-level commands#49
burakdede merged 1 commit into
mainfrom
audit/reliability-security-hardening

Conversation

@burakdede

Copy link
Copy Markdown
Owner

Audits every top-level command plus the auth backends and fixes the defects found.

921 tests pass, 0 fail. Clippy and cargo fmt --check clean. 32 tests added: one regression per defect, plus an end-to-end smoke suite covering all 18 top-level commands (the previous contract matrix covered 7).

Why this branch exists

Running the test suite could log the developer out of their real accounts. Unit tests never sandboxed PATH, and tool_detection::detect() spawns whatever it finds to read --version:

execve("/home/burak/.local/bin/claude", ["--version"], ...)
execve(".../bin/codex", ["--version"], ...)
execve(".../bin/gemini", ["--version"], ...)

HOME was not overridden either, so those ran against live state. Tests run multi-threaded, and these CLIs refresh OAuth tokens on startup — concurrent refreshes race and invalidate the refresh token. This actually happened and required re-login.

Fixed by applying the safety default the keyring already used: under cfg(test), ambient detection resolves nothing unless a test opts in via AISW_TOOL_PATH_TEST_DIR. Tests passing an explicit path are unaffected. Verified with strace: zero real-binary executions, zero real-home access. Lib tests also got ~3x faster (29s → 10s).

Security

  • API key injection. Gemini stores keys as GEMINI_API_KEY=<key> in a .env file the CLI sources, but validation only rejected empty strings. Reproduced — this exited 0:
    $ aisw add gemini work --api-key $'AIzaLegit123\nGOOGLE_CLOUD_PROJECT=attacker-project'
    # .env written:
    GEMINI_API_KEY=AIzaLegit123
    GOOGLE_CLOUD_PROJECT=attacker-project
    
    Now rejected for all three tools via a shared control-character check.
  • Path traversal in profile and stored-file names; the symlink guard now uses symlink_metadata, since Path::exists() follows links and a dangling symlink previously slipped through and let a write create the link's target.
  • uninstall --remove-data purges the keyring secrets it created instead of stranding them forever, and refuses when AISW_HOME is the home directory (rm -rf ~ guard).

Reliability

  • remove destroyed credentials it then refused to delete. It snapshotted, deleted the keyring secret, and deleted the profile directory before the config write rejected a context-referenced profile.
  • Removing the active profile now clears active in the same locked mutation, so config cannot name a nonexistent profile.
  • status panicked (no entry found for key, exit 101) when active named a missing profile.
  • init --json aborted with unreachable!() under /bin/sh — the default in most containers.
  • doctor failed for every Gemini profile. It looked for one hardcoded filename per tool that Gemini never writes, so aisw doctor exited 1 on healthy installs and dragged verify down with it.
  • use --all ignored --emit-env. It fell through to the human summary — and since the shell hook dispatches on $1 = "use", a hooked shell was eval-ing ANSI-colored text:
    OLD: aisw use --all --profile work --emit-env
         → ESC[36mESC[1mClaude Code → workESC[0m     ← eval'd by the hook
    NEW: → export CLAUDE_CONFIG_DIR='/…/profiles/claude/work'
    
    --state-mode was dropped the same way.
  • OAuth capture now reaps its interactive child on every error path (RAII guard); ? returns inside the polling loop previously orphaned a live claude auth login with the terminal attached.
  • Antigravity is now guarded by all shell hooks and present in workspace status --json and status --context --json.

Performance

workspace check runs on every directory change via the shell hook. It was doing full binary detection, credential reads, and OS keyring access; it now reads active profiles from config.

Breaking-change review

Built binaries from main and this branch, ran both against identical fixtures, and diffed every machine-readable output. Three differences, all intentional:

Change Assessment
workspace status --json gains "antigravity": null Additive
doctor --json detail "0600 ok""0600 ok (1 file(s))" Human-readable field, not structured
doctor/verify gemini check failpass (exit 1 → 0) The bug fix

status --json is byte-identical — the new active_profile_registered field is internal and never serialized.

⚠️ One behavior change worth a look

aisw use --all now exits non-zero when a tool switch fails. It previously exited 0 and reported the failure only in a warnings array, contradicting docs/automation.md: "Exit code 0 means success." On partial failure, --json now emits the standard failure envelope instead of {"ok": true, ..., "warnings": [...]}.

OLD: exit 0   {"ok": true,  ..., "warnings": ["codex: could not read ..."]}
NEW: exit 1   {"ok": false, "error": {"message": "1 of 2 attempted tool switches failed: ..."}}

Called out explicitly in CHANGELOG under Unreleased → Fixed.

Not included

Cargo.toml stays at 0.3.8 — version bump and release timing left to you.

Audits every top-level command plus the auth backends, and fixes the
defects found. Adds 32 tests: per-defect regressions and an end-to-end
smoke suite covering all 18 commands (the previous contract matrix
covered 7).

Security:
- Reject API keys containing control characters. Gemini stores keys as
  GEMINI_API_KEY=<key> in a .env file the CLI sources, so a newline in a
  key injected extra environment variables into that file.
- Reject path traversal in profile and stored-file names, and use
  symlink_metadata for the symlink guard so a dangling symlink cannot
  redirect a credential write to its target.
- uninstall --remove-data purges the keyring secrets it created instead
  of stranding them, and refuses when AISW_HOME is the home directory.
- Unit tests no longer resolve tools from the real PATH. Detection
  spawns what it finds to read --version, so the suite was executing the
  developer's real claude/codex/gemini CLIs against their live home,
  which could rotate and invalidate real OAuth tokens.

Reliability:
- remove no longer destroys credentials before checking whether a
  context still references the profile, which then rejected the removal.
- Removing the active profile clears `active` in the same locked config
  mutation, so config cannot name a profile that does not exist.
- status no longer panics when `active` names a missing profile.
- init --json no longer aborts under a shell it has no hook for.
- doctor no longer reports a false failure for every Gemini profile; it
  checks the files a profile actually stores. This also unblocks verify.
- use --all honors --emit-env and --state-mode. --emit-env previously
  printed the human summary, which a shell hook would eval.
- OAuth capture reaps its interactive child on every error path.
- Antigravity is guarded by the shell hooks and present in
  workspace status --json and status --context --json.

Performance:
- workspace check, which the shell hook runs on every directory change,
  reads active profiles from config instead of probing tool binaries,
  credential files, and the OS keyring.

Behavior change: use --all now exits non-zero when a switch fails. It
previously exited 0 while reporting a failure in `warnings`, which
contradicted the documented contract that zero means success. On partial
failure --json now emits the standard failure envelope. Noted in
CHANGELOG.
@burakdede
burakdede merged commit 6793c1a into main Aug 1, 2026
5 checks passed
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.

1 participant