Skip to content

feat(secrets): sandbox credential placeholder registry (unwired) - #6689

Merged
henrypark133 merged 14 commits into
mainfrom
sandbox/pr1-credential-placeholder-skeleton
Jul 27, 2026
Merged

henrypark133 merged 14 commits into
mainfrom
sandbox/pr1-credential-placeholder-skeleton

Conversation

@henrypark133

Copy link
Copy Markdown
Collaborator

What & why

The sandbox container is given only a stable, inert token prefixed icsbx_ (keyed {tenant,user,provider}) instead of a real secret — the invariant being that secret material never enters the container, even transiently. A CredentialSession is minted just-in-time per (invocation × binding) at actual first use; staging every binding up front would itself be a standing grant. Revocation uses an RAII lease so success, error, timeout, and panic paths all revoke — a missed revoke path would silently leave a standing grant. The leak detector gains an icsbx_ pattern so a placeholder can never cross the boundary into model output or logs (sandbox output already runs through the detector, so this is pattern-only).

🔴 Not wired to production

Nothing calls this yet. Grep for the new public symbols (CredentialPlaceholderRegistry, CredentialPlaceholderToken, CredentialSessionLease, mint_on_first_use, find_session_by_placeholder, CREDENTIAL_PLACEHOLDER_PREFIX) across the workspace turns up matches only in three places:

  • crates/ironclaw_secrets/src/lib.rs — re-exports and doc comments
  • crates/ironclaw_secrets/src/placeholder.rs — the definitions themselves and their unit tests
  • crates/ironclaw_safety/src/leak_detector.rs — the icsbx_ leak-pattern constant/comments (pattern-only; no calls into the registry/broker API)

No production call site anywhere else in the workspace. Consumers land in later PRs, and the whole path will ultimately be gated behind a deployment profile.

Series context

First of several PRs landing the sandbox credential firewall incrementally. Deliberately not stacked — stacked PRs drift badly under review plus rebase. Each slice is independently reviewable off main and dormant until activated by profile.

Review history

This slice went through two full review rounds before opening, which found real defects, not nits:

  • a lease-reuse bug where a reused live session handed out a second lease with no refcount, so the first revoke killed a session another caller still held;
  • a single-valued placeholder→session index that silently overwrote bindings across overlapping invocations or two accounts under one provider;
  • revoke_session silently no-opping on a poisoned lock, contradicting the "never leave a standing grant" invariant;
  • unbounded jit_minted growth;
  • and in round 2, a TOCTOU race in round 1's own refcount fix (increment happened after validation in a separate lock acquisition), plus placeholder charset validation that only checked the prefix.

Each has a regression test.

Test coverage

Tests pin these invariants:

  • cross-user placeholder isolation
  • revoke on all four exit paths (success/error/timeout/panic)
  • placeholder-with-no-live-session grants nothing
  • multi-bind with genuinely distinct session ids
  • cache-hit-but-expired remint
  • cross-crate pinning that the icsbx_ leak pattern matches what the registry actually generates

Known deferrals (called out, not hidden)

revoke_session uses O(n) retain() scans and the store uses unsharded global mutexes — both fine while nothing calls this, both worth revisiting when the egress proxy wires in per-connection traffic. Per-account disambiguation by request target is deliberately left to that proxy.

What is NOT in this PR

  • proxy TLS termination
  • the obligation-staging chokepoint
  • host-side config seeding
  • the binding model/UI

🤖 Generated with Claude Code

henrypark133 and others added 4 commits July 26, 2026 18:01
The sandbox container must never see real secret material, even
transiently. It now holds only an inert `icsbx_`-prefixed placeholder
token, stable per (tenant, user, provider) and separate from the
session store, so holding one grants nothing on its own.

InMemoryCredentialBroker gains JIT minting (mint_on_first_use): a
CredentialSession is minted only at actual first use of an
(invocation, binding) pair, not staged up front, and bound to the
placeholder so the egress proxy can find it. CredentialSessionLease
guarantees the session is revoked exactly once regardless of how the
dispatch ends -- success/error via explicit revoke(), timeout/panic
via Drop during future-cancellation or unwind -- because a missed
revoke path is a silent standing grant.

W6 (egress proxy consumer) and W8 (obligation chokepoint) are not
built yet; this lands the placeholder/session primitives unwired, per
plan, for them to call into later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upcoming credential firewall injects inert icsbx_-prefixed
placeholders into the sandbox in place of real secrets; the egress
proxy swaps them for the real credential at request time. Placeholders
are stable and inert but must never cross the trust boundary into
model output, logs, or transcripts. Sandbox exec output already runs
through the leak detector, so only the pattern was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-1 code review found two correctness gaps in the JIT credential-
session skeleton: mint_on_first_use's cache-hit reuse handed out a second
lease over an already-live session with no reference counting, so the
first caller to revoke/drop its lease killed the session out from under a
second caller still holding it; and sessions_by_placeholder was a
single-valued map, so binding a second session to a stable placeholder
(a second account under the same provider, or an overlapping invocation)
silently clobbered the first binding instead of tracking both.

Fixes:
- Add a per-session lease refcount; a session is only actually revoked
  once its last outstanding lease releases it.
- Make sessions_by_placeholder multi-valued (HashSet per token) and prune
  it, plus jit_minted and lease_refcounts, from revoke_session so a
  long-lived process doesn't accumulate stale entries.
- revoke_session recovers from a poisoned lock instead of silently
  no-op'ing, matching the "a missed revoke can never leave a standing
  grant" invariant this module documents.
- Add TryFrom<String>/AsRef<str>/into_inner() to CredentialPlaceholderToken
  and rename CredentialPlaceholderOwner::provider_id to
  provider_or_extension_id, matching this crate's existing newtype/naming
  conventions.
- Pin the icsbx_ prefix shared between ironclaw_secrets and the
  ironclaw_safety leak-detector pattern with a dev-dependency-only
  regression test instead of a real crate dependency, so ironclaw_safety
  stays a dependency-light substrate (ruled via thermo-nuclear review).
- Tighten cross-reference comments (W6 -> W6-EGRESS-PROXY, name the
  concrete sibling leak pattern) per local-patterns review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rset

Round 2 review (performance/tests dimensions round 1 missed):

- mint_on_first_use's cache-hit path validated a jit_minted session and
  only incremented its lease refcount afterward, as separate lock
  acquisitions. A concurrent release_lease on the last outstanding lease
  could revoke the session in that window, handing back a lease for an
  already-dead session. Fixed by incrementing the refcount first (only
  succeeds while a live entry exists) and validating after, backing out
  the reference if invalid — serializes the join against release_lease
  on the same lease_refcounts lock instead of racing across mutexes.
- CredentialPlaceholderToken::validate only checked the prefix, unlike
  this crate's own validate_credential_id convention; a malformed suffix
  (e.g. carrying control characters) could reach a container's env/logs
  once this token round-trips through the not-yet-built egress proxy.
  Now enforces the same shape generate() produces.
