feat: localhost web UI for one-shot secret input#34
Conversation
Adds `!secure` as a post-auth chat command dispatched before any agent
sees the message. Subcommands:
- `!secure set NAME=value` — stores the secret in fnox and replies
with "Stored `NAME`" + a retention-warning. Never echoes the value.
- `!secure list` — lists stored secret names (not values). Parses
fnox's output defensively; extra columns that look like values are
stripped before replying.
- `!secure help` / `!secure` — usage string.
Dispatch model (matches existing `!switch`/`!sessions`/`!default`):
- `CommandHandler::is_secure_command(&str)` — static, case-insensitive.
- `CommandHandler::handle_secure(text, identity_id) -> String` —
async (shells out to fnox), takes identity for future per-identity
audit/ACL though not used yet. Wired into telegram.rs in both
`handle_message_nonblocking` and `handle_message` paths, and added
to the unknown-command exclusion list so `!secure` doesn't get
intercepted as unknown before hitting the post-auth handler.
Subprocess approach today — `Command::new("fnox").args(["set", …])`.
Acknowledged-cost: three places now shell out to fnox (vault.rs,
secure_set, secure_list), which means three places that need an
availability check. **Follow-up PR (in flight) will migrate all three
to the fnox library crate via `fnox = { git = "https://github.com/jdx/fnox" }`**
— fnox already exposes public modules (secret_resolver, commands,
etc.) at `src/lib.rs`, so no upstream PR is needed.
Redaction discipline:
- The success reply names the stored secret but never echoes the
value; tested.
- The failure reply surfaces fnox's stderr (user needs to know why
it failed) but doesn't carry the value; tested.
- The telegram channel's `debug!` logs only that a !secure command
was handled, without the message text. A `!secure set FOO=bar`
would otherwise leak the value into ops logs.
- Name validation: `[A-Za-z0-9_-]+` only (matches the substitution
engine's accepted ref-name syntax — so a stored secret can be
immediately referenced as `{{secret:NAME}}` without a rename).
Tested with three invalid-char cases.
5 given/when/then behavioral tests:
- `secure_set_reply_includes_name_but_not_value`
- `secure_set_surfaces_fnox_error_without_echoing_value`
- `secure_unknown_subcommand_returns_help`
- `secure_set_rejects_invalid_name_chars`
- `secure_list_returns_names_only`
All use a fake-fnox shell script installed on PATH via TempDir, so
the tests are hermetic regardless of whether the real fnox is
installed on the dev/CI machine.
Explicit non-goals for this PR (follow-ups):
- `!secure remove NAME` — pending a decision on whether fnox's
own remove command or a config-file edit is the right approach.
- `!secure request NAME` — the out-of-band localhost-paste flow
that avoids chat-transport retention entirely (see
docs/rfcs/agent-secret-gateway.md §5).
- Matrix/WhatsApp channel wiring — same pattern as telegram, but
adds separate verification surface. Doing in a follow-up to keep
this PR reviewable.
- Library-based fnox integration (swap subprocess → lib calls) —
committed as next PR per user direction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User and I had a back-and-forth on whether to migrate fnox usage to
the upstream Rust library or keep subprocess. After investigating
fnox's actual library API (`fnox::secret_resolver::resolve_secret`
needs pre-loaded `Config` + per-secret `SecretConfig`; no clean
programmatic SET; ~30 transitive deps), subprocess is still the
right tool — but each call-site shouldn't be reinventing the wheel.
This commit lands `crates/onecli-client/src/fnox_client.rs` — one
typed wrapper around `fnox` that all current and future call-sites
use:
- `FnoxClient::get(name) -> Result<String, FnoxError>`
- `FnoxClient::set(name, value) -> Result<(), FnoxError>`
- `FnoxClient::list() -> Result<Vec<String>, FnoxError>`
- `FnoxClient::is_available() -> bool`
Errors are typed (`NotInstalled`, `EmptyValue`, `Failed { exit_code,
stderr }`, `InvalidUtf8`), not stringified-stderr, so callers can
pattern-match: e.g. `!secure set` gives a different message on
NotInstalled (suggest `brew install fnox`) vs Failed (surface fnox's
own error).
Migration in this PR:
- `commands.rs::secure_set` and `secure_list` now call FnoxClient.
Before: each had its own `Command::new("fnox")`, its own io::Error
-> "not available" stringification, its own stderr-to-string error.
After: one-liner calls + match on the typed error.
Migration left for follow-up (when PR #15 lands or this branch is
rebased on it):
- `vault.rs::get_secret_from_fnox` — same swap; needs PR #15 in
history first since that's where the function lives.
Tests:
- 11 new given/when/then tests for FnoxClient itself, using
`FnoxClient::with_binary(path)` to point at fake-fnox shell scripts
in TempDir. No PATH manipulation, no global env mutation, no
`serial_test` requirement (cleaner than the existing fake-fnox-on-
PATH pattern in `vault_fallthrough.rs` and `commands::tests`).
- Existing 5 `!secure` tests in `commands::tests` still pass
unchanged — they used PATH-prepend, which still works because
`FnoxClient::new()` defaults to `"fnox"` (PATH lookup).
- Notable adversarial assertions added:
- `get_empty_value_is_error_not_empty_string` — empty stdout
returned as `EmptyValue(name)` so a downstream `Authorization:
Bearer ` (empty) silent-anonymous failure is impossible.
- `set_passes_name_and_value_in_argv_order` — captures argv to a
temp file and asserts position; guards against an off-by-one
rearrangement that would store the value under the wrong name.
- `list_extracts_names_only_dropping_value_columns` — explicit
negative assertion that no value substring survives.
`tempfile` added as a dev-dep on `onecli-client`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nput Implementation of the design from PR #29 (the secret-input-web-ui RFC): a tiny axum-based HTTP server that spawns on demand, binds to a random localhost port, accepts one paste, then shuts down. ## What this is \`spawn_request(name, description, fnox, config)\` → \`PasteHandle { url, token, expires_at, ... }\`. The caller (chat command, MCP tool, CLI) hands the URL to the user; the user opens it in a browser, pastes a value, submits. ## Security properties - Localhost-only binding (\`127.0.0.1:<random>\`) - Single-use URL with random 64-hex-char token; 5-min default expiry - **New-only by default** — refuses to overwrite an existing secret unless the user appends \`?update=1\`. Eliminates accidental clobber AND limits compromised-browser blast radius (attacker can add new secrets but cannot rotate existing ones). - Origin header check on POST (default on) — defends against DNS rebinding from an attacker page that resolves to 127.0.0.1 - Configurable first/last-N preview on confirmation page (default off) for "right secret, right paste" verification without re-displaying the full value - Confirmation page never echoes the value - Error pages never echo the value ## Why subprocess fnox CLI not lib Same reasoning as PR #21 — uses \`onecli_client::FnoxClient\` so the implementation can swap to library calls behind the same \`get/set/list\` API later if fnox ships a clean programmatic surface. ## Tests (7 given/when/then) - \`token_is_64_hex_chars\` — sanity on entropy - \`truncated_preview_short_input_returns_ellipsis_only\` — no leak of short values - \`happy_path_new_secret_stores_and_confirms\` — end-to-end via reqwest against the live socket - \`new_only_default_refuses_existing_secret\` — captures fnox-set invocations to a log file, asserts the log STAYS EMPTY when the refusal kicks in - \`explicit_update_query_allows_overwrite\` — \`?update=1\` opens the rotation path - \`preview_renders_first_last_n_only\` — explicit negative assertion that the middle of the value never appears in the rendered confirmation - \`missing_origin_header_rejected_when_required\` — DNS-rebinding defense gate ## Stacks on PR #21 Depends on \`onecli_client::FnoxClient\` from PR #21 for the actual secret writes. ## CLI binary \`zeroclawed-secret-paste NAME [DESCRIPTION]\` prints the URL to stdout and waits for completion. Useful for one-off command-line use without going through MCP/chat. ## Follow-up integrations - Wire \`!secure request NAME\` chat command (extends PR #20) to call \`spawn_request\` and reply with the URL - Wire MCP \`add_secret_request\` tool (PR #23, currently stubbed) to do the same - Optional: Tailscale-aware binding for users who need to paste from a phone (currently localhost-only by design) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a localhost-only, one-shot web UI for entering secrets out-of-band (supporting the planned !secure request flow), and introduces a shared FnoxClient wrapper for interacting with the fnox CLI from multiple call sites.
Changes:
- Add
onecli-client::FnoxClient(+ typedFnoxError) to consolidatefnoxsubprocess logic. - Add
zeroclawed-secret-pastecrate implementing an Axum-based localhost paste server with single-use token + expiry + “new-only by default”. - Add
!securecommand interception/handling in the Telegram channel andzeroclawedcommand handler.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/zeroclawed/src/commands.rs | Adds !secure command detection/handler + tests; uses FnoxClient for set/list. |
| crates/zeroclawed/src/channels/telegram.rs | Intercepts !secure post-auth and routes to handler without logging message text. |
| crates/zeroclawed-secret-paste/src/lib.rs | Implements localhost paste server (tokenized URL, expiry, origin checks, new-only). |
| crates/zeroclawed-secret-paste/src/main.rs | Adds CLI entrypoint that spawns the paste server and prints the URL. |
| crates/zeroclawed-secret-paste/Cargo.toml | New crate manifest and dependencies. |
| crates/onecli-client/src/lib.rs | Exposes fnox_client module and re-exports FnoxClient/FnoxError. |
| crates/onecli-client/src/fnox_client.rs | New FnoxClient wrapper + tests for get/set/list/availability. |
| crates/onecli-client/Cargo.toml | Adds tempfile dev-dependency for new tests. |
| Cargo.toml | Adds new workspace member crates/zeroclawed-secret-paste. |
| Cargo.lock | Adds lock entries for the new crate and dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Keep the process alive until the spawn task ends OR the expiry | ||
| // passes. Currently no shutdown signal from inside the request | ||
| // handlers; expiry is the upper bound. | ||
| let until_expiry = (handle.expires_at - chrono::Utc::now()) | ||
| .to_std() | ||
| .unwrap_or_default(); | ||
| tokio::time::sleep(until_expiry).await; |
There was a problem hiding this comment.
Codex integration sweep: addressed in #38 without editing this PR branch. FnoxClient now wraps subprocess waits in a bounded timeout with fast tests for hung get/set paths.
| origin.starts_with("http://127.0.0.1:") | ||
| || origin.starts_with("http://localhost:") | ||
| || origin == "http://localhost" | ||
| || origin == "null" // chrome sets "null" for file:// + some sandbox cases; we accept |
There was a problem hiding this comment.
Codex integration sweep: acknowledged. I am leaving this PR branch untouched per the parallel-agent boundary; this remains actionable for the PR owner or a follow-up unless it is superseded by #38.
| // Only allow the same name-shape substitution accepts, so callers | ||
| // can immediately use the stored name in a `{{secret:NAME}}` ref. | ||
| // See crates/security-proxy/src/substitution.rs. |
There was a problem hiding this comment.
Codex integration sweep: addressed for the !secure command comments in #38 without editing this PR branch. The stale substitution.rs reference was replaced with the local secret-name validation authority in the integrated code; MCP/doc references on other branches remain branch-owner follow-up.
| /// Port: 0 (kernel picks a free port). Override via `PORT` env if you | ||
| /// need a stable port for testing. | ||
| pub async fn spawn_request( | ||
| name: impl Into<String>, | ||
| description: impl Into<String>, | ||
| fnox: onecli_client::FnoxClient, | ||
| config: PasteConfig, | ||
| ) -> Result<PasteHandle, PasteError> { | ||
| let name = name.into(); | ||
| if !is_valid_name(&name) { | ||
| return Err(PasteError::InvalidName); | ||
| } | ||
| let token = mint_token(); | ||
| let expires_at = chrono::Utc::now() | ||
| + chrono::Duration::from_std(config.expiry).unwrap_or(chrono::Duration::minutes(5)); | ||
|
|
||
| let mut state_requests = HashMap::new(); | ||
| state_requests.insert( | ||
| token.clone(), | ||
| PendingRequest { | ||
| name: name.clone(), | ||
| description: description.into(), | ||
| expires_at, | ||
| completed: false, | ||
| }, | ||
| ); | ||
| let state = ServerState { | ||
| fnox, | ||
| config: config.clone(), | ||
| requests: Arc::new(Mutex::new(state_requests)), | ||
| }; | ||
|
|
||
| let app = Router::new() | ||
| .route("/paste/:token", get(get_form).post(post_submit)) | ||
| .with_state(state); | ||
|
|
||
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; | ||
| let addr: SocketAddr = listener.local_addr()?; | ||
| let url = format!("http://{addr}/paste/{token}"); |
There was a problem hiding this comment.
Codex integration sweep: acknowledged. I am leaving this PR branch untouched per the parallel-agent boundary; this remains actionable for the PR owner or a follow-up unless it is superseded by #38.
| /// Spawned paste-request handle. Carries the URL the user visits. | ||
| /// Drop the handle to stop the server; the listener will refuse new | ||
| /// connections cleanly. | ||
| #[derive(Debug)] | ||
| pub struct PasteHandle { | ||
| pub url: String, | ||
| pub token: String, | ||
| pub expires_at: chrono::DateTime<chrono::Utc>, | ||
| _shutdown: tokio::task::JoinHandle<()>, | ||
| } | ||
|
|
||
| /// Errors callers may need to handle. | ||
| #[derive(Debug, thiserror::Error)] | ||
| pub enum PasteError { | ||
| #[error("invalid secret name (allowed: A-Z a-z 0-9 _ -)")] | ||
| InvalidName, | ||
| #[error("io error spawning listener: {0}")] | ||
| Io(#[from] std::io::Error), | ||
| } | ||
|
|
||
| /// Spawn a one-shot paste server bound to a random localhost port. | ||
| /// Returns immediately with the URL the user should open. The server | ||
| /// runs in a background tokio task and shuts itself down on | ||
| /// completion or expiry. | ||
| /// | ||
| /// Port: 0 (kernel picks a free port). Override via `PORT` env if you | ||
| /// need a stable port for testing. | ||
| pub async fn spawn_request( | ||
| name: impl Into<String>, | ||
| description: impl Into<String>, | ||
| fnox: onecli_client::FnoxClient, | ||
| config: PasteConfig, | ||
| ) -> Result<PasteHandle, PasteError> { | ||
| let name = name.into(); | ||
| if !is_valid_name(&name) { | ||
| return Err(PasteError::InvalidName); | ||
| } | ||
| let token = mint_token(); | ||
| let expires_at = chrono::Utc::now() | ||
| + chrono::Duration::from_std(config.expiry).unwrap_or(chrono::Duration::minutes(5)); | ||
|
|
||
| let mut state_requests = HashMap::new(); | ||
| state_requests.insert( | ||
| token.clone(), | ||
| PendingRequest { | ||
| name: name.clone(), | ||
| description: description.into(), | ||
| expires_at, | ||
| completed: false, | ||
| }, | ||
| ); | ||
| let state = ServerState { | ||
| fnox, | ||
| config: config.clone(), | ||
| requests: Arc::new(Mutex::new(state_requests)), | ||
| }; | ||
|
|
||
| let app = Router::new() | ||
| .route("/paste/:token", get(get_form).post(post_submit)) | ||
| .with_state(state); | ||
|
|
||
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; | ||
| let addr: SocketAddr = listener.local_addr()?; | ||
| let url = format!("http://{addr}/paste/{token}"); | ||
|
|
||
| info!(secret = %name, %url, "secret-paste server listening"); | ||
|
|
||
| let shutdown = tokio::spawn(async move { | ||
| let _ = axum::serve(listener, app).await; | ||
| }); | ||
|
|
||
| Ok(PasteHandle { | ||
| url, | ||
| token, | ||
| expires_at, | ||
| _shutdown: shutdown, | ||
| }) |
There was a problem hiding this comment.
Codex integration sweep: acknowledged. I am leaving this PR branch untouched per the parallel-agent boundary; this remains actionable for the PR owner or a follow-up unless it is superseded by #38.
| let addr: SocketAddr = listener.local_addr()?; | ||
| let url = format!("http://{addr}/paste/{token}"); | ||
|
|
||
| info!(secret = %name, %url, "secret-paste server listening"); |
There was a problem hiding this comment.
Codex integration sweep: acknowledged. I am leaving this PR branch untouched per the parallel-agent boundary; this remains actionable for the PR owner or a follow-up unless it is superseded by #38.
| } | ||
| } | ||
|
|
||
| match state.fnox.set(&req.name, &form.value).await { |
There was a problem hiding this comment.
Codex integration sweep: addressed in #38 without editing this PR branch. The integration branch uses FnoxClient::set with the secret on stdin, adds timeout coverage, and keeps argv/name-only behavior under test.
| /// `fnox set <name> <value>` — store a secret. | ||
| /// | ||
| /// `value` is passed as a CLI argument, which fnox docs note is | ||
| /// visible in `ps` output during the call. For our use case | ||
| /// (`!secure set`) the value is already in the chat transport, | ||
| /// so this isn't a regression. Future improvement: pipe value | ||
| /// via stdin, which fnox supports when value is omitted. | ||
| pub async fn set(&self, name: &str, value: &str) -> Result<(), FnoxError> { | ||
| debug!("fnox set {}", name); | ||
| let output = self | ||
| .run(&["set", name, value]) | ||
| .await |
There was a problem hiding this comment.
Codex integration sweep: addressed in #38 without editing this PR branch. The integration branch uses FnoxClient::set with the secret on stdin, adds timeout coverage, and keeps argv/name-only behavior under test.
|
CI is RED on this branch — fix in #40 stacked on top addresses the two highest-impact LEGIT-FIX items: (1) bearer-URL was logged at |
…ning (#44) Squash-merge of integration/super-combined — 4 weeks of feature work + cross-PR security fixes + codex agent's hardening, all green CI (14/14 checks). ## Features landing - **fnox secret-resolver integration** (#15) + FnoxClient subprocess wrapper (#21) - **Adversarial commit-reviewer + mechanical pre-commit gate** (#18) - **{{secret:NAME}} substitution engine** in security-proxy URL/headers/body (#19) - **Per-secret destination allowlist** (#22) — RFC §11.1 attack defense - **!secure chat commands** (set/list) on Telegram (#20), Matrix (#28), WhatsApp (#31) - **zeroclawed-mcp** scaffold — agent-facing secret discovery server (#23) - **install.sh wires MCP** into Claude Code agent configs (#26) - **zeroclawed-secret-paste** — localhost web UI for one-shot secret input (#34) - **Bulk paste UI** — .env-style multi-secret onboarding with per-line results - **LAN-friendly defaults** — bind 0.0.0.0 + RFC 1918 Origin acceptance - **WhatsApp HMAC verification** (was always-true placeholder before — codex hardening) ## Security fixes folded in - /vault/:secret bearer auth + 127.0.0.1 default bind (#39) - URL-embedded secrets honor destination allowlist (#41) - Paste-flow: bearer URL only at debug, fnox set via stdin not argv (#40) - Paste-flow: graceful shutdown, exit-on-submit, reject Origin: null (#43) - Subprocess timeouts + kill_on_drop on FnoxClient - BrokenPipe-tolerant stdin write (Linux CI surface) - Header-value log redaction - OneCLI bound to 127.0.0.1 by default - Sanitized real API token + Telegram IDs from sample configs (#36) ## Architecture / refactors - Consolidated onecli binary into security-proxy (#17) - Hardcoded vault URL removed from onecli-client - security-proxy resolver wired into hot path - Extracted build_app router; migrated /vault/:secret route - !secure parser uses split_whitespace (was splitn), audit-logs invocations ## Test coverage added - security-proxy substitution engine + body/headers tests - onecli-client retry + Http(_) variant + adversarial fallthrough suite - onecli-client client.rs rewritten from tautologies to wiremock-backed - config/validator coverage (was zero, now 290-line module covered) - 16 zeroclawed-secret-paste tests including bulk-mode cases ## Docs / RFCs - agent-secret-gateway holistic architecture - consolidation-findings (what #28 must address) - secret-input-web-ui RFC (input-only, new-by-default) - browser-harness integration spike - test-quality-audit Round 1+2+3 (host-agent + zeroclawed priority files) ## Codex agent's hardening cherry-picks - Subprocess timeouts on fnox calls - map_spawn_error helper - Validator hardening + atomic-counter digest race fix - WhatsApp HMAC implementation + tests - proxy header-value log redaction CI: all 14 checks green at squash time. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
Subsumed by #44 (squashed to |
…ning (#44) Squash-merge of integration/super-combined — 4 weeks of feature work + cross-PR security fixes + codex agent's hardening, all green CI (14/14 checks). ## Features landing - **fnox secret-resolver integration** (#15) + FnoxClient subprocess wrapper (#21) - **Adversarial commit-reviewer + mechanical pre-commit gate** (#18) - **{{secret:NAME}} substitution engine** in security-proxy URL/headers/body (#19) - **Per-secret destination allowlist** (#22) — RFC §11.1 attack defense - **!secure chat commands** (set/list) on Telegram (#20), Matrix (#28), WhatsApp (#31) - **zeroclawed-mcp** scaffold — agent-facing secret discovery server (#23) - **install.sh wires MCP** into Claude Code agent configs (#26) - **zeroclawed-secret-paste** — localhost web UI for one-shot secret input (#34) - **Bulk paste UI** — .env-style multi-secret onboarding with per-line results - **LAN-friendly defaults** — bind 0.0.0.0 + RFC 1918 Origin acceptance - **WhatsApp HMAC verification** (was always-true placeholder before — codex hardening) ## Security fixes folded in - /vault/:secret bearer auth + 127.0.0.1 default bind (#39) - URL-embedded secrets honor destination allowlist (#41) - Paste-flow: bearer URL only at debug, fnox set via stdin not argv (#40) - Paste-flow: graceful shutdown, exit-on-submit, reject Origin: null (#43) - Subprocess timeouts + kill_on_drop on FnoxClient - BrokenPipe-tolerant stdin write (Linux CI surface) - Header-value log redaction - OneCLI bound to 127.0.0.1 by default - Sanitized real API token + Telegram IDs from sample configs (#36) ## Architecture / refactors - Consolidated onecli binary into security-proxy (#17) - Hardcoded vault URL removed from onecli-client - security-proxy resolver wired into hot path - Extracted build_app router; migrated /vault/:secret route - !secure parser uses split_whitespace (was splitn), audit-logs invocations ## Test coverage added - security-proxy substitution engine + body/headers tests - onecli-client retry + Http(_) variant + adversarial fallthrough suite - onecli-client client.rs rewritten from tautologies to wiremock-backed - config/validator coverage (was zero, now 290-line module covered) - 16 zeroclawed-secret-paste tests including bulk-mode cases ## Docs / RFCs - agent-secret-gateway holistic architecture - consolidation-findings (what #28 must address) - secret-input-web-ui RFC (input-only, new-by-default) - browser-harness integration spike - test-quality-audit Round 1+2+3 (host-agent + zeroclawed priority files) ## Codex agent's hardening cherry-picks - Subprocess timeouts on fnox calls - map_spawn_error helper - Validator hardening + atomic-counter digest race fix - WhatsApp HMAC implementation + tests - proxy header-value log redaction CI: all 14 checks green at squash time. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Implementation of PR #29's RFC. New crate
zeroclawed-secret-pastewith axum-based localhost server. Single-use URL, 5-min expiry, new-only by default, optional first/last-N preview, Origin-header DNS-rebinding defense. 7 given/when/then tests using fake-fnox + reqwest. Stacks on PR #21 (FnoxClient).🤖 Generated with Claude Code