use anyhow::{anyhow, Context};
use clap::Parser;
use log::{debug, error, info, warn};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
use tokio::time::timeout;
use aperion_shield::{
decide, fingerprint, identity, orgmode, Adjustments, BurstDetector, Decision, DecisionMemory,
Engine, IdMeProvider, IdentityConfig, IdentityGate, IdentityProvider, MockProvider, Outcome,
ProviderKind, WorkspaceContext,
};
use aperion_shield::engine::{Scope, Severity};
use aperion_shield::orgmode::{
smartflow_provider::ResolveOutcome, AuditEvent, AuditSink, EnrolledHandles, OrgApi, OrgState,
SmartflowProvider,
};
use aperion_shield::sandbox;
use aperion_shield::supply;
use aperion_shield::transport;
#[derive(Debug, Parser)]
#[command(name = "aperion-shield", version, about, long_about = None)]
struct Cli {
#[arg(long, value_name = "PATH")]
rules: Option<PathBuf>,
#[arg(long = "rules-extra", value_name = "PATH")]
rules_extra: Vec<PathBuf>,
#[arg(long, value_name = "LEVEL", default_value = "off")]
sandbox: String,
#[arg(long = "sandbox-allow", value_name = "PATH")]
sandbox_allow: Vec<PathBuf>,
#[arg(long)]
sandbox_allow_network: bool,
#[arg(
long,
value_name = "TARGET",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules"
)]
scan: Option<String>,
#[arg(long, value_name = "FMT", value_parser = ["text", "json"], requires = "scan")]
scan_format: Option<String>,
#[arg(long, requires = "scan")]
scan_offline: bool,
#[arg(long)]
shadow: bool,
#[arg(long)]
auto_deny_high: bool,
#[arg(long)]
no_workspace_probe: bool,
#[arg(long)]
no_memory: bool,
#[arg(long)]
no_burst: bool,
#[arg(long, value_name = "MODE", value_parser = ["public", "off"])]
telemetry: Option<String>,
#[arg(long, conflicts_with = "upstream")]
check: bool,
#[arg(long, value_name = "PATH")]
workspace: Option<PathBuf>,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "enroll",
conflicts_with = "status",
conflicts_with = "disenroll",
conflicts_with = "identity_list",
conflicts_with = "identity_flush"
)]
diff: bool,
#[arg(long, value_name = "PATH", requires = "diff")]
rules_before: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "diff")]
rules_after: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "diff")]
corpus: Option<PathBuf>,
#[arg(long, value_name = "FMT", value_parser = ["text", "markdown", "json"], requires = "diff")]
format: Option<String>,
#[arg(long, value_name = "N", default_value_t = 3, requires = "diff")]
max_samples: usize,
#[arg(long, requires = "diff")]
fail_if_flipped: bool,
#[arg(long, requires = "diff")]
fail_if_loosened: bool,
#[arg(long, value_name = "N", requires = "diff")]
fail_if_allows_loosened: Option<usize>,
#[arg(long, value_name = "PATH")]
identity_config: Option<PathBuf>,
#[arg(long)]
no_identity: bool,
#[arg(long, conflicts_with = "upstream", conflicts_with = "check")]
identity_list: bool,
#[arg(long, conflicts_with = "upstream", conflicts_with = "check")]
identity_flush: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "enroll"
)]
install_hooks: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks"
)]
uninstall_hooks: bool,
#[arg(long, value_name = "PATH")]
repo: Option<PathBuf>,
#[arg(long, requires = "install_hooks")]
chain_existing: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks"
)]
check_staged: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged"
)]
check_pushed_refs: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs"
)]
suggest_rules: bool,
#[arg(long, value_name = "PATH", requires = "suggest_rules")]
audit_log: Option<PathBuf>,
#[arg(long, value_name = "N", requires = "suggest_rules")]
suggest_window_days: Option<u32>,
#[arg(long, value_name = "N", default_value_t = 5, requires = "suggest_rules")]
suggest_min_occurrences: usize,
#[arg(
long,
value_name = "FMT",
value_parser = ["text", "markdown", "md", "yaml-patch", "yaml", "patch"],
requires = "suggest_rules"
)]
suggest_format: Option<String>,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules"
)]
install_shims: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules",
conflicts_with = "install_shims"
)]
uninstall_shims: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules",
conflicts_with = "install_shims",
conflicts_with = "uninstall_shims"
)]
list_shims: bool,
#[arg(
long = "for",
value_name = "CMD,CMD,...",
requires = "install_shims"
)]
shim_for: Option<String>,
#[arg(long, value_name = "PATH")]
shim_dir: Option<PathBuf>,
#[arg(
long,
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules",
conflicts_with = "install_shims",
conflicts_with = "uninstall_shims",
conflicts_with = "list_shims"
)]
check_cmd: bool,
#[arg(
long,
conflicts_with = "upstream",
conflicts_with = "check",
conflicts_with = "diff",
conflicts_with = "install_hooks",
conflicts_with = "uninstall_hooks",
conflicts_with = "check_staged",
conflicts_with = "check_pushed_refs",
conflicts_with = "suggest_rules",
conflicts_with = "install_shims",
conflicts_with = "uninstall_shims",
conflicts_with = "list_shims",
conflicts_with = "check_cmd"
)]
explain: bool,
#[arg(long, value_name = "PATH", requires = "explain")]
input: Option<PathBuf>,
#[arg(
long,
value_name = "FMT",
value_parser = ["text", "txt", "markdown", "md", "json"],
requires = "explain"
)]
explain_format: Option<String>,
#[arg(long, requires = "explain")]
explain_force_prod: bool,
#[arg(long, requires = "explain")]
explain_force_burst: bool,
#[arg(long, requires = "explain")]
explain_force_repeatedly_approved: bool,
#[arg(long, requires = "explain")]
explain_force_recently_denied: bool,
#[arg(long, conflicts_with = "upstream", conflicts_with = "check")]
enroll: bool,
#[arg(long, conflicts_with = "upstream", conflicts_with = "check", conflicts_with = "enroll")]
status: bool,
#[arg(long, conflicts_with = "upstream", conflicts_with = "check", conflicts_with = "enroll")]
disenroll: bool,
#[arg(long, requires = "disenroll")]
revoke: bool,
#[arg(long, value_name = "URL", requires = "enroll")]
smartflow_url: Option<String>,
#[arg(long, value_name = "TOKEN", requires = "enroll")]
token: Option<String>,
#[arg(long, value_name = "NAME", requires = "enroll")]
device_name: Option<String>,
#[arg(long, value_name = "EMAIL", requires = "enroll")]
enroll_email: Option<String>,
#[arg(long, value_name = "URL", conflicts_with = "check")]
upstream_url: Option<String>,
#[arg(long, value_name = "HEADER", requires = "upstream_url")]
upstream_header: Vec<String>,
#[arg(long, value_name = "ADDR")]
http_listen: Option<std::net::SocketAddr>,
#[arg(long)]
no_pin: bool,
#[arg(long)]
repin: bool,
#[arg(trailing_var_arg = true, num_args = 0..)]
upstream: Vec<String>,
}
struct Shield {
engine_rx: tokio::sync::watch::Receiver<Arc<Engine>>,
workspace: WorkspaceContext,
memory: DecisionMemory,
burst: BurstDetector,
shadow: bool,
auto_deny: bool,
identity_gate: Option<Arc<IdentityGate>>,
orgmode: Option<Arc<EnrolledHandles>>,
smartflow_identity: Option<Arc<SmartflowProvider>>,
supply: SupplyState,
}
#[derive(Debug, Clone)]
enum PendingKind {
ToolsList,
ToolCall { tool: String },
}
struct SupplyState {
upstream_label: String,
pinning: bool,
pending: Mutex<HashMap<String, PendingKind>>,
quarantined: Mutex<HashSet<String>>,
}
impl Shield {
fn current_engine(&self) -> Arc<Engine> {
self.engine_rx.borrow().clone()
}
}
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.target(env_logger::Target::Stderr)
.init();
let cli = Cli::parse();
if let Some(mode) = cli.telemetry.as_deref() {
eprintln!("[shield] --telemetry {} requested.", mode);
eprintln!("[shield]");
eprintln!("[shield] Telemetry is not yet available. The public block ticker is");
eprintln!("[shield] currently under privacy / DPO review. See:");
eprintln!("[shield]");
eprintln!("[shield] https://shield.aperion.ai/ticker/privacy");
eprintln!("[shield]");
eprintln!("[shield] Re-run without --telemetry to start Shield.");
std::process::exit(2);
}
if cli.identity_list {
return run_identity_list(&cli).await;
}
if cli.identity_flush {
return run_identity_flush(&cli).await;
}
if cli.check {
return run_check_mode(&cli).await;
}
if cli.diff {
let exit_code = run_diff_mode(&cli).await?;
std::process::exit(exit_code);
}
if cli.install_hooks {
let exit_code = run_install_hooks(&cli)?;
std::process::exit(exit_code);
}
if cli.uninstall_hooks {
let exit_code = run_uninstall_hooks(&cli)?;
std::process::exit(exit_code);
}
if cli.check_staged {
let exit_code = run_check_staged(&cli)?;
std::process::exit(exit_code);
}
if cli.check_pushed_refs {
let exit_code = run_check_pushed_refs(&cli)?;
std::process::exit(exit_code);
}
if cli.suggest_rules {
let exit_code = run_suggest_rules(&cli)?;
std::process::exit(exit_code);
}
if cli.scan.is_some() {
let exit_code = run_scan_mode(&cli).await?;
std::process::exit(exit_code);
}
if cli.install_shims {
let exit_code = run_install_shims(&cli)?;
std::process::exit(exit_code);
}
if cli.uninstall_shims {
let exit_code = run_uninstall_shims(&cli)?;
std::process::exit(exit_code);
}
if cli.list_shims {
let exit_code = run_list_shims(&cli)?;
std::process::exit(exit_code);
}
if cli.check_cmd {
let exit_code = run_check_cmd(&cli)?;
std::process::exit(exit_code);
}
if cli.explain {
let exit_code = run_explain(&cli)?;
std::process::exit(exit_code);
}
if cli.enroll {
let url = cli.smartflow_url.as_deref().ok_or_else(|| {
anyhow!("--enroll requires --smartflow-url <URL>")
})?;
let token = cli.token.as_deref().ok_or_else(|| {
anyhow!("--enroll requires --token <TOKEN>")
})?;
return orgmode::run_enroll(
url,
token,
cli.device_name.as_deref(),
cli.enroll_email.as_deref(),
)
.await;
}
if cli.status {
return orgmode::run_status().await;
}
if cli.disenroll {
return orgmode::run_disenroll(cli.revoke).await;
}
if cli.upstream.is_empty() && cli.upstream_url.is_none() {
return Err(anyhow!(
"no upstream MCP server given. Usage:\n \
aperion-shield [--rules PATH] [--shadow] -- <upstream-mcp> [args...] (stdio upstream)\n \
aperion-shield [--rules PATH] --upstream-url https://host/mcp (remote Streamable HTTP upstream)\n\
(For one-shot rule testing without MCP, use `aperion-shield --check`.)"
));
}
if !cli.upstream.is_empty() && cli.upstream_url.is_some() {
return Err(anyhow!(
"--upstream-url conflicts with a trailing stdio upstream command -- pick one"
));
}
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let workspace = if cli.no_workspace_probe {
let mut p = engine.policy.clone();
p.workspace_probe.enabled = false;
WorkspaceContext::probe(&p)
} else {
WorkspaceContext::probe(&engine.policy)
};
let mut mem_cfg = engine.policy.decision_memory.clone();
if cli.no_memory { mem_cfg.enabled = false; }
let memory = DecisionMemory::open(mem_cfg);
let mut burst_cfg = engine.policy.burst_detector.clone();
if cli.no_burst { burst_cfg.enabled = false; }
let burst = BurstDetector::new(burst_cfg);
let mode_label = if cli.shadow { "SHADOW (warn only)" } else { "ENFORCE" };
let upstream_label_banner = match &cli.upstream_url {
Some(url) => url.clone(),
None => cli.upstream.join(" "),
};
warn!(
"[shield] === aperion-shield v{} starting === mode={} rules={} upstream='{}'",
env!("CARGO_PKG_VERSION"),
mode_label,
engine.rules.len(),
upstream_label_banner,
);
warn!(
"[shield] composite_scoring={} workspace_probe={} decision_memory={} burst_detector={} catalog_pinning={}",
engine.policy.composite_scoring.enabled,
engine.policy.workspace_probe.enabled,
memory.enabled(),
engine.policy.burst_detector.enabled,
engine.policy.supply_chain.pinning && !cli.no_pin,
);
if workspace.is_prod {
warn!(
"[shield] workspace looks like PRODUCTION (matched: {}) -- severity bumped one tier on every match",
workspace.matched_signals.join(", ")
);
} else {
info!("[shield] workspace probe: no prod signals matched in {}", workspace.root.display());
}
let identity_gate = if cli.no_identity {
warn!("[shield] --no-identity: identity-gated rules will fall back to plain Approval/Block");
None
} else if engine.rules.iter().any(|r| r.identity.is_some()) {
match build_identity_gate(cli.identity_config.as_deref()).await {
Ok(g) => {
warn!(
"[shield] identity gate ready: providers=[{}] cached_proofs={} hold={}s",
g.config()
.providers
.iter()
.map(|p| format!(
"{}:{}{}",
p.id,
match p.kind { ProviderKind::IdMe => "id_me", ProviderKind::Mock => "mock" },
if matches!(p.kind, ProviderKind::IdMe)
&& !is_idme_ready(p)
{ "(unready)" } else { "" }
))
.collect::<Vec<_>>()
.join(", "),
g.cached_count(),
g.hold_seconds(),
);
Some(Arc::new(g))
}
Err(e) => {
error!("[shield] identity gate setup failed: {}", e);
None
}
}
} else {
info!("[shield] no rules have `identity:` blocks -- identity gate inactive");
None
};
let (orgmode_state, orgmode_handles, smartflow_identity, engine_rx) =
bootstrap_orgmode(engine).await?;
if orgmode_state.is_some() {
warn!("[shield] running in ORG MODE (centrally managed)");
} else {
info!("[shield] running in STANDALONE mode (no orgmode.json)");
}
let upstream = match &cli.upstream_url {
Some(url) => {
let mut headers = Vec::new();
for raw in &cli.upstream_header {
headers.push(transport::http_upstream::parse_header(raw)?);
}
warn!("[shield] upstream transport: Streamable HTTP -> {}", url);
transport::http_upstream::spawn_http_upstream(url, headers)?
}
None => {
let sandbox_cfg = sandbox::SandboxConfig {
level: sandbox::SandboxLevel::parse(&cli.sandbox)?,
allow_paths: cli.sandbox_allow.clone(),
allow_network: cli.sandbox_allow_network,
home: None,
};
let (wrapped, confinement) = sandbox::wrap_command(&cli.upstream, &sandbox_cfg)?;
if confinement != sandbox::Confinement::None {
warn!("[shield] upstream confinement: {}", confinement);
}
transport::spawn_stdio_upstream(&wrapped)?
}
};
let upstream_label = upstream.label.clone();
let mut child = upstream.child;
let to_upstream = upstream.tx;
let mut from_upstream = upstream.rx;
let pinning_enabled = {
let policy_pinning = engine_rx.borrow().policy.supply_chain.pinning;
policy_pinning && !cli.no_pin
};
if cli.repin {
match supply::clear_pins(&upstream_label) {
Ok(true) => warn!(
"[shield] --repin: cleared stored tool-catalog pins for this upstream; \
the next tools/list will be re-pinned (TOFU)"
),
Ok(false) => info!("[shield] --repin: no pins stored for this upstream"),
Err(e) => error!("[shield] --repin failed: {}", e),
}
}
let shield = Arc::new(Shield {
engine_rx,
workspace,
memory,
burst,
shadow: cli.shadow,
auto_deny: cli.auto_deny_high,
identity_gate,
orgmode: orgmode_handles,
smartflow_identity,
supply: SupplyState {
upstream_label,
pinning: pinning_enabled,
pending: Mutex::new(HashMap::new()),
quarantined: Mutex::new(HashSet::new()),
},
});
if let Some(addr) = cli.http_listen {
let http_state = transport::http_server::HttpDownstream::new();
let pump_state = http_state.clone();
let pump_shield = shield.clone();
let from_upstream_handle = tokio::spawn(async move {
while let Some(frame) = from_upstream.recv().await {
let frame = intercept_upstream_frame(frame, &pump_shield).await;
pump_state.route_upstream_frame(frame).await;
}
debug!("[shield] upstream channel closed");
});
let gate: Arc<dyn transport::http_server::RequestGate> =
Arc::new(ShieldGate(shield.clone()));
let serve_result =
transport::http_server::serve(addr, gate, to_upstream, http_state).await;
let _ = from_upstream_handle.await;
if let Err(e) = serve_result {
error!("[shield] http downstream server error: {}", e);
}
} else {
let stdin = tokio::io::stdin();
let stdout = Arc::new(Mutex::new(tokio::io::stdout()));
let stdout_clone = stdout.clone();
let shield_clone = shield.clone();
let to_upstream_handle = tokio::spawn(async move {
let mut reader = BufReader::new(stdin);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => { debug!("[shield] client EOF"); break; }
Ok(_) => {}
Err(e) => { error!("[shield] client read error: {}", e); break; }
}
let frame = line.trim_end();
if frame.is_empty() { continue; }
debug!("[shield] client -> {}", frame);
let parsed: Option<Value> = serde_json::from_str(frame).ok();
if let Some(req) = parsed.as_ref() {
if let Some(decision_resp) = process_client_frame(req, &shield_clone).await {
let mut out = stdout_clone.lock().await;
let _ = out.write_all(decision_resp.to_string().as_bytes()).await;
let _ = out.write_all(b"\n").await;
let _ = out.flush().await;
continue;
}
}
if to_upstream.send(frame.to_string()).await.is_err() {
error!("[shield] upstream channel closed");
break;
}
}
});
let stdout_clone2 = stdout.clone();
let shield_clone2 = shield.clone();
let from_upstream_handle = tokio::spawn(async move {
while let Some(frame) = from_upstream.recv().await {
debug!("[shield] upstream -> {}", frame);
let frame = intercept_upstream_frame(frame, &shield_clone2).await;
let mut out = stdout_clone2.lock().await;
if out.write_all(frame.as_bytes()).await.is_err() { break; }
if out.write_all(b"\n").await.is_err() { break; }
let _ = out.flush().await;
}
debug!("[shield] upstream channel closed");
});
let _ = to_upstream_handle.await;
let _ = from_upstream_handle.await;
}
if let Some(child) = child.as_mut() {
let _ = child.kill().await;
let _ = child.wait().await;
}
if let Some(handles) = shield.orgmode.as_ref() {
let drain = handles.audit.clone();
let _ = tokio::time::timeout(std::time::Duration::from_secs(6), async move {
drain.drain().await;
})
.await;
}
info!("[shield] shutdown complete");
Ok(())
}
fn load_engine_with_packs(
path: Option<&std::path::Path>,
extra: &[PathBuf],
) -> anyhow::Result<Engine> {
let mut engine = match path {
Some(p) => {
let raw = std::fs::read_to_string(p)
.with_context(|| format!("reading shieldset from {}", p.display()))?;
Engine::from_yaml(&raw)?
}
None => Engine::builtin_default(),
};
for p in extra {
let raw = std::fs::read_to_string(p)
.with_context(|| format!("reading rule pack from {}", p.display()))?;
engine
.extend_from_yaml(&raw)
.with_context(|| format!("merging rule pack {}", p.display()))?;
}
Ok(engine)
}
async fn run_scan_mode(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::scan::{run_scan, ScanOptions};
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let launch = if cli.upstream.is_empty() {
Vec::new()
} else {
let sandbox_cfg = sandbox::SandboxConfig {
level: sandbox::SandboxLevel::parse(&cli.sandbox)?,
allow_paths: cli.sandbox_allow.clone(),
allow_network: cli.sandbox_allow_network,
home: None,
};
let (wrapped, confinement) = sandbox::wrap_command(&cli.upstream, &sandbox_cfg)?;
if confinement != sandbox::Confinement::None {
warn!("[shield] scan launch confinement: {}", confinement);
}
wrapped
};
let opts = ScanOptions {
target: cli.scan.clone().expect("checked by caller"),
launch,
offline: cli.scan_offline,
};
let report = run_scan(&opts, &engine).await?;
match cli.scan_format.as_deref() {
Some("json") => println!("{}", serde_json::to_string_pretty(&report)?),
_ => print!("{}", report.render_text()),
}
Ok(report.exit_code())
}
async fn run_check_mode(cli: &Cli) -> anyhow::Result<()> {
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let workspace = {
let mut policy = engine.policy.clone();
if cli.no_workspace_probe {
policy.workspace_probe.enabled = false;
}
match &cli.workspace {
Some(p) => WorkspaceContext::probe_at(&policy, p),
None => WorkspaceContext::probe(&policy),
}
};
let mut mem_cfg = engine.policy.decision_memory.clone();
if cli.no_memory {
mem_cfg.enabled = false;
}
let memory = DecisionMemory::open(mem_cfg);
let mut burst_cfg = engine.policy.burst_detector.clone();
if cli.no_burst {
burst_cfg.enabled = false;
}
let burst = BurstDetector::new(burst_cfg);
eprintln!(
"[shield-check] engine: {} rules | workspace_prod={} signals={:?} composite={} memory={} burst={}",
engine.rules.len(),
workspace.is_prod,
workspace.matched_signals,
engine.policy.composite_scoring.enabled,
memory.enabled(),
engine.policy.burst_detector.enabled,
);
let mut total = 0usize;
let mut expected_failures = 0usize;
let mut by_decision: std::collections::BTreeMap<&'static str, usize> = Default::default();
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut line = String::new();
let mut stdout = tokio::io::stdout();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
error!("[shield-check] stdin read error: {}", e);
break;
}
}
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
let input: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
let err = json!({"error": format!("invalid JSON: {}", e), "input": trimmed});
let _ = stdout.write_all(err.to_string().as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
expected_failures += 1;
total += 1;
continue;
}
};
let expect = input.get("expect").and_then(|v| v.as_str()).map(str::to_string);
let (eval, scope) = if let Some(text) = input.get("text").and_then(|v| v.as_str()) {
let adj = Adjustments {
workspace_is_prod: workspace.is_prod,
burst_in_progress: burst.in_burst(),
..Default::default()
};
(engine.evaluate_text(text, adj), "llm_response")
} else {
let tool = input.get("tool").and_then(|v| v.as_str()).unwrap_or("");
let params = input.get("params").cloned().unwrap_or(Value::Null);
let canonical = if params.get("name").is_some() || params.get("arguments").is_some() {
params.clone()
} else {
json!({ "name": tool, "arguments": params })
};
let first_adj = Adjustments {
workspace_is_prod: workspace.is_prod,
burst_in_progress: burst.in_burst(),
..Default::default()
};
let first = engine.evaluate(tool, &canonical, first_adj);
let mv = if let Some(primary) = first
.matches
.iter()
.max_by(|a, b| a.severity.cmp(&b.severity).then(a.points.cmp(&b.points)))
{
let fp = fingerprint(&primary.rule_id, &canonical);
memory.verdict_for(&fp)
} else {
Default::default()
};
let adj = Adjustments {
workspace_is_prod: workspace.is_prod,
burst_in_progress: burst.in_burst(),
fingerprint_recently_denied: mv.recent_deny,
fingerprint_repeatedly_approved: mv.repeated_approve,
};
(engine.evaluate(tool, &canonical, adj), "tool_call")
};
let decision = decide(&eval);
let label = decision.label();
*by_decision.entry(label).or_insert(0) += 1;
if decision.is_blocking() || matches!(decision, Decision::Warn { .. }) {
let _ = burst.observe();
}
let passed = expect.as_deref().map(|e| e.eq_ignore_ascii_case(label));
if passed == Some(false) {
expected_failures += 1;
}
total += 1;
let mut record = json!({
"input": input,
"scope": scope,
"decision": label,
"matched_rules": eval.matches.iter().map(|m| &m.rule_id).collect::<Vec<_>>(),
"raw_severity": eval.raw_severity.as_str(),
"composite_points": eval.composite_points,
"composite_severity": eval.composite_severity.as_str(),
"final_severity": eval.final_severity.as_str(),
"adjustments": eval.adjustments_applied,
});
match &decision {
Decision::Block { rule_id, reason, safer_alternative, contributing_rules, .. }
| Decision::Approval { rule_id, reason, safer_alternative, contributing_rules, .. } => {
record["primary_rule_id"] = json!(rule_id);
record["reason"] = json!(reason);
if let Some(s) = safer_alternative {
record["safer_alternative"] = json!(s);
}
record["contributing_rules"] = json!(contributing_rules);
}
Decision::IdentityVerification {
rule_id, reason, safer_alternative, contributing_rules, requirement, ..
} => {
record["primary_rule_id"] = json!(rule_id);
record["reason"] = json!(reason);
if let Some(s) = safer_alternative {
record["safer_alternative"] = json!(s);
}
record["contributing_rules"] = json!(contributing_rules);
record["identity_requirement"] = json!({
"provider": requirement.provider,
"scope": requirement.scope,
"allowed_subjects": requirement.allowed_subjects,
"max_proof_age_seconds": requirement.max_proof_age_seconds,
"loa": requirement.loa,
});
}
Decision::Warn { rule_id, banner, safer_alternative, .. } => {
record["primary_rule_id"] = json!(rule_id);
record["banner"] = json!(banner);
if let Some(s) = safer_alternative {
record["safer_alternative"] = json!(s);
}
}
Decision::Allow => {}
}
if let Some(ok) = passed {
record["expected"] = json!(expect.as_deref().unwrap_or(""));
record["passed"] = json!(ok);
}
let _ = stdout.write_all(record.to_string().as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
}
let _ = stdout.flush().await;
eprintln!(
"[shield-check] total={} {} expected_failures={}",
total,
by_decision
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(" "),
expected_failures,
);
if expected_failures > 0 {
std::process::exit(1);
}
Ok(())
}
async fn run_diff_mode(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::diff::{run_diff_mode as run, DiffOptions, OutputFormat};
let rules_before = cli
.rules_before
.clone()
.ok_or_else(|| anyhow!("--diff requires --rules-before PATH"))?;
let rules_after = cli
.rules_after
.clone()
.ok_or_else(|| anyhow!("--diff requires --rules-after PATH"))?;
let format = match cli.format.as_deref() {
Some(s) => OutputFormat::parse(s)?,
None => OutputFormat::Text,
};
let opts = DiffOptions {
rules_before,
rules_after,
corpus: cli.corpus.clone(),
workspace: cli.workspace.clone(),
format,
max_samples: cli.max_samples,
fail_if_flipped: cli.fail_if_flipped,
fail_if_loosened: cli.fail_if_loosened,
fail_if_allows_loosened: cli.fail_if_allows_loosened,
};
run(opts).await
}
fn run_install_hooks(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::hooks::{install, HookInstallOutcome};
let repo = cli
.repo
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let report = install(&repo, cli.chain_existing)?;
eprintln!(
"[shield] hooks dir: {}",
report.hooks_dir.display()
);
let mut had_unknown = false;
for (name, outcome) in [
("pre-commit", report.pre_commit),
("pre-push", report.pre_push),
] {
match outcome {
HookInstallOutcome::Installed => {
eprintln!("[shield] installed: {}", name);
}
HookInstallOutcome::Refreshed => {
eprintln!("[shield] refreshed (already ours): {}", name);
}
HookInstallOutcome::Chained => {
eprintln!(
"[shield] chained over existing hook: {} \
(original moved to {}.aperion-backup; \
re-execed at end of our hook)",
name, name,
);
}
HookInstallOutcome::UnknownHookPresent => {
had_unknown = true;
eprintln!(
"[shield] refused: {} already exists and isn't ours. \
Re-run with `--chain-existing` to keep it (husky-style chain), \
or remove `.git/hooks/{}` first.",
name, name,
);
}
}
}
if had_unknown {
return Ok(1);
}
eprintln!(
"[shield] done. Bypass any single commit with: git commit --no-verify"
);
eprintln!(
"[shield] bypass for an automation run: SHIELD_HOOKS_DISABLE=1 git commit ..."
);
Ok(0)
}
fn run_uninstall_hooks(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::hooks::uninstall;
let repo = cli
.repo
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let report = uninstall(&repo)?;
eprintln!("[shield] hooks dir: {}", report.hooks_dir.display());
for (name, removed, chain_restored) in [
(
"pre-commit",
report.pre_commit_removed,
report.pre_commit_chain_restored,
),
(
"pre-push",
report.pre_push_removed,
report.pre_push_chain_restored,
),
] {
match (removed, chain_restored) {
(true, true) => eprintln!(
"[shield] removed: {} (restored chained-aside original)",
name
),
(true, false) => eprintln!("[shield] removed: {}", name),
(false, _) => eprintln!("[shield] not present: {} (nothing to do)", name),
}
}
Ok(0)
}
fn run_check_staged(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::hooks::check_staged::{run, StagedFinding};
let repo = cli
.repo
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let report = run(&repo, &engine, cli.workspace.as_deref())?;
if report.findings.is_empty() {
eprintln!(
"[shield-check-staged] OK -- inspected {} file(s), {} line(s); no destructive matches.",
report.files_scanned, report.lines_scanned
);
return Ok(report.exit_code() as i32);
}
eprintln!(
"[shield-check-staged] {} finding(s) across {} file(s):",
report.findings.len(),
report.files_scanned
);
eprintln!();
for (rule_id, findings) in report.group_by_rule() {
let first: &StagedFinding = findings[0];
eprintln!(
" [{}] {} ({} match{})",
first.severity,
rule_id,
findings.len(),
if findings.len() == 1 { "" } else { "es" },
);
eprintln!(" why: {}", first.reason);
if let Some(s) = &first.safer_alternative {
eprintln!(" safer alternative: {}", s);
}
for f in findings.iter().take(5) {
eprintln!(
" {}:{} ({}) {}",
f.file,
f.line_no,
f.decision,
truncate(&f.line, 96)
);
}
if findings.len() > 5 {
eprintln!(" ... and {} more match(es) elided", findings.len() - 5);
}
eprintln!();
}
let code = report.exit_code();
match code {
1 => eprintln!(
"[shield-check-staged] commit REFUSED (Block-severity match). \
To override: git commit --no-verify OR SHIELD_HOOKS_DISABLE=1 git commit ..."
),
2 => eprintln!(
"[shield-check-staged] commit REFUSED (Approval-severity match; \
pre-commit cannot prompt). To override: git commit --no-verify"
),
_ => {}
}
Ok(code as i32)
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max])
}
}
fn run_suggest_rules(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::suggest::{run, AnalyzeOptions, OutputFormat};
let audit_path = cli
.audit_log
.clone()
.ok_or_else(|| anyhow!("--suggest-rules requires --audit-log PATH"))?;
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let opts = AnalyzeOptions {
window_days: match cli.suggest_window_days {
Some(0) => None, Some(n) => Some(n),
None => Some(30),
},
min_occurrences: cli.suggest_min_occurrences,
};
let format = match cli.suggest_format.as_deref() {
Some(s) => OutputFormat::parse(s)?,
None => OutputFormat::Text,
};
let (body, count, skipped) = run(&engine, &audit_path, opts, format)?;
print!("{}", body);
if skipped > 0 {
eprintln!(
"[shield-suggest-rules] note: skipped {} non-shield_eval / unparseable line(s)",
skipped
);
}
eprintln!(
"[shield-suggest-rules] {} suggestion(s) from {} ({} days)",
count,
audit_path.display(),
opts.window_days
.map(|d| d.to_string())
.unwrap_or_else(|| "all".to_string()),
);
Ok(if count == 0 { 0 } else { 1 })
}
fn run_check_pushed_refs(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::hooks::check_pushed::{run, PushVerdict};
use std::io::BufReader;
let repo = cli
.repo
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let stdin = BufReader::new(std::io::stdin());
let report = run(&repo, stdin)?;
if report.violations.is_empty() {
eprintln!(
"[shield-check-pushed-refs] OK -- inspected {} ref update(s); no destructive pushes.",
report.refs_inspected
);
return Ok(0);
}
eprintln!(
"[shield-check-pushed-refs] REFUSED -- {} of {} ref update(s) target a protected branch:",
report.violations.len(),
report.refs_inspected,
);
eprintln!();
for (upd, v) in &report.violations {
match v {
PushVerdict::Deletion { protected_branch } => {
eprintln!(
" - DELETE protected branch '{}' (ref: {})",
protected_branch, upd.remote_ref,
);
}
PushVerdict::ForcePush {
protected_branch,
remote_sha,
local_sha,
} => {
eprintln!(
" - FORCE-PUSH to '{}' rewrites history: {} ... {}",
protected_branch,
&remote_sha[..7.min(remote_sha.len())],
&local_sha[..7.min(local_sha.len())],
);
}
PushVerdict::Ok => unreachable!("Ok shouldn't be in violations"),
}
}
eprintln!();
eprintln!(
"[shield-check-pushed-refs] To override: git push --no-verify OR \
SHIELD_HOOKS_DISABLE=1 git push ..."
);
eprintln!(
"[shield-check-pushed-refs] To change the protected set: \
SHIELD_PROTECTED_BRANCHES='main,trunk,release/*' git push ..."
);
Ok(1)
}
fn run_install_shims(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::shims::install::{
install, parse_for_arg, resolve_shim_dir, ShimInstallOutcome,
};
let shim_dir = resolve_shim_dir(cli.shim_dir.as_deref())?;
let commands = match cli.shim_for.as_deref() {
Some(raw) => parse_for_arg(raw)?,
None => Vec::new(), };
let report = install(&shim_dir, &commands)?;
eprintln!(
"[shield-install-shims] shim dir: {}",
report.shim_dir.display()
);
for e in &report.entries {
let label = match e.outcome {
ShimInstallOutcome::Installed => "INSTALLED ",
ShimInstallOutcome::Refreshed => "REFRESHED ",
ShimInstallOutcome::ForeignPresent => "SKIPPED ",
ShimInstallOutcome::UpstreamBinaryNotFound => "NO-UPSTREAM",
};
let detail = match &e.resolved_path {
Some(p) => format!("-> {}", p.display()),
None => match e.outcome {
ShimInstallOutcome::ForeignPresent => {
"existing file at target is not Shield-managed; refusing to overwrite".to_string()
}
ShimInstallOutcome::UpstreamBinaryNotFound => {
"real binary not found on $PATH; skipped".to_string()
}
_ => String::new(),
},
};
eprintln!(" {} {:<14} {}", label, e.command, detail);
}
eprintln!();
eprintln!(
"[shield-install-shims] {} shim(s) installed / refreshed.",
report.successful()
);
eprintln!();
eprintln!("Next step: put this directory FIRST on your $PATH so shims win lookup.");
eprintln!(" zsh : echo 'export PATH=\"{}:$PATH\"' >> ~/.zshrc", report.shim_dir.display());
eprintln!(" bash : echo 'export PATH=\"{}:$PATH\"' >> ~/.bashrc", report.shim_dir.display());
eprintln!(" fish : fish_add_path -p '{}'", report.shim_dir.display());
eprintln!();
eprintln!("Bypass for a single invocation: SHIELD_SHIMS_DISABLE=1 <command> ...");
eprintln!("Uninstall later: aperion-shield --uninstall-shims");
if report.any_foreign() {
return Ok(1);
}
Ok(0)
}
fn run_uninstall_shims(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::shims::install::{resolve_shim_dir, uninstall, ShimUninstallOutcome};
let shim_dir = resolve_shim_dir(cli.shim_dir.as_deref())?;
let report = uninstall(&shim_dir)?;
eprintln!(
"[shield-uninstall-shims] shim dir: {}",
report.shim_dir.display()
);
if report.entries.is_empty() {
eprintln!(" (nothing to remove)");
return Ok(0);
}
for e in &report.entries {
let label = match e.outcome {
ShimUninstallOutcome::Removed => "REMOVED ",
ShimUninstallOutcome::ForeignPresent => "KEPT ",
ShimUninstallOutcome::AbsentNoop => "ABSENT ",
};
let detail = match e.outcome {
ShimUninstallOutcome::ForeignPresent => "(no Aperion marker; left alone)",
_ => "",
};
eprintln!(" {} {:<14} {}", label, e.command, detail);
}
Ok(0)
}
fn run_list_shims(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::shims::install::{list, resolve_shim_dir};
let shim_dir = resolve_shim_dir(cli.shim_dir.as_deref())?;
let entries = list(&shim_dir)?;
if entries.is_empty() {
eprintln!(
"[shield-list-shims] {}: (none installed)",
shim_dir.display()
);
return Ok(0);
}
eprintln!("[shield-list-shims] {}:", shim_dir.display());
for (name, ours) in entries {
let label = if ours { "shield " } else { "foreign" };
eprintln!(" [{}] {}", label, name);
}
Ok(0)
}
fn run_explain(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::explain::{
explain, read_descriptor_from, render::{render, ExplainFormat}, ExplainOptions,
};
let input = cli
.input
.as_ref()
.ok_or_else(|| anyhow!("--explain requires --input <PATH | -> (use `-` for stdin)"))?;
let path_str = input.to_string_lossy().to_string();
let descriptor = read_descriptor_from(&path_str)?;
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let mut opts = ExplainOptions::default();
if cli.explain_force_prod {
opts.force_workspace_prod = Some(true);
}
if cli.explain_force_burst {
opts.force_burst = Some(true);
}
opts.force_repeatedly_approved = cli.explain_force_repeatedly_approved;
opts.force_recently_denied = cli.explain_force_recently_denied;
let report = explain(&engine, &descriptor, &opts)?;
let format = match cli.explain_format.as_deref() {
Some(s) => ExplainFormat::parse(s)?,
None => ExplainFormat::Text,
};
print!("{}", render(&report, format));
Ok(report.exit_code() as i32)
}
fn run_check_cmd(cli: &Cli) -> anyhow::Result<i32> {
use aperion_shield::shims::check_cmd::{refusal_banner, run};
if cli.upstream.is_empty() {
eprintln!(
"[shield-check-cmd] usage: aperion-shield --check-cmd -- <command> [args...]"
);
return Ok(3);
}
let engine = load_engine_with_packs(cli.rules.as_deref(), &cli.rules_extra)?;
let report = run(&engine, &cli.upstream)?;
let audit_record = serde_json::json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "shield_eval",
"source": "check-cmd",
"tool": "shell",
"command": report.command_line,
"decision": report.decision.label(),
"rule_id": report.primary.as_ref().map(|p| p.rule_id.as_str()),
"severity": report.primary.as_ref().map(|p| p.severity.as_str()),
});
eprintln!("{}", audit_record);
if report.exit_code() != 0 {
eprint!("{}", refusal_banner(&report));
}
Ok(report.exit_code() as i32)
}
async fn process_client_frame(req: &Value, shield: &Arc<Shield>) -> Option<Value> {
let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
let id = req.get("id").cloned().unwrap_or(Value::Null);
if method == "tools/call" {
let tool_name = req
.pointer("/params/name")
.and_then(|v| v.as_str())
.unwrap_or("");
if shield.supply.quarantined.lock().await.contains(tool_name) {
if shield.shadow {
warn!(
"[shield][shadow] would have BLOCKED quarantined tool '{}' (rug-pull / poisoned description)",
tool_name
);
} else {
error!(
"[shield] BLOCK tools/call '{}' -- tool is quarantined (its definition changed \
or its description matched poisoning rules). Review with `aperion-shield --repin` \
after verifying the change is legitimate.",
tool_name
);
audit_supply_event(
shield,
"quarantine_block",
tool_name,
"supply.quarantined",
"block",
Severity::Critical,
json!({ "tool": tool_name }),
)
.await;
return Some(jsonrpc_error(
id,
-32096,
"shield_supply_chain_blocked",
json!({
"rule_id": "supply.quarantined",
"severity": "critical",
"reason": format!(
"Tool '{}' is quarantined: its pinned definition changed underneath you \
(possible rug pull) or its description matched tool-poisoning rules.",
tool_name
),
"safer_alternative": "Inspect the server's tools/list diff, then run `aperion-shield --repin` if the change is legitimate.",
"tool": tool_name,
}),
));
}
}
}
if let Some(resp) = evaluate_request(req, shield).await {
return Some(resp);
}
if !id.is_null() {
let kind = match method {
"tools/list" => Some(PendingKind::ToolsList),
"tools/call" => req
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|t| PendingKind::ToolCall { tool: t.to_string() }),
_ => None,
};
if let Some(kind) = kind {
shield
.supply
.pending
.lock()
.await
.insert(transport::http_server::canonical_id(&id), kind);
}
}
None
}
struct ShieldGate(Arc<Shield>);
#[async_trait::async_trait]
impl transport::http_server::RequestGate for ShieldGate {
async fn intercept(&self, req: &Value) -> Option<Value> {
process_client_frame(req, &self.0).await
}
}
async fn intercept_upstream_frame(frame: String, shield: &Arc<Shield>) -> String {
let parsed: Value = match serde_json::from_str(&frame) {
Ok(v) => v,
Err(_) => return frame,
};
let id = match parsed.get("id") {
Some(id) if !id.is_null() && parsed.get("method").is_none() => id.clone(),
_ => return frame,
};
let kind = shield
.supply
.pending
.lock()
.await
.remove(&transport::http_server::canonical_id(&id));
match kind {
Some(PendingKind::ToolsList) => inspect_tools_list_response(frame, parsed, shield).await,
Some(PendingKind::ToolCall { tool }) => {
inspect_tool_call_response(frame, parsed, &tool, shield).await
}
None => frame,
}
}
async fn inspect_tools_list_response(
frame: String,
mut parsed: Value,
shield: &Arc<Shield>,
) -> String {
let result = match parsed.get("result") {
Some(r) => r,
None => return frame, };
let catalog = match supply::extract_catalog(result) {
Some(c) => c,
None => return frame,
};
let engine = shield.current_engine();
let adj = Adjustments {
workspace_is_prod: shield.workspace.is_prod,
burst_in_progress: shield.burst.in_burst(),
..Default::default()
};
let mut strip: HashMap<String, String> = HashMap::new();
for tool in &catalog {
if tool.description.is_empty() {
continue;
}
let eval = engine.evaluate_scoped_text(
Scope::ToolDescription,
Some(&tool.name),
&tool.description,
adj,
);
if eval.matches.is_empty() {
continue;
}
let decision = decide(&eval);
let primary = eval
.matches
.iter()
.max_by(|a, b| a.severity.cmp(&b.severity).then(a.points.cmp(&b.points)))
.map(|m| m.rule_id.clone())
.unwrap_or_default();
audit_supply_event(
shield,
"tool_description_scan",
&tool.name,
&primary,
decision.label(),
eval.final_severity,
json!({
"matched_rules": eval.matches.iter().map(|m| &m.rule_id).collect::<Vec<_>>(),
}),
)
.await;
match &decision {
d if d.is_blocking() => {
error!(
"[shield] TOOL POISONING: description of '{}' matched rule {} ({}) -- {}",
tool.name,
primary,
eval.final_severity.as_str(),
if shield.shadow { "shadow: forwarding anyway" } else { "stripping tool from catalog" }
);
strip.insert(tool.name.clone(), format!("description matched rule {}", primary));
}
Decision::Warn { .. } => {
warn!(
"[shield] WARN: description of '{}' matched rule {} ({})",
tool.name, primary, eval.final_severity.as_str()
);
}
_ => {}
}
}
if shield.supply.pinning {
let policy = engine.policy.supply_chain.clone();
let pin_new = policy.on_new_tool != "block";
match supply::check_catalog(&shield.supply.upstream_label, &catalog, pin_new) {
Ok(check) => {
if check.first_contact {
warn!(
"[shield] first contact with this upstream -- pinned {} tool definition(s) \
to ~/.aperion-shield/pins/ (TOFU)",
catalog.len()
);
}
for name in check.changed() {
audit_supply_event(
shield,
"rug_pull",
name,
"supply.pin_changed",
&policy.on_changed_tool,
Severity::Critical,
json!({ "action": policy.on_changed_tool }),
)
.await;
match policy.on_changed_tool.as_str() {
"allow" => {}
"warn" => warn!(
"[shield] RUG PULL (warn-only by policy): tool '{}' changed since it was pinned",
name
),
_ => {
error!(
"[shield] RUG PULL: tool '{}' changed since it was pinned -- {}. \
Review the change, then `aperion-shield --repin` to accept it.",
name,
if shield.shadow { "shadow: forwarding anyway" } else { "stripping + quarantining" }
);
strip.insert(
name.to_string(),
"pinned definition changed (rug pull)".to_string(),
);
}
}
}
for name in check.new_tools() {
match policy.on_new_tool.as_str() {
"allow" => {}
"block" => {
error!(
"[shield] NEW TOOL '{}' appeared after first pin -- blocked by \
policy (supply_chain.on_new_tool: block)",
name
);
strip.insert(name.to_string(), "new tool blocked by policy".to_string());
}
_ => warn!(
"[shield] new tool '{}' appeared after first pin -- pinned and allowed \
(supply_chain.on_new_tool: warn)",
name
),
}
}
for name in &check.removed {
info!("[shield] pinned tool '{}' no longer offered by the upstream", name);
}
}
Err(e) => error!("[shield] catalog pin check failed: {}", e),
}
}
{
let mut q = shield.supply.quarantined.lock().await;
for tool in &catalog {
if strip.contains_key(&tool.name) {
q.insert(tool.name.clone());
} else {
q.remove(&tool.name);
}
}
}
if strip.is_empty() || shield.shadow {
return frame;
}
if let Some(tools) = parsed
.pointer_mut("/result/tools")
.and_then(|t| t.as_array_mut())
{
tools.retain(|t| {
t.get("name")
.and_then(|n| n.as_str())
.map(|n| !strip.contains_key(n))
.unwrap_or(true)
});
}
parsed.to_string()
}
async fn inspect_tool_call_response(
frame: String,
parsed: Value,
tool: &str,
shield: &Arc<Shield>,
) -> String {
let result = match parsed.get("result") {
Some(r) => r,
None => return frame,
};
let texts = supply::extract_result_text(result);
if texts.is_empty() {
return frame;
}
let engine = shield.current_engine();
let adj = Adjustments {
workspace_is_prod: shield.workspace.is_prod,
burst_in_progress: shield.burst.in_burst(),
..Default::default()
};
let mut worst: Option<(aperion_shield::Evaluation, String)> = None;
for text in &texts {
let eval = engine.evaluate_scoped_text(Scope::ToolResult, Some(tool), text, adj);
if eval.matches.is_empty() {
continue;
}
let replace = match &worst {
Some((w, _)) => eval.final_severity > w.final_severity,
None => true,
};
if replace {
let snippet: String = text.chars().take(160).collect();
worst = Some((eval, snippet));
}
}
let (eval, snippet) = match worst {
Some(w) => w,
None => return frame,
};
let decision = decide(&eval);
let primary = eval
.matches
.iter()
.max_by(|a, b| a.severity.cmp(&b.severity).then(a.points.cmp(&b.points)))
.map(|m| m.rule_id.clone())
.unwrap_or_default();
audit_supply_event(
shield,
"tool_result_scan",
tool,
&primary,
decision.label(),
eval.final_severity,
json!({
"matched_rules": eval.matches.iter().map(|m| &m.rule_id).collect::<Vec<_>>(),
"snippet": snippet,
}),
)
.await;
match decision {
d if d.is_blocking() => {
if shield.shadow {
warn!(
"[shield][shadow] would have BLOCKED result of '{}' -- rule {} ({})",
tool, primary, eval.final_severity.as_str()
);
return frame;
}
error!(
"[shield] BLOCKED tool result from '{}' -- rule {} ({}): suspected prompt \
injection in returned content",
tool, primary, eval.final_severity.as_str()
);
let id = parsed.get("id").cloned().unwrap_or(Value::Null);
jsonrpc_error(
id,
-32095,
"shield_blocked_tool_result",
json!({
"rule_id": primary,
"severity": eval.final_severity.as_str(),
"reason": format!(
"The result returned by tool '{}' matched Shield's tool_result rules \
(suspected prompt injection). The content was withheld from the agent.",
tool
),
"matched_rules": eval.matches.iter().map(|m| &m.rule_id).collect::<Vec<_>>(),
"tool": tool,
}),
)
.to_string()
}
Decision::Warn { .. } => {
warn!(
"[shield] WARN: result of '{}' matched rule {} ({}) -- forwarded",
tool, primary, eval.final_severity.as_str()
);
frame
}
_ => frame,
}
}
async fn audit_supply_event(
shield: &Arc<Shield>,
event: &str,
tool: &str,
rule_id: &str,
decision: &str,
severity: Severity,
extra: Value,
) {
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "shield_eval",
"source": "supply_chain",
"event": event,
"tool": tool,
"primary_rule_id": rule_id,
"decision": decision,
"final_severity": severity.as_str(),
"detail": extra,
});
eprintln!("{}", audit);
if let Some(handles) = shield.orgmode.as_ref() {
handles
.audit
.record(AuditEvent {
id: uuid::Uuid::new_v4().to_string(),
ts: chrono::Utc::now(),
rule_id: rule_id.to_string(),
decision: decision.to_string(),
severity: severity.as_str().to_string(),
tool: tool.to_string(),
fingerprint: String::new(),
context: audit.clone(),
})
.await;
}
}
async fn evaluate_request(req: &Value, shield: &Shield) -> Option<Value> {
let method = req.get("method")?.as_str()?;
let id = req.get("id").cloned().unwrap_or(Value::Null);
if method != "tools/call" {
return None;
}
let params = req.get("params").cloned().unwrap_or(Value::Null);
let tool_name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
let canonical_params = json!({ "name": tool_name, "arguments": arguments });
let initial_adj = Adjustments {
workspace_is_prod: shield.workspace.is_prod,
burst_in_progress: shield.burst.in_burst(),
..Default::default()
};
let engine = shield.current_engine();
let first = engine.evaluate(tool_name, &canonical_params, initial_adj);
if first.matches.is_empty() {
return None;
}
let primary_id = first
.matches
.iter()
.max_by(|a, b| a.severity.cmp(&b.severity).then(a.points.cmp(&b.points)))
.map(|m| m.rule_id.clone())
.unwrap_or_default();
let fp = fingerprint(&primary_id, &canonical_params);
let mv = shield.memory.verdict_for(&fp);
let adj = Adjustments {
workspace_is_prod: shield.workspace.is_prod,
burst_in_progress: shield.burst.in_burst(),
fingerprint_recently_denied: mv.recent_deny,
fingerprint_repeatedly_approved: mv.repeated_approve,
};
let eval = engine.evaluate(tool_name, &canonical_params, adj);
let decision = decide(&eval);
if decision.is_blocking() || matches!(decision, Decision::Warn { .. }) {
let _ = shield.burst.observe();
}
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "shield_eval",
"tool": tool_name,
"primary_rule_id": primary_id,
"fingerprint": fp,
"matched_rules": eval.matches.iter().map(|m| &m.rule_id).collect::<Vec<_>>(),
"raw_severity": eval.raw_severity.as_str(),
"composite_points": eval.composite_points,
"composite_severity": eval.composite_severity.as_str(),
"final_severity": eval.final_severity.as_str(),
"adjustments": eval.adjustments_applied,
"decision": decision.label(),
"memory": { "approves": mv.approve_count, "denies": mv.deny_count },
});
eprintln!("{}", audit);
if let Some(handles) = shield.orgmode.as_ref() {
handles
.audit
.record(AuditEvent {
id: uuid::Uuid::new_v4().to_string(),
ts: chrono::Utc::now(),
rule_id: primary_id.clone(),
decision: decision.label().to_string(),
severity: eval.final_severity.as_str().to_string(),
tool: tool_name.to_string(),
fingerprint: fp.clone(),
context: audit.clone(),
})
.await;
}
match decision {
Decision::Allow => None,
Decision::IdentityVerification {
rule_id,
severity,
reason,
safer_alternative,
contributing_rules,
requirement,
} => {
if let Some(sf) = shield.smartflow_identity.clone() {
return handle_identity_decision_orgmode(
id,
tool_name,
&fp,
rule_id,
severity,
requirement,
sf,
)
.await;
}
handle_identity_decision(
id,
tool_name,
&fp,
shield,
rule_id,
severity,
reason,
safer_alternative,
contributing_rules,
requirement,
)
.await
}
Decision::Warn { rule_id, severity, banner, safer_alternative } => {
warn!(
"[shield] WARN rule={} severity={} tool={}: {}",
rule_id, severity.as_str(), tool_name, banner
);
if let Some(s) = safer_alternative {
warn!("[shield] safer alternative: {}", s);
}
None
}
Decision::Block { rule_id, severity, reason, safer_alternative, contributing_rules } => {
if shield.shadow {
warn!(
"[shield][shadow] would have BLOCKED rule={} severity={} tool={}: {}",
rule_id, severity.as_str(), tool_name, reason
);
None
} else {
error!(
"[shield] BLOCK rule={} severity={} tool={}: {}",
rule_id, severity.as_str(), tool_name, reason
);
if let Some(ref s) = safer_alternative {
error!("[shield] safer alternative: {}", s);
}
Some(jsonrpc_error(
id,
-32099,
"shield_blocked",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"reason": reason,
"safer_alternative": safer_alternative,
"contributing_rules": contributing_rules,
"fingerprint": fp,
"tool": tool_name,
}),
))
}
}
Decision::Approval { rule_id, severity, reason, safer_alternative, contributing_rules } => {
if shield.shadow {
warn!(
"[shield][shadow] would have queued APPROVAL rule={} tool={}: {}",
rule_id, tool_name, reason
);
return None;
}
let ticket = format!("shld_{}", uuid::Uuid::new_v4().simple());
if shield.auto_deny {
warn!(
"[shield] AUTO-DENY (--auto-deny-high) rule={} ticket={} tool={}",
rule_id, ticket, tool_name
);
shield.memory.record(&rule_id, &fp, Outcome::Deny, tool_name);
return Some(jsonrpc_error(
id,
-32098,
"shield_approval_denied",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"ticket_id": ticket,
"reason": format!("Auto-denied by --auto-deny-high: {}", reason),
"safer_alternative": safer_alternative,
"contributing_rules": contributing_rules,
"fingerprint": fp,
"tool": tool_name,
}),
));
}
warn!(
"[shield] APPROVAL REQUIRED rule={} ticket={} tool={}: {}",
rule_id, ticket, tool_name, reason
);
if let Some(ref s) = safer_alternative {
warn!("[shield] safer alternative: {}", s);
}
warn!(
"[shield] To approve: echo 'approve {}' >> ./.aperion-shield/inbox (waiting 60s)",
ticket
);
match wait_for_approval(&ticket).await {
Ok(true) => {
info!("[shield] APPROVED ticket={} -- allowing call", ticket);
shield.memory.record(&rule_id, &fp, Outcome::Approve, tool_name);
None
}
Ok(false) => {
info!("[shield] DENIED ticket={} -- blocking call", ticket);
shield.memory.record(&rule_id, &fp, Outcome::Deny, tool_name);
Some(jsonrpc_error(
id,
-32098,
"shield_approval_denied",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"ticket_id": ticket,
"reason": "Human reviewer denied this request",
"safer_alternative": safer_alternative,
"contributing_rules": contributing_rules,
"fingerprint": fp,
"tool": tool_name,
}),
))
}
Err(_) => {
warn!("[shield] TIMEOUT ticket={} -- defaulting to deny", ticket);
Some(jsonrpc_error(
id,
-32097,
"shield_approval_timeout",
json!({
"rule_id": rule_id,
"ticket_id": ticket,
"reason": "Approval window elapsed without a human decision",
"safer_alternative": safer_alternative,
"fingerprint": fp,
}),
))
}
}
}
}
}
async fn wait_for_approval(ticket: &str) -> anyhow::Result<bool> {
let inbox = PathBuf::from(".aperion-shield/inbox");
if let Some(parent) = inbox.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&inbox, "");
let res = timeout(Duration::from_secs(60), async move {
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if let Ok(body) = std::fs::read_to_string(&inbox) {
for line in body.lines() {
let l = line.trim();
if l.is_empty() { continue; }
if let Some(rest) = l.strip_prefix("approve") {
if rest.trim() == ticket { return Ok::<bool, std::io::Error>(true); }
}
if let Some(rest) = l.strip_prefix("deny") {
if rest.trim() == ticket { return Ok::<bool, std::io::Error>(false); }
}
}
}
}
}).await?;
Ok(res?)
}
fn jsonrpc_error(id: Value, code: i64, msg: &str, data: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": code,
"message": msg,
"data": data,
}
})
}
fn is_idme_ready(p: &aperion_shield::ProviderConfig) -> bool {
let cid = p.client_id_env.as_deref()
.and_then(|v| std::env::var(v).ok())
.filter(|s| !s.is_empty());
let csec = p.client_secret_env.as_deref()
.and_then(|v| std::env::var(v).ok())
.filter(|s| !s.is_empty());
cid.is_some() && csec.is_some()
}
async fn build_identity_gate(explicit: Option<&std::path::Path>) -> anyhow::Result<IdentityGate> {
let cfg = IdentityConfig::load(explicit)?;
let state_dir = IdentityConfig::state_dir();
let mut providers: Vec<Arc<dyn IdentityProvider>> = Vec::new();
for p in &cfg.providers {
match p.kind {
ProviderKind::Mock => {
providers.push(Arc::new(MockProvider::new(
p.id.clone(),
p.subject.clone().unwrap_or_else(|| format!("{}-subject", p.id)),
p.email.clone(),
p.loa,
)));
}
ProviderKind::IdMe => {
let (a_def, t_def, u_def) = aperion_shield::identity::providers::idme::IdMeConfig::endpoint_defaults(p.sandbox);
let cfg_idme = aperion_shield::identity::providers::idme::IdMeConfig {
id: p.id.clone(),
sandbox: p.sandbox,
client_id: p.client_id_env.as_deref().and_then(|v| std::env::var(v).ok()),
client_secret: p.client_secret_env.as_deref().and_then(|v| std::env::var(v).ok()),
scopes: p.scopes.clone(),
authorize_url: p.authorize_url.clone().unwrap_or(a_def),
token_url: p.token_url.clone().unwrap_or(t_def),
userinfo_url: p.userinfo_url.clone().unwrap_or(u_def),
};
providers.push(Arc::new(IdMeProvider::new(cfg_idme)));
}
}
}
IdentityGate::new(cfg, providers, state_dir)
}
#[allow(clippy::too_many_arguments)]
async fn handle_identity_decision(
id: Value,
tool_name: &str,
fp: &str,
shield: &Shield,
rule_id: String,
severity: aperion_shield::Severity,
reason: String,
safer_alternative: Option<String>,
contributing_rules: Vec<String>,
requirement: aperion_shield::IdentityRequirement,
) -> Option<Value> {
let gate = match shield.identity_gate.as_ref() {
Some(g) => g.clone(),
None => {
error!(
"[shield] identity rule {} fired but identity gate is disabled -- denying",
rule_id
);
return Some(jsonrpc_error(
id,
-32096,
"shield_identity_unavailable",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"reason": "Identity gate is disabled (--no-identity). Re-run Shield without that flag to allow this call.",
"fingerprint": fp,
"tool": tool_name,
}),
));
}
};
if let Some(p) = gate.cached_proof_for(&requirement) {
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "identity_satisfied",
"tool": tool_name,
"rule_id": rule_id,
"fingerprint": fp,
"provider": p.provider,
"subject": p.subject,
"email": p.email,
"loa": p.loa,
"scope": p.scope,
"verified_at": p.verified_at,
"expires_at": p.expires_at,
});
eprintln!("{}", audit);
info!(
"[shield] identity satisfied rule={} subject={} loa={} (cached) -- allowing tool={}",
rule_id, p.subject, p.loa, tool_name
);
return None;
}
let provider = match gate.provider(&requirement.provider) {
Some(p) => p,
None => {
error!(
"[shield] identity rule {} references unknown provider '{}'",
rule_id, requirement.provider
);
return Some(jsonrpc_error(
id,
-32095,
"shield_identity_provider_unknown",
json!({
"rule_id": rule_id,
"requested_provider": requirement.provider,
"available_providers": gate.config().providers.iter().map(|p| &p.id).collect::<Vec<_>>(),
"fingerprint": fp,
"tool": tool_name,
}),
));
}
};
if !provider.is_ready() {
warn!(
"[shield] identity provider '{}' not ready (credentials missing) -- denying tool={}",
provider.id(),
tool_name
);
return Some(jsonrpc_error(
id,
-32094,
"shield_identity_provider_unready",
json!({
"rule_id": rule_id,
"provider": provider.id(),
"reason": format!(
"Provider '{}' is not yet activated. For id_me, set the env vars referenced by client_id_env / client_secret_env in identity.yaml.",
provider.id()
),
"fingerprint": fp,
"tool": tool_name,
}),
));
}
let base = match gate.callback_base().await {
Ok(b) => b,
Err(e) => {
error!("[shield] failed to start callback server: {}", e);
return Some(jsonrpc_error(
id,
-32093,
"shield_identity_callback_unavailable",
json!({
"rule_id": rule_id,
"error": e.to_string(),
"fingerprint": fp,
"tool": tool_name,
}),
));
}
};
let callback_url = format!("{}/callback", base);
let challenge_id = format!("ch_{}", uuid::Uuid::new_v4().simple());
let creq = identity::ChallengeRequest {
rule_id: rule_id.clone(),
requirement: requirement.clone(),
callback_url,
challenge_id: challenge_id.clone(),
};
let challenge = match provider.begin(creq).await {
Ok(c) => c,
Err(e) => {
error!("[shield] identity begin failed: {}", e);
return Some(jsonrpc_error(
id,
-32092,
"shield_identity_begin_failed",
json!({
"rule_id": rule_id,
"error": e.to_string(),
"fingerprint": fp,
"tool": tool_name,
}),
));
}
};
if let Err(e) = gate
.register_inflight(&challenge, requirement.clone(), provider.id().to_string(), rule_id.clone())
.await
{
error!("[shield] failed to register inflight: {}", e);
}
let user_url = format!("{}/verify/{}", base, challenge_id);
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "identity_required",
"tool": tool_name,
"rule_id": rule_id,
"fingerprint": fp,
"provider": provider.id(),
"scope": requirement.scope,
"allowed_subjects": requirement.allowed_subjects,
"loa": requirement.loa,
"verify_url": user_url,
"challenge_id": challenge_id,
"hold_seconds": gate.hold_seconds(),
});
eprintln!("{}", audit);
warn!(
"[shield] IDENTITY VERIFICATION REQUIRED rule={} tool={}: {}",
rule_id, tool_name, reason
);
warn!("[shield] open this URL to verify: {}", user_url);
if let Some(ref s) = safer_alternative {
warn!("[shield] safer alternative: {}", s);
}
if let Some(proof) = gate.wait_for_proof(&requirement, gate.hold_seconds()).await {
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "identity_satisfied",
"tool": tool_name,
"rule_id": rule_id,
"fingerprint": fp,
"provider": proof.provider,
"subject": proof.subject,
"email": proof.email,
"loa": proof.loa,
"scope": proof.scope,
"challenge_id": challenge_id,
"via": "hold",
});
eprintln!("{}", audit);
info!(
"[shield] identity verified by {} (subject={} loa={}) -- releasing tool={}",
proof.email.clone().unwrap_or_else(|| proof.subject.clone()),
proof.subject,
proof.loa,
tool_name
);
return None;
}
Some(jsonrpc_error(
id,
-32091,
"shield_identity_required",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"reason": reason,
"safer_alternative": safer_alternative,
"contributing_rules": contributing_rules,
"fingerprint": fp,
"tool": tool_name,
"verify_url": user_url,
"challenge_id": challenge_id,
"provider": provider.id(),
"scope": requirement.scope,
"loa": requirement.loa,
"instructions": format!(
"Open {} in a browser to complete identity verification, then retry the tool call.",
user_url
),
}),
))
}
async fn run_identity_list(cli: &Cli) -> anyhow::Result<()> {
let gate = build_identity_gate(cli.identity_config.as_deref()).await?;
let cfg = gate.config();
println!("identity providers:");
for p in &cfg.providers {
let ready = match p.kind {
ProviderKind::Mock => "ready",
ProviderKind::IdMe => if is_idme_ready(p) { "ready" } else { "unready (set client_id_env/client_secret_env)" },
};
println!(
" - id={:<10} kind={:<6} sandbox={:<5} -- {}",
p.id,
match p.kind { ProviderKind::IdMe => "id_me", ProviderKind::Mock => "mock" },
p.sandbox,
ready
);
}
println!();
println!(
"cached proofs (signature-verified, non-expired): {}",
gate.cached_count()
);
println!("state dir: {}", IdentityConfig::state_dir().display());
Ok(())
}
async fn run_identity_flush(cli: &Cli) -> anyhow::Result<()> {
let gate = build_identity_gate(cli.identity_config.as_deref()).await?;
let n = gate.flush()?;
println!("flushed {} cached identity proof(s).", n);
Ok(())
}
async fn bootstrap_orgmode(
local_engine: Engine,
) -> anyhow::Result<(
Option<OrgState>,
Option<Arc<EnrolledHandles>>,
Option<Arc<SmartflowProvider>>,
tokio::sync::watch::Receiver<Arc<Engine>>,
)> {
let state = match OrgState::load() {
Ok(s) => s,
Err(e) => {
warn!(
"[shield] could not load orgmode state ({}); continuing standalone",
e
);
None
}
};
let Some(state) = state else {
let (tx, rx) = tokio::sync::watch::channel(Arc::new(local_engine));
drop(tx);
return Ok((None, None, None, rx));
};
let api = Arc::new(OrgApi::from_state(&state));
let initial_engine = orgmode::load_initial_engine(&state, &api, local_engine).await;
let initial_version = api
.get_shieldset_version(&state.policy_group)
.await
.ok()
.map(|v| v.version)
.unwrap_or(0);
let pull = aperion_shield::orgmode::start_policy_pull(
api.clone(),
state.clone(),
Arc::new(initial_engine),
initial_version,
);
let engine_rx = pull.current.clone();
let heartbeat_task = aperion_shield::orgmode::start_heartbeat(api.clone(), state.clone());
let audit = AuditSink::new(api.clone());
let smartflow_identity = Arc::new(SmartflowProvider::new(api.clone()));
let handles = Arc::new(EnrolledHandles {
state: state.clone(),
api,
policy: pull,
audit,
_heartbeat_task: heartbeat_task,
});
Ok((Some(state), Some(handles), Some(smartflow_identity), engine_rx))
}
async fn handle_identity_decision_orgmode(
id: Value,
tool_name: &str,
fp: &str,
rule_id: String,
severity: aperion_shield::Severity,
requirement: aperion_shield::IdentityRequirement,
sf: Arc<SmartflowProvider>,
) -> Option<Value> {
match sf.resolve(&requirement).await {
ResolveOutcome::Verified(proof) => {
let audit = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"kind": "identity_satisfied",
"via": "smartflow",
"tool": tool_name,
"rule_id": rule_id,
"fingerprint": fp,
"provider": proof.provider,
"subject": proof.subject,
"loa": proof.loa,
"scope": requirement.scope,
"expires_at": proof.expires_at,
"signature": proof.signature,
});
eprintln!("{}", audit);
info!(
"[shield] identity satisfied via smartflow subject={} loa={} -- releasing tool={}",
proof.subject, proof.loa, tool_name
);
None
}
ResolveOutcome::HoldExpired {
verify_url,
challenge_id,
} => {
warn!(
"[shield] identity hold expired rule={} tool={} challenge={}",
rule_id, tool_name, challenge_id
);
Some(jsonrpc_error(
id,
-32091,
"shield_identity_required",
json!({
"rule_id": rule_id,
"severity": severity.as_str(),
"fingerprint": fp,
"tool": tool_name,
"via": "smartflow",
"verify_url": verify_url,
"challenge_id": challenge_id,
"provider": requirement.provider,
"scope": requirement.scope,
"loa": requirement.loa,
"instructions": format!(
"Open {} in a browser to complete identity verification, then retry the tool call.",
verify_url
),
}),
))
}
ResolveOutcome::ProviderUnready { provider, message } => {
error!(
"[shield] smartflow identity provider '{}' is unready: {} -- denying tool={}",
provider, message, tool_name
);
Some(jsonrpc_error(
id,
-32094,
"shield_identity_provider_unready",
json!({
"rule_id": rule_id,
"provider": provider,
"via": "smartflow",
"message": message,
"fingerprint": fp,
"tool": tool_name,
}),
))
}
ResolveOutcome::Error(e) => {
error!(
"[shield] smartflow identity check failed for rule={}: {} -- denying tool={}",
rule_id, e, tool_name
);
Some(jsonrpc_error(
id,
-32092,
"shield_identity_unavailable",
json!({
"rule_id": rule_id,
"via": "smartflow",
"message": e.to_string(),
"fingerprint": fp,
"tool": tool_name,
}),
))
}
}
}