- Dropped acquire_lease_refcount's vestigial Result (it can't fail) and
  deduplicated mint_on_first_use's bind+lease-construction tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 01:51
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@ironloopai

ironloopai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: 5d552254ceac9b91ef71f88ec844d7a5eb600313
Result: One or more review results were superseded by a newer PR head.
Next: Run @ironloopai review on the latest PR head.
Updated: 2026-07-27T03:37:13.637Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Superseded N/A N/A 2026-07-27T03:37:13.627Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Superseded by a newer PR head. New head: 5d55225.
Recent activity
Time Reviewer State Detail
2026-07-27T01:55:27.562Z ironloop/common-reviewer (reviewer) Result captured Changes requested; 2 blocking findings.
2026-07-27T01:55:27.562Z ironloop/common-reviewer (reviewer) Completed Review completed and terminal status was persisted.
2026-07-27T02:15:48.663Z ironloop/common-reviewer (reviewer) Superseded A newer PR head replaced this review (a36423c).
2026-07-27T03:33:56.222Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head 38947f2.
2026-07-27T03:33:56.222Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-27T03:33:56.271Z ironloop/common-reviewer (reviewer) Started Reviewer worker started.
2026-07-27T03:33:59.087Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (merge_ref) at 0c56705.
2026-07-27T03:37:13.627Z ironloop/common-reviewer (reviewer) Superseded A newer PR head replaced this review (5d55225).
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
Run metadata

Admission: webhook accepted the request and IronLoop persisted reviewer state before this projection.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 01:52 Destroyed
@github-actions github-actions Bot added scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules labels Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 46d7d6e2-7137-41fb-8e18-3363d56fa101

📥 Commits

Reviewing files that changed from the base of the PR and between 38947f2 and 5d55225.

📒 Files selected for processing (2)
  • crates/ironclaw_secrets/src/placeholder.rs
  • crates/ironclaw_secrets/src/placeholder/tests.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added stable credential placeholders that can be resolved to their associated account context.
    • Added just-in-time credential session creation and lookup through placeholders.
    • Added automatic session lease cleanup, including explicit revocation and expiration handling.
    • Added validation and redaction support for credential placeholders to prevent accidental exposure.
  • Bug Fixes

    • Credential session lifetimes now receive safe defaults and are capped at a maximum duration.
    • Improved session cleanup and recovery when internal state is interrupted.

Walkthrough

Adds validated icsbx_ credential placeholders, registry ownership, JIT session leases with bounded expiry and refcounted cleanup, and critical sandbox leak detection with cross-crate contract tests.

Changes

Credential placeholder flow

Layer / File(s) Summary
Placeholder contracts and registry
crates/ironclaw_secrets/src/placeholder.rs, crates/ironclaw_secrets/src/lib.rs, crates/ironclaw_secrets/src/placeholder/tests.rs
Adds validated placeholder tokens, stable tenant/user/provider ownership mappings, public exports, invalid-token classification, and registry stability and validation tests.
JIT session leasing and cleanup
crates/ironclaw_secrets/src/placeholder.rs, crates/ironclaw_secrets/src/placeholder/tests.rs, crates/ironclaw_secrets/src/lib.rs, crates/ironclaw_secrets/Cargo.toml
Unifies session state, bounds expiry, adds placeholder-bound minting and scoped lookup, tracks lease references, prunes indices on release, recovers poisoned locks, and tests lifecycle and concurrency behavior.
Sandbox placeholder leak protection
crates/ironclaw_safety/src/leak_detector.rs, crates/ironclaw_safety/Cargo.toml
Adds a critical blocking detector for icsbx_ placeholders, preserves surrounding context during redaction, and verifies alignment with the secrets placeholder contract.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Sandbox
  participant LeakDetector
  participant CredentialPlaceholderRegistry
  participant InMemoryCredentialBroker
  participant CredentialSessionLease
  Sandbox->>LeakDetector: scan output containing placeholder
  LeakDetector->>LeakDetector: block or redact matching token
  CredentialPlaceholderRegistry->>InMemoryCredentialBroker: provide validated placeholder
  InMemoryCredentialBroker->>CredentialSessionLease: mint or reuse leased session
  CredentialSessionLease->>InMemoryCredentialBroker: release on revoke or drop
Loading

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning Missing the required template sections such as Summary, Change Type, Linked Issue, Validation, Test Strategy, and Security Impact. Rewrite the description to follow the template and fill every section, especially Change Type, Linked Issue, Validation, Test Strategy, Security Impact, and Review track.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is Conventional Commits style and accurately summarizes the sandbox credential placeholder work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the contributor: core 20+ merged PRs label Jul 27, 2026

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ IronLoop Review: reviewer

Review at a glance

Verdict Blocking Notes Inline Head
❌ Changes requested 2 0 2 4bcc1ae28129

Head: 4bcc1ae28129738e96833d38f8d772bc6cd4c6f1
Next: Fix the blocking findings, push the PR branch, then re-run this reviewer.

Run details

Status: Current
Needs human: no
Needs validation: no

Summary

Reviewed the normal, focused 6-file/1,329-line change. Found two lifecycle/concurrency defects in the new JIT session path.

Findings

Blocking: 2 / Notes: 0

Blocking findings

1. ❌ [HIGH] Make JIT mint publication atomic

Location: crates/ironclaw_secrets/src/placeholder.rs:346-351
Concurrent first uses of the same (invocation, capability, account) can both miss jit_minted, create separate sessions, and overwrite the cache entry at record_jit_mint. The sessions then receive independent refcounts and leases, violating the documented single-session reuse guarantee; with max_uses: Some(1), this also permits multiple independently usable sessions for one binding. Protect cache lookup/create/publication/refcount acquisition with one synchronization boundary (or an in-flight entry) and add a concurrent regression test.

2. ❌ [HIGH] Do not rely on Drop as the only session-expiry mechanism

Location: crates/ironclaw_secrets/src/placeholder.rs:276-280
CredentialSessionLease can be safely passed to std::mem::forget, which suppresses Drop and leaves its broker session live indefinitely when the request supplies expires_at: None and max_uses: None. This contradicts the stated guarantee that missed revoke paths cannot leave a standing grant. Enforce a broker-side expiry/lease deadline (and cleanup) independent of the RAII guard, and test a deliberately forgotten lease.

Developer follow-up

