Skip to content

feat(approval-gate): config rules inline + infinite holds - #283

Merged
ytallo merged 5 commits into
mainfrom
feat/approval-gate-config-rules-infinite-holds
Jun 18, 2026
Merged

feat(approval-gate): config rules inline + infinite holds#283
ytallo merged 5 commits into
mainfrom
feat/approval-gate-config-rules-infinite-holds

Conversation

@ytallo

@ytallo ytallo commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Evaluate permission rules from the approval-gate configuration entry via a new inline permissions/ module, replacing the policy::check_permissions RPC in the gate.
  • Remove hold expiry: drop pending_timeout_ms, expires_at, approval::sweep, and the timeout resolved outcome — holds wait until human resolve or turn/session purge.
  • Scope is approval-gate only; harness iii-permissions.yaml, harness policy worker, and claude-code are unchanged for now.

Test plan

  • cd approval-gate && cargo test
  • cargo test --test integration
  • cargo test --test schemas
  • cargo clippy --all-targets --all-features -- -D warnings
  • Restart approval-gate worker after merge

Summary by CodeRabbit

  • New Features

    • Inline permission rules with first-match-wins evaluation and built-in defaults.
    • Rule matching supports glob-style function ids and regex/typed constraints.
    • Console default permission mode and auto-allowlist are now driven by approval-gate configuration.
  • Bug Fixes

    • Pending approvals no longer expire automatically; explicit resolution is required.
  • Refactor

    • Removed the automatic sweep mechanism; hold/resolution follows an explicit lifecycle.
    • Simplified responses: “hold” has no timeout, and resolve outcomes are now allow/deny/aborted.
  • Chores

    • Updated wire schemas/tests and added regex support for rule matching.

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jun 18, 2026 1:22am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ytallo, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 33 minutes and 11 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 897e8a36-ae8d-46e3-aca5-6a0301ee6082

📥 Commits

Reviewing files that changed from the base of the PR and between e2d56ab and 0754670.

📒 Files selected for processing (7)
  • approval-gate/src/config.rs
  • approval-gate/src/functions/remove_always_allow.rs
  • approval-gate/src/permissions/mod.rs
  • approval-gate/src/settings.rs
  • approval-gate/tests/harness_integration.rs
  • approval-gate/tests/integration.rs
  • harness/src/deferred.rs
📝 Walkthrough

Walkthrough

The approval-gate worker replaces its external YAML policy check and cron-based approval::sweep with a new inline permissions/ module that compiles first-match rules from worker configuration. expires_at is removed from PendingApprovalRecord, ResolvedOutcome::Timeout is replaced by Aborted, and holds no longer carry or expire with pending timeouts. Configuration gains a rules field with backfill-on-boot, and the BootSignature is simplified to only the hook binding. All state/session/harness APIs drop explicit timeout parameters, and the harness is updated to exclude held calls from expiry logic. Console backends now read approval defaults from the canonical approval-gate configuration instead of localStorage.

Changes

Inline Permissions Rules and Sweep Removal

