Skip to content

refactor(reborn): delete host_runtime pre-auth + enforce policy on resume in the kernel (§5.3.2/§9) - #6392

Merged
ilblackdragon merged 1 commit into
feat/authorize-inline-policyfrom
feat/authorize-followups
Jul 21, 2026
Merged

ilblackdragon merged 1 commit into
feat/authorize-inline-policyfrom
feat/authorize-followups

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Stacked on #6386 (feat/authorize-inline-policy). Review/merge that first; this branch targets it, not main, and will rebase onto main once #6386 lands.

What & why

#6386 moved the five policy decisions into the kernel's authorize() fold, but left host_runtime's open_pre_authorization running redundantly in front of the kernel (documented there as a transitional wart). This PR removes it — and in doing so uncovered that the redundancy was load-bearing on the resume paths: the kernel did not reproduce runtime-policy enforcement on resume/auth-resume. So this is not a pure deletion; it completes the kernel's authority over the resume paths and then deletes the now-truly-redundant host_runtime copy.

Net −218 LOC (production.rs −638; kernel +232 for the resume preflight).

Changes

  1. Kernel now enforces policy on resume. New resume_preflight in ironclaw_capabilities resolves the descriptor and runs plan_capability (runtime-policy) on the resume/auth-resume/resume-spawn folds — reversing refactor(reborn): consolidate all pre-flight policy into authorize() (§5.3.2/§9 security milestone) #6386's "no planning on resume" decision, which was only safe because host_runtime was still doing it. This is strictly more correct: resume now fail-closes if runtime policy tightened between the original invoke and the resume. Existence-first ordering, scope-isolated failure of the matching blocked run (BlockedResumeKind), and the internal run-state error_kind (e.g. process_backend_none) preserved.
  2. host_runtime pre-authorization deleted — open_pre_authorization, evaluate_invocation_trust, enforce_runtime_policy, PreAuthorizationRejected, the trust/runtime-policy failure helpers, and the resume fail-matching helpers. Now genuinely redundant on every path. Kept: trust_policy/runtime_policy fields (wired to the kernel), plan_capability for surface-visibility, the context.validate() forged-scope guard, and the credential/gate helpers used by translate_invocation_error + the HostPolicyFacts impl.
  3. Descriptor lookup reordered ahead of run_state.start in authorize()/authorize_spawn — an unknown capability now short-circuits before a run record is created, restoring the "no record for unknown capability" behavior the deleted host_runtime check used to provide.

Behavior (production parity, verified)