After fixing this feedback:

  1. Push the fix to this PR branch.
  2. Re-run this reviewer with @ironloopai review --agent reviewer if you only changed this reviewer's findings.
  3. Re-run all reviewers with @ironloopai review when the fix may affect multiple areas.

Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
/// A missed revoke path would leave a standing grant, so this type has no
/// safe way to leak the session past its own lifetime: there is no `mem::forget`-safe
/// accessor, and the only way to keep the session alive is to hold the lease.
pub struct CredentialSessionLease {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RAII alone cannot guarantee revocation: std::mem::forget(lease) is safe and prevents Drop. Since requests may set both expiry and max uses to None, that leaves a standing session indefinitely. Add broker-side expiry/cleanup independent of the guard.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partially addressed, not closed. 10f65fa softened the doc overclaim (dropping/revoking the lease releases the reference; deliberately forgetting it does not), and 7b39800 caps session expiry at 30 minutes by default at creation, so a forgotten lease can no longer be an indefinite standing grant — it now expires. Proactive eviction of expired entries independent of the guard is deliberately deferred: ironclaw_secrets owns no runtime to hang a sweep task off, and nothing calls this path yet (the consumer, the egress proxy, isn't built). It lands with that wiring. Leaving this open rather than marking it resolved, since the broker-side cleanup you asked for isn't there yet.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bcc1ae281

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/ironclaw_safety/src/leak_detector.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_safety/src/leak_detector.rs`:
- Around line 1259-1292: Update
sandbox_credential_placeholder_prefix_matches_registry to also validate the
detector’s minimum suffix length against
ironclaw_secrets::placeholder::CredentialPlaceholderToken::MIN_SUFFIX_LEN.
Expose MIN_SUFFIX_LEN as a public constant if necessary, then construct and scan
a token using the minimum valid suffix length and assert it matches the
sandbox_credential_placeholder pattern, preserving the existing prefix
synchronization check.
- Around line 626-631: Update the sandbox_credential_placeholder regex in the
LeakPattern definition so matching is not blocked by underscores adjacent to the
credential prefix or suffix; use non-identifier left context or a substring
match while preserving the existing 16+ alphanumeric credential shape. Extend
the boundary test near the existing case to cover both a leading underscore and
a trailing underscore, ensuring both are blocked.

In `@crates/ironclaw_secrets/src/lib.rs`:
- Around line 473-474: The InvalidPlaceholderToken error must not expose the
untrusted value through its Display message. Update InvalidPlaceholderToken and
its construction or formatting so the user-facing error reports only the
actionable reason, or uses an established redacting wrapper such as RedactedJson
or CredentialSessionId when triage requires the value; preserve the existing
validation behavior.

In `@crates/ironclaw_secrets/src/placeholder.rs`:
- Around line 272-275: Update the documentation around the lease type to avoid
claiming that session lifetime cannot be extended via mem::forget. Describe the
intended behavior instead: dropping or explicitly revoking the lease releases
its reference, while deliberately forgetting the lease skips cleanup and can
keep the session alive indefinitely.
- Around line 205-231: Replace the separate by_owner and by_token mutexes in
CredentialPlaceholderRegistry with one Mutex<RegistryState> containing both
maps. Update get_or_create and resolve to lock the shared state and perform
lookup/insertion through its by_owner and by_token fields, keeping both mappings
updated before get_or_create returns.
- Around line 92-104: Update the suffix length validation in the placeholder
token validator to use character counting (`chars().count()`) instead of byte
length, matching the error message’s “characters” wording. Keep the existing
ASCII alphanumeric validation and InvalidPlaceholderToken error behavior
unchanged.
- Around line 344-352: Register the initial lease refcount before publishing the
session in the new-session path around create_session, record_jit_mint, and
finish_lease. Move acquire_lease_refcount(session_id) ahead of record_jit_mint,
then update the safety comment to reflect that the id is unpublished until its
refcount exists; preserve the existing reuse-path behavior.
- Around line 357-368: Update finish_lease to construct the
CredentialSessionLease before binding so a bind_placeholder_to_session error
drops the lease and releases the caller-held session reference. Modify
bind_placeholder_to_session to acquire sessions_by_placeholder through the
module’s lock_or_recover helper, preserving cleanup on poisoned-lock failures
and the existing successful binding behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c0f92a05-1168-45e7-935e-278c74153eb7

📥 Commits

Reviewing files that changed from the base of the PR and between f0d01de and 4bcc1ae.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (5)
  • crates/ironclaw_safety/Cargo.toml
  • crates/ironclaw_safety/src/leak_detector.rs
  • crates/ironclaw_secrets/Cargo.toml
  • crates/ironclaw_secrets/src/lib.rs
  • crates/ironclaw_secrets/src/placeholder.rs

Comment thread crates/ironclaw_safety/src/leak_detector.rs
Comment thread crates/ironclaw_safety/src/leak_detector.rs
Comment thread crates/ironclaw_secrets/src/lib.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
@railway-app

railway-app Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-6689 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Jul 27, 2026 at 3:46 am

henrypark133 and others added 2 commits July 26, 2026 19:15
InvalidPlaceholderToken interpolated the raw, untrusted `value` verbatim
into its Display impl, so a rejected sandbox-supplied token — which can
carry control characters, ANSI escapes, or a real secret pasted into the
wrong slot — propagated into every log line derived from the error.
`reason` already carries all actionable information, so drop `value`
entirely rather than sanitizing it (per the repo's boundary-mapping
guideline for credential-adjacent errors, which wins over matching the
sibling `InvalidAccountId` convention here since this value is
attacker-controlled).

Also cap the parsed token length: validation only enforced a minimum
suffix length, so an arbitrarily long `icsbx_...` string handed back
from the sandbox was accepted and then hashed/cloned/inserted into maps
- unbounded work driven by untrusted input. Registry-issued tokens are
always exactly 32 alphanumeric characters (a UUIDv4 `simple()` suffix),
and nothing needs a variable-length suffix, so validation now requires
that exact length instead of "at least 16". The length constant is
exposed publicly (CREDENTIAL_PLACEHOLDER_SUFFIX_LEN) so the leak
detector's cross-crate pinning test can assert against it instead of a
bare literal (see the companion ironclaw_safety commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sandbox_credential_placeholder regex was \bicsbx_[A-Za-z0-9]{16,}\b.
`_` is a word character, so \b does not fire next to it: a single
leading or trailing byte (`_icsbx_...`, `icsbx_..._x`) defeated the one
pattern standing between a leaked placeholder and model output/logs.
Drop the word boundaries entirely — `icsbx_` plus 16+ alphanumerics is
a distinctive shape with no realistic false-positive risk, and
over-matching here fails safe. Extends the existing test rather than
adding a parallel file: added leading-underscore, trailing-underscore,
and leading-letter cases, and flipped the prior "substring of a longer
word is NOT flagged" test to assert it IS flagged now, since that
flip is the deliberate fail-safe tradeoff.

Also pins the length half of the sandbox_credential_placeholder /
ironclaw_secrets contract, not just the prefix: the cross-crate test
now asserts CREDENTIAL_PLACEHOLDER_SUFFIX_LEN stays at or above the
regex's own 16-char floor, and additionally constructs a minimum-shaped
token through the registry's public parse() API and asserts the
detector actually flags it - pinning behavior instead of a literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 02:15
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 02:15 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_secrets/src/placeholder.rs (1)

1-1195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split placeholder.rs to keep the new Rust file under the size ceiling.

crates/ironclaw_secrets/src/placeholder.rs is newly added at 1,195 lines, with the tests spanning lines 607–1195. This violates the repo invariant keeping new Rust files below ~800 lines, and the 589-line test addition also exceeds the ~200-line inline-justification threshold. Move the #[cfg(test)] mod tests into a sibling module, such as crates/ironclaw_secrets/src/placeholder/tests.rs with #[path], or an explicit nested mod tests { include!("placeholder/tests.rs"); }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_secrets/src/placeholder.rs` around lines 1 - 1195, Split the
inline #[cfg(test)] mod tests from placeholder.rs into a sibling test module
such as placeholder/tests.rs, wiring it from placeholder.rs with #[path] or
include!. Preserve all existing tests and their access to private placeholder
symbols, while reducing placeholder.rs below the repository’s size ceiling and
keeping production code unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/ironclaw_secrets/src/placeholder.rs`:
- Around line 1-1195: Split the inline #[cfg(test)] mod tests from
placeholder.rs into a sibling test module such as placeholder/tests.rs, wiring
it from placeholder.rs with #[path] or include!. Preserve all existing tests and
their access to private placeholder symbols, while reducing placeholder.rs below
the repository’s size ceiling and keeping production code unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6687fd64-cf87-497b-bbd9-88eb34b9579a

📥 Commits

Reviewing files that changed from the base of the PR and between 4bcc1ae and a36423c.

📒 Files selected for processing (3)
  • crates/ironclaw_safety/src/leak_detector.rs
  • crates/ironclaw_secrets/src/lib.rs
  • crates/ironclaw_secrets/src/placeholder.rs

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Add an unwired sandbox credential placeholder registry with just-in-time leases, reliable revocation, and leak detection for inert tokens.

Stats: 4 net-new findings (from 7 raw reviewer findings, 6 after lane dedup) across 3 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0. Two performance findings were suppressed because the PR explicitly documents those O(n) deferrals; existing unresolved threads covering first-use serialization, RAII-forget cleanup, placeholder bounds/redaction, and binding cleanup were also suppressed as duplicates.

Bugs / lifecycle

  1. High Session leaks when JIT index publication fails (crates/ironclaw_secrets/src/placeholder.rs:363-365, confidence 85) — create_session inserts the session before fallible record_jit_mint; a poisoned JIT index can therefore leave an unowned session indefinitely when no expiry is configured. Anchor: crates/ironclaw_secrets/src/placeholder.rs:365.

Conventions

  1. Medium Avoid unwrap in production leak-pattern initialization (crates/ironclaw_safety/src/leak_detector.rs:637, confidence 100) — the new production regex initialization uses .unwrap(), contrary to the repository production rule. Anchor: AGENTS.md:103.

Tests

  1. Medium Missing lookup tests for expired and exhausted candidates (crates/ironclaw_secrets/src/placeholder.rs:458-471, confidence 90) — the lookup skips these states, but coverage does not prove a stale candidate cannot prevent a later valid binding from being found. Anchor: crates/ironclaw_secrets/src/placeholder.rs:463.

  2. Medium Placeholder redaction is not tested in model-visible details (crates/ironclaw_safety/src/leak_detector.rs:307-315, confidence 80) — redact_all_secrets lacks a placeholder-specific test confirming the token is removed while surrounding diagnostic context remains. Anchor: crates/ironclaw_safety/src/leak_detector.rs:307.

Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
// the two ever drift apart.
LeakPattern {
name: "sandbox_credential_placeholder".to_string(),
regex: Regex::new(r"icsbx_[A-Za-z0-9]{16,}").unwrap(), // safety: hardcoded literal

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Avoid unwrap in production leak-pattern initialization.

The new production pattern initialization calls Regex::new(...).unwrap(). This violates the repository rule forbidding .unwrap() and .expect() in production code, even though the regex is currently a hardcoded literal.

Fix: Propagate or otherwise handle the regex construction error without using unwrap().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this one. scripts/check_no_panics.py explicitly skips any added line containing // safety: (script line 309), and the script's own error output documents that as the intended suppression mechanism ('Suppress false positives with an inline...'). The new sandbox_credential_placeholder pattern uses that convention exactly like the 21 pre-existing hardcoded-literal patterns in the same default_patterns() function — none of those are LazyLock-wrapped either. Making this one line a LazyLock/fallible exception would be inconsistent with its neighbors for a literal that cannot fail to compile as a regex. Open to being convinced otherwise, but keeping it as-is for now.

Comment thread crates/ironclaw_secrets/src/placeholder.rs Outdated
Comment thread crates/ironclaw_safety/src/leak_detector.rs
henrypark133 and others added 2 commits July 26, 2026 19:30
mint_on_first_use looked up and recorded a JIT mint through jit_minted
in two separate lock/unlock cycles, so two threads racing on the
identical (invocation, capability, account) binding could both observe
"nothing minted yet" and each mint their own session — defeating the
one-session-per-binding guarantee and multiplying a max_uses: Some(1)
budget. Hold jit_minted once across the whole lookup-or-mint sequence
instead, and drop it before binding the result to a placeholder (that
part only needs "at most one session per binding", not "at most one
bind call").

This is the first nested-lock pattern in this module, so it documents
an explicit lock-ordering rule (jit_minted outermost) and splits
release_lease/revoke_session into jit_minted-touching and
jit_minted-free variants: try_join_live_lease's failure path runs
while mint_on_first_use already holds jit_minted, and its old cleanup
route (via revoke_session) would have re-locked jit_minted on the same
thread and deadlocked on the non-reentrant std::sync::Mutex.

Adds a Barrier-aligned 32-thread regression test asserting every
racing lease resolves to the same session id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CredentialSessionRequest.expires_at was a plain Option, and
create_session stored it unchanged, so None meant a genuinely
unbounded session. Combined with revocation that depends on Drop
running, a lease held or leaked forever was a standing grant for the
process lifetime.

Default a None expiry to CREDENTIAL_SESSION_DEFAULT_TTL_SECONDS out
and clamp anything longer than CREDENTIAL_SESSION_MAX_TTL_SECONDS
(30 minutes each, per the project's already-decided design: explicit
revoke stays primary, this is only the backstop). Enforcement is the
existing lazy check in validate_session; no new enforcement path.

Deliberately no background sweep task: proactive eviction would be
standing infrastructure for a path with zero live callers today, and
this crate owns no runtime to host it on — deferred to whichever
later PR gives this store an owning runtime (noted in the code).
create_session has no callers outside this crate's own tests today
(egress proxy not built yet), so the blast radius is contained here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 02:30
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 02:30 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 27, 2026 02:48
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 02:48 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

redact_all_secrets tested detection-adjacent secrets but never an
icsbx_ sandbox credential placeholder, and never confirmed that
redaction masks only the token value while surrounding context (path,
status code) survives. A redaction that nuked the whole string would
pass a detection-only check while destroying the diagnostic value of
sandbox output.

The unwrap() in default_patterns() flagged in review is unchanged: it
matches the same `// safety: hardcoded literal` convention as the 21
sibling regex patterns in this function, and the "No panics in
production code" CI job (scripts/check_no_panics.py, which explicitly
exempts any line containing `// safety:`) passed on this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 03:02
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 03:02 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_safety/src/leak_detector.rs`:
- Around line 868-884: Strengthen the assertions in the redaction regression
test around the existing redacted value by asserting the complete expected
output, rather than only checking that the full token is absent. Ensure the
assertion proves no token fragment or suffix remains while preserving the
workspace path, HTTP 401 status, and [REDACTED] marker.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c337e440-f919-4f32-87f7-5c019b24086b

📥 Commits

Reviewing files that changed from the base of the PR and between 10f65fa and a266ce3.

📒 Files selected for processing (1)
  • crates/ironclaw_safety/src/leak_detector.rs

Comment thread crates/ironclaw_safety/src/leak_detector.rs
henrypark133 and others added 2 commits July 26, 2026 20:21
sessions, jit_minted, sessions_by_placeholder, and lease_refcounts were
four separately-lockable HashMaps on InMemoryCredentialBroker, even
though jit_minted and sessions_by_placeholder are just secondary
indices over the same session records. That fragmentation forced
mint_on_first_use to document a lock-ordering rule (jit_minted always
outermost) and keep two "ignoring_jit_minted" twin methods purely to
dodge self-deadlock on the non-reentrant std::sync::Mutex when a
failure path needed to revoke while jit_minted was already held.

Collapse all four into one Mutex<SessionState>, and fold the
outstanding-lease count into CredentialSessionRecord as lease_count,
replacing the parallel HashMap<SessionId, usize>. mint_on_first_use now
holds session_state locked across the whole lookup-or-mint-and-publish
sequence as plain field access, so there is no second acquisition left
to deadlock against. The twins (release_lease_ignoring_jit_minted,
revoke_session_ignoring_jit_minted) and the lock-ordering doc comment
are deleted outright, not refactored: the hazard they existed to dodge
no longer exists.

accounts stays a separate Mutex — create_session (via the new
build_session split) already drops it before touching session state,
so nesting it inside an already-held session_state lock cannot
deadlock and cannot widen its own critical section.

lock_or_recover moves from placeholder.rs to lib.rs (crate root) since
it is now needed by inherent methods on InMemoryCredentialBroker
defined there, and a private item in a child module is not visible to
its parent.

All previously-pinned properties (race-free concurrent first use,
increment-then-validate ordering, revoke on all four lease exit paths,
reused-session survival, secondary-index pruning, no standing grant on
error, poisoned-lock recovery, cross-user isolation) still pass
unchanged; the poisoned-lock regression test is updated to poison the
single merged session_state mutex instead of the now-gone
sessions_by_placeholder field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
placeholder.rs's #[cfg(test)] mod was ~745 lines, pushing the file to
1436 lines and past the repo's 1000-line convention despite the
production body itself being small. Move it to a sibling
placeholder_tests.rs via #[path], zero behavior change: super::
references still resolve since the module tree is identical, only the
file that backs it moves.

Also merge lease_revokes_on_explicit_success_call and
lease_revokes_on_explicit_error_call into one
lease_revokes_on_explicit_call: both called the identical
CredentialSessionLease::revoke API and asserted the identical
postcondition, differing only in a narrative
`if dispatch_result.is_err()` wrapper that touches no broker code
path. The timeout (cancellation-drop) and panic (unwind-drop) tests
stay separate since those exercise genuinely different Rust
mechanisms.

Add the missing lookup-coverage case: a stale (expired or
use-exhausted) session bound to a placeholder under the same scope as
a later, still-valid one must not prevent the valid one from being
found by find_session_by_placeholder. The existing multi-session test
only covered scope-mismatch skipping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 03:26
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 03:26 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@henrypark133

Copy link
Copy Markdown
Collaborator Author

Review follow-up

Replied to and resolved the fixed inline threads (IronLoop, Codex, CodeRabbit, and the /code-review findings posted as a review). Grouped by theme:

Leak-pattern evasion — the sandbox_credential_placeholder regex's leading \b let a word-char prefix (_icsbx_..., xicsbx_...) slip past the leak detector. Fixed in a36423cab: dropped the boundary and match icsbx_[A-Za-z0-9]{16,} as a plain substring — the shape is distinctive enough that over-matching carries no false-positive risk. Added leading/trailing-underscore and leading-letter regression tests, plus a stronger sync test pinning the leak-detector's suffix-length assumption against the registry's own public CREDENTIAL_PLACEHOLDER_SUFFIX_LEN constant instead of a hardcoded literal.

Session lifecycle / concurrency — three related races and one lifecycle gap:

  • Concurrent first-use could mint two sessions for one binding, multiplying max_uses budgets (d24b0528b).
  • finish_lease could leak a standing grant if the fallible bind failed after the lease's reference was already taken (4ff93e99f, critical).
  • A safety comment claimed a publish-before-refcount ordering that was off by one line (closed by the structural fix below, not patched in place).
  • Session expiry now defaults and caps at 30 minutes at creation (7b398003c), so an unbounded lease can no longer be a truly indefinite standing grant.

Error hygiene — InvalidPlaceholderToken's Display echoed the raw rejected value verbatim; the untrusted, unbounded value could carry control characters or a misplaced real secret. Fixed in c5c58d076 by dropping the value field from the error entirely (stronger than the suggested redacting-wrapper approach), and the suffix-length check was tightened from "at least 16" to "exactly 32" characters — the actual generated shape — measured in chars() not bytes.

P0 simplification (6623ab7e0) — six rounds of correct-but-local fixes above had fragmented the broker into five mutexes plus a hand-documented lock-ordering rule and two near-duplicate revoke paths (*_ignoring_jit_minted twins). Collapsing session-lifecycle state — sessions, jit_minted, sessions_by_placeholder, and lease refcounts — behind one Mutex<SessionState> let the twins and the ordering rule be deleted outright: the production body went 1436 → 614 lines (tests moved to a sibling placeholder_tests.rs in 38947f288, zero behavior change). Same guarantees, less machinery. Also added: a redaction test driving the model-visible redact_all_secrets path directly, and a lookup test proving expired/exhausted candidates don't block finding a later valid session.

Deliberate deferrals, stated not buried (all fine while nothing calls this path — the consumer is the not-yet-built egress proxy):

  • Proactive expiry sweep independent of the RAII guard — ironclaw_secrets owns no runtime to hang a sweep task off; the 30-minute expiry cap bounds the blast radius in the meantime. Left the IronLoop RAII thread open rather than resolving it, since this part of that finding isn't done.
  • O(n) retain() scans over the session map.
  • Unsharded mutex contention under the single Mutex<SessionState>.

One rejected finding: the .unwrap() in the new leak-pattern's Regex::new(...) construction. scripts/check_no_panics.py explicitly skips any line containing // safety: (line 309) — its own error output documents that as the intended suppression mechanism — and the new pattern uses that convention exactly like the 21 pre-existing hardcoded-literal patterns in the same function. Kept it consistent with its neighbors rather than carving out a LazyLock exception for one line; said so on the thread and left it open for disagreement.

🤖 Generated with Claude Code

@henrypark133

Copy link
Copy Markdown
Collaborator Author

@ironloopai review

placeholder_tests.rs didn't match scripts/check_no_panics.py's
is_test_only_path() exemption (src/**/tests.rs or src/**/tests/*.rs),
so its test-fixture .unwrap() calls were scanned as production code,
failing "No panics in production code" CI with 13 violations.

Move it to placeholder/tests.rs, which matches the tests.rs filename
exemption, instead of loosening the shared repo-wide panic scanner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 03:37
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6689 July 27, 2026 03:37 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@henrypark133
henrypark133 enabled auto-merge July 27, 2026 03:54
@henrypark133
henrypark133 disabled auto-merge July 27, 2026 03:55
@github-actions

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 85.55% (307491 / 359422 lines)
  floor:    80.81% (tolerance 0.5pp -> effective floor 80.31%)
  denominator: 359422 lines now vs 377084 at floor capture (-17662 lines, -4.68%) — not a material change

⚠️ 2 Reborn crate(s) have 0 int-tier coverage (target: 0) — ironclaw_prompt_envelope, ironclaw_scripts

Reborn integration-tier coverage

Line coverage (Reborn crates): 85.55% — 307491 / 359422 lines

Per-crate breakdown (60 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_prompt_envelope 0% 0 / 88
ironclaw_scripts 0% 0 / 345
ironclaw_host_ingress 42.5% 17 / 40
ironclaw_event_projections 43.71% 684 / 1565
ironclaw_observability 61.54% 16 / 26
ironclaw_telegram_v2_adapter 62.35% 631 / 1012
ironclaw_authorization 62.98% 609 / 967
ironclaw_memory 70.15% 919 / 1310
ironclaw_trust 73.21% 664 / 907
ironclaw_filesystem 73.65% 4584 / 6224
ironclaw_wasm_limiter 74.6% 47 / 63
ironclaw_extractors 74.72% 538 / 720
ironclaw_capabilities 75.45% 2879 / 3816
ironclaw_mcp 76.2% 775 / 1017
ironclaw_projects 76.48% 400 / 523
ironclaw_reborn_cli 78.19% 10670 / 13647
ironclaw_telegram_extension 78.59% 962 / 1224
ironclaw_llm 78.95% 20989 / 26584
ironclaw_wasm 79.72% 735 / 922
ironclaw_process_sandbox 80.46% 671 / 834
ironclaw_memory_native 80.97% 3114 / 3846
ironclaw_auth 81.88% 6679 / 8157
ironclaw_first_party_extensions 82.38% 6682 / 8111
ironclaw_events 82.47% 1604 / 1945
ironclaw_host_api 82.65% 9120 / 11035
ironclaw_processes 83.3% 933 / 1120
ironclaw_reborn_identity 83.8% 450 / 537
ironclaw_operator 84.37% 5558 / 6588
ironclaw_secrets 84.56% 2798 / 3309
ironclaw_reborn_config 85.24% 2102 / 2466
ironclaw_skills 85.27% 4493 / 5269
ironclaw_extension_host 85.39% 18814 / 22032
ironclaw_reborn_composition 85.67% 24387 / 28465
ironclaw_run_state 85.77% 458 / 534
ironclaw_webui 85.87% 10914 / 12710
ironclaw_triggers 85.92% 2783 / 3239
ironclaw_network 85.97% 913 / 1062
ironclaw_reborn_event_store 86.51% 1251 / 1446
ironclaw_hooks 86.63% 9931 / 11464
ironclaw_extensions 86.98% 3669 / 4218
ironclaw_common 86.99% 1772 / 2037
ironclaw_approvals 87.18% 1543 / 1770
ironclaw_threads 87.2% 4851 / 5563
ironclaw_product 87.52% 19698 / 22507
ironclaw_reborn_traces 88.13% 11986 / 13600
ironclaw_turns 88.29% 14310 / 16208
ironclaw_slack_extension 88.47% 1934 / 2186
ironclaw_host_runtime 88.49% 18983 / 21451
ironclaw_reborn_openai_compat 89.32% 3780 / 4232
ironclaw_conversations 90.01% 3164 / 3515
ironclaw_resources 90.84% 4474 / 4925
ironclaw_runner 90.87% 17192 / 18920
ironclaw_event_streams 91.24% 1063 / 1165
ironclaw_loop_host 91.9% 16503 / 17958
ironclaw_attachments 93.06% 630 / 677
ironclaw_outbound 94.39% 3985 / 4222
ironclaw_agent_loop 94.88% 9837 / 10368
ironclaw_safety 95.28% 3858 / 4049
ironclaw_first_party_extension_ports 95.62% 3672 / 3840
ironclaw_runtime_policy 96.56% 813 / 842

This table itself is informational and never gates the PR on its own — not the percentage, not the per-crate holes, not the 0-coverage callout. A separate coverage ratchet (dry-run until enforce=true; see tests/integration/coverage-floor.toml) can fail the build on specific configured floors.

Exemptions (3 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_embeddings v1-only: consumed only by root ironclaw (src/app.rs, src/tools/builtin/memory.rs, src/workspace/mod.rs, src/config/{mod,embeddings}.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_gateway v1-only: consumed only by root ironclaw (src/channels/web/platform/static_files.rs, src/channels/web/handlers/frontend.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_tui v1-only: consumed only by root ironclaw (src/main.rs, src/channels/tui.rs); no crates/* dependents. Crate's own doc comment confirms it bridges INTO v1, not Reborn. Covered by "Tests (Legacy)". #5657

@henrypark133
henrypark133 merged commit a091876 into main Jul 27, 2026
64 checks passed
@henrypark133
henrypark133 deleted the sandbox/pr1-credential-placeholder-skeleton branch July 27, 2026 04:05
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Jul 27, 2026
henrypark133 added a commit that referenced this pull request Jul 28, 2026
…, W8)

Two unwired primitives for the sandbox credential firewall. Neither has a
production caller yet; W6 (proxy TLS termination + injection) is the named
consumer for both. Landing them separately keeps the eventual PR series
unstacked.

W5 - sandbox_process/ca.rs: in-memory root key + short-lived per-host leaf
certs via rcgen. The root private key never touches disk and is never
reachable from a container: it lives only in a private field, no method
returns it, Debug is hand-written to omit it, and root_certificate_pem()
returns only the public trust anchor (test-pinned to contain no PRIVATE KEY
block). Architecture review on the rcgen dependency edge: SHIP.

W8 - sandbox_process/credential_firewall.rs: obligation staging keyed
(tenant, user), because invocation identity is unrecoverable from the
proxy's source IP. The fail-closed matrix uses distinct types so a caller
cannot collapse grant-denial (Ok(NoGrant) - strip placeholder, forward
bare) into connection-denial (Err - deny outright). stage() returns an RAII
lease whose Drop revokes, mirroring ironclaw_secrets' CredentialSessionLease;
caller discipline alone was rejected because it shipped a Critical
standing-grant leak in that sibling mechanism (PR #6689). TTL is clamped to
MAX_GRANT_TTL as the numeric backstop, with the lease as the primary bound.
Expired entries are reclaimed on read rather than only filtered.

Regression tests: 11 CA tests (chain-of-trust, per-host SAN isolation, TTL
expiry, cache eviction, no key material in the container-facing artifact)
and 14 firewall tests (cross-tenant and cross-user isolation, both denial
categories, deadline and TTL expiry, lease-drop and drop-during-unwind
revocation, TTL clamp, reclamation observed via map size). Ratchet baselines
added for both new files at the counts the ratchet itself reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
…rai#6689)

* feat(secrets): stable placeholder registry + JIT sessions

The sandbox container must never see real secret material, even
transiently. It now holds only an inert `icsbx_`-prefixed placeholder
token, stable per (tenant, user, provider) and separate from the
session store, so holding one grants nothing on its own.

InMemoryCredentialBroker gains JIT minting (mint_on_first_use): a
CredentialSession is minted only at actual first use of an
(invocation, binding) pair, not staged up front, and bound to the
placeholder so the egress proxy can find it. CredentialSessionLease
guarantees the session is revoked exactly once regardless of how the
dispatch ends -- success/error via explicit revoke(), timeout/panic
via Drop during future-cancellation or unwind -- because a missed
revoke path is a silent standing grant.

W6 (egress proxy consumer) and W8 (obligation chokepoint) are not
built yet; this lands the placeholder/session primitives unwired, per
plan, for them to call into later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(safety): detect sandbox credential placeholders

The upcoming credential firewall injects inert icsbx_-prefixed
placeholders into the sandbox in place of real secrets; the egress
proxy swaps them for the real credential at request time. Placeholders
are stable and inert but must never cross the trust boundary into
model output, logs, or transcripts. Sandbox exec output already runs
through the leak detector, so only the pattern was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): refcount JIT leases and multi-bind placeholder sessions

Round-1 code review found two correctness gaps in the JIT credential-
session skeleton: mint_on_first_use's cache-hit reuse handed out a second
lease over an already-live session with no reference counting, so the
first caller to revoke/drop its lease killed the session out from under a
second caller still holding it; and sessions_by_placeholder was a
single-valued map, so binding a second session to a stable placeholder
(a second account under the same provider, or an overlapping invocation)
silently clobbered the first binding instead of tracking both.

Fixes:
- Add a per-session lease refcount; a session is only actually revoked
  once its last outstanding lease releases it.
- Make sessions_by_placeholder multi-valued (HashSet per token) and prune
  it, plus jit_minted and lease_refcounts, from revoke_session so a
  long-lived process doesn't accumulate stale entries.
- revoke_session recovers from a poisoned lock instead of silently
  no-op'ing, matching the "a missed revoke can never leave a standing
  grant" invariant this module documents.
- Add TryFrom<String>/AsRef<str>/into_inner() to CredentialPlaceholderToken
  and rename CredentialPlaceholderOwner::provider_id to
  provider_or_extension_id, matching this crate's existing newtype/naming
  conventions.
- Pin the icsbx_ prefix shared between ironclaw_secrets and the
  ironclaw_safety leak-detector pattern with a dev-dependency-only
  regression test instead of a real crate dependency, so ironclaw_safety
  stays a dependency-light substrate (ruled via thermo-nuclear review).
- Tighten cross-reference comments (W6 -> W6-EGRESS-PROXY, name the
  concrete sibling leak pattern) per local-patterns review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): close JIT lease reuse race and validate placeholder charset

Round 2 review (performance/tests dimensions round 1 missed):

- mint_on_first_use's cache-hit path validated a jit_minted session and
  only incremented its lease refcount afterward, as separate lock
  acquisitions. A concurrent release_lease on the last outstanding lease
  could revoke the session in that window, handing back a lease for an
  already-dead session. Fixed by incrementing the refcount first (only
  succeeds while a live entry exists) and validating after, backing out
  the reference if invalid — serializes the join against release_lease
  on the same lease_refcounts lock instead of racing across mutexes.
- CredentialPlaceholderToken::validate only checked the prefix, unlike
  this crate's own validate_credential_id convention; a malformed suffix
  (e.g. carrying control characters) could reach a container's env/logs
  once this token round-trips through the not-yet-built egress proxy.
  Now enforces the same shape generate() produces.
- Dropped acquire_lease_refcount's vestigial Result (it can't fail) and
  deduplicated mint_on_first_use's bind+lease-construction tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): stop echoing raw placeholder tokens and cap parsed length

InvalidPlaceholderToken interpolated the raw, untrusted `value` verbatim
into its Display impl, so a rejected sandbox-supplied token — which can
carry control characters, ANSI escapes, or a real secret pasted into the
wrong slot — propagated into every log line derived from the error.
`reason` already carries all actionable information, so drop `value`
entirely rather than sanitizing it (per the repo's boundary-mapping
guideline for credential-adjacent errors, which wins over matching the
sibling `InvalidAccountId` convention here since this value is
attacker-controlled).

Also cap the parsed token length: validation only enforced a minimum
suffix length, so an arbitrarily long `icsbx_...` string handed back
from the sandbox was accepted and then hashed/cloned/inserted into maps
- unbounded work driven by untrusted input. Registry-issued tokens are
always exactly 32 alphanumeric characters (a UUIDv4 `simple()` suffix),
and nothing needs a variable-length suffix, so validation now requires
that exact length instead of "at least 16". The length constant is
exposed publicly (CREDENTIAL_PLACEHOLDER_SUFFIX_LEN) so the leak
detector's cross-crate pinning test can assert against it instead of a
bare literal (see the companion ironclaw_safety commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(safety): close word-boundary bypass in placeholder leak pattern

The sandbox_credential_placeholder regex was \bicsbx_[A-Za-z0-9]{16,}\b.
`_` is a word character, so \b does not fire next to it: a single
leading or trailing byte (`_icsbx_...`, `icsbx_..._x`) defeated the one
pattern standing between a leaked placeholder and model output/logs.
Drop the word boundaries entirely — `icsbx_` plus 16+ alphanumerics is
a distinctive shape with no realistic false-positive risk, and
over-matching here fails safe. Extends the existing test rather than
adding a parallel file: added leading-underscore, trailing-underscore,
and leading-letter cases, and flipped the prior "substring of a longer
word is NOT flagged" test to assert it IS flagged now, since that
flip is the deliberate fail-safe tradeoff.

Also pins the length half of the sandbox_credential_placeholder /
ironclaw_secrets contract, not just the prefix: the cross-crate test
now asserts CREDENTIAL_PLACEHOLDER_SUFFIX_LEN stays at or above the
regex's own 16-char floor, and additionally constructs a minimum-shaped
token through the registry's public parse() API and asserts the
detector actually flags it - pinning behavior instead of a literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): close concurrent-first-use race in JIT session minting

mint_on_first_use looked up and recorded a JIT mint through jit_minted
in two separate lock/unlock cycles, so two threads racing on the
identical (invocation, capability, account) binding could both observe
"nothing minted yet" and each mint their own session — defeating the
one-session-per-binding guarantee and multiplying a max_uses: Some(1)
budget. Hold jit_minted once across the whole lookup-or-mint sequence
instead, and drop it before binding the result to a placeholder (that
part only needs "at most one session per binding", not "at most one
bind call").

This is the first nested-lock pattern in this module, so it documents
an explicit lock-ordering rule (jit_minted outermost) and splits
release_lease/revoke_session into jit_minted-touching and
jit_minted-free variants: try_join_live_lease's failure path runs
while mint_on_first_use already holds jit_minted, and its old cleanup
route (via revoke_session) would have re-locked jit_minted on the same
thread and deadlocked on the non-reentrant std::sync::Mutex.

Adds a Barrier-aligned 32-thread regression test asserting every
racing lease resolves to the same session id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): cap credential session expiry at creation

CredentialSessionRequest.expires_at was a plain Option, and
create_session stored it unchanged, so None meant a genuinely
unbounded session. Combined with revocation that depends on Drop
running, a lease held or leaked forever was a standing grant for the
process lifetime.

Default a None expiry to CREDENTIAL_SESSION_DEFAULT_TTL_SECONDS out
and clamp anything longer than CREDENTIAL_SESSION_MAX_TTL_SECONDS
(30 minutes each, per the project's already-decided design: explicit
revoke stays primary, this is only the backstop). Enforcement is the
existing lazy check in validate_session; no new enforcement path.

Deliberately no background sweep task: proactive eviction would be
standing infrastructure for a path with zero live callers today, and
this crate owns no runtime to host it on — deferred to whichever
later PR gives this store an owning runtime (noted in the code).
create_session has no callers outside this crate's own tests today
(egress proxy not built yet), so the blast radius is contained here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): close standing-grant leak when placeholder bind fails

finish_lease constructed the CredentialSessionLease only after the
fallible bind_placeholder_to_session call, so a bind failure propagated
`?` with the session already refcounted by the caller (mint or reuse
path) but no lease ever built to drop and release that reference — an
unrevocable standing grant surviving to expiry. Construct the lease
first so any early return still drops it through the normal path, and
switch bind_placeholder_to_session (and the sibling read,
placeholder_session_ids) to lock_or_recover so a poisoned
sessions_by_placeholder mutex is recovered instead of failing closed
with a dangling reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): collapse registry to one lock, tighten docs and length check

CredentialPlaceholderRegistry::get_or_create wrote by_owner and
by_token under two separate mutexes, so a concurrent get_or_create for
the same triple could take the by_owner early-return before by_token
caught up, and resolve() would answer None for a token already handed
out. Collapse both maps into one Mutex<RegistryState> so every token
resolves the instant get_or_create returns.

Also: soften CredentialSessionLease's doc comment, which overclaimed
"no safe way to leak the session" — std::mem::forget is safe, skips
Drop, and strands the refcount; note the 30-minute expiry cap as the
actual backstop. Tighten a stale comment in mint_on_first_use (the
jit_minted-held-across-the-sequence fix already closed the double-mint
window; the comment now says so precisely instead of leaving room to
misread it as still open). And measure the placeholder suffix length in
chars, matching the error message's "characters" wording, instead of
bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(safety): pin placeholder redaction preserves diagnostic context

redact_all_secrets tested detection-adjacent secrets but never an
icsbx_ sandbox credential placeholder, and never confirmed that
redaction masks only the token value while surrounding context (path,
status code) survives. A redaction that nuked the whole string would
pass a detection-only check while destroying the diagnostic value of
sandbox output.

The unwrap() in default_patterns() flagged in review is unchanged: it
matches the same `// safety: hardcoded literal` convention as the 21
sibling regex patterns in this function, and the "No panics in
production code" CI job (scripts/check_no_panics.py, which explicitly
exempts any line containing `// safety:`) passed on this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): collapse session-lifecycle mutexes into one lock

