Skip to content

feat: localhost web UI for one-shot secret input#34

Closed
bglusman wants to merge 3 commits intomainfrom
feat/secret-paste-web-ui
Closed

feat: localhost web UI for one-shot secret input#34
bglusman wants to merge 3 commits intomainfrom
feat/secret-paste-web-ui

Conversation

@bglusman
Copy link
Copy Markdown
Owner

Implementation of PR #29's RFC. New crate zeroclawed-secret-paste with 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

bglusman and others added 3 commits April 24, 2026 13:40
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>
Copilot AI review requested due to automatic review settings April 25, 2026 03:14
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (+ typed FnoxError) to consolidate fnox subprocess logic.
  • Add zeroclawed-secret-paste crate implementing an Axum-based localhost paste server with single-use token + expiry + “new-only by default”.
  • Add !secure command interception/handling in the Telegram channel and zeroclawed command 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.

Comment on lines +42 to +48
// 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;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1217 to +1219
// 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.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +111 to +149
/// 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}");
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +86 to +162
/// 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,
})
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +167 to +178
/// `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
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bglusman
Copy link
Copy Markdown
Owner Author

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 info! (now address-only at info, full URL at debug), and (2) fnox set argv leak (now value via stdin). Other items from triage (graceful shutdown, exit-on-submit, Origin: null) deferred to a follow-up.

bglusman added a commit that referenced this pull request Apr 25, 2026
…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)
@bglusman
Copy link
Copy Markdown
Owner Author

Subsumed by #44 (squashed to 9ed51fbc on main). All commits from this branch are present in the squash. Closing as redundant rather than merging again.

@bglusman bglusman closed this Apr 25, 2026
bglusman added a commit that referenced this pull request Apr 25, 2026
…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)
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