The system manages all routine types (cron, webhook, event, manual),
not just cron schedules. Rename the CLI subcommand to reflect this:
- `ironclaw cron` -> `ironclaw routines` (with `cron` as hidden alias)
- List shows all routines by default, add --trigger filter
- Remove cron-trigger-only validation
- Simplify require_routine helper (no trigger type check)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Rebase onto staging branch and address collaborator review:
- Fix .unwrap_or(None) → proper error propagation in set_enabled()
- Add --yes/-y flag for non-interactive deletion with confirmation prompt
- Add --json flag for machine-readable output in list and history
- Preserve error context chain with {e:#} in run_cron_cli()
Note: GATEWAY_USER_ID is trusted from the environment; future work may
add authentication for multi-tenant deployments.
* refactor: extract safety module into ironclaw_safety crate
Move prompt injection defense, input validation, secret leak detection,
and safety policy enforcement into a standalone crate under crates/.
The safety module was a leaf dependency with no async, no database, and
no other ironclaw traits — only pure computation with pattern matching.
SafetyConfig (2 fields) moves into the crate; env-var resolution stays
in ironclaw's config module as a free function. src/safety/mod.rs becomes
a thin re-export so all existing `crate::safety::*` imports keep working.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* docs: update CLAUDE.md for ironclaw_safety crate extraction
Add guidance to migrate imports from crate::safety to ironclaw_safety
when touching files. Update project structure to reflect crates/ dir.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* refactor: move safety fuzz targets into ironclaw_safety crate
Split fuzz infrastructure:
- crates/ironclaw_safety/fuzz/ — 5 safety-only targets (sanitizer,
validator, leak_detector, credential_detect, config_env) depending
only on ironclaw_safety for faster builds
- fuzz/ — keeps fuzz_tool_params which needs ironclaw::tools
Add seed corpus files (51 total) covering each pattern family:
sanitizer injection patterns, validator edge cases, leak detector
secret formats, credential detect HTTP param shapes.
Add new fuzz_credential_detect target exercising
params_contain_manual_credentials with arbitrary JSON.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address PR review — single-pass XML escaping and versioned path dep
Rewrite escape_xml_attr from chained .replace() to single-pass char
iteration (O(n) instead of O(4n) with intermediate allocations). Add
version = "0.1.0" to ironclaw_safety path dep to satisfy cargo-deny
wildcards = "deny".
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* Add event-triggered routines and workflow skill templates
* Add generic host-verified webhook ingress for tools
* Migrate GitHub webhook normalization into github tool
* Bump github tool registry version
* Stabilize trace E2E test rig and approval behavior
* Add reusable gateway workflow harness with mock LLM server (#762)
* Add reusable gateway workflow test harness with mock LLM server
* Fix clippy issues in workflow harness
* Stabilize trace E2E test rig and approval behavior
* Address PR review feedback on gateway workflow harness
- Extract shared TestChannelHandle into test_channel.rs with name override
support, eliminating ~55 lines of duplication between test_rig.rs and
gateway_workflow_harness.rs
- Remove redundant RoutineEngine creation that was immediately overwritten
by Agent::run()
- Replace flaky sleep(500ms) with polling loop for routine run count check
- Use components.context_manager instead of creating a fresh ContextManager
for job tools, ensuring agent and tools share the same instance
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix import ordering in gateway_workflow_harness
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* Address PR #758 review feedback
- Fix header_value to use fully case-insensitive lookup (iterate with
to_ascii_lowercase) instead of checking only exact/lower/upper variants
- Change comment_id from u32 to u64 to handle GitHub's billion-range IDs
- Remove handle_webhook from LLM-facing JSON schema to prevent direct
invocation bypassing HMAC verification
- Rename enrichment keys from repository/sender to repository_name/
sender_login to preserve original JSON objects in webhook payloads
- Remove put_string_normalized helper (no longer needed)
- Replace no-op tests (test_validate_event_in_create_pr_review,
test_validate_merge_method) with test_header_value_case_insensitive
- Add README docs for 6 undocumented actions (list_issue_comments,
create_issue_comment, list_pull_request_comments,
reply_pull_request_comment, get_pull_request_reviews,
get_combined_status)
- Add comment explaining max_tool_calls <= 8 bound in e2e test
- Fix gateway workflow harness: add webhook_capability with secret auth
to MockGithubWebhookTool, matching staging's hardened webhook security
- Fix merge artifacts: remove duplicate test function, orphaned code
fragment in e2e_routine_heartbeat
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix formatting in gateway workflow harness
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Address Copilot review: filter keys, pr_number fallback, feature gate, version alignment
- Update SKILL.md and workflow-routines.md templates to use `repository_name`
and `sender_login` (matching enriched payload field names)
- Mark webhook HMAC secret as required in SKILL.md prerequisites
- Fall back to `/issue/number` for `pr_number` on issue_comment PR webhooks
- Gate `gateway_workflow_harness` module behind `#[cfg(feature = "libsql")]`
- Align tool version to 0.2.1 in Cargo.toml and capabilities.json
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* feat: add cargo-deny for supply chain safety
Add dependency auditing via cargo-deny to catch license violations,
security advisories, and untrusted sources. Integrates into CI as a
parallel job alongside clippy, and into the local quality gate script.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: use cargo-deny action in CI, improve quality gate script
- Use EmbarkStudios/cargo-deny-action@v2 instead of cargo install
for faster CI execution
- Fix quality_gate_strict.sh to check for cargo-deny availability
instead of suppressing stderr
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: add missing Unlicense and CDLA-Permissive-2.0 to license allowlist
Add Unlicense (used by aho-corasick, memchr, etc.) and
CDLA-Permissive-2.0 (used by webpki-roots) to prevent
cargo deny check from failing on the current dependency tree.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* chore: trigger CI after retargeting PR to staging
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: use valid cargo-deny v0.19 syntax for unmaintained advisories
The `unmaintained` field in [advisories] accepts "all", "workspace",
"transitive", or "none" — not "warn". Use "workspace" to flag
unmaintained direct dependencies without failing on transitive ones.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* chore: re-trigger CI after adding skip-regression-check label
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: migrate deny.toml [licenses] to version 2 format
Remove deprecated `unlicensed` and `default` fields, add `version = 2`.
In v2, all licenses are denied unless explicitly in the allow list,
making these fields redundant.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: ignore pre-existing advisories in deny.toml with justification
Add known RUSTSEC IDs to the ignore list so cargo-deny CI passes.
Each advisory is documented with mitigation context. Dependency
upgrades to resolve these should be tracked separately.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address PR review feedback for cargo-deny integration
- quality_gate_strict.sh: fail hard when cargo-deny is not installed
instead of silently skipping, and let set -e handle check failures
- deny.toml: remove empty [graph].targets so cargo-deny checks all
platforms instead of only the runner's default target
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(deny.toml): correct serde_yml advisory comment to reflect direct dependency
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: tighten clippy-windows check in roll-up job
Change from checking only `== "failure"` to checking
`!= "success" && != "skipped"`. This ensures any unexpected
result (e.g., cancelled) also blocks the merge, while still
allowing the expected "skipped" state for non-main PRs.
Addresses zmanian's review feedback on PR #834.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: cd to repo root in strict gate, deny wildcard versions
- quality_gate_strict.sh: add `cd` to repo root so the script works
when invoked from any working directory.
- deny.toml: change `wildcards = "allow"` to `"deny"` to catch `*`
version requirements in dependencies.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix(security): make unsafe env::set_var calls safe with explicit invariants
`std::env::set_var` is unsafe in Rust 1.82+ because concurrent calls
from multiple threads cause undefined behavior. This commit addresses
the two production-code call sites:
1. `bootstrap.rs:load_ironclaw_env()` -- called before the Tokio
runtime starts (genuinely single-threaded). Added a `debug_assert!`
that verifies no Tokio runtime is active, making the safety
invariant machine-checkable rather than relying on a comment.
2. `llm/session.rs:api_key_login()` -- was calling `set_var` mid-
execution inside the multi-threaded Tokio runtime (UB risk).
Replaced with `set_runtime_env()`, a new thread-safe overlay
backed by `OnceLock<Mutex<HashMap>>`. The overlay integrates with
the existing `optional_env()` config resolution and a new
`env_or_override()` reader function.
All call sites that read `NEARAI_API_KEY` via raw `std::env::var()`
(wizard.rs, main.rs, doctor.rs) are updated to use the thread-safe
`env_or_override()` helper instead, so the value set during
interactive login is visible without mutating the process environment.
Test code `set_var`/`remove_var` calls (bootstrap tests, config tests,
shell tests, oauth tests, wizard tests) are left as-is since they run
under `ENV_MUTEX` serialization and are not production paths.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: address review feedback on thread-safe env overlay PR
- Replace debug_assert! with runtime check in bootstrap.rs so release
builds skip unsafe set_var when a Tokio runtime is active
- Recover from mutex poison in set_runtime_env instead of silently
dropping writes (poisoned HashMap is still usable)
- Skip empty override values in env_or_override and optional_env for
consistency with real env var handling
- Fix doc comment on env_or_override (real env checked first, not
runtime overrides)
- Update api_key_login doc to describe runtime overlay instead of
env var mutation
[skip-regression-check]
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: use LazyLock::lock() for INJECTED_VARS; use set_runtime_env() in bootstrap fallback
- helpers.rs: fix env_or_override() to call INJECTED_VARS.lock() instead
of .get() — INJECTED_VARS was changed upstream from OnceLock<HashMap>
to LazyLock<Mutex<HashMap>>; calling .get() caused a compile error
(E0599: no method named 'get' for LazyLock)
- bootstrap.rs: when load_ironclaw_env() is called with an active Tokio
runtime, use set_runtime_env("DATABASE_BACKEND", "libsql") instead of
silently dropping the write. This ensures DATABASE_BACKEND is always
set regardless of thread context (addresses ilblackdragon review item 1).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* fix(security): require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy
FullAccess policy bypasses Docker entirely and runs commands via sh -c
directly on the host. Previously, setting SANDBOX_POLICY=full_access
alone was sufficient to enable this, which could be triggered
accidentally or via prompt injection if tool approval is bypassed.
This adds a double opt-in guard:
- New SANDBOX_ALLOW_FULL_ACCESS=true env var must ALSO be set for
FullAccess to take effect. Without it, the policy is downgraded to
WorkspaceWrite with a tracing::error! log.
- At execution time, every FullAccess command emits a tracing::warn!
with the command and working directory for audit visibility.
- The FullAccess variant now documents its blast radius (host shell,
unrestricted filesystem/network/environment).
- SandboxConfig and SandboxModeConfig gain an allow_full_access field,
wired through from_env() and the builder.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix(sandbox): address review feedback on FullAccess double opt-in
- Add doc comment on builder .policy() warning that FullAccess requires
.allow_full_access(true) or execution will return SandboxError::Config
- Sanitize audit log: log only binary name instead of full command to
prevent secret leakage; add [FullAccess] prefix for grep-ability
- Add test_builder_full_access_without_allow_returns_error test covering
the builder path without explicit allow_full_access(true)
- Fix doc comment mismatch: config.rs and SandboxPolicy::FullAccess docs
said "will downgrade to WorkspaceWrite" but runtime returns
SandboxError::Config -- aligned docs with actual behavior
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: merge duplicate mod tests; add allow_full_access to struct initializers
After upstream merge, src/config/sandbox.rs had two issues:
- Duplicate mod tests block (upstream's original tests at line 271 + our
new FullAccess guard tests at line 478) caused E0428 compile error
- Upstream test struct literals for SandboxModeConfig were missing the
new allow_full_access field (E0063)
Fixes: merge the two mod tests into one; add allow_full_access: false to
the sandbox_mode_config_custom_values and sandbox_mode_to_sandbox_config
test struct initializers.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* fix(security): add Content-Security-Policy header to web gateway
The web gateway set X-Frame-Options and X-Content-Type-Options but had
no Content-Security-Policy header. Without CSP, there is no browser-
enforced mitigation against XSS attacks. This adds a tailored CSP that
matches the resources the frontend actually loads.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix(security): address CSP review feedback
- Remove cdnjs.cloudflare.com from script-src (not used in codebase)
- Add explicit object-src 'none' per security best practice
- Add regression test asserting CSP header presence and directives
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* fix(test): stabilize openai compat oversized-body regression
* docs(web): fix stale body limit in CLAUDE.md (1 MB → 10 MB)
CLAUDE.md:200 still documented the pre-#725 body limit of 1 MB, but
server.rs:354 was changed to 10 MB in #725 (image upload support).
Update the documentation to match the actual production value.
* fix(ci): disambiguate WASM bundle filenames to prevent tool/channel collision
When a tool and channel share the same name (e.g. slack, telegram), the
CI build produced identical bundle filenames, causing the second to
overwrite the first. Both manifests then pointed to the wrong binary.
Prefix bundle filenames with the extension kind (tool-slack-... vs
channel-slack-...) and parse the prefix when patching manifests, so each
manifest receives the correct artifact URL and SHA256.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* test(registry): add installer tests for tool/channel name disambiguation
Regression tests for the CI artifact collision fix (PR #964). Verifies:
- extract_tar_gz rejects archives with wrong wasm name (the collision bug)
- Tool bundle extracts slack-tool.wasm correctly
- Channel bundle extracts slack.wasm correctly
- Tool and channel manifests install to separate directories
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(ci): add kind validation and filter non-WASM checksum entries
- Validate .kind is "tool" or "channel" before using in build-wasm-extensions (hard error)
- Filter checksums.txt to *-wasm32-wasip2.tar.gz entries before parsing, avoiding noisy warnings from binary artifact entries in build-local-artifacts
- Add kind validation with warning+skip in both checksum-parsing loops
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: fix rustfmt formatting in installer tests
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix(setup): validate channel credentials during setup
Validate channel setup credentials against the declared validation endpoint so users get immediate feedback before startup failures. Substitute stored secrets into the validation URL, block private or local targets, and warn on failed checks without interrupting setup.
Made-with: Cursor
* fix(setup): harden channel credential validation
Pin setup-time validation requests to vetted DNS results, disable redirects, and avoid leaking substituted secrets in error output. URL-encode placeholder substitutions and add regressions for DNS failure, trailing-dot localhost, and IPv4-mapped IPv6 SSRF bypasses.
Made-with: Cursor
* refactor(setup): cache validation placeholder regex
Reuse a static placeholder regex in channel credential validation so the SSRF hardening path avoids recompiling the same pattern on every call.
* fix(tunnel): drain ngrok stdout/stderr to prevent zombie process
* fix: limit stderr lines read on startup failure to prevent OOM
* fix: drain pipes in cloudflare and custom tunnel to prevent zombie process
* style: fix formatting in custom tunnel
* test: add regression test for stdout drain preventing zombie process
* style: apply rustfmt
* fix(mcp): header safety validation and Authorization conflict bug from #704
* fix(mcp): enforce RFC 9110 header validation on all config load paths
Replace hand-written CRLF checks with reqwest::header::HeaderName::from_bytes()
and HeaderValue::from_str(), catching spaces, colons, null bytes, and all
non-token characters that the previous validation missed.
Add validation to load_mcp_servers_from() and load_mcp_servers_from_db() so
corrupted configs from disk or DB are rejected at load time instead of silently
flowing through to McpClient. Improve app.rs error handling to distinguish
"no config" from "corrupted config" (including malformed JSON).
Also fix build_request_headers() to check self.custom_headers directly instead
of indirectly via server_config, and clarify the wire test comment about
HeaderMap::insert replacement semantics.
* fix ci issue
fixes#890
- Always call initialize() before list_tools()/call_tool(), removing
the session_manager.is_some() guard that caused stdio/unix clients
to skip the MCP protocol handshake entirely
- Add local AtomicBool flag for idempotent initialization when no
session manager is present
- Fire-and-forget JSON-RPC notifications (id=None) in stdio/unix
transports instead of registering a pending response that would
block for 30s waiting on a reply that never comes
- Fix mcp test panic on stdio/unix servers by using
create_client_from_config() instead of new_with_config() which
asserts HTTP-only transport
On Windows, single keypresses during `ironclaw onboard` are registered
twice, causing channel/tool selection to skip or toggle incorrectly.
Two root causes:
1. select_many() had no residual event drain, so Enter from a prior
prompt was immediately consumed on entry, skipping the selection.
2. Neither select_many() nor read_secret_line() filtered on
KeyEventKind::Press, so Windows Key Release/Repeat events caused
every keypress to fire twice (Space toggles cancel out, Enter
triggers double-advance, arrows jump two positions).
Extract a shared drain_pending_events() helper (replacing the inline
drain in read_secret_line from #849), add it to select_many() entry,
and filter both event loops to only handle KeyEventKind::Press.
Fixes#937
[skip-regression-check]
The extract_tool_description and extract_tool_schema stubs in runtime.rs
returned permissive fallbacks ("WASM sandboxed tool" and
additionalProperties:true) for every WASM tool, defeating parameter
validation and preventing the LLM from using tools correctly.
Add optional `description` and `parameters` fields to CapabilitiesFile so
tool authors can declare proper metadata in their sidecar JSON. The
WasmToolLoader now extracts these fields and passes them through to the
tool registry as overrides. Tools without a capabilities.json or without
these fields get a tracing::warn and fall back to the old stubs.
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* fix(security): resolve DNS once and reuse for SSRF validation to prevent rebinding
The previous SSRF protection resolved DNS in validate_url() to check IPs
against a blocklist, but then reqwest independently re-resolved DNS when
making the actual HTTP connection. Between validation and connection, a
DNS rebinding attack could flip the record from a public IP (passes
validation) to a private IP like 169.254.169.254 (AWS metadata endpoint).
Fix: split URL validation into two phases:
- validate_url(): synchronous URL structure checks (scheme, localhost,
IP literals) -- no DNS resolution
- validate_and_resolve_url(): async DNS resolution via
tokio::net::lookup_host, validates all resolved IPs, returns
SocketAddrs
- build_pinned_client(): constructs a per-request reqwest Client with
resolve() pinning so reqwest connects to the pre-validated IPs without
a second DNS lookup
Applied to both HttpTool and WebFetchTool. WebFetchTool builds a fresh
pinned client per redirect hop, ensuring DNS rebinding cannot occur at
any point in a redirect chain.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* style: run cargo fmt
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* feat(extensions): unify auth and configure into single entrypoint
Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).
Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
providing secrets to any extension (WasmChannel, WasmTool, MCP).
Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
(chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
delete token-storing branches from auth_mcp/auth_wasm_tool,
rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use ValidationFailed error variant instead of string matching
Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth
1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add regression tests for extension lifecycle refactoring
- test_configure_token_picks_first_missing_secret: verifies multi-secret
channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
error variant can be pattern-matched (commit a318161)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address review comments — activation dispatch, dead code, caps consolidation
- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
instead of unconditionally calling activate_wasm_channel() for all
non-WasmTool types (MCP servers and channel relays now use their
correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS
The previous sanitizeRenderedHtml() used regex patterns to strip dangerous
HTML tags and event handler attributes before assigning to innerHTML. Regex-
based HTML sanitization is notoriously bypassable via:
- SVG/MathML elements not in the blocklist (<svg onload=...>)
- Newline-split event handlers (<img src=x on\nload=alert(1)>)
- Mutation XSS (browser parsing quirks that reconstruct dangerous DOM)
- Encoded attribute values and alternative quote styles
- Nested/recursive tag patterns that defeat linear regex
This is exploitable through prompt injection: if an LLM tool output contains
crafted HTML, it flows through marked.parse() -> sanitizeRenderedHtml() ->
innerHTML, allowing script execution in the user's browser session.
Replace the regex sanitizer with DOMPurify 3.2.3, the industry-standard
DOM-based HTML sanitizer. DOMPurify parses HTML into a real DOM tree and
walks it node-by-node, which eliminates all known bypass vectors. It is
used by Mozilla, Google, and most major web applications.
CDN: cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js
SRI: sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl
Audited all 60+ innerHTML assignments in app.js:
- 5 use renderMarkdown() -> now protected by DOMPurify
- Remainder use escapeHtml(), static literals, or empty strings
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix(security): guard sanitizeRenderedHtml against DOMPurify CDN unavailability [skip-regression-check]
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
The Claude review step was failing ~40% of the time because:
- --allowedTools didn't include Read, Glob, Grep, Agent, causing 8-9
permission denials per run and preventing Claude from reading files
or spawning the subagents the prompt required
- Step 4 spawned N additional scoring agents per issue found, exhausting
the 50-turn budget before the PR comment could be posted
- Subagents could independently post PR comments, causing fragmented output
Fix: add missing tools to --allowedTools, merge per-issue scoring into
the review agents themselves, and add guardrails ensuring exactly one
consolidated comment is always posted.
Co-authored-by: Claude Opus 4.6 <[email protected]>
The telegram-tests, windows-build, wasm-wit-compat, and docker-build
jobs were skipped during staging CI because their `if` conditions only
matched `push` and `pull_request` events. When staging-ci.yml calls
test.yml via workflow_call, github.event_name is `schedule` (inherited
from the caller), which matched neither condition.
Invert the conditions to blocklist the one case we want to skip (PRs
targeting staging) instead of allowlisting specific events. This handles
schedule, workflow_dispatch, and any future trigger types.
Co-authored-by: Claude Opus 4.6 <[email protected]>
- Use fetch-depth: 0 in update-tag to ensure current_head SHA is available
even when staging receives new commits during the CI run
- Only merge promotion PRs targeting main; leave chained PRs open to
prevent delete_branch_on_merge from auto-closing downstream PRs
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix(ci): use explicit features in WASM WIT compat test to avoid sqlite3 symbol conflicts
The `import` feature (added in #903) brings in `rusqlite[bundled]` which
conflicts with `libsql-ffi` — both bundle SQLite C code, causing duplicate
symbol linker errors. Use explicit features matching the test matrix instead
of `--all-features`.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: replace rusqlite with libsql in import module to fix sqlite3 symbol conflict
The `import` feature used `rusqlite[bundled]` which bundled its own SQLite
C code, conflicting with `libsql-ffi` (also bundles SQLite). This caused
duplicate `sqlite3_*` symbol linker errors when both features were enabled
via `--all-features`.
Replace `rusqlite` with `libsql` (already a dependency) in the import
reader. The `import` feature now implies `libsql`. This eliminates the
duplicate symbol conflict and allows `--all-features` to compile cleanly.
Also restores `--all-features` in the WASM WIT compat CI test (now safe)
and converts all import test helpers from rusqlite to libsql.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: apply cargo fmt formatting fixes
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* feat(i18n): Add internationalization support with Chinese and English translations
* fix(i18n): fix duplicate keys, broken placeholders, and dead overrides
---------
Co-authored-by: zwb1982 <[email protected]>
Address three deferred implementation items flagged during code review:
1. SIGHUP lock held across .await (#883): Split restart_with_addr into
merged_router_clone() + install_listener() so the async TcpListener
bind happens outside the mutex, eliminating lock contention risk.
2. Recursion depth limit for check_strings (#848): Cap JSON traversal
at 32 levels to prevent stack overflow on pathological tool params.
3. Named error type for add_tokens (#788): Replace Result<(), String>
with TokenBudgetExceeded { used, limit } for type-safe budget errors.
Co-authored-by: Claude Opus 4.6 <[email protected]>
* Add generic host-verified webhook ingress for tools
* Stabilize trace E2E test rig and approval behavior
* Fix webhook security issues from review feedback
- Reject tools without webhook_capability() (was unauthenticated RCE)
- Remove secret-in-query-string fallback (leak via logs/referrers)
- Require approval for event_emit tool (escalation via routine triggers)
- Simplify header_value() (HeaderMap already case-insensitive)
- Redact internal errors from webhook HTTP responses
- Remove unused hmac_timestamp_tolerance_secs field
- Add regression test for tool without webhook capability
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Harden webhook ingress: require auth mechanism, body limit layer, health check
- Reject webhook capabilities that declare no auth mechanism (empty
WebhookCapability would previously allow unauthenticated access)
- Add DefaultBodyLimit layer to reject oversized payloads before buffering
- Health check (GET) now verifies tool has webhook_capability(), not just
existence
- Add regression tests for all three fixes
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix auto_approve_tools inconsistency between dispatcher and thread_ops
dispatcher.rs skips all approval checks (including Always) when
auto_approve_tools is true, but thread_ops.rs still required approval
for Always tools. This caused deferred tool calls to unexpectedly halt
in test rigs and auto-approve configurations.
Match dispatcher behavior: short-circuit all approval when
auto_approve_tools is enabled.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)
GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* feat: add channel-relay integration for Slack via external relay service
- Add RelayChannel and RelayClient for connecting to channel-relay SSE streams
- Add RelayConfig with env-based configuration (CHANNEL_RELAY_URL, CHANNEL_RELAY_API_KEY)
- Add channel-relay extension lifecycle: install, OAuth auth, activate with hot-add
- Add proxy message sending through channel-relay for Slack chat.postMessage
- Add extension registry entry for Slack relay with OAuth auth hint
- Add relay integration test with mock SSE server
- Wire relay channel into app startup with reconnect on stored credentials
- Add AuthRequired extension error variant for cleaner auth flow detection
[skip-regression-check]
* chore: apply cargo fmt
* fix: remove remaining Telegram test references in relay channel
* fix: address PR #790 review feedback — parser handle leak, CSRF, circuit breaker
- Fix parser handle leak on reconnect by sharing Arc<RwLock> instead of
creating a local copy in start() (shutdown now aborts the correct task)
- Add CSRF state nonce to OAuth flow: generate in auth_channel_relay,
validate in slack_relay_oauth_callback_handler, one-time use
- Remove dead proxy_slack method, update integration test to use
proxy_provider
- Add reconnect circuit breaker (max_consecutive_failures, default 50)
- Fix stale docs (Telegram refs), extract event_types constants
---------
Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Keep staging versions for all registry JSON files (sha256: null) and
LLM module helpers. CHANGELOG.md and Cargo updates from main applied.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: address staging-ci-review issues (batch 1)
- #811: Fix unreachable error handling in worker — restructure .await?
to explicit match on nested Result so token budget errors are properly
logged and marked as failed
- #813: Combine metadata + token budget into single update_context()
call to prevent concurrent worker observing partial state
- #814: Persist max_tokens and total_tokens_used to both PostgreSQL and
libSQL backends — add V12 migration, update save_job/get_job
- #815: Cap user-supplied max_tokens at configured max_tokens_per_job
to prevent budget bypass via metadata injection
- #869: Release locks before async I/O in webhook handler (http.rs) and
SIGHUP handler (main.rs) to prevent blocking concurrent requests
Fixes: #811, #813, #814, #815, #869
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address PR #883 review feedback
- Fix min(user_val, 0) bug: guard for unlimited config (max_tokens_per_job == 0)
- Remove duplicate columns from libSQL base SCHEMA (v12 migration is sole source)
- Use get_i64() helper for consistency in libsql/jobs.rs
- Add regression tests for scheduler token budget capping
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix: optimize agent logging to reduce DataDog bill
* fix: log permanent repair failures as ERROR not WARN
RepairResult::Failed is permanent failure requiring attention (ERROR level)
not a temporary/retryable condition (WARN level).
[skip-regression-check]
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* security: remove user message content from trace logs
Never log user message content at any log level (includes TRACE).
Log only safe metadata: content length, message ID, image count.
This prevents accidental exposure of sensitive user data in logs
even at the most verbose logging level.
[skip-regression-check]
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* security: move LLM response body logging to TRACE level
Response bodies can contain user-generated content, tool outputs, and
leaked secrets. Moving to TRACE (not enabled in production) prevents
exposure in DEBUG logs. Status log remains at DEBUG.
[skip-regression-check]
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* refactor: simplify URL sanitization using url::Url API
Use set_query, set_fragment, set_username, set_password methods
instead of manual string reconstruction. Cleaner, handles edge cases,
eliminates port branching complexity.
[skip-regression-check]
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* test: add comprehensive unit tests for sanitize_url_for_logging
Add 9 test cases covering:
- URL with query parameters
- URL with credentials (user:pass@host)
- URL with fragment
- URL with port
- URL with all components combined
- Malformed URL fallback behavior
- Short strings (pass-through)
- Non-URL-like strings
- Path preservation
Tests verify that sanitization correctly removes sensitive components
while preserving safe components like host, port, and path.
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* fix: libsql per-migration logs should be DEBUG, not TRACE
Individual migration logs are now visible with standard debug logging
(RUST_LOG=ironclaw=debug), improving debuggability when troubleshooting
migration issues. Summary log remains at INFO level.
Fixes behavioral change that made it harder to identify which specific
migration ran or failed without enabling full TRACE logging.
[skip-regression-check]
---------
Co-authored-by: Claude Haiku 4.5 <[email protected]>
* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch fallback (#439)
Root cause: all artifact URLs used releases/latest/download/, which is a
moving target. Every release rebuilds all WASM extensions non-deterministically,
so sha256 baked into an older binary diverges from the content at 'latest'.
ChecksumMismatch was also a hard block with no source-build fallback.
Three-layer fix:
1. src/registry/installer.rs — allow source-build fallback for ChecksumMismatch
on releases/latest URLs (moving-target artifact rotation, not tampering).
Version-pinned URLs (releases/download/vX.Y.Z/) remain a hard block.
Adds regression test (test_source_fallback_on_latest_url_mismatch) and
updates test_should_attempt_source_fallback_policy to cover both URL types.
2. .github/workflows/release.yml — three CI changes:
- build-wasm-extensions: version-detect, skip-if-unchanged, versioned filenames
(name-{version}-wasm32-wasip2.tar.gz). Skip rebuild when manifest already has
a non-null sha256 and the URL embeds the current version — stable checksums
until source actually changes.
- build-local-artifacts: patch manifests with version-pinned URL + sha256 (for
binary embedding via build.rs).
- update-registry-checksums: same URL patching for the main-branch PR.
All three sed patterns use '.*' (greedy) to correctly handle pre-release
version strings like 0.1.0-alpha.1.
3. registry/{tools,channels}/*.json — null out all 14 stale sha256 values.
Null sha256 -> MissingChecksum -> source-build fallback (works on all binaries).
Next release CI will populate version-pinned URLs + stable checksums.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* style: cargo fmt
* fix(ci): use JSON filename stem for WASM bundle names to fix manifest lookup
Manifests like registry/tools/slack.json have name='slack-tool', causing
the patching step to look for registry/tools/slack-tool.json (missing).
Introduce file_stem (JSON filename without .json) for the bundle filename
and checksums.txt entry, while keeping ext_name (manifest .name) for archive
contents — the installer extracts files by manifest.name so those must still
match. The patching step strips -{version}-wasm32-wasip2.tar.gz from the
filename stem and looks up registry/tools/slack.json correctly.
* fix(registry): tighten fallback URL check + deduplicate tests
Address PR review feedback:
1. Make should_attempt_source_fallback check repo-specific
(github.com/nearai/ironclaw/releases/latest/) instead of a
generic substring (/releases/latest/download/).
2. Remove duplicate ChecksumMismatch cases from
test_should_attempt_source_fallback_policy — that coverage
lives in the dedicated regression test
test_source_fallback_on_latest_url_mismatch.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* refactor: centralize test credential constants into testing::credentials
Scattered test credential strings (API keys, OAuth tokens, crypto keys,
Telegram tokens, session tokens) across ~25 files made security auditing
harder and created unnecessary duplication. Centralize all test-only fake
credentials into a new `src/testing/credentials.rs` module with named
constants and a shared `test_secrets_store()` helper.
- Convert `src/testing.rs` to directory module (`src/testing/mod.rs`)
- Add `src/testing/credentials.rs` with ~30 named constants
- Replace hardcoded literals in 24 source files
- Deduplicate `test_store()` helper (was copy-pasted in 3 files)
- Leave leak_detector/shell/signature tests as-is (inline values
aid readability for pattern detection tests)
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor: replace real Telegram bot token with obviously fake test stub
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* Update src/testing/credentials.rs
Co-authored-by: Copilot <[email protected]>
* Update src/testing/credentials.rs
Co-authored-by: Copilot <[email protected]>
* refactor: address PR review feedback on test credentials
- Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string")
- Rename confusing "real"/"fake" Anthropic constant names and values
- Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners
- Use test_secrets_store() helper in orchestrator and http tool tests
- Clarify config_round_trip.rs doc comment about integration test visibility
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Copilot <[email protected]>
* Revert "Feat/docker shell edition (#804)"
This reverts commit c566faf28f.
* style: fix formatting issues from revert
Run cargo fmt to fix formatting across 7 files after the revert of
the docker shell edition feature.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>