Layer / File(s) Summary
Permissions engine: types, compilation, matching, and defaults
approval-gate/Cargo.toml, approval-gate/src/permissions/types.rs, approval-gate/src/permissions/compile.rs, approval-gate/src/permissions/default_rules.rs, approval-gate/src/permissions/mod.rs
Introduces Action, Decision, RuleSpec, and ConstraintSpec types; CompiledRule/CompiledConstraint with glob-to-anchored-regex compilation; runtime function-id and constraint matching; a single default rule !approval::*; and the Permissions container with tolerant compile, tolerant fallback on errors, JSON parsing helpers, and rule evaluation via mode-scoped check. Adds regex = "1" dependency.
WorkerConfig: add rules field, simplify BootSignature, drop sweep/policy fields
approval-gate/src/config.rs, approval-gate/src/manifest.rs
WorkerConfig removes sweep_expression, policy_timeout_ms, pending_timeout_ms; adds rules: Vec<Value> with serde default and JSON schema support; adds permissions() method; BootSignature reduced to hook only; manifest derives default_config from WorkerConfig::default(); tests updated to assert field removal and rules-based defaults behavior.
Wire contract: remove expires_at, update ResolvedOutcome and HookOutput
approval-gate/src/types.rs, approval-gate/src/pending.rs
PendingApprovalRecord drops expires_at; ResolvedOutcome::Timeout replaced by Aborted; HookOutput::Hold changes from struct with pending_timeout_ms: i64 to unit variant; tests and seed helpers updated accordingly.
Gate hook: switch from policy::check to cfg.permissions().check
approval-gate/src/functions/gate.rs, approval-gate/src/decision.rs
Gate's pre_trigger replaces async policy::check with cfg.permissions().check; maps Decision::Allow/Deny/NeedsApproval to HookOutput outcomes; narrows is_human_only to approval:: prefix; removes hold expiry computation and sets pending_timeout_ms: 0; drops expires_at from new pending records; adds idempotency for re-holds; new tests for config-rules allow/deny/needs-approval paths.
Remove approval::sweep and policy.rs from public surface
approval-gate/src/functions/mod.rs, approval-gate/src/functions/sweep.rs, approval-gate/src/policy.rs, approval-gate/src/lib.rs, iii-permissions.yaml
sweep.rs deleted entirely; functions/mod.rs removes sweep module, constants, registration, and catalog; lib.rs swaps policy for permissions export; policy.rs emptied; iii-permissions.yaml removes policy::check_permissions denial and adds approval::resolve denial.
Configuration: rules backfill seeding and hook-only hot-reload
approval-gate/src/configuration.rs
TriggerHandles drops sweep field; bind_sweep removed; bind_hook fixed to startup-only registration; ensure_rules_seeded added for idempotent backfill; on_config_change re-binds only hook; internal trigger calls use timeout_ms: None; unit test for rebind behavior removed.
Main boot: 12-function surface and hook-only wiring
approval-gate/src/main.rs
Boot documentation and logging updated to reflect 12 approval functions; TriggerHandles construction replaced with hook-only binding; sweep handler removed from startup sequence; configuration imports adjusted.
Resolve and state/session/harness/settings APIs: drop explicit timeout parameters
approval-gate/src/functions/resolve.rs, approval-gate/src/pending.rs, approval-gate/src/state.rs, approval-gate/src/session.rs, approval-gate/src/harness.rs, approval-gate/src/settings.rs, and all calling functions
resolve removes expires_at early-return check; all pending::get/put/delete_with_gate/list_all, state::get/set/delete/list, session::get, harness::function_resolve, and settings::read_tolerant/read_strict/materialize_and/clear drop optional timeout_ms parameters; internal RPC calls always use timeout_ms: None; test helpers updated.
Testkit: RulesOverride replaces PolicyStub; optional real harness spawning
approval-gate/src/testkit/engine.rs, approval-gate/src/testkit/mod.rs
BootOpts.policy: PolicyStub replaced by BootOpts.rules: RulesOverride + real_harness: bool; boot() mutates cfg.rules directly; fake policy handler removed; optional real harness worker spawning with cleanup; PolicyStub re-export removed; new constructors for rule override scenarios.
Golden schemas and integration tests updated for new shapes
approval-gate/tests/golden/schemas/*, approval-gate/tests/integration.rs, approval-gate/tests/schemas.rs, approval-gate/tests/contract_parity.rs, approval-gate/tests/harness_integration.rs
Golden schemas remove expires_at from pending records and timeout from ResolvedOutcome; approval.sweep.json deleted; sweep integration test removed; catalog count updated to 12; new contract-parity tests lock hold/allow/deny JSON shapes; new harness_integration tests validate real harness/gate cross-worker hold/resolve flows.
Architecture and README documentation aligned to inline rules, no-expiry holds, explicit deletions
approval-gate/README.md, approval-gate/architecture/README.md, approval-gate/architecture/integration.md, approval-gate/architecture/internals.md, approval-gate/architecture/permissions-source.md
Documentation updated to describe gate evaluation via config rules, hold/deny/continue outcomes, removed sweep/cron install, updated event payloads/outcomes, hook-only binding, no-expiring holds, explicit deletion paths; new permissions-source.md defines single-source-of-truth model.
Harness: remove pending_timeout_ms from hold outcomes; exclude holds from expiry
harness/src/hooks/runner.rs, harness/src/functions/function_trigger.rs, harness/src/turn_loop.rs, harness/src/deferred.rs
HookOutcome::Hold and PreTriggerOutcome::Hold changed to unit/payload-less variants; hook runner sites updated; PendingInfo construction sets pending_timeout_ms: None; new pending_call_expired helper excludes held calls from expiry; tests updated.
Console backend: new approval-gate-config module and integration
console/web/src/lib/backend/approval-gate-config.ts, console/web/src/lib/backend/approval-gate-config.test.ts, console/web/src/lib/backend/real.ts, console/web/src/components/permissions/DefaultPermissionModePicker.tsx, console/web/src/hooks/use-approval-settings.ts, console/web/src/pages/Configuration/tabs/ConsoleSettingsTab.tsx
New approval-gate-config.ts module treats approval-gate configuration as single source of truth; adds helpers for auto-allowlist and harness function policy derivation; real.ts loads gate defaults at runtime; DefaultPermissionModePicker and useApprovalSettings use deployment defaults instead of localStorage; ConsoleSettingsTab refactored to async approval-gate load/save workflow.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant gate as approval::gate
  participant perms as cfg.permissions()
  participant pending as pending state

  Caller->>gate: pre_trigger(function_id, args)
  gate->>gate: is_human_only()?
  alt human-only approval::* functions
    gate-->>Caller: HookOutput::Deny (fail-closed)
  else non-human functions
    gate->>perms: check(function_id, args, mode)
    alt Decision::Allow
      perms-->>gate: Allow { rule_id }
      gate-->>Caller: HookOutput::Continue
    else Decision::Deny
      perms-->>gate: Deny { rule_id, matched_constraint }
      gate-->>Caller: HookOutput::Deny (permissions envelope)
    else Decision::NeedsApproval
      perms-->>gate: NeedsApproval
      gate->>pending: get/put PendingApprovalRecord
      gate-->>Caller: HookOutput::Hold (unit variant)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • iii-hq/workers#260: This PR directly supersedes the earlier approval-gate implementation by replacing policy::check/PolicyOutcome-based gate evaluation and the approval::sweep/expires_at timeout workflow with inline rules permissions and explicit deletion paths.
  • iii-hq/workers#276: Both PRs modify approval-gate/src/configuration.rs to refactor configuration hot-reload wiring and trigger binding behavior, including hook-only startup binding.

Suggested reviewers

  • andersonleal

🐇 No more YAML to check, no more cron to sweep,
The rules live inline now — compiled, precise, and neat.
!approval::* denies, the rest waits in hold,
expires_at is gone; no deadlines of old.
Twelve functions strong, the rabbit hops on! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the two main features being added: inline config rules for permissions evaluation and infinite holds (no expiry). It accurately reflects the primary architectural changes in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 93.04% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/approval-gate-config-rules-infinite-holds

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 and usage tips.

@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: 6

Caution

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

⚠️ Outside diff range comments (1)
approval-gate/src/decision.rs (1)

15-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Docstring mentions configuration::* but implementation excludes it.

The docstring at lines 15-18 states that both approval::* and configuration::* are operator surfaces that should be human-only. However, the implementation at line 20 only checks for the approval:: prefix, and the test at line 108 explicitly asserts that configuration::set is not human-only.

If the narrowing is intentional (configuration functions no longer need human-only protection), update the docstring to match:

Proposed docstring fix
-/// `approval::*` and `configuration::*` are operator surfaces — an agent
-/// that could call them would approve its own calls (spec § Human-only
-/// defense). Prefix match deliberately broadens the prior art's
-/// six-function list.
+/// `approval::*` functions are operator surfaces — an agent that could
+/// call them would approve its own calls (spec § Human-only defense).
+/// Prefix match deliberately broadens the prior art's explicit list.
🤖 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 `@approval-gate/src/decision.rs` around lines 15 - 21, The docstring for the
is_human_only function mentions that both `approval::*` and `configuration::*`
are operator surfaces requiring human-only protection, but the actual
implementation only checks for the `approval::` prefix. Since the test confirms
that `configuration::set` should not be treated as human-only, update the
docstring to remove the reference to `configuration::*` and clarify that only
functions with the `approval::` prefix are considered human-only. This will
align the documentation with the actual implementation behavior.
🤖 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 `@approval-gate/architecture/integration.md`:
- Line 24: The function IDs in the documentation on line 24 use underscores
(on_session_deleted, on_turn_completed) but the registered wire IDs use hyphens
(approval::on-session-deleted, approval::on-turn-completed). Update the table
entry to replace the underscore versions with the hyphenated versions to match
the actual registered IDs shown in the first column, ensuring consistency and
preventing confusion for integrators.

In `@approval-gate/src/gate_config.rs`:
- Around line 66-74: The parse_permissions function currently treats an explicit
empty rules array the same as a missing rules key, both falling back to
default_permissions(). To fix this, remove the second condition that checks if
specs.is_empty() and returns built_in, since this prevents operators from
intentionally disabling built-in permissions with an explicit empty rules array.
Instead, only return default_permissions() when the rules key is not present,
and allow the specs from parse_rules_from_config to be used directly even when
empty, so that an explicit "rules": [] configuration actually results in empty
permissions rather than silently reverting to defaults.

In `@approval-gate/src/permissions/compile.rs`:
- Around line 39-42: The CompileError::Rule instances are hardcoding the index
field to 0 instead of using the actual rule index value, which causes
inconsistent error diagnostics with duplicated rule prefixes in the message.
Replace the hardcoded index value of 0 with the actual rule index from the
function parameters or loop iteration in all three locations where
CompileError::Rule is used (around lines 39-42, 52-58, and 78-81), ensuring each
error reports the correct index of the rule that failed to compile.

In `@approval-gate/src/permissions/mod.rs`:
- Around line 94-102: The current implementation uses filter_map to parse
constraints from the args object, which silently discards any constraints that
fail to parse. This is a security issue because invalid constraints in allow
rules can be dropped, potentially allowing unauthorized calls. Instead of using
filter_map, collect the results from parse_constraint into a collection and
check for parse errors. If any constraint fails to parse, the entire rule
parsing should fail and return an error rather than silently dropping the
invalid constraint. This strict parsing approach should be applied consistently
to all constraint parsing locations where args are processed (including the
section mentioned at lines 122-127).

In `@approval-gate/src/testkit/engine.rs`:
- Around line 124-126: In the timeout path where Instant::now() exceeds the
deadline and child.kill() is called, you need to fully clean up resources before
returning None. After calling child.kill(), add a call to wait() on the child
process to ensure it's properly reaped and not left as a zombie. Additionally,
before returning None, ensure the temporary directory (likely self.work_dir or
similar) is explicitly cleaned up to remove test artifacts. This ensures no
zombie processes or leftover files are left behind when a test engine boot times
out.
- Around line 134-141: The issue is that the require_engine function uses
OnceCell<Option<Engine>> which permanently caches a None result when
spawn_engine() fails, preventing retries on transient failures. Modify the
require_engine function to only cache successful Engine instances in the
OnceCell. Change the logic so that failed initializations from spawn_engine()
are not stored as None but instead allow the initialization to be retried on
subsequent calls. Consider changing the OnceCell to store only the Engine
directly rather than Option<Engine>, or modify the initialization logic to only
call get_or_init when spawn_engine() succeeds, allowing transient boot failures
to be retried in future calls.

---

Outside diff comments:
In `@approval-gate/src/decision.rs`:
- Around line 15-21: The docstring for the is_human_only function mentions that
both `approval::*` and `configuration::*` are operator surfaces requiring
human-only protection, but the actual implementation only checks for the
`approval::` prefix. Since the test confirms that `configuration::set` should
not be treated as human-only, update the docstring to remove the reference to
`configuration::*` and clarify that only functions with the `approval::` prefix
are considered human-only. This will align the documentation with the actual
implementation 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 13a322f7-02ea-442a-abbb-4bec3a30354b

📥 Commits

Reviewing files that changed from the base of the PR and between 591fe1e and 336531b.

⛔ Files ignored due to path filters (1)
  • approval-gate/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (42)
  • approval-gate/Cargo.toml
  • approval-gate/README.md
  • approval-gate/architecture/README.md
  • approval-gate/architecture/integration.md
  • approval-gate/architecture/internals.md
  • approval-gate/config.yaml
  • approval-gate/src/config.rs
  • approval-gate/src/decision.rs
  • approval-gate/src/error.rs
  • approval-gate/src/events.rs
  • approval-gate/src/functions/gate.rs
  • approval-gate/src/functions/get_pending.rs
  • approval-gate/src/functions/list_pending.rs
  • approval-gate/src/functions/mod.rs
  • approval-gate/src/functions/on_config_change.rs
  • approval-gate/src/functions/on_session_deleted.rs
  • approval-gate/src/functions/on_turn_completed.rs
  • approval-gate/src/functions/purge.rs
  • approval-gate/src/functions/remove_always_allow.rs
  • approval-gate/src/functions/resolve.rs
  • approval-gate/src/functions/sweep.rs
  • approval-gate/src/gate_config.rs
  • approval-gate/src/lib.rs
  • approval-gate/src/main.rs
  • approval-gate/src/manifest.rs
  • approval-gate/src/pending.rs
  • approval-gate/src/permissions/compile.rs
  • approval-gate/src/permissions/default_rules.rs
  • approval-gate/src/permissions/mod.rs
  • approval-gate/src/permissions/types.rs
  • approval-gate/src/policy.rs
  • approval-gate/src/settings.rs
  • approval-gate/src/testkit/engine.rs
  • approval-gate/src/testkit/mod.rs
  • approval-gate/src/types.rs
  • approval-gate/tests/golden/schemas/approval.get-pending.json
  • approval-gate/tests/golden/schemas/approval.list-pending.json
  • approval-gate/tests/golden/schemas/approval.pending-created.json
  • approval-gate/tests/golden/schemas/approval.pending-resolved.json
  • approval-gate/tests/golden/schemas/approval.sweep.json
  • approval-gate/tests/integration.rs
  • approval-gate/tests/schemas.rs
💤 Files with no reviewable changes (16)
  • approval-gate/src/functions/on_session_deleted.rs
  • approval-gate/src/functions/on_turn_completed.rs
  • approval-gate/tests/golden/schemas/approval.pending-resolved.json
  • approval-gate/tests/golden/schemas/approval.sweep.json
  • approval-gate/src/policy.rs
  • approval-gate/tests/golden/schemas/approval.pending-created.json
  • approval-gate/src/functions/get_pending.rs
  • approval-gate/src/functions/sweep.rs
  • approval-gate/tests/golden/schemas/approval.list-pending.json
  • approval-gate/src/functions/list_pending.rs
  • approval-gate/src/functions/mod.rs
  • approval-gate/src/config.rs
  • approval-gate/src/manifest.rs
  • approval-gate/src/events.rs
  • approval-gate/tests/integration.rs
  • approval-gate/tests/golden/schemas/approval.get-pending.json

Comment thread approval-gate/architecture/integration.md Outdated
Comment thread approval-gate/src/gate_config.rs Outdated
Comment on lines +66 to +74
fn parse_permissions(value: &Value) -> Permissions {
let built_in = default_permissions();
let Some(rules_val) = value.get("rules") else {
return built_in;
};
let specs = parse_rules_from_config(rules_val);
if specs.is_empty() {
return built_in;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Explicit empty rules cannot disable built-in permissions.

Line 72 treats an explicit empty array ("rules": []) the same as “no rules provided” and falls back to default_permissions(). That prevents operators from enforcing a strict “no pre-allow rules” posture and can silently widen access versus intended config.

🔧 Suggested fix
 fn parse_permissions(value: &Value) -> Permissions {
     let built_in = default_permissions();
     let Some(rules_val) = value.get("rules") else {
         return built_in;
     };
+    let Some(raw_rules) = rules_val.as_array() else {
+        return built_in;
+    };
+    if raw_rules.is_empty() {
+        return Permissions::empty();
+    }
     let specs = parse_rules_from_config(rules_val);
     if specs.is_empty() {
         return built_in;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn parse_permissions(value: &Value) -> Permissions {
let built_in = default_permissions();
let Some(rules_val) = value.get("rules") else {
return built_in;
};
let specs = parse_rules_from_config(rules_val);
if specs.is_empty() {
return built_in;
}
fn parse_permissions(value: &Value) -> Permissions {
let built_in = default_permissions();
let Some(rules_val) = value.get("rules") else {
return built_in;
};
let Some(raw_rules) = rules_val.as_array() else {
return built_in;
};
if raw_rules.is_empty() {
return Permissions::empty();
}
let specs = parse_rules_from_config(rules_val);
if specs.is_empty() {
return built_in;
}
🤖 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 `@approval-gate/src/gate_config.rs` around lines 66 - 74, The parse_permissions
function currently treats an explicit empty rules array the same as a missing
rules key, both falling back to default_permissions(). To fix this, remove the
second condition that checks if specs.is_empty() and returns built_in, since
this prevents operators from intentionally disabling built-in permissions with
an explicit empty rules array. Instead, only return default_permissions() when
the rules key is not present, and allow the specs from parse_rules_from_config
to be used directly even when empty, so that an explicit "rules": []
configuration actually results in empty permissions rather than silently
reverting to defaults.

Comment on lines +39 to +42
Regex::new(&re).map_err(|e| CompileError::Rule {
index: 0,
message: format!("invalid glob {pattern:?}: {e}"),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix incorrect rule index propagation in compile errors.

CompileError::Rule.index is hardcoded to 0 and then the real index is spliced into the message string, producing inconsistent diagnostics (e.g., duplicated rule prefixes). Use the real rule index in the structured field instead of message rewriting.

Suggested fix
-fn compile_function_glob(pattern: &str) -> Result<Regex, CompileError> {
+fn compile_function_glob(pattern: &str, index: usize) -> Result<Regex, CompileError> {
...
-    Regex::new(&re).map_err(|e| CompileError::Rule {
-        index: 0,
+    Regex::new(&re).map_err(|e| CompileError::Rule {
+        index,
         message: format!("invalid glob {pattern:?}: {e}"),
     })
 }
...
-    let glob = compile_function_glob(pattern).map_err(|mut e| {
-        let CompileError::Rule {
-            index: _,
-            ref mut message,
-        } = &mut e;
-        *message = format!("rule {index}: {message}");
-        e
-    })?;
+    let glob = compile_function_glob(pattern, index)?;

Also applies to: 52-58, 78-81

🤖 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 `@approval-gate/src/permissions/compile.rs` around lines 39 - 42, The
CompileError::Rule instances are hardcoding the index field to 0 instead of
using the actual rule index value, which causes inconsistent error diagnostics
with duplicated rule prefixes in the message. Replace the hardcoded index value
of 0 with the actual rule index from the function parameters or loop iteration
in all three locations where CompileError::Rule is used (around lines 39-42,
52-58, and 78-81), ensuring each error reports the correct index of the rule
that failed to compile.

Comment on lines +94 to +102
let args = obj
.get("args")
.and_then(Value::as_object)
.map(|map| {
map.iter()
.filter_map(|(field, c)| parse_constraint(c).map(|spec| (field.clone(), spec)))
.collect()
})
.unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not silently drop invalid constraints/rules during config parsing.

Current parsing broadens policy on malformed input (filter_map drops bad constraints/rules). For structured allow rules, this can unintentionally allow calls that should require approval. Parse structured args strictly (fail the whole rule when any constraint is invalid) and surface a warning/error path.

Suggested strict-parse adjustment
-    let args = obj
-        .get("args")
-        .and_then(Value::as_object)
-        .map(|map| {
-            map.iter()
-                .filter_map(|(field, c)| parse_constraint(c).map(|spec| (field.clone(), spec)))
-                .collect()
-        })
-        .unwrap_or_default();
+    let args = match obj.get("args") {
+        None => Vec::new(),
+        Some(v) => {
+            let map = v.as_object()?;
+            map.iter()
+                .map(|(field, c)| parse_constraint(c).map(|spec| (field.clone(), spec)))
+                .collect::<Option<Vec<_>>>()?
+        }
+    };

Also applies to: 122-127

🤖 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 `@approval-gate/src/permissions/mod.rs` around lines 94 - 102, The current
implementation uses filter_map to parse constraints from the args object, which
silently discards any constraints that fail to parse. This is a security issue
because invalid constraints in allow rules can be dropped, potentially allowing
unauthorized calls. Instead of using filter_map, collect the results from
parse_constraint into a collection and check for parse errors. If any constraint
fails to parse, the entire rule parsing should fail and return an error rather
than silently dropping the invalid constraint. This strict parsing approach
should be applied consistently to all constraint parsing locations where args
are processed (including the section mentioned at lines 122-127).

Comment thread approval-gate/src/testkit/engine.rs Outdated
Comment thread approval-gate/src/testkit/engine.rs Outdated
ytallo added 2 commits June 17, 2026 14:12
Move permission evaluation into the approval-gate configuration entry so harness turns no longer depend on policy::check_permissions, and drop sweep/timeout so holds persist until human resolve or session/turn purge.
Scope built-in defaults to denying approval::* only, persist rules via initial_value and backfill on boot so the console Configuration editor is pre-filled, and hold all other calls until operators extend rules.
@ytallo
ytallo force-pushed the feat/approval-gate-config-rules-infinite-holds branch from 7399f72 to cbefa00 Compare June 17, 2026 17:18
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 22 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@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: 2

🧹 Nitpick comments (1)
approval-gate/src/permissions/compile.rs (1)

208-212: 💤 Low value

Unreachable else branch — dead code.

When constraints is non-empty, the loop always sets matched = Some(...) on the first iteration (line 205). If all constraints pass, matched is guaranteed to be Some after the loop, making the else branch at lines 210-211 unreachable.

Simplify by removing the unreachable branch
-    if matched.is_some() {
-        (ConstraintMatch::With, matched)
-    } else {
-        (ConstraintMatch::NoConstraint, None)
-    }
+    (ConstraintMatch::With, matched)
🤖 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 `@approval-gate/src/permissions/compile.rs` around lines 208 - 212, The else
branch in the conditional block checking if matched.is_some() is unreachable
dead code because the prior loop iteration guarantees that matched will be Some
when constraints is non-empty. Remove the else branch that returns
ConstraintMatch::NoConstraint and simplify the code to only return the
ConstraintMatch::With case with the matched value, since that is the only path
that will ever execute.
🤖 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 `@approval-gate/src/config.rs`:
- Around line 387-394: The test function from_yaml_expands_env_var has
formatting that does not conform to Rust's standard formatting conventions as
enforced by cargo fmt. Run cargo fmt --all locally to automatically reformat the
entire codebase including this test function to match the project's formatting
standards, then commit the changes.

In `@approval-gate/src/configuration.rs`:
- Around line 108-112: The set_config_value function has formatting that does
not comply with Rust's standard formatting rules as enforced by cargo fmt. To
fix this, run cargo fmt --all from the root of the project to automatically
apply the required formatting changes to the function and any other files that
need formatting adjustments.

---

Nitpick comments:
In `@approval-gate/src/permissions/compile.rs`:
- Around line 208-212: The else branch in the conditional block checking if
matched.is_some() is unreachable dead code because the prior loop iteration
guarantees that matched will be Some when constraints is non-empty. Remove the
else branch that returns ConstraintMatch::NoConstraint and simplify the code to
only return the ConstraintMatch::With case with the matched value, since that is
the only path that will ever execute.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 735edb99-44ff-4b55-9588-b9f7abd13b44

📥 Commits

Reviewing files that changed from the base of the PR and between 336531b and cbefa00.

⛔ Files ignored due to path filters (1)
  • approval-gate/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • approval-gate/Cargo.toml
  • approval-gate/README.md
  • approval-gate/architecture/README.md
  • approval-gate/architecture/integration.md
  • approval-gate/architecture/internals.md
  • approval-gate/src/config.rs
  • approval-gate/src/configuration.rs
  • approval-gate/src/decision.rs
  • approval-gate/src/error.rs
  • approval-gate/src/events.rs
  • approval-gate/src/functions/gate.rs
  • approval-gate/src/functions/get_pending.rs
  • approval-gate/src/functions/list_pending.rs
  • approval-gate/src/functions/mod.rs
  • approval-gate/src/functions/on_session_deleted.rs
  • approval-gate/src/functions/on_turn_completed.rs
  • approval-gate/src/functions/purge.rs
  • approval-gate/src/functions/resolve.rs
  • approval-gate/src/functions/sweep.rs
  • approval-gate/src/lib.rs
  • approval-gate/src/main.rs
  • approval-gate/src/manifest.rs
  • approval-gate/src/pending.rs
  • approval-gate/src/permissions/compile.rs
  • approval-gate/src/permissions/default_rules.rs
  • approval-gate/src/permissions/mod.rs
  • approval-gate/src/permissions/types.rs
  • approval-gate/src/policy.rs
  • approval-gate/src/testkit/engine.rs
  • approval-gate/src/testkit/mod.rs
  • approval-gate/src/types.rs
  • approval-gate/tests/golden/schemas/approval.get-pending.json
  • approval-gate/tests/golden/schemas/approval.list-pending.json
  • approval-gate/tests/golden/schemas/approval.pending-created.json
  • approval-gate/tests/golden/schemas/approval.pending-resolved.json
  • approval-gate/tests/golden/schemas/approval.sweep.json
  • approval-gate/tests/integration.rs
  • approval-gate/tests/schemas.rs
💤 Files with no reviewable changes (9)
  • approval-gate/tests/golden/schemas/approval.sweep.json
  • approval-gate/src/functions/sweep.rs
  • approval-gate/src/events.rs
  • approval-gate/tests/golden/schemas/approval.pending-resolved.json
  • approval-gate/tests/golden/schemas/approval.get-pending.json
  • approval-gate/src/functions/on_turn_completed.rs
  • approval-gate/tests/golden/schemas/approval.pending-created.json
  • approval-gate/tests/golden/schemas/approval.list-pending.json
  • approval-gate/src/policy.rs
✅ Files skipped from review due to trivial changes (6)
  • approval-gate/Cargo.toml
  • approval-gate/src/functions/list_pending.rs
  • approval-gate/architecture/README.md
  • approval-gate/src/functions/purge.rs
  • approval-gate/src/error.rs
  • approval-gate/src/pending.rs
🚧 Files skipped from review as they are similar to previous changes (11)
  • approval-gate/src/permissions/default_rules.rs
  • approval-gate/src/testkit/mod.rs
  • approval-gate/src/functions/get_pending.rs
  • approval-gate/src/permissions/types.rs
  • approval-gate/src/functions/on_session_deleted.rs
  • approval-gate/src/lib.rs
  • approval-gate/architecture/integration.md
  • approval-gate/src/functions/mod.rs
  • approval-gate/src/permissions/mod.rs
  • approval-gate/src/functions/resolve.rs
  • approval-gate/src/types.rs

Comment thread approval-gate/src/config.rs Outdated
Comment thread approval-gate/src/configuration.rs Outdated
ytallo added 2 commits June 17, 2026 21:39
…holds never expire

Consolidate the four overlapping permission sources (deployment rules,
per-session deltas, console localStorage defaults, harness structural floor)
into one canonical source: the `approval-gate` configuration `rules`
(architecture/permissions-source.md). Auto-mode trust formerly in
`always_allow_seed` is now expressed as allow rules with `modes: ["auto"]`;
session `always_allow` / `approved_always` stay as per-session human deltas.

- permissions: inline first-match rule evaluation in `approval::gate`
  (allow / deny / no-match holds); mode-scoped allow rules seed the auto allowlist
- holds never expire: the harness sweep skips any pending with `held_by`
  (approval/hook holds); only sub-agent child pendings time out
- slim config.rs / configuration.rs to the consolidated model; drop the dead
  per-call timeout_ms plumbing and the STATE_/CONFIG_TIMEOUT_MS constants
  (state/config trigger calls use the SDK default)
- harness v1 wiring: deferred resolve, hook runner, turn loop
- tests: contract_parity + harness_integration lock the cross-worker wire shapes
- drop the retired !policy::check_permissions floor from iii-permissions.yaml
Replace the console's localStorage permission defaults with the canonical
`approval-gate` configuration entry, matching the worker-side single-source
consolidation.

- approval-gate-config.ts (new): load/save default_mode + auto allowlist via
  configuration::get/set on the `approval-gate` entry; derive the harness
  FunctionPolicy (deny floor) from the deployment rules
- real.ts: send the policy derived from the config on each turn, with a
  fail-closed fallback (deny approval::*/configuration::*) when the read fails