Everything is behavior-neutral versus pre-#6386 production. The redundant host_runtime layer had been masking four behaviors that only became observable once it was removed — each was resolved to match production, none by weakening a test:

  • Message sanitization — invoke-path runtime-policy denials now surface the sanitized DenyReason::PolicyDenied message instead of a leaked planner enum token (NetworkMode::Deny). This is refactor(reborn): consolidate all pre-flight policy into authorize() (§5.3.2/§9 security milestone) #6386's documented sanitization, now observable on the invoke path; the internal audit error_kind still records the planner-specific reason.
  • Runtime-policy on resume — now enforced by the kernel (was previously only host_runtime; see change 1).
  • Unknown-capability run-record ordering — no record created for an unknown capability (change 3).
  • Mismatch vs. unknown precedence on resume — existence-first matches old production (host_runtime resolved the registry before the kernel's mismatch check); the three mismatch tests now trigger the mismatch with a known-but-different capability, isolating "mismatch" (ResumeContextMismatch) from "unknown" (UnknownCapability), and a new test pins the unknown-on-resume path.

context.trust: verified its only reader is the trust-aware authorizer, which always receives the kernel-stamped authorize_context clone — so removing host_runtime's stamp is inert.

Testing

Full unfiltered suites green: ironclaw_host_runtime 982/0, ironclaw_capabilities 136/0; ironclaw_reborn_composition builds; ironclaw_architecture, workspace clippy -D warnings, and cargo fmt clean. The three resume runtime-policy/trust contract tests pass unweakened; every modified test preserves its original intent (only triggers/sanitized-message assertions changed).

🤖 Generated with Claude Code

… host_runtime pre-authorization (§5.3.2/§9, R-A)

Extends the capability kernel to be the single authority site for resume as it
already is for invoke, then deletes host_runtime's now-truly-redundant
pre-authorization. Concretely:

(a) Kernel resume authority. authorize_resumed and the resume_spawn_json fold now
    run runtime-policy planning on resume via a new resume_preflight that resolves
    the descriptor and enforces plan_capability BEFORE the run-state lookup —
    reversing #6386's "planning is NOT re-run on resume" so a policy tightened
    between invoke and resume fails closed. Descriptor/trust existence is checked
    first, so an unknown resume capability short-circuits to UnknownCapability
    (-> MissingRuntime) instead of the run-state-not-found Backend path; failure
    fails ONLY the matching blocked run (scope isolation), keyed by
    scope+invocation+status+capability[+approval]. This restores host_runtime's
    old production precedence (its deleted open_pre_authorization resolved the
    registry before the kernel's mismatch check ran). The run-state fail records
    the planner-specific INTERNAL error_kind (planner_error_kind, e.g.
    "process_backend_none") while the model-visible message stays the sanitized
    DenyReason::PolicyDenied — the planner enum name never reaches the model. The
    kernel still stamps context.trust on the resume authorize_context clone before
    the authorizer.

(b) host_runtime deletion. open_pre_authorization + its invoke/spawn/resume/
    auth-resume/resume-spawn callers, the fail_matching_blocked_{,auth_}resume
    helpers, and the now-dead evaluate_invocation_trust / enforce_runtime_policy /
    TrustEvaluationError / RuntimePolicyEvaluationError / failure + trust-input
    helpers are removed; the local-manifest trust-input unit test moves to
    ironclaw_capabilities::trust. Kept: trust_policy/runtime_policy fields (wired to
    the kernel), plan_capability for surface.rs visibility, the context.validate()
    forged-scope guard, the credential/gate helpers, and the HostPolicyFacts impl.

(c) Descriptor/start reorder. authorize() and authorize_spawn() resolve the
    descriptor BEFORE run_state.start, so an unknown capability creates no run
    record (restoring the no-record-for-unknown behavior the deleted pre-check gave).

(d) Message sanitization tests. Invoke-path runtime-policy-denial tests
    (builtin_http + tool_surface extension/secret/mcp) now assert the sanitized
    DenyReason message ("denied", not the leaked NetworkMode::/SecretMode:: planner
    enum tokens) — the #6386 sanitization, now observable once the redundant
    host_runtime pre-check is gone.

(e) Mismatch tests corrected. The three capability-id-mismatch resume tests now
    trigger the mismatch with a known-but-different, plannable capability
    (echo.other) so the run-state mismatch check fires (ResumeContextMismatch),
    isolating "mismatch" from "unknown-capability"; a new kernel test pins
    "unknown capability on resume -> UnknownCapability + fail matching run".

Verified context.trust has no reader except the kernel-stamped authorize_context
(ironclaw_authorization::authorize_from_grants_with_trust).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@ironloopai

ironloopai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: e7dbca8bb2a303c928e16e688c750d2cd03d44ca
Result: 1/1 reviewers completed without blocking findings.
Next: Ready for normal human review and CI checks.
Updated: 2026-07-21T03:50:31.454Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Completed Approved 0 blocking findings / 0 notes 2026-07-21T03:50:31.446Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Approved; 0 blocking findings; Approved stack-layer review of 8 files (438 additions, 656 deletions). The kernel now performs resume policy preflight and the removed host-runtime preauthorization paths are cons…
Recent activity
Time Reviewer State Detail
2026-07-21T03:47:04.850Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head e7dbca8.
2026-07-21T03:47:04.850Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-21T03:47:05.441Z ironloop/common-reviewer (reviewer) Started Reviewer worker started.
2026-07-21T03:47:08.099Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (merge_ref) at 7a568b1.
2026-07-21T03:50:31.446Z ironloop/common-reviewer (reviewer) Result captured Approved; 0 blocking findings.
2026-07-21T03:50:31.446Z ironloop/common-reviewer (reviewer) Completed Review completed and terminal status was persisted.
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.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • staging
  • reborn-integration

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 79f93206-2519-406e-a58b-baf541b0b0c1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-6392 July 21, 2026 03:47 Destroyed
@github-actions github-actions Bot added size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jul 21, 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
✅ Approved 0 0 0 e7dbca8bb2a3

Head: e7dbca8bb2a303c928e16e688c750d2cd03d44ca
Next: No reviewer action needed.

Run details

Status: Current
Needs human: no
Needs validation: no

Summary

Approved stack-layer review of 8 files (438 additions, 656 deletions). The kernel now performs resume policy preflight and the removed host-runtime preauthorization paths are consistently delegated.

Findings

None.

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.

@railway-app

railway-app Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

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

Service Status Web Updated (UTC)
ironclaw ❌ Build Failed (View Logs) Web Jul 21, 2026 at 4:06 am

@ilblackdragon
ilblackdragon merged commit 9cc6fde into feat/authorize-inline-policy Jul 21, 2026
20 of 21 checks passed
@ilblackdragon
ilblackdragon deleted the feat/authorize-followups branch July 21, 2026 05:31
ilblackdragon added a commit that referenced this pull request Jul 21, 2026
…r' review discipline (#6399)

Adds a section to review-discipline.md drawn from the authorize() policy
consolidation (PRs #6386/#6392): deleting a layer believed redundant exposes
behaviors it was silently backstopping. Codifies the discipline that surfaced
five masked behaviors there — run the full unfiltered suite (--no-fail-fast, no
head/tail), treat each surfaced failure as a real behavior to preserve (never
weaken the assertion to go green), the load-bearing observable is the failure
kind/durable state not the message, 'redundant' is per-path, and sliced
subagents stop-and-report rather than commit green.

Documentation-only.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Jul 21, 2026
…(§5.3.2/§9 security milestone) (#6386)

* refactor(reborn): relocate planner to runtime_policy + add HostPolicyFacts port (§5.3.2/§9 groundwork)

Move-only relocation of plan_capability/ExecutionPlan/PlannerError from
ironclaw_host_runtime to ironclaw_runtime_policy (its charter home — 'picks
backend kinds'), so the capability kernel can run runtime-policy enforcement
inside authorize() without an upward dependency. Repoints host_runtime's four
consumers; behavior unchanged.

Adds the inverted HostPolicyFacts port to ironclaw_capabilities (facts-only:
credential presence + active persistent grants), implemented up-layer in a
later step. Zero new dependency edge on the kernel — all types are host_api or
kernel-local.

Groundwork for inlining the four/five policy checks into authorize() (the
§5.3.2 security milestone that makes the Authorized seal non-vacuous).

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

* test(capabilities): route CapabilityHost construction through a support builder

Extract the ~121 inline CapabilityHost::new(&reg,&disp,&auth) call sites across
the 10 capability-host test files into one support::capability_host helper
(forwards verbatim for now). This collapses the blast radius of the upcoming
§5.3.2 construction-signature change (trust-policy/runtime-policy/policy-facts
inputs) from ~121 sites to a single helper.

Also lands the (unwired) trust.rs classifier relocated from host_runtime,
consumed by authorize() in the next step. Pure refactor; all tests green.

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

* refactor(reborn): compute trust + run runtime-policy planning inside authorize() (§5.3.2/§9)

The capability kernel now computes provider trust and runs runtime-policy
planning in-fold instead of receiving a pre-stamped `trust_decision` request
field. `CapabilityHost` gains an injected `&dyn TrustPolicy` and
`&EffectiveRuntimePolicy`; `authorize()` (and the spawn/resume folds) call the
relocated `evaluate_invocation_trust` and `plan_capability`, stamping
`context.trust` from the kernel-computed decision exactly as host_runtime's
`open_pre_authorization` did before the authorizer runs.

`trust_decision` is removed from all four request DTOs
(`CapabilityInvocationRequest`, `CapabilityResumeRequest`,
`CapabilityAuthResumeRequest`, `CapabilitySpawnRequest`) and from
`ResumedDispatchParams`. Trust is now recomputed locally in each entry path
(invoke/spawn/resume/auth-resume/resume-spawn); runtime-policy planning is
enforced on the invoke and spawn paths (not re-run on dispatch resume, matching
today's behavior).

host_runtime still computes both redundantly: `open_pre_authorization` /
`evaluate_invocation_trust` / `enforce_runtime_policy` remain (they feed
`apply_persistent_approval_policy` and still stamp `context.trust`); the
redundancy is intentional and behavior-preserving, cleaned up in later slices.

Failure kinds are preserved: trust "unknown capability" ->
`UnknownCapability` -> `MissingRuntime`; every other trust failure and any
planner refusal -> `AuthorizationDenied` -> `Authorization`. Known delta: the
model-visible *message* for these rare failures degrades to the generic
`DenyReason` text (`AuthorizationDenied` carries no free-text).

Test seam: `support::capability_host` supplies permissive `&'static` trust and
runtime policies; a new `capability_host_with_trust_policy` + `FixedTrustPolicy`
lets the four trust-ceiling tests inject the restricted ceiling they previously
stamped on the request. Also repoints a pre-existing stale import in
`runtime_policy_planner_contract.rs` (`plan_capability`/`PlannerError` moved to
`ironclaw_runtime_policy` in the planner relocation but the test wasn't updated).

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

* refactor(reborn): credential pre-flight moves into authorize() via HostPolicyFacts (§5.3.2/§9)

The capability kernel's authorize() fold now reads HostPolicyFacts::credential_presence
before the approval decision and maps a missing credential to
CapabilityInvocationError::AuthorizationRequiresAuth. The host mapping to
auth_required_outcome is unchanged (same fields, same stable gate id via
stable_auth_gate_id / translate_invocation_error), so the AuthRequired outcome is
byte-identical — it is now produced by the kernel instead of a host-runtime preamble.
A transient secret-store fault maps to CredentialPresence::Indeterminate (fail-open,
skip pre-flight) so it never burns a user auth interaction.

DefaultHostRuntime now impls HostPolicyFacts (credential_presence reusing
capability_credential_requirements + secret_owner_scope; persistent_grants mirroring
apply_persistent_approval_policy's scope×grantee lookup). persistent_grants is
implemented but not yet consumed by the kernel (the approval relocation slice wires
the re-authorize loop). host_runtime's two credential_preflight_check call sites
(invoke + spawn) and the now-dead private fn are removed; its coverage is preserved
through the host_runtime_credential_preflight_contract integration suite (driven
through the DefaultHostRuntime caller) plus a new kernel regression test
(capability_host_missing_credential_blocks_before_approval_decision) proving
credential-before-approval ordering. The context.validate() guard stays in the
host_runtime preamble so forged-scope requests still fail with InvalidRequest.

CapabilityHost::new gains a 6th policy_facts arg; test builders/doubles updated.
Credential-before-approval ordering preserved.

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

* refactor(reborn): persistent-approval decision moves into authorize() (§5.2.7/§5.3.2)

The kernel now runs the persistent-approval re-authorize loop inside its
authorize()/authorize_spawn folds (and the auth-resume authorize_resumed fold),
reading candidate grants via HostPolicyFacts::persistent_grants and adopting the
first grant that flips the trust-aware decision to Allow before the main
authorization — so a prior scoped approval authorizes dispatch without raising a
fresh approval gate.

HostPolicyFacts::persistent_grants evolved to take &ExecutionContext instead of
&ResourceScope so the impl derives the full grantee fan-out (via the existing
persistent_approval_grantees + persistent_approval_lookup_scopes helpers),
recovering the Principal::Extension grantee that a bare ResourceScope cannot
carry — without this the extension-principal persistent approvals would silently
stop auto-applying. The scope-only persistent_approval_grantees_from_scope
helper is deleted.

host_runtime's apply_persistent_approval_policy and its three call sites (invoke,
spawn, auth-resume) are removed; the permission-mode gating predicate is
relocated into the kernel (pure over PermissionMode, no host_runtime/approvals
dependency). open_pre_authorization is kept for its planning gate + trust stamp
side effects; its now-unused TrustDecision is discarded via if-let-Err on the
invoke/spawn/auth-resume paths.

Observable dispatch-vs-gate outcome preserved, incl. the extension grantee
(covered by host_runtime_persistent_approvals_contract's dispatch/spawn/
auth-resume authority tests). New kernel regression:
capability_host_persistent_approval_contract asserts a flipping grant dispatches
without a gate and a non-flipping grant still raises the gate.

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

* refactor(reborn): seal the real reservation + fact-derived deadline on Authorized (§5.3.2)

Authorized.reservation becomes Option carrying the real obligation-produced
reservation (the estimate is already reserved in-fold by the ReserveResources
obligation; NO new governor path — that would double-reserve); the synthesized-
fake reservation placeholder in seal_authorization is removed, so the witness
carries Some only when a resource obligation ran and None otherwise. The witness
deadline is derived from the shortest-lived frozen fact — the adopted
persistent-grant expiry (invoke/spawn) or the claimed approval lease's expiry
(resume) — via a witness_deadline helper, falling back to a bounded
WITNESS_DEFAULT_TTL (5 minutes) instead of the fixed 5-minute window when no
frozen fact is present. apply_persistent_approval now returns the adopted grant's
expires_at so callers can thread it in. Witness still does not drive dispatch
(dispatch uses obligation_outcome.resource_reservation directly through the
dispatcher guard); this only makes the seal truthful.

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

* fix(capabilities): bound resume witness deadline by lease expiry; log folded-away causes

Thread PendingClaim's approval-lease expiry through the pending-claim spec so
the sealed authorization witness minted in `authorize_resumed` is bounded by
the approval that authorized it, instead of falling back to the 5-minute
default TTL. Previously a `resume_json` (PendingClaim) resume dropped the lease
expiry (it retained only the grant id), so a shorter-lived approval could be
outlived by its witness. The claim is deferred past the seal, so the expiry
must travel on the spec rather than being read back from a not-yet-claimed
lease. Regression test drives `authorize_resumed` directly (the resume seal is
discarded before `resume_json` returns, so no public caller surfaces it).

Also preserve underlying causes in three authorize-fold error mappers that
discarded them: `runtime_policy_error_to_invocation_error` now `debug!`s the
bound `PlannerError`, and trust.rs `debug!`s the bound `TrustError` and
manifest `ExtensionError` before collapsing to `Policy`/`TrustInput`. Uses
`debug!` (not `info!`/`warn!`) to avoid REPL/TUI corruption; no behavior change.

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

* refactor(reborn): move resume-path authority into the kernel + delete host_runtime pre-authorization (§5.3.2/§9, R-A) (#6392)

Extends the capability kernel to be the single authority site for resume as it
already is for invoke, then deletes host_runtime's now-truly-redundant
pre-authorization. Concretely:

(a) Kernel resume authority. authorize_resumed and the resume_spawn_json fold now
    run runtime-policy planning on resume via a new resume_preflight that resolves
    the descriptor and enforces plan_capability BEFORE the run-state lookup —
    reversing #6386's "planning is NOT re-run on resume" so a policy tightened
    between invoke and resume fails closed. Descriptor/trust existence is checked
    first, so an unknown resume capability short-circuits to UnknownCapability
    (-> MissingRuntime) instead of the run-state-not-found Backend path; failure
    fails ONLY the matching blocked run (scope isolation), keyed by
    scope+invocation+status+capability[+approval]. This restores host_runtime's
    old production precedence (its deleted open_pre_authorization resolved the
    registry before the kernel's mismatch check ran). The run-state fail records
    the planner-specific INTERNAL error_kind (planner_error_kind, e.g.
    "process_backend_none") while the model-visible message stays the sanitized
    DenyReason::PolicyDenied — the planner enum name never reaches the model. The
    kernel still stamps context.trust on the resume authorize_context clone before
    the authorizer.

(b) host_runtime deletion. open_pre_authorization + its invoke/spawn/resume/
    auth-resume/resume-spawn callers, the fail_matching_blocked_{,auth_}resume
    helpers, and the now-dead evaluate_invocation_trust / enforce_runtime_policy /
    TrustEvaluationError / RuntimePolicyEvaluationError / failure + trust-input
    helpers are removed; the local-manifest trust-input unit test moves to
    ironclaw_capabilities::trust. Kept: trust_policy/runtime_policy fields (wired to
    the kernel), plan_capability for surface.rs visibility, the context.validate()
    forged-scope guard, the credential/gate helpers, and the HostPolicyFacts impl.

(c) Descriptor/start reorder. authorize() and authorize_spawn() resolve the
    descriptor BEFORE run_state.start, so an unknown capability creates no run
    record (restoring the no-record-for-unknown behavior the deleted pre-check gave).

(d) Message sanitization tests. Invoke-path runtime-policy-denial tests
    (builtin_http + tool_surface extension/secret/mcp) now assert the sanitized
    DenyReason message ("denied", not the leaked NetworkMode::/SecretMode:: planner
    enum tokens) — the #6386 sanitization, now observable once the redundant
    host_runtime pre-check is gone.

(e) Mismatch tests corrected. The three capability-id-mismatch resume tests now
    trigger the mismatch with a known-but-different, plannable capability
    (echo.other) so the run-state mismatch check fires (ResumeContextMismatch),
    isolating "mismatch" from "unknown-capability"; a new kernel test pins
    "unknown capability on resume -> UnknownCapability + fail matching run".

Verified context.trust has no reader except the kernel-stamped authorize_context
(ironclaw_authorization::authorize_from_grants_with_trust).

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

* fix(capabilities): explain policy denials in plain language, without leaking planner enum tokens

The authorize() consolidation collapsed a runtime-policy planner refusal into a
bare AuthorizationDenied { PolicyDenied }, which tells the model nothing about
why it was denied. Add an optional `detail` to AuthorizationDenied that the
planner mapper fills with a sanitized, plain-language reason (e.g. "this
capability needs to run a process, but process execution is disabled by policy
for this runtime"); sanitized_failure_message surfaces it. The full message with
the internal ProcessBackendKind::/NetworkMode::/SecretMode:: enum tokens stays
server-side in the debug! log only — the model never sees those tokens (matching
the builtin_http_runtime_policy_denial_stops_before_egress invariant).

facade_factory now asserts the readable reason (and that no planner enum token
leaks); the two process-capability tests and the HTTP denial test all pass.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Jul 21, 2026
…spatch-through-witness (#6396) (#6432)

* refactor(reborn): thread real InvocationOrigin + Actor::System into the seal — witness always-present (§5.2.1/§9, S1)

First slice of the "witness always-present + dispatch-routes-through-witness"
arc (#6396). Retires the two fakes in `CapabilityHost::seal_authorization` so
the sealed `Authorized` witness carries authoritative facts for every
dispatchable invocation, WITHOUT changing any authorization decision:

- Origin: deleted the `Product(ProductKind::new("provisional"))` placeholder.
  Added an ingress-stamped `ExecutionContext.origin: Option<InvocationOrigin>`;
  the seal resolves `context.origin` → else `LoopRun(run_id)` (transitional
  compat) → else fail-closed (Option `?`, never a placeholder). The loop host
  (`invocation_context_from_visible`) now stamps `LoopRun` explicitly.
- Actor: an actor-less/host-internal context now seals `Actor::System` as its
  own class instead of early-returning `None`, so the witness is minted for it
  too. The witness is `None` now only for the `System` runtime-kind (no lane).

Behavior-neutral: the witness is still a forward-looking artifact not consumed
by dispatch (that lands in a later slice), so origin/actor become accurate
recorded facts on the sealed `Invocation` without influencing allow/deny/gate.
Verified there is no production `Product`/`Automation` capability ingress today
(invocation is loop-initiated; the former `"provisional"` fallback only ever
fired for test contexts), so those origin variants are wired for future
ingresses with no live producer yet.

Tests: extended the seal allow-path test to assert the real origin; added
`authorize_seals_system_actor_and_real_origin_across_ingresses` (System actor +
Product/LoopRun/Automation origins driven through `authorize`); extended the
loop-host context test to assert the stamped `LoopRun` origin. No test weakened.

Gates: cargo fmt; cargo test -p ironclaw_capabilities -p ironclaw_host_api
-p ironclaw_host_runtime -p ironclaw_loop_host (1796 pass); clippy
-p ironclaw_capabilities -p ironclaw_host_api --all-targets --all-features
-D warnings; cargo test -p ironclaw_architecture.

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

* refactor(reborn): add OriginGatePolicy matrix as declarative capability data (§5.2.1, S2)

Second slice of #6396. Introduces the §5.2.1 origin→gate matrix as declarative
data threaded from the manifest to the kernel descriptor, with NO authorization
logic reading it yet (behavior-neutral):

- host_api: `OriginGatePolicy` (Forbidden/AskAlways/GatedUnlessGranted/
  ConsentSufficient/Ungated; wire-stable snake_case; default Forbidden) and
  `OriginGateMatrix` (per-origin loop_run/product/automation, each defaulting to
  Forbidden) with `policy_for(&InvocationOrigin)`. `CapabilityDescriptor` gains
  `origin_gate_matrix: Option<OriginGateMatrix>` — `None` = undeclared, treated
  as all-Forbidden (fail-closed) and targeted by the §5 ratchet (S5).
- manifest: `CapabilityDeclV2` and `RawCapabilityV2` gain the same optional
  field (`#[serde(default)]`, so existing TOML without the key parses to `None`;
  a partial matrix defaults omitted origins to Forbidden). `from_raw` passes it
  through. Threaded into both descriptor-mint sites
  (`capability_descriptors_from_manifest`, hosted-MCP discovery).

Absence = Forbidden (deny-by-default). No real matrices are populated here — that
is the next slice; every fan-out literal (22 sites, mostly tests) is `None`.

Tests: OriginGatePolicy snake_case round-trip; OriginGateMatrix per-origin
default-Forbidden; policy_for variant mapping; v2 manifest parse (absent key →
None, partial matrix → omitted origin Forbidden). No authz test touched.

Gates: cargo fmt; cargo build --workspace; cargo test -p ironclaw_host_api
-p ironclaw_extensions; clippy --all-targets --all-features -D warnings;
cargo test -p ironclaw_architecture.

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

* refactor(reborn): populate origin_gate_matrix for every capability + seed Ungated allowlist (§5.2.1, S3)

Third slice of #6396. Declares the per-origin gate matrix on every production
capability, behavior-preservingly (mirrors today's effect-based LoopRun gate).
Still behavior-neutral: nothing reads the matrix yet (the fold is S4).

- `loop_run` mirrors current gating under the canonical LocalDev/AskDestructive
  profile (the only live LoopRun producer). Since `ask_writes` ≡ `ask_destructive`,
  GATED-today ⇔ effects reach beyond {read_filesystem, dispatch_capability};
  nothing is hard-floored, so no AskAlways. Result: 17 builtin caps `Ungated`,
  the other 160 (19 builtin + all 141 extension — every extension carries
  `network`) `GatedUnlessGranted`.
- `product`/`automation` = `Forbidden` everywhere: deny-by-default, no live
  producer today; a later reviewed ingress slice fills them. Behavior-neutral now.
- Checked-in `UNGATED_LOOP_RUN_CAPABILITIES` allowlist (host_api) pins the 17
  ungated-for-model builtins (§5.2.1/§10 seed; the S5 ratchet enforces it).
  Builtin `loop_run` derives from this allowlist via `builtin_loop_run_seed` —
  fail-safe: an unlisted builtin defaults to GatedUnlessGranted.
- Extension matrices are hand-authored into the 13 first-party TOML assets (the
  production descriptor source; gsuite Rust builder is test-only), so the ratchet
  does real work there. Discovered hosted-MCP tools inherit from their template.

Load-bearing invariant (allowlist ⇔ builtin loop_run==Ungated) verified by
construction + tests; every in-scope descriptor now declares Some(matrix); test
fixtures stay None; SyntheticCapabilityDescriptor untouched.

Gates: cargo fmt; cargo build --workspace; cargo test across host_api/extensions/
composition/host_runtime (one unrelated pre-existing env-sensitive failure,
detect_env_llm_*, caused by LLM_BACKEND set in the shell); clippy --all-targets
--all-features -D warnings clean; cargo test -p ironclaw_architecture 10/0.

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

* refactor(reborn): fold origin→gate matrix into authorize() as a two-tier gate (§5.2.1/§5.2.7, S4)

Fourth slice of #6396. Authorization now CONSULTS the origin→gate matrix, keyed
on the invocation's resolved InvocationOrigin (context.origin, else LoopRun from
run_id — same resolution as seal_authorization) and the descriptor's
origin_gate_matrix. Composed inside require_approval_for_profile_policy at the
class-A intrinsic-gate layer, so per-scope class-B state (leases, auto-approve,
always-allow) stays above it unchanged. Provably behavior-neutral in production.

Two gate tiers, each mapped onto the existing gate machinery so semantics are
correct — not just present:
- `Forbidden` → hard Deny (sanitized DenyReason::PolicyDenied; internal reason in
  debug log), short-circuited ahead of every class-B step.
- `AskAlways` → hard-floor gate composed at step 3 with effects_force_approval:
  beats class-B auto-approve/always-allow and is NOT suppressed by the Minimal
  (yolo) bypass — "every invocation gates; persistent grants never honored"
  (§5.2.7). Only a genuine one-shot approval lease satisfies it.
- `GatedUnlessGranted` → soft gate OR'd at step 9: satisfied by the same scoped
  grant/always-allow machinery as the effect gate, and suppressed under the
  Minimal-bypass guard so yolo stays "no prompts".
- `ConsentSufficient` / `Ungated` → no gate contribution.

Behavior-neutral: no production capability declares loop_run == AskAlways or
Forbidden (S3 seeds only Ungated / GatedUnlessGranted), so the hard-floor and
deny paths are unreachable in production today; GatedUnlessGranted is neutral by
the invariant matrix_gate ⟹ effect_gate under every non-Minimal profile, and is
yolo-suppressed. The mechanism is fully wired and fail-closed for future
Product/Automation ingresses.

Tests (driven through the real RuntimeProfileApprovalGatePolicy +
profile_approval_authorizer): neutrality across 5 caps × 4 profiles × 2
permission postures (LoopRun decision identical with vs without the matrix — zero
flips); ask_always_matrix_gates_even_when_class_b_would_allow (the §5.2.7 proof:
AskAlways gates despite global_auto_approve + always_allow); AskAlways gates under
Minimal-yolo; GatedUnlessGranted suppressed under yolo; Forbidden→Deny;
Ungated/ConsentSufficient add no gate. 27 prior profile-approval tests unchanged.

Gates: cargo fmt; test -p ironclaw_reborn_composition -p ironclaw_capabilities
-p ironclaw_host_api -p ironclaw_approvals (1678 pass; only the pre-existing
env-sensitive detect_env_llm_* fails in this shell); clippy --all-targets
--all-features -D warnings clean; cargo test -p ironclaw_architecture.

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

* test(reborn): ratchet the origin→gate matrix invariants + per-descriptor well-formedness (§5.2.1/§10, S5)

Fifth slice of #6396. Locks in the S2–S4 origin→gate matrix invariants as
guardrails; test-only, no production behavior change.

New `crates/ironclaw_architecture/tests/reborn_origin_gate_matrix_ratchet.rs`
(mirrors the sibling reborn_*_ratchet.rs pattern) enforces the invariants a leaf
test crate can own soundly:
- The `UNGATED_LOOP_RUN_CAPABILITIES` allowlist (imported from its owner, so the
  pin checks the REAL constant) is frozen to the reviewed 17-id seed — any
  addition/removal must update the checked-in EXPECTED_UNGATED_SEED in the same
  PR, so ungating a capability for the model is a review-visible diff. (This is
  the behavior-preserving grandfathered seed, deliberately not the doc's
  "starts empty" §10 ideal — documented in the file header; a future tightening
  removes entries with review.)
- The 13 hand-authored first-party extension TOML assets each declare an
  origin_gate_matrix with a loop_run, never `consent_sufficient` on
  loop_run/automation (Product-only per §5.2.1), and no `ungated` loop_run
  outside the allowlist — the source where a typo could silently ungate a
  networked capability.

"Every production descriptor declares Some(matrix)" (invariant 1) is enforced at
the owning-crate build sites (ironclaw_architecture cannot depend on the
composition/host_runtime crates that assemble descriptors): the builtin
enumeration test in ironclaw_host_runtime (extended here to assert every builtin
declares Some, loop_run matches the allowlist, product/automation Forbidden, no
Product-only policy misused) plus the existing S3 extension-lifecycle and
bundled-extension tests. The ratchet header cross-references all three.

Proportionate §11.7 conformance: per-descriptor well-formedness/self-consistency
checks added; a full golden-file origin→gate conformance harness (none exists
today) is left as a follow-up.

Dev-only deps on ironclaw_architecture (ironclaw_host_api, toml), documented as
non-production edges. No STOP-item surfaced: no TOML uses ungated/consent_sufficient,
no production descriptor is None.

Gates: cargo fmt; cargo test -p ironclaw_architecture (10 pass, new ratchet 3/3);
cargo test -p ironclaw_host_api -p ironclaw_extensions; the extended builtin
enumeration test passes; clippy -p ironclaw_architecture --all-targets
--all-features -D warnings clean.

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

* refactor(reborn): dispatch consumes the Authorized witness — single-use, expiry-fail-closed, byte-neutral (§5.3.2/§9, S6)

Sixth/final slice of #6396 (its "Slice 2"). `invoke_json`/`spawn_json` now take
the sealed `Authorized` out of the authorize fold and route dispatch through it
instead of re-deriving from loose request fields:

- The witness is consumed single-use via `Authorized::into_parts(now)` (moves
  it — a second dispatch is a compile error). Its sealed mounts/reservation drive
  dispatch; the lane is consumed as proof (the dispatcher owns closed-lane
  routing and re-derives the identical descriptor lane).
- Expired witness at dispatch fails closed: aborts the prepared obligations
  (releasing the reservation through the obligation lifecycle), consumes the
  witness via `Authorized::abort` (never `Drop`), fails the run, returns a
  terminal denial. Unreachable in a synchronous authorize→dispatch today;
  new-behavior-by-design for the async/held-witness future.

Byte-neutral: the witness carries the fold's `obligation_outcome.{mounts,
resource_reservation}` verbatim, so dispatch inputs are identical to the pre-S6
path for every capability that seals a witness.

Findings from the security-critical review (issue warned these paths hide
masked behaviors):
- Mounts kept as `Option<MountView>` end-to-end (witness `mounts()` →
  `Option<&MountView>`; seal no longer collapses `None` to default): a downstream
  filesystem-plan consumer distinguishes `None` (fail-closed) from `Some(empty)`,
  so preserving the Option keeps dispatch byte-identical rather than
  behaviorally-equivalent.
- The issue's "remove the dispatcher's reserve-when-None fallback" premise was
  stale: `ironclaw_dispatcher` has no such fallback (it only holds/releases a
  passed reservation); the real reserve-when-None lives in the runtime adapters
  and is load-bearing. Removed nothing.
- `None`-witness path is NOT failed closed: `system.process_sandbox`
  (RuntimeKind::System → no lane → no witness) is legitimately spawned through
  `spawn_json`, so a `None` witness falls back to the obligation-derived dispatch
  inputs (exactly today's values). Failing closed would regress production. This
  leaves a documented dual path (witness for untrusted lanes, obligation fallback
  for host-internal System spawns); collapsing it fully requires representing
  host-internal spawns in the lane model — a follow-up beyond S6's neutrality
  mandate.

Tests (ironclaw_capabilities + host_api): witness-none-mounts dispatched verbatim
(not a collapsed default); spawn uses context-mounts fallback via the witness
Option; expired witness fails closed and releases the reservation via abort
(invoke + spawn); single-use is structural. Pre-existing byte-neutral dispatch
tests remain green unchanged.

Gates: cargo fmt; workspace clippy --all-targets --all-features -D warnings clean;
targeted Reborn crate suites green (host_api/capabilities/dispatcher/host_runtime/
loop_host/extensions/reborn_composition/approvals/architecture). Two pre-existing
facade_factory failures inherited from the #6392 base (its runtime-policy message
sanitization vs stale test expectations) are unrelated to this stack.

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

* refactor(reborn): code-quality pass on the #6396 stack (dedup witness consumption; centralize origin resolution)

Addresses findings from a strict code-quality review of the six-slice stack.
No behavior change — pure structural cleanup; all touched-crate tests green
(4164 passed / 0 failed), clippy -D warnings clean, fmt clean.

- Dedup (S6): `invoke_json` and `spawn_json` carried an identical ~35-line
  witness-consumption block (single-use `into_parts`, expiry fail-closed +
  `abort`, None→obligation fallback) differing only in the obligation phase.
  Extracted `CapabilityHost::dispatch_inputs_from_witness`, consolidating the
  logic and its (security-sensitive) rationale in one place and shrinking the
  oversized host.rs. The helper carries the `abort_obligations` arg set plus the
  witness; invoke's and spawn's request types differ so no shared bundle unifies
  them (same justification as the adjacent `seal_authorization` exemption) —
  annotated arch-exempt, folds into a prepared-invocation bundle with plan #6175.
- Centralize origin resolution: the `context.origin` else `LoopRun(run_id)` rule
  was duplicated in `seal_authorization` (capabilities) and
  `require_approval_for_profile_policy` (composition). Moved to
  `ExecutionContext::resolved_origin()` in host_api — the single definition of
  the "run_id implies LoopRun" rule, resolved through the type that owns the
  fields. Dropped the now-unused `InvocationOrigin` import from host.rs.

Also fixes a rebase-induced test breakage: main replaced the `UnusedDispatcher`
test double with `dispatch_test_support::TestDispatcher`; updated the S1 seal
test accordingly.

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

* fix(reborn): address origin gate review feedback

---------

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

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-6392 — e7dbca8b Deployed Jul 21, 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 size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant