Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dd734de
feat(approval-gate): layered policy rules with findLast evaluator
ytallo May 15, 2026
aa9c4bf
feat(approval-gate): cascade allow on `always: true` resolve reply
ytallo May 15, 2026
04bb1d4
chore(approval-gate): remove legacy migration surface
ytallo May 15, 2026
025aac6
feat(approval-gate): add typed Record, Status, Next schema
ytallo May 15, 2026
61f5cea
refactor(approval-gate): extract wire.rs from lib.rs
ytallo May 15, 2026
e53a71b
refactor(approval-gate): extract lifecycle.rs from lib.rs
ytallo May 15, 2026
6ca444f
refactor(approval-gate): extract state.rs from lib.rs
ytallo May 15, 2026
b2fa112
refactor(approval-gate): extract intercept.rs from lib.rs
ytallo May 15, 2026
ff17905
refactor(approval-gate): extract resolve.rs from lib.rs
ytallo May 15, 2026
c8e1cfb
refactor(approval-gate): extract delivery.rs from lib.rs
ytallo May 15, 2026
29f4c95
refactor(approval-gate): extract sweeper.rs from lib.rs
ytallo May 15, 2026
b6e3402
refactor(approval-gate): extract register.rs from lib.rs
ytallo May 15, 2026
60e7d6a
refactor(approval-gate): move pub(crate)-using tests inline
ytallo May 16, 2026
cf95b3e
refactor(approval-gate): move public-API tests to tests/*.rs
ytallo May 16, 2026
0c6f95f
feat(approval-gate): new Record schema with Pending|InFlight|Done lif…
ytallo May 16, 2026
4f305f8
fix(approval-gate): keep lifecycle.rs as transitional compat shim
ytallo May 16, 2026
84b886b
feat(approval-gate): Denial::Policy carries rule_permission + rule_pa…
ytallo May 16, 2026
1cad8c1
feat(approval-gate): pattern_for(function_id, args) extractor
ytallo May 16, 2026
06401ba
feat(approval-gate): StateBus::delete primitive
ytallo May 16, 2026
f5b945c
feat(approval-gate): verdict-driven handle_intercept
ytallo May 16, 2026
d163794
feat(approval-gate): three-phase resolve + cascade exact-pattern (T6+T7)
ytallo May 16, 2026
2b316f9
feat(approval-gate): approval::consume + strip delivery dead RPCs + d…
ytallo May 16, 2026
cdfe5ba
feat(approval-gate): strip __from_approval marker plumbing (T10)
ytallo May 16, 2026
114cdf0
feat(approval-gate): strip config to topic+scope+timeout+rules (T12)
ytallo May 16, 2026
ad2ed8e
feat(shell): strip classify_argv / allowlist / __from_approval marker…
ytallo May 16, 2026
40ad326
feat(turn-orchestrator): switch stitch to approval::consume (T14)
ytallo May 16, 2026
80a8ecc
test(approval-gate): E2E lifecycle tests + delete dead integration te…
ytallo May 16, 2026
51e0c75
feat(harness/web): Allow+Always, Deny feedback, expires_at countdown …
ytallo May 16, 2026
2e4410d
docs(harness/fanout): note T16 stream-rewrite as deferred follow-up
ytallo May 16, 2026
beebbb0
fix(shell/e2e): drop allowlist/denylist tests + configs
ytallo May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions approval-gate/iii.worker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ language: rust
deploy: binary
manifest: Cargo.toml
bin: approval-gate
description: Hook subscriber on agent::before_function_call that pauses function calls listed in approval_required until the UI resolves them via approval::resolve.
description: |
Hook subscriber on agent::before_function_call. Decides every LLM-initiated
function call via a layered rules engine. Allow → pass through. Deny →
structured Denial::Policy. Ask → write a Pending record and wait for
approval::resolve. The classifier surface and __from_approval marker are gone;
policy lives entirely in the rules layer.

runtime:
kind: rust
Expand All @@ -17,12 +22,34 @@ config:
topic: agent::before_function_call
approval_state_scope: approvals
default_timeout_ms: 300000
interceptors:
- function_id: shell::exec
classifier: shell::classify_argv
classifier_timeout_ms: 2000
inject_approval_marker: true
- function_id: shell::exec_bg
classifier: shell::classify_argv
classifier_timeout_ms: 2000
inject_approval_marker: true

# Curated default ruleset. `before_function_call` fires for every tool
# call; with no rules and no-match defaulting to Ask, an empty ruleset
# would prompt for every read-only function. The defaults below
# auto-allow safe reads and ask for everything that writes/executes/
# mutates. Operators stack their own rules on top — last-match wins.
rules:
# Read-only filesystem / introspection
- { permission: "fs::read", pattern: "*", action: allow }
- { permission: "fs::list", pattern: "*", action: allow }
- { permission: "fs::stat", pattern: "*", action: allow }
- { permission: "fs::glob", pattern: "*", action: allow }
- { permission: "fs::grep", pattern: "*", action: allow }

# Read-only git
- { permission: "shell::exec", pattern: "git status*", action: allow }
- { permission: "shell::exec", pattern: "git log*", action: allow }
- { permission: "shell::exec", pattern: "git diff*", action: allow }
- { permission: "shell::exec", pattern: "git show*", action: allow }
- { permission: "shell::exec", pattern: "git branch*", action: allow }
- { permission: "shell::exec", pattern: "git remote*", action: allow }

# Approval API — the gate must not gate itself
- { permission: "approval::*", pattern: "*", action: allow }

# All remaining shell exec calls → ask
- { permission: "shell::exec", pattern: "*", action: ask }
- { permission: "shell::exec_bg", pattern: "*", action: ask }

# Catch-all: anything else → ask. (Operator overrides go above.)
- { permission: "*", pattern: "*", action: ask }
5 changes: 3 additions & 2 deletions approval-gate/skills/sweep_session.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# approval::sweep_session

Sweep all pending approval records for a session to `timed_out` with reason `session_deleted`.
Sweep all pending approval records for a session to `timed_out`.

**Payload:**
- `session_id` (string, required)
Expand All @@ -12,4 +12,5 @@ Sweep all pending approval records for a session to `timed_out` with reason `ses
**Behavior:**
- Only records with `status: "pending"` are flipped.
- Non-pending records (already resolved, executed, denied, etc.) are left untouched.
- Intended to be called by the session worker or turn-orchestrator when a session is being deleted, so that pending approvals don't dangle forever.
- The flipped records carry no `Denial` — `status: "timed_out"` is self-describing per the Denial refactor. Callers that need to distinguish session-delete from run-stop sweeps should log that context in their own worker.
- Intended to be called by the session worker or turn-orchestrator when a session is being deleted or a run is stopped, so pending approvals don't dangle forever.
86 changes: 36 additions & 50 deletions approval-gate/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
//! YAML-backed runtime settings for [`WorkerConfig`].
//!
//! Post-refactor surface (T12):
//! - `topic` — hook bus topic the gate subscribes to.
//! - `approval_state_scope` — iii-state scope for approval records.
//! - `default_timeout_ms` — Pending-row TTL.
//! - `rules` — the layered ruleset (default + operator-shipped),
//! evaluated in order with last-match winning.
//!
//! Deleted in T12: `interceptors`, `sweeper_interval_ms`,
//! `InterceptorRule` (the classifier surface is gone).

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
Expand All @@ -15,21 +25,16 @@ fn default_default_timeout_ms() -> u64 {
300_000
}

fn default_classifier_timeout_ms() -> u64 {
2000
}

/// Per-function iii intercept rule: optional classifier trigger before pending +
/// optional `__from_approval` injection on post-resolve `iii.trigger`.
/// Temporary alias retained while register.rs's classifier-alias warning
/// loop still references the symbol. The struct is structurally unused
/// (no fields populated from config) and will be deleted alongside the
/// warning loop when there are no more callers. Provided here so the
/// crate builds.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct InterceptorRule {
pub function_id: String,
#[serde(default)]
pub classifier: Option<String>,
#[serde(default = "default_classifier_timeout_ms")]
pub classifier_timeout_ms: u64,
#[serde(default)]
pub inject_approval_marker: bool,
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
Expand All @@ -40,8 +45,11 @@ pub struct WorkerConfig {
pub approval_state_scope: String,
#[serde(default = "default_default_timeout_ms")]
pub default_timeout_ms: u64,
/// Layered permission ruleset. Allow / Deny / Ask actions. Evaluated
/// last-match-wins; the YAML's curated defaults ship at the bottom,
/// operator overrides stack on top. See [`crate::rules`].
#[serde(default)]
pub interceptors: Vec<InterceptorRule>,
pub rules: crate::rules::Ruleset,
}

impl Default for WorkerConfig {
Expand All @@ -50,7 +58,7 @@ impl Default for WorkerConfig {
topic: default_topic(),
approval_state_scope: default_approval_state_scope(),
default_timeout_ms: default_default_timeout_ms(),
interceptors: Vec::new(),
rules: Vec::new(),
}
}
}
Expand All @@ -69,57 +77,35 @@ pub fn load_config(path: &str) -> Result<WorkerConfig> {
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Action, Rule};

#[test]
fn defaults_from_empty_yaml_mapping() {
let cfg: WorkerConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(cfg.topic, default_topic());
assert_eq!(cfg.approval_state_scope, "approvals");
assert_eq!(cfg.default_timeout_ms, 300_000);
assert!(cfg.interceptors.is_empty());
}

#[test]
fn interceptors_default_empty() {
assert!(WorkerConfig::default().interceptors.is_empty());
}

#[test]
fn interceptors_parse_from_nested_config_block() {
let yaml = r#"
interceptors:
- function_id: shell::exec
classifier: shell::classify_argv
classifier_timeout_ms: 1500
inject_approval_marker: true
- function_id: other::fn
classifier: null
"#;
let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.interceptors.len(), 2);
assert_eq!(cfg.interceptors[0].function_id, "shell::exec");
assert_eq!(
cfg.interceptors[0].classifier.as_deref(),
Some("shell::classify_argv")
);
assert_eq!(cfg.interceptors[0].classifier_timeout_ms, 1500);
assert!(cfg.interceptors[0].inject_approval_marker);
assert_eq!(cfg.interceptors[1].function_id, "other::fn");
assert!(cfg.interceptors[1].classifier.is_none());
assert!(!cfg.interceptors[1].inject_approval_marker);
assert!(cfg.rules.is_empty());
}

#[test]
fn interceptor_rule_marker_defaults_false() {
fn rules_parse_from_yaml() {
let yaml = r#"
interceptors:
- function_id: x::y
classifier: c::f
rules:
- { permission: "shell::exec", pattern: "git status*", action: allow }
- { permission: "shell::exec", pattern: "*", action: ask }
"#;
let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.interceptors.len(), 1);
assert!(!cfg.interceptors[0].inject_approval_marker);
assert_eq!(cfg.interceptors[0].classifier_timeout_ms, 2000);
assert_eq!(cfg.rules.len(), 2);
assert_eq!(cfg.rules[0].permission, "shell::exec");
assert_eq!(cfg.rules[0].pattern, "git status*");
assert_eq!(cfg.rules[0].action, Action::Allow);
assert_eq!(cfg.rules[1].action, Action::Ask);
let _ = Rule { // smoke check on the imported type
permission: "x".into(),
pattern: "*".into(),
action: Action::Deny,
};
}

#[test]
Expand Down
Loading
Loading