- use-approval-settings.ts: seed a new conversation's mode + allowlist from the
  config instead of localStorage; degrade to manual on read failure
- ConsoleSettingsTab.tsx: edit the deployment config entry (load on mount,
  persist on change) instead of localStorage
- DefaultPermissionModePicker.tsx: skip localStorage writes when the parent
  controls the value via onChange

@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: 5

Caution

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

⚠️ Outside diff range comments (3)
approval-gate/architecture/integration.md (1)

79-79: ⚠️ Potential issue | 🟡 Minor

Update gate hold response in diagram to reflect removal of pending_timeout_ms

The diagram at line 79 shows { decision: "hold", pending_timeout_ms: 0 }, but the code has completely removed pending_timeout_ms from the response. The test at types.rs:413 and the function comment at functions/gate.rs:97-99 both confirm the response is now simply { decision: "hold" }.

Update line 79 to: AG-->>H: { decision: "hold" }

🤖 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 `@approval-gate/architecture/integration.md` at line 79, Update the sequence
diagram response at line 79 in the integration.md file to remove the
pending_timeout_ms field from the gate hold response. The response from AG to H
should be changed to only include the decision field set to "hold", removing the
pending_timeout_ms: 0 part entirely to match the actual implementation confirmed
in the gate function and test cases.
approval-gate/src/types.rs (2)