sessions, jit_minted, sessions_by_placeholder, and lease_refcounts were
four separately-lockable HashMaps on InMemoryCredentialBroker, even
though jit_minted and sessions_by_placeholder are just secondary
indices over the same session records. That fragmentation forced
mint_on_first_use to document a lock-ordering rule (jit_minted always
outermost) and keep two "ignoring_jit_minted" twin methods purely to
dodge self-deadlock on the non-reentrant std::sync::Mutex when a
failure path needed to revoke while jit_minted was already held.

Collapse all four into one Mutex<SessionState>, and fold the
outstanding-lease count into CredentialSessionRecord as lease_count,
replacing the parallel HashMap<SessionId, usize>. mint_on_first_use now
holds session_state locked across the whole lookup-or-mint-and-publish
sequence as plain field access, so there is no second acquisition left
to deadlock against. The twins (release_lease_ignoring_jit_minted,
revoke_session_ignoring_jit_minted) and the lock-ordering doc comment
are deleted outright, not refactored: the hazard they existed to dodge
no longer exists.

accounts stays a separate Mutex — create_session (via the new
build_session split) already drops it before touching session state,
so nesting it inside an already-held session_state lock cannot
deadlock and cannot widen its own critical section.

lock_or_recover moves from placeholder.rs to lib.rs (crate root) since
it is now needed by inherent methods on InMemoryCredentialBroker
defined there, and a private item in a child module is not visible to
its parent.

