Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@ rand = "0.8"
toml = "0.9"
globset = "0.4"
cliclack = "0.5"
console = "0.16"
dirs = "6.0"
tempfile = "3.20"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Reflex Control decides whether an AI task can finish on its own, retry, or escal
> [!NOTE]
> **Status:** Research prototype (v0.4.0). Tested on synthetic fixtures; not production-validated.

![Accept, Retry and Escalate decisions for four task results](docs/demo/runtime-gate.gif)
![Reflex Control guarding a simulated coding agent: two protected files, a force push and failing tests](docs/demo/agent.gif)

- Runs fast local checks (exit codes, test results, git diffs) alongside semantic risk signals.
- Blocks high-risk actions like credential leaks or schema changes with a mandatory safety veto.
Expand All @@ -28,10 +28,10 @@ The prebuilt CLI does not require Rust. The installer downloads a release, check
curl -fsSL https://raw.githubusercontent.com/rustfuture/reflex-control/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
reflex --version
reflex demo verifier-gate
reflex demo agent
```

The demo prints local policy decisions without an API key or model download. Prebuilt Linux binaries require glibc; unsupported platforms can build from source. On Windows, use the [PowerShell installer](install.ps1).
The demo shows Reflex Control guarding a simulated coding agent, without an API key or model download. Prebuilt Linux binaries require glibc; unsupported platforms can build from source. On Windows, use the [PowerShell installer](install.ps1).

<details><summary>Build from source</summary>

Expand Down
1 change: 1 addition & 0 deletions crates/reflex-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ rand = { workspace = true }
thiserror = { workspace = true }
reflex-hooks = { workspace = true }
cliclack = { workspace = true }
console = { workspace = true }
298 changes: 298 additions & 0 deletions crates/reflex-cli/src/commands/agent_demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
//! `reflex demo agent`: terminal demo showing Reflex Control guarding a simulated coding agent.

use console::{style, Style};
use reflex_hooks::config::Config;
use reflex_hooks::guard::{
check_command_in_dir, check_paths, decide_turn_end, PathPolicy, TestOutcome, TurnEndInput,
Verdict,
};
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::time::Duration;

/// One action attempted by the simulated agent, paired with Reflex Control's verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
pub verb: &'static str,
pub target: String,
pub verdict: Verdict,
}

/// Evaluates the six scenario steps against Reflex Control's real hook engine.
pub fn run_scenario() -> Vec<Step> {
let root = Path::new("/demo/project");
let mut cfg = Config::default();
cfg.tests.command = "cargo test".to_string();
let policy =
PathPolicy::new(&cfg.protect.paths, root).expect("default config paths are valid globs");

vec![
// a) agent edits src/lib.rs -> check_paths (expect Allow)
Step {
verb: "edit",
target: "src/lib.rs".to_string(),
verdict: check_paths(&["src/lib.rs"], &policy),
},
// b) agent writes .env -> check_paths (expect Block)
Step {
verb: "write",
target: ".env".to_string(),
verdict: check_paths(&[".env"], &policy),
},
// c) agent runs: echo "token=abc" >> secrets/api.txt -> check_command_in_dir (expect Block)
Step {
verb: "run",
target: "echo \"token=abc\" >> secrets/api.txt".to_string(),
verdict: check_command_in_dir(
"echo \"token=abc\" >> secrets/api.txt",
&policy,
Some(root),
),
},
// d) agent runs: git push --force origin main -> check_command_in_dir (expect Ask)
Step {
verb: "run",
target: "git push --force origin main".to_string(),
verdict: check_command_in_dir("git push --force origin main", &policy, Some(root)),
},
// e) agent says it is done; tests fail
// (TestOutcome::Failed with output "test parser::empty_input ... FAILED", diff_lines 12, retries_so_far 0)
// -> decide_turn_end (expect RetryAgent)
Step {
verb: "done",
target: "cargo test → 1 failed".to_string(),
verdict: decide_turn_end(
&TurnEndInput {
tests: TestOutcome::Failed {
output: "test parser::empty_input ... FAILED".to_string(),
},
diff_lines: 12,
retries_so_far: 0,
},
&cfg,
),
},
// f) agent fixes it; tests pass (TestOutcome::Passed, diff_lines 14, retries_so_far 1)
// -> decide_turn_end (expect Allow)
Step {
verb: "done",
target: "cargo test → passed".to_string(),
verdict: decide_turn_end(
&TurnEndInput {
tests: TestOutcome::Passed,
diff_lines: 14,
retries_so_far: 1,
},
&cfg,
),
},
]
}

fn format_badge(verdict: &Verdict) -> console::StyledObject<&'static str> {
match verdict {
Verdict::Allow => Style::new().on_green().black().bold().apply_to(" ALLOW "),
Verdict::Block { .. } => Style::new().on_red().black().bold().apply_to(" BLOCK "),
Verdict::Ask { .. } | Verdict::AskHuman { .. } => {
Style::new().on_yellow().black().bold().apply_to(" ASK ")
}
Verdict::RetryAgent { .. } => Style::new().on_magenta().black().bold().apply_to(" RETRY "),
Verdict::Notify { .. } => Style::new().on_cyan().black().bold().apply_to(" NOTIFY"),
}
}

fn get_reason(verdict: &Verdict) -> Option<&str> {
match verdict {
Verdict::Block { reason } => Some(reason),
Verdict::Ask { reason } | Verdict::AskHuman { reason } => Some(reason),
Verdict::RetryAgent { reason } => Some(reason),
Verdict::Notify { message } => Some(message),
Verdict::Allow => None,
}
}

fn format_reason(reason: &str, is_retry: bool) -> String {
let stripped = reason.strip_prefix("Reflex Control: ").unwrap_or(reason);

let sentence = if is_retry {
if let Some(pos1) = stripped.find(". ") {
let after_first = &stripped[pos1 + 2..];
if let Some(pos2) = after_first.find(". ") {
&stripped[..pos1 + 2 + pos2 + 1]
} else if let Some(dot_pos) = after_first.find('.') {
&stripped[..pos1 + 2 + dot_pos + 1]
} else {
stripped
}
} else if let Some(dot_pos) = stripped.find('.') {
&stripped[..dot_pos + 1]
} else {
stripped
}
} else if let Some(pos) = stripped.find(". ") {
&stripped[..pos + 1]
} else if let Some(dot_pos) = stripped.find('.') {
&stripped[..dot_pos + 1]
} else {
stripped
};

if sentence.chars().count() > 110 {
let max_prefix: String = sentence.chars().take(110).collect();
if let Some(last_space) = max_prefix.rfind(' ') {
format!("{}…", &max_prefix[..last_space])
} else {
format!("{max_prefix}…")
}
} else {
sentence.to_string()
}
}

/// Renders the demo output to terminal.
pub fn render_demo(steps: &[Step]) -> Result<(), Box<dyn std::error::Error>> {
let is_fast = std::env::var("REFLEX_DEMO_FAST")
.map(|v| v != "0" && !v.is_empty())
.unwrap_or(false)
|| !io::stdout().is_terminal();

let box_width = 46;
let horizontal = "─".repeat(box_width);
println!(" {}", style(format!("╭{horizontal}╮")).dim());
println!(
" {} {} · {} {}",
style("│").dim(),
style("Reflex Control").bold(),
style("guarding a coding agent").dim(),
style("│").dim()
);
println!(" {}", style(format!("╰{horizontal}╯")).dim());
println!();

for step in steps {
let badge = format_badge(&step.verdict);
let verb_col = format!("{:<6}", step.verb);
println!(" {badge} {} {}", style(verb_col).dim(), step.target);

if matches!(
step.verdict,
Verdict::Block { .. } | Verdict::Ask { .. } | Verdict::RetryAgent { .. }
) {
if let Some(reason) = get_reason(&step.verdict) {
let is_retry = matches!(step.verdict, Verdict::RetryAgent { .. });
let formatted = format_reason(reason, is_retry);
println!(" ↳ {}", style(formatted).dim());
}
}

io::stdout().flush()?;
if !is_fast {
std::thread::sleep(Duration::from_millis(450));
}
}

let total = steps.len();
let blocked = steps
.iter()
.filter(|s| matches!(s.verdict, Verdict::Block { .. }))
.count();
let needs_approval = steps
.iter()
.filter(|s| matches!(s.verdict, Verdict::Ask { .. } | Verdict::AskHuman { .. }))
.count();
let sent_back = steps
.iter()
.filter(|s| matches!(s.verdict, Verdict::RetryAgent { .. }))
.count();

println!();
println!(
" {total} agent steps {} blocked {} needs your approval {} sent back {}",
style(blocked).red().bold(),
style(needs_approval).yellow().bold(),
style(sent_back).magenta().bold(),
style("tests green").green().bold()
);
println!();
let disclaimer = concat!(
" Decisions come from Reflex Control's real hook engine; ",
"the agent and its tests are simulated."
);
println!("{}", style(disclaimer).dim());
io::stdout().flush()?;

Ok(())
}

/// Entry point for `reflex demo agent`.
pub fn execute() -> Result<(), Box<dyn std::error::Error>> {
let steps = run_scenario();
render_demo(&steps)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use reflex_hooks::guard::Verdict;

#[test]
fn test_scenario_verdicts() {
let steps = run_scenario();
assert_eq!(steps.len(), 6);

let kinds: Vec<&'static str> = steps
.iter()
.map(|s| match s.verdict {
Verdict::Allow => "Allow",
Verdict::Block { .. } => "Block",
Verdict::Ask { .. } => "Ask",
Verdict::RetryAgent { .. } => "RetryAgent",
Verdict::AskHuman { .. } => "AskHuman",
Verdict::Notify { .. } => "Notify",
})
.collect();

assert_eq!(
kinds,
vec!["Allow", "Block", "Block", "Ask", "RetryAgent", "Allow"]
);

assert!(matches!(steps[0].verdict, Verdict::Allow));
assert!(matches!(steps[1].verdict, Verdict::Block { .. }));
assert!(matches!(steps[2].verdict, Verdict::Block { .. }));
assert!(matches!(steps[3].verdict, Verdict::Ask { .. }));
assert!(matches!(steps[4].verdict, Verdict::RetryAgent { .. }));
assert!(matches!(steps[5].verdict, Verdict::Allow));
}

#[test]
fn test_format_reason() {
let r_block = "Reflex Control: this edit writes to `.env`, which is protected \
(matches `.env`). Do not change it. To allow it, the user can edit.";
assert_eq!(
format_reason(r_block, false),
"this edit writes to `.env`, which is protected (matches `.env`)."
);

let r_retry = "Reflex Control: the tests failed (`cargo test`). This is fix \
attempt 1 of 2. Fix the failures, then finish again.";
assert_eq!(
format_reason(r_retry, true),
"the tests failed (`cargo test`). This is fix attempt 1 of 2."
);

let long = "word ".repeat(30);
let formatted = format_reason(&long, false);
assert!(formatted.chars().count() <= 111);
assert!(formatted.ends_with('…'));
assert!(!formatted.ends_with(" …"));
}

#[test]
fn test_render_fast() {
std::env::set_var("REFLEX_DEMO_FAST", "1");
let steps = run_scenario();
render_demo(&steps).expect("render should succeed");
}
}
1 change: 1 addition & 0 deletions crates/reflex-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod agent_demo;
pub mod benchmark;
pub mod calibrate;
pub mod demo;
Expand Down
6 changes: 6 additions & 0 deletions crates/reflex-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ struct BenchmarkArgs {

#[derive(Subcommand, Debug)]
enum DemoSubcommands {
/// Watch Reflex Control guard a simulated agent session (no API key)
Agent,

/// Run the primary Verifier Gate demo
VerifierGate {
#[arg(long, default_value = "reflex.db")]
Expand Down Expand Up @@ -265,6 +268,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
commands::experiment::execute(args).await?;
}
Commands::Demo { sub } => match sub {
DemoSubcommands::Agent => {
commands::agent_demo::execute()?;
}
DemoSubcommands::VerifierGate { db } => {
commands::demo::execute_verifier_gate(db).await?;
}
Expand Down
Binary file added docs/demo/agent.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 5 additions & 8 deletions docs/demo/runtime-gate.tape → docs/demo/agent.tape
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
Output docs/demo/runtime-gate.gif
Output docs/demo/agent.gif
Set Shell "bash"
Set FontSize 16
Set Width 1200
Set Height 260
Set Padding 24
Set Height 470
Set Padding 28
Set Theme "Catppuccin Mocha"
Set TypingSpeed 40ms
Hide
Type `export PS1='$ ' PATH="$PWD/target/release:$PATH"` Enter
Type "clear" Enter
Show
Type `example-agent-runtime-gate`
Sleep 500ms
Enter
Sleep 4s
Type "reflex demo agent" Sleep 400ms Enter
Sleep 6s
Binary file removed docs/demo/runtime-gate.gif
Binary file not shown.
Loading