177-183: ⚠️ Potential issue | 🔴 Critical

Wire contract change: TimeoutAborted requires downstream consumer updates.

The ResolvedOutcome enum now produces "aborted" instead of "timeout" in the wire format. However, the TypeScript consumer in console/web/src/types/iii-agent-event.ts (line 56) still defines the type with 'timeout', and the tech spec at tech-specs/2026-06-agentic/approval-gate.md (line 427) still documents "timeout" as valid. These must be updated to remove 'timeout' to match the Rust changes.

🤖 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 `@approval-gate/src/types.rs` around lines 177 - 183, The ResolvedOutcome enum
in Rust now serializes "aborted" instead of "timeout" for the wire format.
Update the TypeScript type definition in
console/web/src/types/iii-agent-event.ts (around line 56) to replace the
'timeout' variant with 'aborted' to match the Rust enum. Additionally, update
the tech spec documentation in tech-specs/2026-06-agentic/approval-gate.md
(around line 427) to replace all references to "timeout" with "aborted" to keep
the specification aligned with the actual wire contract.

144-175: ⚠️ Potential issue | 🔴 Critical

Update console TypeScript type to match Rust removal of expires_at.

The Rust PendingApprovalRecord struct (lines 144–175) no longer includes expires_at, but console/web/src/types/iii-agent-event.ts still defines it as a required field (expires_at: number). When the approval-gate sends records without this field, deserialization will fail or create type mismatches. Either remove expires_at from the TypeScript interface or mark it optional (expires_at?: number).

🤖 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 `@approval-gate/src/types.rs` around lines 144 - 175, The TypeScript interface
definition for PendingApprovalRecord still includes expires_at as a required
field, but the Rust PendingApprovalRecord struct no longer contains this field,
causing deserialization failures. Update the TypeScript interface to either
remove the expires_at field entirely or make it optional by changing it to
expires_at?: number to match the Rust struct definition.
🧹 Nitpick comments (2)
approval-gate/src/permissions/mod.rs (1)

123-136: 💤 Low value

Silent dropping of invalid mode strings may widen policy unintentionally.

If an operator specifies modes: ["auto", "typo"], the typo is silently ignored and the rule applies only to auto mode. This may not match operator intent — consider logging a warning when unknown mode strings are encountered.

🤖 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 `@approval-gate/src/permissions/mod.rs` around lines 123 - 136, The modes
parsing logic silently drops invalid mode strings using filter_map without
notifying the operator of the issue. Modify the filter_map call that matches
mode strings against the PermissionMode variants so that when a string doesn't
match any known mode (manual, auto, or full), log a warning message indicating
the unrecognized mode value before filtering it out. This ensures operators are
aware when their configuration contains typos or invalid mode values instead of
silently narrowing the policy scope.
console/web/src/pages/Configuration/tabs/ConsoleSettingsTab.tsx (1)