All previously-pinned properties (race-free concurrent first use,
increment-then-validate ordering, revoke on all four lease exit paths,
reused-session survival, secondary-index pruning, no standing grant on
error, poisoned-lock recovery, cross-user isolation) still pass
unchanged; the poisoned-lock regression test is updated to poison the
single merged session_state mutex instead of the now-gone
sessions_by_placeholder field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(secrets): split placeholder tests out, merge redundant case

placeholder.rs's #[cfg(test)] mod was ~745 lines, pushing the file to
1436 lines and past the repo's 1000-line convention despite the
production body itself being small. Move it to a sibling
placeholder_tests.rs via #[path], zero behavior change: super::
references still resolve since the module tree is identical, only the
file that backs it moves.

Also merge lease_revokes_on_explicit_success_call and
lease_revokes_on_explicit_error_call into one
lease_revokes_on_explicit_call: both called the identical
CredentialSessionLease::revoke API and asserted the identical
postcondition, differing only in a narrative
`if dispatch_result.is_err()` wrapper that touches no broker code
path. The timeout (cancellation-drop) and panic (unwind-drop) tests
stay separate since those exercise genuinely different Rust
mechanisms.

Add the missing lookup-coverage case: a stale (expired or
use-exhausted) session bound to a placeholder under the same scope as
a later, still-valid one must not prevent the valid one from being
found by find_session_by_placeholder. The existing multi-session test
only covered scope-mismatch skipping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): move placeholder tests to conform to test-path convention

placeholder_tests.rs didn't match scripts/check_no_panics.py's
is_test_only_path() exemption (src/**/tests.rs or src/**/tests/*.rs),
so its test-fixture .unwrap() calls were scanned as production code,
failing "No panics in production code" CI with 13 violations.

Move it to placeholder/tests.rs, which matches the tests.rs filename
exemption, instead of loosening the shared repo-wide panic scanner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-6689 — 5d552254 Deployed Jul 27, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: dependencies Dependency updates size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants