use std::path::PathBuf;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::policy::Policy;
use tirith_core::policy_validate::{self, IssueLevel};
use tirith_core::scan;
use tirith_core::tokenize::ShellType;
use tirith_core::verdict::Severity;
const FULL_TEMPLATE: &str = r#"# Tirith security policy
# Documentation: https://tirith.dev/docs/policy
# Fail mode: "open" (allow on error) or "closed" (block on error)
fail_mode: open
# Paranoia level (1-4): higher = more sensitive detection
paranoia: 1
# Allow TIRITH=0 bypass in interactive terminals
allow_bypass_env: true
# Require explicit acknowledgement for warn findings in interactive mode
strict_warn: false
# Severity overrides per rule (e.g., shortened_url: LOW)
severity_overrides: {}
# URL patterns to always allow
allowlist: []
# URL patterns to always block (overrides allowlist)
blocklist: []
# Force specific rules to block (upgrade only, cannot downgrade)
# action_overrides:
# shortened_url: block
# Escalation: upgrade warnings to blocks based on session history
# escalation:
# - trigger: repeat_count # block after N warnings for the same rule
# rule_ids: ["*"] # "*" = any rule, or list specific rule IDs
# threshold: 5
# window_minutes: 60
# action: block
# - trigger: multi_medium # block when N+ medium findings on one command
# min_findings: 3
# action: block
# Scan configuration overrides.
scan:
# Glob patterns to ignore during scan
ignore_patterns: []
# Exact MCP server identities you trust. Each mcp:v1 key binds source path,
# name, and transport; bare names intentionally match nothing. Trust suppresses
# per-server config findings and ordinary drift, but never structural ambiguity
# or an explicit tool-policy violation. Run `tirith mcp policy init` to scaffold
# these keys from `.tirith/mcp.lock`.
# trusted_mcp_servers:
# - "mcp:v1:<sha256>"
# Per-server allowed tools. Keys are the same exact identities; values are the
# tool names that server may expose. An explicit entry requires an approved
# live descriptor baseline and checks both static and live names. Servers not
# listed here are unconstrained.
# mcp_allowed_tools:
# "mcp:v1:<sha256>":
# - read_only
# Per-agent governance rules — M4 item 8 (enforcement).
#
# `agent_rules` lets a policy declare which AgentOrigin variants it
# allows or denies, where `AgentOrigin` is the recorded caller — Human,
# Agent, Mcp, Gateway, Ci, or Ide. A `deny` match forces the verdict to
# Block and appends an `agent_denied_by_policy` finding naming the
# matched origin and policy file; a `deny` entry beats any matching
# `allow` entry, mirroring how `blocklist` beats `allowlist`. `allow`
# is NOT a bypass — a verdict the engine already blocked stays blocked
# even if the caller is on the allow list. See `rule_explanations.toml`
# (`agent_denied_by_policy`) for the operator-facing description.
#
# Enforcement scope: `apply_agent_rules` runs on every analysis path —
# `tirith check`, the gateway request / notification paths, `tirith
# paste`, `tirith install`, `tirith ecosystem scan`, and all MCP
# `tools/call_check_*` handlers (`call_check_command`, `call_check_url`,
# `call_check_paste`). The interactive `TIRITH=0` bypass currently
# skips `apply_agent_rules` (pinned by
# `agent_rules_deny_skipped_under_tirith_bypass_today`); revisit that
# semantic in M5.
#
# Trust caveat: every signal feeding AgentOrigin is OPERATOR-TRUST,
# never adversary-resistant — TIRITH_INTEGRATION, MCP clientInfo, CI
# env vars are all settable by any process running as the user. Use
# `agent_rules` for operator-trust scoping ("I do not run my MCP
# server's commands on traffic my CI ran"), not adversarial security;
# layer real authentication elsewhere if the decision must withstand a
# hostile environment.
#
# Run `tirith agent policy init` to scaffold this block from the local
# audit log's observed origins.
# agent_rules:
# allow:
# - kind: agent
# name: claude-code
# - kind: human
# deny:
# - kind: agent
# name: untrusted-tool
"#;
const MINIMAL_TEMPLATE: &str = r#"fail_mode: open
paranoia: 1
allowlist: []
blocklist: []
"#;
fn project_policy_cli_text(value: &str) -> String {
let share_safe = tirith_core::redact::redact_for_audience(
value,
tirith_core::redact::ShareAudience::PublicPaste,
)
.redacted_content;
tirith_core::redact::redact_blocked_output(&share_safe)
}
fn project_policy_cli_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => *text = project_policy_cli_text(text),
serde_json::Value::Array(values) => {
for value in values {
project_policy_cli_json(value);
}
}
serde_json::Value::Object(values) => {
for value in values.values_mut() {
project_policy_cli_json(value);
}
}
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
}
}
const TEMPLATE_INDIVIDUAL: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/individual.yaml"
));
const TEMPLATE_CI_STRICT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/ci-strict.yaml"
));
const TEMPLATE_AI_AGENT_HEAVY: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/ai-agent-heavy.yaml"
));
const TEMPLATE_OSS_MAINTAINER: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/oss-maintainer.yaml"
));
const TEMPLATE_STARTUP: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/startup.yaml"
));
const TEMPLATE_ENTERPRISE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/enterprise.yaml"
));
const TEMPLATE_MCP_STRICT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/policy_templates/mcp-strict.yaml"
));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyTemplate {
Individual,
CiStrict,
AiAgentHeavy,
OssMaintainer,
Startup,
Enterprise,
McpStrict,
}
impl PolicyTemplate {
pub const ALL: &'static [PolicyTemplate] = &[
Self::Individual,
Self::CiStrict,
Self::AiAgentHeavy,
Self::OssMaintainer,
Self::Startup,
Self::Enterprise,
Self::McpStrict,
];
fn names_csv() -> String {
Self::ALL
.iter()
.map(|t| t.canonical_name())
.collect::<Vec<_>>()
.join(", ")
}
pub fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"individual" | "personal" => Some(Self::Individual),
"ci-strict" | "ci_strict" => Some(Self::CiStrict),
"ai-agent-heavy" | "ai_agent_heavy" => Some(Self::AiAgentHeavy),
"oss-maintainer" | "oss_maintainer" => Some(Self::OssMaintainer),
"startup" => Some(Self::Startup),
"enterprise" => Some(Self::Enterprise),
"mcp-strict" | "mcp_strict" => Some(Self::McpStrict),
_ => None,
}
}
pub fn canonical_name(self) -> &'static str {
match self {
Self::Individual => "individual",
Self::CiStrict => "ci-strict",
Self::AiAgentHeavy => "ai-agent-heavy",
Self::OssMaintainer => "oss-maintainer",
Self::Startup => "startup",
Self::Enterprise => "enterprise",
Self::McpStrict => "mcp-strict",
}
}
fn body(self) -> &'static str {
match self {
Self::Individual => TEMPLATE_INDIVIDUAL,
Self::CiStrict => TEMPLATE_CI_STRICT,
Self::AiAgentHeavy => TEMPLATE_AI_AGENT_HEAVY,
Self::OssMaintainer => TEMPLATE_OSS_MAINTAINER,
Self::Startup => TEMPLATE_STARTUP,
Self::Enterprise => TEMPLATE_ENTERPRISE,
Self::McpStrict => TEMPLATE_MCP_STRICT,
}
}
}
pub fn init(force: bool, minimal: bool, template: Option<&str>) -> i32 {
let selected_template = match template {
Some(name) => match PolicyTemplate::parse(name) {
Some(t) => Some(t),
None => {
eprintln!("tirith policy init: unknown template '{name}'");
eprintln!(" valid templates: {}", PolicyTemplate::names_csv());
eprintln!(" ('personal' is accepted as an alias of 'individual')");
return 1;
}
},
None => None,
};
if selected_template.is_some() && minimal {
eprintln!("tirith policy init: --template and --minimal cannot be combined");
return 1;
}
init_with_template(force, minimal, selected_template)
}
fn init_with_template(force: bool, minimal: bool, template: Option<PolicyTemplate>) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let repo_root = match tirith_core::policy::find_repo_root(cwd.as_deref()) {
Some(r) => r,
None => {
match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
eprintln!("tirith policy init: cannot determine working directory: {e}");
return 1;
}
}
}
};
let tirith_dir = repo_root.join(".tirith");
let policy_path = tirith_dir.join("policy.yaml");
if policy_path.exists() && !force {
eprintln!(
"tirith policy init: {} already exists (use --force to overwrite)",
policy_path.display()
);
return 1;
}
let template_body = match (template, minimal) {
(Some(t), _) => t.body(),
(None, true) => MINIMAL_TEMPLATE,
(None, false) => FULL_TEMPLATE,
};
let operator_policy = tirith_core::policy::Policy::discover_local_only(cwd.as_deref());
if let Err(e) = super::write_config_file_permitted_with_parent_creation(
&repo_root,
&policy_path,
template_body.as_bytes(),
force,
&operator_policy,
true,
true,
) {
eprintln!(
"tirith policy init: cannot write {}: {e}",
policy_path.display()
);
return 1;
}
let label = match template {
Some(PolicyTemplate::Individual) => " (individual template)",
Some(PolicyTemplate::CiStrict) => " (ci-strict template)",
Some(PolicyTemplate::AiAgentHeavy) => " (ai-agent-heavy template)",
Some(PolicyTemplate::OssMaintainer) => " (oss-maintainer template)",
Some(PolicyTemplate::Startup) => " (startup template)",
Some(PolicyTemplate::Enterprise) => " (enterprise template)",
Some(PolicyTemplate::McpStrict) => " (mcp-strict template)",
None if minimal => " (minimal template)",
None => "",
};
eprintln!(
"tirith policy init: created {}{label}",
policy_path.display()
);
0
}
pub fn validate(path: Option<&str>, json: bool) -> i32 {
let policy_path = match resolve_policy_path(path) {
Some(p) => p,
None => {
eprintln!("tirith policy validate: no policy file found");
eprintln!(" run `tirith policy init` to create one");
return 1;
}
};
let yaml = match std::fs::read_to_string(&policy_path) {
Ok(s) => s,
Err(e) => {
let display_path = bounded_human_value(&policy_path.display().to_string(), 512);
let error = bounded_human_value(&e.to_string(), 512);
eprintln!("tirith policy validate: cannot read {display_path}: {error}");
return 1;
}
};
let issues = policy_validate::validate(&yaml);
if json {
print_validate_json(&policy_path, &issues);
} else {
print_validate_human(&policy_path, &issues);
}
if issues.iter().any(|i| i.level == IssueLevel::Error) {
1
} else {
0
}
}
fn print_validate_json(path: &std::path::Path, issues: &[policy_validate::PolicyIssue]) {
#[derive(serde::Serialize)]
struct Output<'a> {
path: String,
valid: bool,
error_count: usize,
warning_count: usize,
issues: &'a [policy_validate::PolicyIssue],
}
let error_count = issues
.iter()
.filter(|i| i.level == IssueLevel::Error)
.count();
let warning_count = issues
.iter()
.filter(|i| i.level == IssueLevel::Warning)
.count();
let output = Output {
path: path.display().to_string(),
valid: error_count == 0,
error_count,
warning_count,
issues,
};
let mut output = match serde_json::to_value(&output) {
Ok(output) => output,
Err(error) => {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy validate: failed to construct JSON output: {error}");
return;
}
};
project_policy_cli_json(&mut output);
if let Err(error) = serde_json::to_writer_pretty(std::io::stdout().lock(), &output) {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy validate: failed to write JSON output: {error}");
}
println!();
}
fn print_validate_human(path: &std::path::Path, issues: &[policy_validate::PolicyIssue]) {
let display_path = bounded_human_value(&path.display().to_string(), 512);
if issues.is_empty() {
eprintln!(
"tirith policy validate: {} — valid, no issues",
display_path
);
return;
}
let error_count = issues
.iter()
.filter(|i| i.level == IssueLevel::Error)
.count();
let warning_count = issues
.iter()
.filter(|i| i.level == IssueLevel::Warning)
.count();
eprintln!(
"tirith policy validate: {} — {} error(s), {} warning(s)",
display_path, error_count, warning_count
);
for (index, issue) in issues.iter().enumerate() {
let s = tirith_core::style::Stream::Stderr;
let prefix = match issue.level {
IssueLevel::Error => tirith_core::style::red("error", s),
IssueLevel::Warning => tirith_core::style::yellow("warning", s),
};
eprintln!(
" {prefix}: {}",
human_validation_issue(issue, index.saturating_add(1))
);
}
}
fn human_validation_issue(issue: &policy_validate::PolicyIssue, ordinal: usize) -> String {
let category = if issue.message.starts_with("YAML parse error") {
"YAML parse error"
} else if issue.message.starts_with("Policy migration error") {
"policy migration error"
} else if issue.message.contains("invalid regex") {
"invalid regular expression"
} else if issue.message.starts_with("unknown field") {
"unknown policy field"
} else if issue.message.contains("too long") || issue.message.contains("maximum") {
"policy value exceeds its allowed size"
} else {
match issue.level {
IssueLevel::Error => "invalid policy value",
IssueLevel::Warning => "policy validation warning",
}
};
format!("{category} (issue #{ordinal}; details available with --json)")
}
fn bounded_human_value(value: &str, max_chars: usize) -> String {
let projected = project_policy_cli_text(value);
let safe = super::sanitize_for_human_output(&projected, false);
if safe.chars().count() <= max_chars {
return safe;
}
let mut bounded = safe.chars().take(max_chars).collect::<String>();
bounded.push('…');
bounded
}
pub fn test(command: Option<&str>, file: Option<&str>, json: bool) -> i32 {
if command.is_none() && file.is_none() {
eprintln!("tirith policy test: specify a command or --file <path>");
return 1;
}
if let Some(file_path) = file {
return test_file(file_path, json);
}
test_command(command.unwrap(), json)
}
fn test_command(command: &str, json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let ctx = AnalysisContext {
input: command.to_string(),
shell: ShellType::Posix,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: false,
cwd: cwd.clone(),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
card_ref: None,
clipboard_source: tirith_core::clipboard::ClipboardSourceState::Unread,
};
let mut verdict = engine::analyze(&ctx);
let policy = Policy::discover(cwd.as_deref());
engine::filter_findings_by_paranoia(&mut verdict, policy.paranoia);
let verdict = tirith_core::escalation::finalize_static_verdict(
verdict.findings,
&policy,
verdict.tier_reached,
verdict.timings_ms.clone(),
);
let trace = build_policy_trace(command, &policy);
if json {
print_test_command_json(command, &verdict, &policy, &trace);
} else {
print_test_command_human(command, &verdict, &policy, &trace);
}
verdict.action.exit_code()
}
fn test_file(file_path: &str, json: bool) -> i32 {
let path = PathBuf::from(file_path);
if !path.exists() {
let file_path = bounded_human_value(file_path, 512);
eprintln!("tirith policy test: file not found: {file_path}");
return 1;
}
use scan::{GuardedScanOutcome, ScanFileOutcome};
let result = match scan::scan_single_file_guarded(&path) {
GuardedScanOutcome::Completed(ScanFileOutcome::Scanned(r)) => r,
GuardedScanOutcome::Completed(ScanFileOutcome::Skipped(gap)) => {
let file_path = bounded_human_value(file_path, 512);
eprintln!(
"tirith policy test: could not analyze {file_path}: coverage gap ({})",
gap.kind.as_str()
);
return 1;
}
GuardedScanOutcome::RulePanic(_) => {
let file_path = bounded_human_value(file_path, 512);
eprintln!("tirith policy test: internal error scanning {file_path}: a rule panicked");
return 1;
}
};
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let policy = Policy::discover(cwd.as_deref());
if json {
print_test_file_json(file_path, &result, &policy);
} else {
print_test_file_human(file_path, &result, &policy);
}
if result.findings.is_empty() {
0
} else if result.findings.iter().any(|f| f.severity >= Severity::High) {
1 } else {
2 }
}
pub fn tune(from_audit: bool, json: bool) -> i32 {
if !from_audit {
eprintln!("tirith policy tune: specify a source — currently only --from-audit");
eprintln!(" try: tirith policy tune --from-audit");
return 1;
}
let log_path = match tirith_core::policy::data_dir() {
Some(d) => d.join("log.jsonl"),
None => {
eprintln!("tirith policy tune: could not determine audit log path");
return 1;
}
};
if !log_path.exists() {
eprintln!(
"tirith policy tune: no audit log found at {}",
log_path.display()
);
eprintln!(" tirith records an audit log as you use it; come back once you have history.");
return 1;
}
let result = match tirith_core::audit_aggregator::read_log(&log_path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"tirith policy tune: could not read the audit log at {}",
log_path.display()
);
match std::fs::File::open(&log_path) {
Err(probe) if probe.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
" permission denied — check that you can read the file \
(its directory may also need execute permission)."
);
}
_ => {
eprintln!(" {e}");
eprintln!(
" the file may be unreadable or have been removed mid-read; \
retry, or check the path's permissions."
);
}
}
return 1;
}
};
if result.skipped_lines > 0 {
eprintln!(
"tirith policy tune: warning: {} malformed audit log line(s) skipped",
result.skipped_lines
);
}
let known_rules: Vec<&str> = tirith_core::rule_explanations::list_all()
.iter()
.map(|r| r.id)
.collect();
let report = tirith_core::audit_tune::analyze(&result.records, &known_rules);
if json {
if serde_json::to_writer_pretty(std::io::stdout().lock(), &report).is_err() {
eprintln!("tirith policy tune: failed to write JSON output");
return 1;
}
println!();
} else {
print_tune_human(&report);
}
0
}
fn print_tune_human(report: &tirith_core::audit_tune::TuneReport) {
eprintln!(
"tirith policy tune: analyzed {} audit record(s)",
report.records_analyzed
);
if report.data_is_thin {
eprintln!(
" not enough audit history to suggest anything yet (need at least {}).",
tirith_core::audit_tune::MIN_OBSERVATIONS
);
eprintln!(" keep using tirith and re-run this once more commands have been analyzed.");
return;
}
if report.suggestions.is_empty() {
eprintln!(
" no policy changes suggested — your current policy looks well matched to your usage."
);
return;
}
eprintln!(
" {} suggestion(s) — these are SUGGESTIONS only; review each, then edit your policy yourself:",
report.suggestions.len()
);
eprintln!();
for (i, s) in report.suggestions.iter().enumerate() {
let conf = match s.confidence {
tirith_core::audit_tune::Confidence::Strong => "strong",
tirith_core::audit_tune::Confidence::Moderate => "moderate",
};
eprintln!(" {}. [{}] {}", i + 1, conf, s.observation);
eprintln!(" {}", s.recommendation);
if let Some(snippet) = &s.policy_snippet {
eprintln!(" suggested policy snippet:");
for line in snippet.lines() {
eprintln!(" {line}");
}
}
eprintln!();
}
eprintln!(" tirith did not change your policy. Apply any suggestion by editing your .tirith/policy.yaml.");
}
struct EffectivePolicy {
source_path: Option<String>,
scope: tirith_core::policy::PolicyScope,
policy: Policy,
}
fn scope_label(scope: tirith_core::policy::PolicyScope) -> &'static str {
scope.as_str()
}
fn gather_effective(cwd: Option<&str>) -> EffectivePolicy {
let (source_path, scope) = match tirith_core::policy::discover_local_policy_path_scoped(cwd) {
Some((path, scope)) => (Some(path.display().to_string()), scope),
None => (None, tirith_core::policy::PolicyScope::Default),
};
let policy = Policy::discover_local_only(cwd);
EffectivePolicy {
source_path,
scope,
policy,
}
}
pub fn effective(json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let info = gather_effective(cwd.as_deref());
if json {
print_effective_json(&info)
} else {
print_effective_human(&info)
}
}
fn print_effective_json(info: &EffectivePolicy) -> i32 {
#[derive(serde::Serialize)]
struct Output<'a> {
source_path: Option<&'a str>,
scope: &'a str,
neutralized_fields: &'a [&'static str],
policy: &'a Policy,
}
let output = Output {
source_path: info.source_path.as_deref(),
scope: scope_label(info.scope),
neutralized_fields: &info.policy.neutralized_fields,
policy: &info.policy,
};
if super::write_json_stdout(
&output,
"tirith policy effective: failed to write JSON output",
) {
0
} else {
1
}
}
fn print_effective_human(info: &EffectivePolicy) -> i32 {
use tirith_core::policy::PolicyScope;
eprintln!(
"tirith policy effective: source = {}",
info.source_path
.as_deref()
.unwrap_or("(none — built-in defaults)")
);
eprintln!(" scope: {}", scope_label(info.scope));
eprintln!();
match serde_yaml::to_string(&info.policy) {
Ok(yaml) => {
eprintln!(" effective policy:");
for line in yaml.lines() {
eprintln!(" {line}");
}
}
Err(e) => {
eprintln!(" (could not render effective policy as YAML: {e})");
}
}
eprintln!();
let neutralized = &info.policy.neutralized_fields;
match info.scope {
PolicyScope::Repo if !neutralized.is_empty() => {
eprintln!(
" Neutralized (this repo policy is tightening-only; these weakening fields \
were ignored): {}",
neutralized.join(", ")
);
}
PolicyScope::Repo => {
eprintln!(" No weakening fields — this repo policy only tightens.");
}
_ => {
eprintln!(" Operator-scoped policy — all fields honored (nothing neutralized).");
}
}
0
}
#[derive(serde::Serialize)]
struct PolicyTrace {
policy_path: Option<String>,
allowlist_checked: Vec<AllowBlockMatch>,
blocklist_checked: Vec<AllowBlockMatch>,
}
#[derive(serde::Serialize)]
struct AllowBlockMatch {
pattern: String,
matched: bool,
}
fn build_policy_trace(input: &str, policy: &Policy) -> PolicyTrace {
let input_lower = input.to_lowercase();
let allowlist_checked: Vec<AllowBlockMatch> = policy
.allowlist
.iter()
.map(|pattern| AllowBlockMatch {
pattern: pattern.clone(),
matched: tirith_core::policy::allowlist_pattern_matches(pattern, input),
})
.collect();
let blocklist_checked: Vec<AllowBlockMatch> = policy
.blocklist
.iter()
.map(|pattern| AllowBlockMatch {
pattern: pattern.clone(),
matched: input_lower.contains(&pattern.to_lowercase()),
})
.collect();
PolicyTrace {
policy_path: policy.path.clone(),
allowlist_checked,
blocklist_checked,
}
}
fn print_test_command_json(
command: &str,
verdict: &tirith_core::verdict::Verdict,
_policy: &Policy,
trace: &PolicyTrace,
) {
#[derive(serde::Serialize)]
struct Output<'a> {
command: &'a str,
action: &'a tirith_core::verdict::Action,
finding_count: usize,
findings: &'a [tirith_core::verdict::Finding],
policy_trace: &'a PolicyTrace,
}
let output = Output {
command,
action: &verdict.action,
finding_count: verdict.findings.len(),
findings: &verdict.findings,
policy_trace: trace,
};
let mut output = match serde_json::to_value(&output) {
Ok(output) => output,
Err(error) => {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy test: failed to construct JSON output: {error}");
return;
}
};
project_policy_cli_json(&mut output);
if let Err(error) = serde_json::to_writer_pretty(std::io::stdout().lock(), &output) {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy test: failed to write JSON output: {error}");
}
println!();
}
fn print_test_file_json(file_path: &str, result: &scan::FileScanResult, _policy: &Policy) {
#[derive(serde::Serialize)]
struct Output<'a> {
file: &'a str,
finding_count: usize,
findings: &'a [tirith_core::verdict::Finding],
}
let output = Output {
file: file_path,
finding_count: result.findings.len(),
findings: &result.findings,
};
let mut output = match serde_json::to_value(&output) {
Ok(output) => output,
Err(error) => {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy test: failed to construct JSON output: {error}");
return;
}
};
project_policy_cli_json(&mut output);
if let Err(error) = serde_json::to_writer_pretty(std::io::stdout().lock(), &output) {
let error = bounded_human_value(&error.to_string(), 512);
eprintln!("tirith policy test: failed to write JSON output: {error}");
}
println!();
}
fn print_test_command_human(
command: &str,
verdict: &tirith_core::verdict::Verdict,
_policy: &Policy,
trace: &PolicyTrace,
) {
let command = bounded_human_value(command, 2 * 1024);
let policy_path = trace
.policy_path
.as_deref()
.map(|path| bounded_human_value(path, 512))
.unwrap_or_else(|| "(default — no policy file)".to_string());
eprintln!("tirith policy test: command = {:?}", command);
eprintln!(" policy: {policy_path}");
eprintln!(" action: {:?}", verdict.action);
eprintln!(" findings: {}", verdict.findings.len());
for finding in &verdict.findings {
let sev = tirith_core::style::severity_label(
&finding.severity,
tirith_core::style::Stream::Stderr,
);
let title = bounded_human_value(&finding.title, 2 * 1024);
eprintln!(" {} {} — {}", sev, finding.rule_id, title);
}
if !trace.allowlist_checked.is_empty() || !trace.blocklist_checked.is_empty() {
eprintln!();
eprintln!(" policy trace:");
for entry in &trace.allowlist_checked {
let mark = if entry.matched { "MATCH" } else { "no match" };
let pattern = bounded_human_value(&entry.pattern, 2 * 1024);
eprintln!(" allowlist: {pattern:?} -> {mark}");
}
for entry in &trace.blocklist_checked {
let mark = if entry.matched { "MATCH" } else { "no match" };
let pattern = bounded_human_value(&entry.pattern, 2 * 1024);
eprintln!(" blocklist: {pattern:?} -> {mark}");
}
}
}
fn print_test_file_human(file_path: &str, result: &scan::FileScanResult, _policy: &Policy) {
let file_path = bounded_human_value(file_path, 512);
if result.findings.is_empty() {
eprintln!("tirith policy test: {file_path} — no findings");
return;
}
eprintln!(
"tirith policy test: {file_path} — {} finding(s)",
result.findings.len()
);
for finding in &result.findings {
let sev = tirith_core::style::severity_label(
&finding.severity,
tirith_core::style::Stream::Stderr,
);
let title = bounded_human_value(&finding.title, 2 * 1024);
let description = bounded_human_value(&finding.description, 4 * 1024);
eprintln!(" {} {} — {}", sev, finding.rule_id, title);
eprintln!(" {description}");
}
}
fn resolve_policy_path(explicit: Option<&str>) -> Option<PathBuf> {
if let Some(p) = explicit {
let path = PathBuf::from(p);
if path.exists() {
return Some(path);
}
let path = bounded_human_value(p, 512);
eprintln!("tirith policy validate: specified path does not exist: {path}");
return None;
}
tirith_core::policy::discover_local_policy_path(None)
}
#[cfg(test)]
mod tests {
use super::*;
use tirith_core::policy_validate::{self, IssueLevel};
#[test]
fn human_validation_issue_never_echoes_policy_values_or_controls() {
let issue = policy_validate::PolicyIssue {
level: IssueLevel::Error,
message: "custom_rules.secret: invalid regex 'TOKEN-42\x1b]52;c;YQ==\x07\nFORGED'"
.to_string(),
field: Some("custom_rules.TOKEN-42\u{202e}\nFORGED.pattern".to_string()),
};
let rendered = human_validation_issue(&issue, 7);
assert!(
rendered.contains("invalid regular expression"),
"{rendered:?}"
);
assert!(rendered.contains("issue #7"), "{rendered:?}");
assert!(!rendered.contains("TOKEN-42"), "{rendered:?}");
assert!(!rendered.contains('\x1b'), "{rendered:?}");
assert!(!rendered.contains('\n'), "{rendered:?}");
assert!(!rendered.contains('\u{202e}'), "{rendered:?}");
}
#[test]
fn validation_presenters_project_paths_and_nested_json_before_rendering() {
let canary = format!("ghp_canary_{}", "A".repeat(30));
let private_scalar = format!("{}1", "0".repeat(63));
let local_path = format!("/Users/alice/{canary}/policy.yaml");
let projected = bounded_human_value(
&format!("{local_path} command-private-key={private_scalar}"),
512,
);
assert!(!projected.contains(&canary), "{projected:?}");
assert!(!projected.contains(&private_scalar), "{projected:?}");
assert!(!projected.contains("/Users/alice"), "{projected:?}");
let mut json = serde_json::json!({
"path": local_path,
"command": private_scalar.clone(),
"nested": [{"error": format!("cannot read {canary}")}],
"valid": false,
"error_count": 1,
});
project_policy_cli_json(&mut json);
let rendered = serde_json::to_string(&json).unwrap();
assert!(!rendered.contains(&canary), "{rendered}");
assert!(!rendered.contains(&private_scalar), "{rendered}");
assert!(!rendered.contains("/Users/alice"), "{rendered}");
assert_eq!(json["valid"], serde_json::Value::Bool(false));
assert_eq!(json["error_count"], serde_json::json!(1));
assert_eq!(bounded_human_value("policy.yaml", 512), "policy.yaml");
}
fn assert_template_valid(name: &str, body: &str) {
let issues = policy_validate::validate(body);
let errors: Vec<_> = issues
.iter()
.filter(|i| i.level == IssueLevel::Error)
.collect();
let warnings: Vec<_> = issues
.iter()
.filter(|i| i.level == IssueLevel::Warning)
.collect();
assert!(
errors.is_empty(),
"{name} template must have no validation errors: {errors:?}"
);
assert!(
warnings.is_empty(),
"{name} template must have no validation warnings \
(unknown/typo keys are warnings): {warnings:?}"
);
}
#[test]
fn individual_template_validates() {
assert_template_valid("individual", TEMPLATE_INDIVIDUAL);
}
#[test]
fn template_names_csv_covers_every_variant() {
let csv = PolicyTemplate::names_csv();
for t in PolicyTemplate::ALL {
let name = t.canonical_name();
assert!(
csv.split(", ").any(|n| n == name),
"names_csv ({csv:?}) must list the canonical name {name:?} for {t:?}"
);
}
for entry in csv.split(", ") {
assert!(
PolicyTemplate::parse(entry).is_some(),
"names_csv entry {entry:?} must parse back to a PolicyTemplate variant"
);
}
assert_eq!(
csv.split(", ").count(),
PolicyTemplate::ALL.len(),
"names_csv must have exactly one entry per variant"
);
}
#[test]
fn ci_strict_template_validates() {
assert_template_valid("ci-strict", TEMPLATE_CI_STRICT);
}
#[test]
fn ai_agent_heavy_template_validates() {
assert_template_valid("ai-agent-heavy", TEMPLATE_AI_AGENT_HEAVY);
}
#[test]
fn oss_maintainer_template_validates() {
assert_template_valid("oss-maintainer", TEMPLATE_OSS_MAINTAINER);
}
#[test]
fn startup_template_validates() {
assert_template_valid("startup", TEMPLATE_STARTUP);
}
#[test]
fn enterprise_template_validates() {
assert_template_valid("enterprise", TEMPLATE_ENTERPRISE);
}
#[test]
fn mcp_strict_template_validates() {
assert_template_valid("mcp-strict", TEMPLATE_MCP_STRICT);
}
#[test]
fn builtin_full_and_minimal_templates_validate() {
assert_template_valid("full", FULL_TEMPLATE);
assert_template_valid("minimal", MINIMAL_TEMPLATE);
}
#[test]
fn template_parse_accepts_canonical_and_underscore_names() {
assert_eq!(
PolicyTemplate::parse("individual"),
Some(PolicyTemplate::Individual)
);
assert_eq!(
PolicyTemplate::parse("ci-strict"),
Some(PolicyTemplate::CiStrict)
);
assert_eq!(
PolicyTemplate::parse("CI-STRICT"),
Some(PolicyTemplate::CiStrict)
);
assert_eq!(
PolicyTemplate::parse("ai-agent-heavy"),
Some(PolicyTemplate::AiAgentHeavy)
);
assert_eq!(
PolicyTemplate::parse(" ai_agent_heavy "),
Some(PolicyTemplate::AiAgentHeavy)
);
assert_eq!(
PolicyTemplate::parse("oss-maintainer"),
Some(PolicyTemplate::OssMaintainer)
);
assert_eq!(
PolicyTemplate::parse("oss_maintainer"),
Some(PolicyTemplate::OssMaintainer)
);
assert_eq!(
PolicyTemplate::parse("startup"),
Some(PolicyTemplate::Startup)
);
assert_eq!(
PolicyTemplate::parse("Enterprise"),
Some(PolicyTemplate::Enterprise)
);
assert_eq!(
PolicyTemplate::parse("mcp-strict"),
Some(PolicyTemplate::McpStrict)
);
assert_eq!(
PolicyTemplate::parse("mcp_strict"),
Some(PolicyTemplate::McpStrict)
);
}
#[test]
fn template_parse_personal_is_alias_for_individual() {
assert_eq!(
PolicyTemplate::parse("personal"),
Some(PolicyTemplate::Individual)
);
assert_eq!(
PolicyTemplate::parse(" PERSONAL "),
Some(PolicyTemplate::Individual)
);
assert_eq!(
PolicyTemplate::parse("personal").unwrap().canonical_name(),
"individual"
);
assert_eq!(
PolicyTemplate::parse("personal").unwrap().body(),
TEMPLATE_INDIVIDUAL
);
assert_eq!(
PolicyTemplate::Individual.body(),
PolicyTemplate::parse("personal").unwrap().body()
);
}
#[test]
fn template_parse_rejects_unknown_and_deferred_names() {
assert_eq!(PolicyTemplate::parse("fintech"), None);
assert_eq!(PolicyTemplate::parse("windows-enterprise"), None);
assert_eq!(PolicyTemplate::parse(""), None);
assert_eq!(PolicyTemplate::parse("default"), None);
}
#[test]
fn all_templates_deserialize_into_policy() {
for t in PolicyTemplate::ALL {
let body = t.body();
let parsed: Result<tirith_core::policy::Policy, _> = serde_yaml::from_str(body);
assert!(
parsed.is_ok(),
"{} template must deserialize into Policy: {:?}",
t.canonical_name(),
parsed.err()
);
}
}
#[test]
fn oss_maintainer_template_is_moderate_fail_open() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_OSS_MAINTAINER).unwrap();
assert_eq!(p.fail_mode, tirith_core::policy::FailMode::Open);
assert_eq!(p.paranoia, 2);
assert!(p.allow_bypass_env);
assert!(!p.allow_bypass_env_noninteractive);
}
#[test]
fn startup_template_is_balanced_strict_warn() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_STARTUP).unwrap();
assert_eq!(p.fail_mode, tirith_core::policy::FailMode::Open);
assert_eq!(p.paranoia, 2);
assert!(p.strict_warn);
assert!(!p.allow_bypass_env_noninteractive);
}
#[test]
fn enterprise_template_is_strict_with_active_package_policy() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_ENTERPRISE).unwrap();
assert_eq!(p.fail_mode, tirith_core::policy::FailMode::Closed);
assert!(!p.allow_bypass_env);
assert!(!p.allow_bypass_env_noninteractive);
assert!(
p.package_policy.block_not_found,
"enterprise must ship block_not_found: true"
);
assert_eq!(
p.package_policy.block_osv_min_cvss,
Some(7.0),
"enterprise must ship block_osv_min_cvss: 7.0"
);
assert_eq!(p.package_policy.block_newer_than_days, Some(7));
assert_eq!(p.package_policy.block_typosquat_distance, Some(1));
assert!(p.package_policy.block_repo_mismatch);
}
#[test]
fn mcp_strict_template_escalates_mcp_rules() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_MCP_STRICT).unwrap();
assert_eq!(p.fail_mode, tirith_core::policy::FailMode::Closed);
for rule in [
"mcp_insecure_server",
"mcp_untrusted_server",
"mcp_overly_permissive",
"mcp_suspicious_args",
"mcp_server_drift",
] {
assert!(
p.severity_overrides.contains_key(rule),
"mcp-strict must escalate {rule}"
);
}
assert_eq!(
p.action_overrides
.get("mcp_untrusted_server")
.map(String::as_str),
Some("block")
);
}
#[test]
fn ci_strict_template_is_fail_closed_no_bypass() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_CI_STRICT).unwrap();
assert_eq!(p.fail_mode, tirith_core::policy::FailMode::Closed);
assert!(!p.allow_bypass_env);
assert!(!p.allow_bypass_env_noninteractive);
}
#[test]
fn ai_agent_heavy_template_blocks_agent_bypass() {
let p: tirith_core::policy::Policy = serde_yaml::from_str(TEMPLATE_AI_AGENT_HEAVY).unwrap();
assert!(!p.allow_bypass_env_noninteractive);
assert!(!p.approval_rules.is_empty());
assert!(!p.escalation.is_empty());
}
#[test]
fn effective_repo_scope_lists_neutralized_allowlist() {
use crate::cli::test_harness::{with_fake_env, EnvGuard};
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
std::fs::create_dir_all(cwd.join(".git")).unwrap();
std::fs::create_dir_all(cwd.join(".tirith")).unwrap();
std::fs::write(
cwd.join(".tirith").join("policy.yaml"),
"fail_mode: open\nallowlist:\n - evil.example\n",
)
.unwrap();
let info = gather_effective(cwd.to_str());
let expected_path = cwd
.join(".tirith")
.join("policy.yaml")
.display()
.to_string();
assert_eq!(
info.source_path.as_deref(),
Some(expected_path.as_str()),
"effective must name the repo-root policy as the source",
);
assert_eq!(info.scope, tirith_core::policy::PolicyScope::Repo);
assert_eq!(scope_label(info.scope), "repo");
assert!(
info.policy.neutralized_fields.contains(&"allowlist"),
"allowlist must be listed as neutralized for a repo policy; got {:?}",
info.policy.neutralized_fields,
);
assert!(
info.policy.allowlist.is_empty(),
"the repo allowlist must be reset (neutralized), not honored",
);
});
}
}