95-117: ⚡ Quick win

Avoid calling async functions inside setState callbacks.

The addAllow and removeAllow callbacks invoke persistDefaults (an async operation) inside the setAllowlist updater function. While the current code happens to work because persistDefaults uses its own argument rather than reading from state, this pattern mixes side effects with state updates and can lead to subtle bugs if the logic evolves.

♻️ Suggested refactor to separate concerns
   const addAllow = useCallback(
     (functionId: string) => {
-      setAllowlist((prev) => {
-        if (prev.includes(functionId)) return prev
-        const next = [...prev, functionId]
-        void persistDefaults(defaultMode, next)
-        return next
-      })
+      setAllowlist((prev) => {
+        if (prev.includes(functionId)) return prev
+        return [...prev, functionId]
+      })
+      // Fire persistence after state update (uses updated closure value next render)
+      const next = allowlist.includes(functionId) ? allowlist : [...allowlist, functionId]
+      void persistDefaults(defaultMode, next)
     },
     [defaultMode, persistDefaults],
+    [allowlist, defaultMode, persistDefaults],
   )

   const removeAllow = useCallback(
     (functionId: string) => {
-      setAllowlist((prev) => {
-        if (!prev.includes(functionId)) return prev
-        const next = prev.filter((id) => id !== functionId)
-        void persistDefaults(defaultMode, next)
-        return next
-      })
+      setAllowlist((prev) => {
+        if (!prev.includes(functionId)) return prev
+        return prev.filter((id) => id !== functionId)
+      })
+      const next = allowlist.filter((id) => id !== functionId)
+      void persistDefaults(defaultMode, next)
     },
-    [defaultMode, persistDefaults],
+    [allowlist, defaultMode, persistDefaults],
   )

Alternatively, keep the current approach but add a comment explaining that persistDefaults intentionally uses its argument, not closure state.

🤖 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 `@console/web/src/pages/Configuration/tabs/ConsoleSettingsTab.tsx` around lines
95 - 117, Move the persistDefaults async function call outside the setState
updater callback in both addAllow and removeAllow functions. Instead of invoking
persistDefaults inside the setAllowlist updater, first update the state with the
new array value, then call persistDefaults with the updated array after the
state setter. This separates the side effect (async persistence) from the state
update logic, following the principle of separating concerns and avoiding side
effects within state updaters.
🤖 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 `@approval-gate/src/config.rs`:
- Around line 276-281: The from_yaml_expands_env_var_in_rules test function uses
std::env::set_var and std::env::remove_var which are not thread-safe and will
cause race conditions when tests run in parallel. Fix this by either adding the
#[serial] attribute from the serial_test crate above the test function to ensure
it runs serially, or refactor the expand_env function to accept an environment
variable lookup closure parameter that can be mocked in tests, allowing tests to
run without relying on global environment state.

In `@approval-gate/src/testkit/engine.rs`:
- Around line 446-452: The harness subprocess spawned by spawn_harness_worker
can be orphaned if the wait_for_harness readiness check fails, since the panic
occurs before the child process is stored in TestStack where Drop cleanup would
normally occur. Modify the code to explicitly kill and wait for the child
process before the assertion panics when wait_for_harness fails, ensuring the
subprocess is properly cleaned up regardless of the readiness check outcome.

In `@approval-gate/tests/contract_parity.rs`:
- Around line 5-37: The tests in gate_hold_matches_harness_parse_output,
resolve_allow_payload_matches_harness_function_resolve_request, and
resolve_deny_payload_matches_harness_function_resolve_request are
self-referential because they hardcode the expected JSON structure and only
validate against that same hardcoded structure. To properly verify contract
parity between approval-gate and harness, replace the hardcoded json! macro
payloads with actual calls to the approval-gate serialization functions that
produce these payloads in the real code, then validate that the actual
serialized output matches the expected harness contract. This ensures the tests
catch real contract drift when the producer changes.

In `@approval-gate/tests/harness_integration.rs`:
- Around line 12-31: The with_harness_stack function calls spawn_engine()
directly which can cause race conditions in parallel tests. After the harness
binary environment variable check, replace the current engine spawning logic
with a call to testkit::with_stack instead. This will reuse the testkit boot
lock mechanism to serialize engine startup and prevent concurrent boot races.
Refactor the function to use testkit::with_stack which handles the engine
spawning with proper serialization, ensuring all tests inherit the same
thread-safe boot guarantees.

In `@harness/src/deferred.rs`:
- Around line 249-259: The pending_call_expired function has a bug on the line
computing elapsed time where now.saturating_sub(pending_at) as u64 can produce
incorrect results when pending_at is greater than now due to clock skew. When
the subtraction yields a negative i64 value and is cast to u64, it wraps around
to a huge positive value, causing premature timeout resolution. Fix this by
clamping the elapsed time calculation to ensure it does not go negative before
the u64 cast, such as by using max(0, elapsed_time) or by checking if the
elapsed time is negative and returning false in that case to indicate the call
is not expired when clock skew causes inverted timestamps.

---

Outside diff comments:
In `@approval-gate/architecture/integration.md`:
- Line 79: Update the sequence diagram response at line 79 in the integration.md
file to remove the pending_timeout_ms field from the gate hold response. The
response from AG to H should be changed to only include the decision field set
to "hold", removing the pending_timeout_ms: 0 part entirely to match the actual
implementation confirmed in the gate function and test cases.

In `@approval-gate/src/types.rs`:
- Around line 177-183: The ResolvedOutcome enum in Rust now serializes "aborted"
instead of "timeout" for the wire format. Update the TypeScript type definition
in console/web/src/types/iii-agent-event.ts (around line 56) to replace the
'timeout' variant with 'aborted' to match the Rust enum. Additionally, update
the tech spec documentation in tech-specs/2026-06-agentic/approval-gate.md
(around line 427) to replace all references to "timeout" with "aborted" to keep
the specification aligned with the actual wire contract.
- Around line 144-175: The TypeScript interface definition for
PendingApprovalRecord still includes expires_at as a required field, but the
Rust PendingApprovalRecord struct no longer contains this field, causing
deserialization failures. Update the TypeScript interface to either remove the
expires_at field entirely or make it optional by changing it to expires_at?:
number to match the Rust struct definition.

---

Nitpick comments:
In `@approval-gate/src/permissions/mod.rs`:
- Around line 123-136: The modes parsing logic silently drops invalid mode
strings using filter_map without notifying the operator of the issue. Modify the
filter_map call that matches mode strings against the PermissionMode variants so
that when a string doesn't match any known mode (manual, auto, or full), log a
warning message indicating the unrecognized mode value before filtering it out.
This ensures operators are aware when their configuration contains typos or
invalid mode values instead of silently narrowing the policy scope.

In `@console/web/src/pages/Configuration/tabs/ConsoleSettingsTab.tsx`:
- Around line 95-117: Move the persistDefaults async function call outside the
setState updater callback in both addAllow and removeAllow functions. Instead of
invoking persistDefaults inside the setAllowlist updater, first update the state
with the new array value, then call persistDefaults with the updated array after
the state setter. This separates the side effect (async persistence) from the
state update logic, following the principle of separating concerns and avoiding
side effects within state updaters.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c88e17a6-a532-48f5-9ead-e0ec5eb96f00

📥 Commits

Reviewing files that changed from the base of the PR and between cbefa00 and e2d56ab.

⛔ Files ignored due to path filters (1)
  • approval-gate/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (52)
  • approval-gate/Cargo.toml
  • approval-gate/README.md
  • approval-gate/architecture/README.md
  • approval-gate/architecture/integration.md
  • approval-gate/architecture/internals.md
  • approval-gate/architecture/permissions-source.md
  • approval-gate/src/config.rs
  • approval-gate/src/configuration.rs
  • approval-gate/src/functions/add_always_allow.rs
  • approval-gate/src/functions/approve_always.rs
  • approval-gate/src/functions/clear_settings.rs
  • approval-gate/src/functions/gate.rs
  • approval-gate/src/functions/get_pending.rs
  • approval-gate/src/functions/get_settings.rs
  • approval-gate/src/functions/list_pending.rs
  • approval-gate/src/functions/on_session_deleted.rs
  • approval-gate/src/functions/purge.rs
  • approval-gate/src/functions/remove_always_allow.rs
  • approval-gate/src/functions/resolve.rs
  • approval-gate/src/functions/set_mode.rs
  • approval-gate/src/harness.rs
  • approval-gate/src/main.rs
  • approval-gate/src/pending.rs
  • approval-gate/src/permissions/compile.rs
  • approval-gate/src/permissions/default_rules.rs
  • approval-gate/src/permissions/mod.rs
  • approval-gate/src/permissions/types.rs
  • approval-gate/src/session.rs
  • approval-gate/src/settings.rs
  • approval-gate/src/state.rs
  • approval-gate/src/testkit/engine.rs
  • approval-gate/src/types.rs
  • approval-gate/tests/contract_parity.rs
  • approval-gate/tests/golden/schemas/approval.add-always-allow.json
  • approval-gate/tests/golden/schemas/approval.approve-always.json
  • approval-gate/tests/golden/schemas/approval.gate.json
  • approval-gate/tests/golden/schemas/approval.get-settings.json
  • approval-gate/tests/golden/schemas/approval.remove-always-allow.json
  • approval-gate/tests/golden/schemas/approval.set-mode.json
  • approval-gate/tests/harness_integration.rs
  • approval-gate/tests/integration.rs
  • console/web/src/components/permissions/DefaultPermissionModePicker.tsx
  • console/web/src/hooks/use-approval-settings.ts
  • console/web/src/lib/backend/approval-gate-config.test.ts
  • console/web/src/lib/backend/approval-gate-config.ts
  • console/web/src/lib/backend/real.ts
  • console/web/src/pages/Configuration/tabs/ConsoleSettingsTab.tsx
  • harness/src/deferred.rs
  • harness/src/functions/function_trigger.rs
  • harness/src/hooks/runner.rs
  • harness/src/turn_loop.rs
  • iii-permissions.yaml
💤 Files with no reviewable changes (1)
  • iii-permissions.yaml
✅ Files skipped from review due to trivial changes (9)
  • approval-gate/architecture/permissions-source.md
  • approval-gate/tests/golden/schemas/approval.add-always-allow.json
  • approval-gate/tests/golden/schemas/approval.set-mode.json
  • approval-gate/tests/golden/schemas/approval.remove-always-allow.json
  • approval-gate/tests/golden/schemas/approval.approve-always.json
  • approval-gate/tests/golden/schemas/approval.get-settings.json
  • approval-gate/src/functions/purge.rs
  • approval-gate/architecture/README.md
  • approval-gate/README.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • approval-gate/src/permissions/types.rs
  • approval-gate/tests/integration.rs
  • approval-gate/architecture/internals.md
  • approval-gate/src/permissions/compile.rs

Comment on lines +276 to 281
fn from_yaml_expands_env_var_in_rules() {
std::env::set_var("APPROVAL_GATE_TEST_RULE", "state::get");
let cfg = WorkerConfig::from_yaml("rules:\n - \"${APPROVAL_GATE_TEST_RULE}\"\n").unwrap();
assert_eq!(cfg.rules, vec![json!("state::get")]);
std::env::remove_var("APPROVAL_GATE_TEST_RULE");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

std::env::set_var is not thread-safe and can cause flaky tests.

set_var/remove_var are inherently racy when tests run in parallel. Consider using a serial test attribute or extracting expand_env testing to use a mock environment lookup.

Suggested approach

Use #[serial] from the serial_test crate, or refactor expand_env to accept an environment lookup closure for testability:

// Option 1: Add serial_test dependency and use #[serial]
#[test]
#[serial]
fn from_yaml_expands_env_var_in_rules() { ... }

// Option 2: Parameterize expand_env for testing
fn expand_env_with<F>(input: &str, lookup: F) -> String
where F: Fn(&str) -> Option<String> { ... }
🤖 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 `@approval-gate/src/config.rs` around lines 276 - 281, The
from_yaml_expands_env_var_in_rules test function uses std::env::set_var and
std::env::remove_var which are not thread-safe and will cause race conditions
when tests run in parallel. Fix this by either adding the #[serial] attribute
from the serial_test crate above the test function to ensure it runs serially,
or refactor the expand_env function to accept an environment variable lookup
closure parameter that can be mocked in tests, allowing tests to run without
relying on global environment state.

Comment on lines +446 to +452
let harness_child = if opts.real_harness {
let child = spawn_harness_worker(&engine.url).expect("spawn harness worker");
assert!(
wait_for_harness(&iii).await,
"harness worker did not become ready"
);
Some(child)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid orphaning the harness subprocess when readiness fails.

At Line 448, a failed readiness check panics before child is stored in TestStack, so the TestStack::Drop cleanup path never runs for that process. Add explicit kill/wait before panicking.

Suggested fix
-    let harness_child = if opts.real_harness {
-        let child = spawn_harness_worker(&engine.url).expect("spawn harness worker");
-        assert!(
-            wait_for_harness(&iii).await,
-            "harness worker did not become ready"
-        );
-        Some(child)
+    let harness_child = if opts.real_harness {
+        let mut child = spawn_harness_worker(&engine.url).expect("spawn harness worker");
+        if !wait_for_harness(&iii).await {
+            let _ = child.kill();
+            let _ = child.wait();
+            panic!("harness worker did not become ready");
+        }
+        Some(child)
     } else {
         None
     };
🤖 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 `@approval-gate/src/testkit/engine.rs` around lines 446 - 452, The harness
subprocess spawned by spawn_harness_worker can be orphaned if the
wait_for_harness readiness check fails, since the panic occurs before the child
process is stored in TestStack where Drop cleanup would normally occur. Modify
the code to explicitly kill and wait for the child process before the assertion
panics when wait_for_harness fails, ensuring the subprocess is properly cleaned
up regardless of the readiness check outcome.

Comment on lines +5 to +37
#[test]
fn gate_hold_matches_harness_parse_output() {
let hold = json!({ "decision": "hold" });
assert!(hold.get("pending_timeout_ms").is_none());
}

#[test]
fn resolve_allow_payload_matches_harness_function_resolve_request() {
let payload = json!({
"session_id": "s_1",
"turn_id": "t_1",
"function_call_id": "c_1",
"action": "execute",
});
assert_eq!(payload["action"], json!("execute"));
assert!(payload.get("content").is_none());
assert!(payload.get("is_error").is_none());
}

#[test]
fn resolve_deny_payload_matches_harness_function_resolve_request() {
let payload = json!({
"session_id": "s_1",
"turn_id": "t_1",
"function_call_id": "c_1",
"action": "deliver",
"is_error": true,
"content": [{ "type": "text", "text": "denied" }],
"details": { "status": "denied", "denied_by": "user" },
});
assert_eq!(payload["action"], json!("deliver"));
assert_eq!(payload["is_error"], json!(true));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

These parity tests are self-referential and won’t catch real contract drift.

At Lines 7-37, each test constructs inline JSON and then validates that same literal shape. This does not verify the actual approval-gate/harness serialization path, so producer/consumer contract regressions can still pass.

🤖 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 `@approval-gate/tests/contract_parity.rs` around lines 5 - 37, The tests in
gate_hold_matches_harness_parse_output,
resolve_allow_payload_matches_harness_function_resolve_request, and
resolve_deny_payload_matches_harness_function_resolve_request are
self-referential because they hardcode the expected JSON structure and only
validate against that same hardcoded structure. To properly verify contract
parity between approval-gate and harness, replace the hardcoded json! macro
payloads with actual calls to the approval-gate serialization functions that
produce these payloads in the real code, then validate that the actual
serialized output matches the expected harness contract. This ensures the tests
catch real contract drift when the producer changes.

Comment on lines +12 to +31
async fn with_harness_stack<F, Fut>(opts: BootOpts, f: F)
where
F: FnOnce(approval_gate::testkit::TestStack) -> Fut,
Fut: std::future::Future<Output = ()>,
{
if engine_bin().is_none() {
eprintln!("skipping: no iii engine");
return;
}
let Some(engine) = spawn_engine().await else {
eprintln!("skipping: failed to spawn engine");
return;
};
if std::env::var("CARGO_BIN_EXE_harness").is_err() {
eprintln!("skipping: harness binary not built for integration tests");
return;
}
let stack = boot(&engine, opts).await;
f(stack).await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize harness integration engine boot to prevent parallel startup races.

At Line 21, spawn_engine() is called directly in multi-thread tests, bypassing the testkit boot lock that was added to avoid concurrent boot races/flakes. Reuse testkit::with_stack (after the harness-binary env check) so these tests inherit the same serialization guarantees.

Suggested fix
-use approval_gate::testkit::{
-    boot, call, engine_bin, hook_input, spawn_engine, state_get, state_set, BootOpts,
-};
+use approval_gate::testkit::{call, hook_input, state_get, state_set, with_stack, BootOpts};

 async fn with_harness_stack<F, Fut>(opts: BootOpts, f: F)
 where
     F: FnOnce(approval_gate::testkit::TestStack) -> Fut,
     Fut: std::future::Future<Output = ()>,
 {
-    if engine_bin().is_none() {
-        eprintln!("skipping: no iii engine");
-        return;
-    }
-    let Some(engine) = spawn_engine().await else {
-        eprintln!("skipping: failed to spawn engine");
-        return;
-    };
     if std::env::var("CARGO_BIN_EXE_harness").is_err() {
         eprintln!("skipping: harness binary not built for integration tests");
         return;
     }
-    let stack = boot(&engine, opts).await;
-    f(stack).await;
+    with_stack(opts, f).await;
 }
🤖 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 `@approval-gate/tests/harness_integration.rs` around lines 12 - 31, The
with_harness_stack function calls spawn_engine() directly which can cause race
conditions in parallel tests. After the harness binary environment variable
check, replace the current engine spawning logic with a call to
testkit::with_stack instead. This will reuse the testkit boot lock mechanism to
serialize engine startup and prevent concurrent boot races. Refactor the
function to use testkit::with_stack which handles the engine spawning with
proper serialization, ensuring all tests inherit the same thread-safe boot
guarantees.

Comment thread harness/src/deferred.rs Outdated
Comment on lines +249 to +259
fn pending_call_expired(cp: &crate::types::turn::CallCheckpoint, default_timeout_ms: u64, now: i64) -> bool {
if cp.state != CallState::Pending {
return false;
}
if cp.held_by.is_some() {
return false;
}
let timeout = cp.pending_timeout_ms.unwrap_or(default_timeout_ms);
let pending_at = cp.pending_at.unwrap_or(now);
now.saturating_sub(pending_at) as u64 >= timeout
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp negative elapsed time before converting to u64.

On Line 258, now.saturating_sub(pending_at) as u64 can turn negative deltas into huge positive values, causing premature timeout resolution when pending_at > now (e.g., clock skew).

Suggested fix
 fn pending_call_expired(cp: &crate::types::turn::CallCheckpoint, default_timeout_ms: u64, now: i64) -> bool {
     if cp.state != CallState::Pending {
         return false;
     }
     if cp.held_by.is_some() {
         return false;
     }
     let timeout = cp.pending_timeout_ms.unwrap_or(default_timeout_ms);
     let pending_at = cp.pending_at.unwrap_or(now);
-    now.saturating_sub(pending_at) as u64 >= timeout
+    let elapsed_ms = now.saturating_sub(pending_at).max(0) as u64;
+    elapsed_ms >= timeout
 }
 #[test]
 fn sub_agent_pending_uses_default_timeout_when_unset() {
@@
 }
+
+#[test]
+fn future_pending_at_does_not_expire_immediately() {
+    let now = 1_000_000;
+    let checkpoint = cp(CallState::Pending, None, Some(60_000), now + 5_000);
+    assert!(!pending_call_expired(&checkpoint, 1_800_000, now));
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn pending_call_expired(cp: &crate::types::turn::CallCheckpoint, default_timeout_ms: u64, now: i64) -> bool {
if cp.state != CallState::Pending {
return false;
}
if cp.held_by.is_some() {
return false;
}
let timeout = cp.pending_timeout_ms.unwrap_or(default_timeout_ms);
let pending_at = cp.pending_at.unwrap_or(now);
now.saturating_sub(pending_at) as u64 >= timeout
}
fn pending_call_expired(cp: &crate::types::turn::CallCheckpoint, default_timeout_ms: u64, now: i64) -> bool {
if cp.state != CallState::Pending {
return false;
}
if cp.held_by.is_some() {
return false;
}
let timeout = cp.pending_timeout_ms.unwrap_or(default_timeout_ms);
let pending_at = cp.pending_at.unwrap_or(now);
let elapsed_ms = now.saturating_sub(pending_at).max(0) as u64;
elapsed_ms >= timeout
}
🤖 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 `@harness/src/deferred.rs` around lines 249 - 259, The pending_call_expired
function has a bug on the line computing elapsed time where
now.saturating_sub(pending_at) as u64 can produce incorrect results when
pending_at is greater than now due to clock skew. When the subtraction yields a
negative i64 value and is cast to u64, it wraps around to a huge positive value,
causing premature timeout resolution. Fix this by clamping the elapsed time
calculation to ensure it does not go negative before the u64 cast, such as by
using max(0, elapsed_time) or by checking if the elapsed time is negative and
returning false in that case to indicate the call is not expired when clock skew
causes inverted timestamps.

The consolidation commit left two latent failures that CI hit once the
formatting check ran:

- rustfmt: format files the change touched but didn't run fmt on
  (config.rs, permissions/mod.rs, tests/*, harness/deferred.rs)
- clippy::needless_update: drop the now-redundant `..WorkerConfig::default()`
  in two tests — WorkerConfig is just { default_mode, rules }, so every field
  was already specified

No behavior change.
@ytallo
ytallo merged commit 557f692 into main Jun 18, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants