use crate::*;
use candor_classify::policy::ReasonClass;
struct GateReport {
entries: Vec<ReportEntry>,
analyzed_count: usize,
unanalyzed: Vec<candor_report::UnanalyzedUnit>,
out_of_scope: Vec<candor_report::OutOfScopeFinding>,
net_partners: Vec<candor_report::NetPartners>,
coverage_packages: BTreeSet<String>,
judged_nothing_pkgs: Vec<String>,
}
fn load_gate_report(prefix: &str) -> Result<GateReport, String> {
let paths = glob_reports(prefix);
if paths.is_empty() {
let why = format!(
"no report files at prefix `{prefix}` — nothing to gate (scan first: candor-scan . --out \
{prefix})"
);
eprintln!("candor-query gate: {why}");
return Err(why);
}
let mut out = GateReport {
entries: Vec::new(),
analyzed_count: 0,
unanalyzed: Vec::new(),
out_of_scope: Vec::new(),
net_partners: Vec::new(),
coverage_packages: BTreeSet::new(),
judged_nothing_pkgs: Vec::new(),
};
let mut hard_fail = false;
for path in &paths {
let Ok(text) = std::fs::read_to_string(path) else {
eprintln!("candor-query gate: report {} could not be read", path.display());
hard_fail = true;
continue;
};
match candor_report::report_entries_counted(&text) {
Some((es, dropped)) => {
if dropped > 0 {
eprintln!(
"candor-query gate: report {} — {dropped} function entr{} could not be parsed; \
a dropped entry reads as PURE to the gate, so this verdict would under-report",
path.display(),
if dropped == 1 { "y" } else { "ies" }
);
hard_fail = true;
}
out.entries.extend(es);
}
None => {
eprintln!(
"candor-query gate: report {} failed to parse — corrupt input, not an effect-free \
package (a gate over the empty map would PASS)",
path.display()
);
hard_fail = true;
continue;
}
}
macro_rules! strict {
($read:expr, $key:literal, $shape:literal, $permissive:literal, $absent:expr) => {
match $read {
candor_report::KeyRead::Absent => $absent,
candor_report::KeyRead::Present(v) => v,
candor_report::KeyRead::Corrupt => {
eprintln!(
"candor-query gate: report {} — the `{}` key is PRESENT but is not {} (SPEC §2). \
A key that cannot be READ is corrupt input, never its empty value: coerced to \
the default it would become a claim, and here that default is {}. Fix the key, \
or re-run the scan that wrote it.",
path.display(),
$key,
$shape,
$permissive,
);
hard_fail = true;
continue;
}
}
};
}
out.analyzed_count += strict!(
candor_report::report_analyzed(&text),
"analyzed",
"`{ count: <integer>, digest: <hex> }`",
"`count: 0`, which understates the judged universe every downstream number is scaled against",
Default::default()
)
.count;
if candor_report::report_judged_nothing(&text) {
let pkg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("package").and_then(|p| p.as_str()).map(str::to_owned))
.filter(|p| !p.is_empty())
.unwrap_or_else(|| path.display().to_string());
out.judged_nothing_pkgs.push(pkg);
}
out.unanalyzed.extend(strict!(
candor_report::report_unanalyzed(&text),
"unanalyzed",
"a list of `{ path, reason }`",
"the EMPTY list — and `unanalyzed` non-emptiness IS the fail-closed trigger, so that default \
turns this verb's exit 2 into `policy ✓`",
Vec::new()
));
match candor_report::report_net_partners(&text) {
candor_report::KeyRead::Present(np) if !out.net_partners.contains(&np) => {
out.net_partners.push(np);
}
_ => {}
}
out.out_of_scope.extend(strict!(
candor_report::report_out_of_scope(&text),
"outOfScope",
"a list of `{ fn, path, effects, class, reason }`",
"the EMPTY list — and ⟨0.30⟩ makes `outOfScope` non-emptiness a fail-closed trigger, so that \
default turns this verb's exit 2 into `policy ✓`",
Vec::new()
));
let cov: candor_report::Coverage = strict!(
candor_report::report_coverage_strict(&text),
"coverage",
"`{ uncovered: [{ name, calls }] }`",
"an EMPTY κ ledger, which deletes the coverage hedge from the verdict a machine reads",
Default::default()
);
out.coverage_packages.extend(cov.uncovered.into_iter().map(|e| e.name));
}
if hard_fail {
let why = "refusing to gate over a report that did not load cleanly — re-run the scan (a \
partial signature makes a green verdict meaningless); the specific key or file is \
named on stderr above"
.to_string();
eprintln!("candor-query gate: {why}");
return Err(why);
}
Ok(out)
}
fn gate_report_input_files(report_flag: Option<&str>) -> Vec<String> {
let Some(prefix) = report_flag.map(resolve_locator).or_else(discover_report_prefix) else {
return Vec::new();
};
let mut out = Vec::new();
for r in glob_reports(&prefix) {
let r = r.display().to_string();
if let Some(stem) = r.strip_suffix(".json") {
for kind in candor_report::SIDECAR_KINDS {
if kind == "gate" {
continue;
}
let side = format!("{stem}.{kind}.json");
if Path::new(&side).is_file() {
out.push(side);
}
}
}
out.push(r);
}
out
}
pub(crate) struct ReportSignature {
all: Vec<String>,
inferred: HashMap<String, BTreeSet<String>>,
calls: HashMap<String, BTreeSet<String>>,
hosts: HashMap<String, BTreeSet<String>>,
cmds: HashMap<String, BTreeSet<String>>,
paths: HashMap<String, BTreeSet<String>>,
tables: HashMap<String, BTreeSet<String>>,
surface_incomplete: HashMap<String, BTreeSet<String>>,
pub(crate) reason_classes: HashMap<String, BTreeSet<String>>,
net_classes: HashMap<String, Vec<String>>,
}
impl ReportSignature {
fn as_input(&self) -> candor_classify::gate::GateInput<'_, String> {
candor_classify::gate::GateInput {
all: &self.all,
inferred: &self.inferred,
calls: &self.calls,
hosts: &self.hosts,
cmds: &self.cmds,
paths: &self.paths,
tables: &self.tables,
surface_incomplete: &self.surface_incomplete,
reason_classes: &self.reason_classes,
net_classes: &self.net_classes,
}
}
}
pub(crate) fn report_signature(entries: &[ReportEntry]) -> ReportSignature {
let mut inferred: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut hosts: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut cmds: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut paths: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut tables: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut net: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut why_direct: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut names: BTreeSet<String> = BTreeSet::new();
for e in entries {
let fn_ = e.func.clone();
names.insert(fn_.clone());
inferred.entry(fn_.clone()).or_default().extend(e.inferred.iter().cloned());
calls.entry(fn_.clone()).or_default().extend(e.calls.iter().cloned());
if !e.hosts.is_empty() {
hosts.entry(fn_.clone()).or_default().extend(e.hosts.iter().cloned());
}
if !e.cmds.is_empty() {
cmds.entry(fn_.clone()).or_default().extend(e.cmds.iter().cloned());
}
if !e.paths.is_empty() {
paths.entry(fn_.clone()).or_default().extend(e.paths.iter().cloned());
}
if !e.tables.is_empty() {
tables.entry(fn_.clone()).or_default().extend(e.tables.iter().cloned());
}
if !e.net_class.is_empty() {
net.entry(fn_.clone()).or_default().extend(e.net_class.iter().cloned());
}
for why in &e.unknown_why {
why_direct.entry(fn_.clone()).or_default().insert(ReasonClass::classify(why).token().to_string());
}
if e.direct.iter().any(|d| d == "Unknown") && e.unknown_why.is_empty() {
why_direct.entry(fn_).or_default().insert(ReasonClass::Unresolved.token().to_string());
}
}
let all: Vec<String> = names.into_iter().collect();
let reason_classes = candor_classify::propagate::propagate_str(&why_direct, &calls, &all);
ReportSignature {
net_classes: net.into_iter().map(|(k, v)| (k, v.into_iter().collect())).collect(),
all,
inferred,
calls,
hosts,
cmds,
paths,
tables,
surface_incomplete: HashMap::new(),
reason_classes,
}
}
fn unanswerable_scoped_filters(
p: &candor_classify::policy::ParsedPolicy,
sig: &ReportSignature,
) -> Vec<candor_report::Unevaluated> {
let mut seen: BTreeSet<String> = BTreeSet::new();
unanswerable_pairs(p, sig)
.into_iter()
.filter(|u| seen.insert(u.rule.clone()))
.map(|u| candor_report::Unevaluated { rule: u.rule, why: u.why })
.collect()
}
pub(crate) struct Unanswerable {
pub(crate) rule: String,
pub(crate) func: String,
pub(crate) why: String,
}
pub(crate) fn whole_policy_refusals(
p: &candor_classify::policy::ParsedPolicy,
policy_path: &str,
) -> Vec<candor_report::Unevaluated> {
let mut policy_refusals: Vec<candor_report::Unevaluated> = Vec::new();
if !p.layer_rules.is_empty() {
let why = format!(
"`gate --report` cannot evaluate a `forbid` rule — a report's `calls` graph is \
EFFECT-RELEVANT (only callees with a non-empty effect set are written), so a crossing into a \
wholly PURE unit is invisible in it, while `forbid` matches on NAME. The rule would read \
green over a crossing a scan fails on. Gate layering at scan time: candor-scan . --policy \
{policy_path}"
);
policy_refusals.extend(p.layer_rules.iter().map(|r| candor_report::Unevaluated {
rule: r.raw.trim().to_string(),
why: why.clone(),
}));
}
if !p.only_rules.is_empty() {
let why = format!(
"`gate --report` cannot evaluate an `only` rule — it asks whether EVERYTHING a scope reaches \
is on a list, and a report carries an effect-relevant call surface rather than the complete \
dependency graph a NAME-matching rule needs. Answering it here would certify completeness \
from evidence that is not complete. Gate permissions at scan time: candor-scan . --policy \
{policy_path}"
);
policy_refusals.extend(p.only_rules.iter().map(|r| candor_report::Unevaluated {
rule: r.raw.trim().to_string(),
why: why.clone(),
}));
}
if !p.allow_rules.is_empty() {
let effects: BTreeSet<&str> = p.allow_rules.iter().map(|r| r.effect).collect();
let why = format!(
"`gate --report` cannot evaluate an `allow {}` rule — the AS-EFF-008 surface-completeness \
marker WAS said not to ride the report wire; ⟨0.29⟩ made it ride, but only when the \
producing report declares `incomplete` in `resolves`. This verb refuses UNIFORMLY \
rather than answering per-report, because an engine that evaluated where its \
siblings refuse would SPLIT THE VERB — a benign visible literal beside a \
runtime-computed endpoint would be CERTIFIED here and flagged by a scan. \
(`netClass: unknown-host` is NOT that marker — it also names a merely \
unrecognised host.) \
Gate allowlists at scan time: candor-scan . --policy {policy_path}",
effects.into_iter().collect::<Vec<_>>().join("`/`")
);
policy_refusals.extend(p.allow_rules.iter().map(|r| candor_report::Unevaluated {
rule: r.raw.trim().to_string(),
why: why.clone(),
}));
}
policy_refusals
}
pub(crate) fn unanswerable_pairs(
p: &candor_classify::policy::ParsedPolicy,
sig: &ReportSignature,
) -> Vec<Unanswerable> {
let mut out = Vec::new();
for r in &p.rules {
for q in &sig.all {
if let Some(s) = &r.scope
&& !candor_classify::policy::scope_matches(q, s)
{
continue;
}
let inf = sig.inferred.get(q);
let has = |e: &str| inf.is_some_and(|s| s.iter().any(|x| x == e));
if !r.net_classes.is_empty()
&& has("Net")
&& sig.net_classes.get(q).map(|c| c.is_empty()).unwrap_or(true)
{
out.push(Unanswerable {
rule: r.raw.trim().to_string(),
func: q.clone(),
why: format!(
"it narrows on the Net DESTINATION CLASS, but `{q}` carries Net with no \
`netClass` in this report — the field the filter reads is absent, so the \
narrowing would succeed for lack of evidence and drop a Net the bare `deny Net` \
catches. The rule is WITHHELD on `{q}` rather than tolerated there: an absent \
optional field must not relax a fail-closed gate. Use the bare `deny Net`, or \
gate at scan time."
),
});
continue;
}
if !r.unknown_classes.is_empty()
&& has("Unknown")
&& sig.reason_classes.get(q).map(|c| c.is_empty()).unwrap_or(true)
{
out.push(Unanswerable {
rule: r.raw.trim().to_string(),
func: q.clone(),
why: format!(
"it narrows on the Unknown REASON CLASS, but `{q}` carries Unknown with no reason \
reachable in this report — neither its own `unknownWhy` nor a `calls` edge to \
one. §6.2 resolves the class set TRANSITIVELY over the gate's reach; with the \
channel missing there is nothing for the filter to read, so the rule is WITHHELD \
on `{q}` — neither charged (which would assert a reason nobody recorded) nor \
tolerated (which would relax the gate for lack of evidence). Use the bare `deny \
Unknown`, or gate at scan time."
),
});
}
}
}
out
}
const GATE_USAGE: &str =
"usage: candor-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
fn refuse(reason: &str, want_json: bool, gate_json: Option<&str>) -> i32 {
refuse_disclosing(reason, &[], want_json, gate_json)
}
fn refuse_disclosing(
reason: &str,
unevaluated: &[candor_report::Unevaluated],
want_json: bool,
gate_json: Option<&str>,
) -> i32 {
let mut targets: Vec<&str> = Vec::new();
if want_json {
targets.push("-");
}
if let Some(p) = gate_json
&& !(want_json && p == "-")
{
targets.push(p);
}
if !targets.is_empty() {
match candor_report::gate_refusal_json_v24(reason, unevaluated) {
Ok(json) => {
for t in targets {
if t == "-" {
println!("{json}");
} else if let Err(e) =
candor_report::write_atomic(Path::new(t), format!("{json}\n").as_bytes())
{
eprintln!(
"candor-query gate: could not write the refusal document to --gate-json {t} \
({e}) — a consumer reading that path will see the PREVIOUS run's verdict, \
which is stale. Delete it, or treat exit 2 as a failure."
);
}
}
}
Err(e) => eprintln!("candor-query gate: could not serialize the refusal document ({e})"),
}
}
2
}
fn same_artifact(a: &str, b: &str) -> bool {
if a == "-" || b == "-" {
return false;
}
fn resolve(p: &str) -> Option<std::path::PathBuf> {
let p = std::path::Path::new(p);
if let Ok(c) = p.canonicalize() {
return Some(c);
}
let parent = p.parent().filter(|x| !x.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
Some(parent.canonicalize().ok()?.join(p.file_name()?))
}
matches!((resolve(a), resolve(b)), (Some(x), Some(y)) if x == y)
}
fn is_candor_config(p: &str) -> bool {
let path = std::path::Path::new(p);
path.file_name().is_some_and(|n| n == "config")
&& path
.parent()
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d })
.and_then(|d| d.canonicalize().ok().or_else(|| Some(d.to_path_buf())))
.and_then(|d| d.file_name().map(|n| n == ".candor"))
.unwrap_or(false)
}
static QUERY_GATE_JSON: std::sync::OnceLock<String> = std::sync::OnceLock::new();
fn all_gate_sinks(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if let Some(v) = args
.get(i + 1)
.filter(|_| args[i] == "--gate-json")
.filter(|v| v.as_str() == "-" || !v.starts_with('-'))
{
out.push(v.clone());
i += 2;
continue;
}
i += 1;
}
out
}
fn distinct_gate_sinks(all: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for s in all {
if !out.iter().any(|k| k == s || (k != "-" && s != "-" && same_artifact(k, s))) {
out.push(s.clone());
}
}
out
}
fn refuse_via_registered_sink(reason: &str) -> ! {
refuse(reason, false, QUERY_GATE_JSON.get().map(String::as_str));
std::process::exit(2)
}
pub(crate) fn cmd_gate(args: &[String]) -> i32 {
{
let (mut gate, mut policy, mut report) = (None::<&str>, None::<&str>, None::<&str>);
let mut i = 0;
while i < args.len() {
let takes = args[i] == "--gate-json" || args[i] == "--policy" || args[i] == "--report";
if let Some(v) = args
.get(i + 1)
.filter(|_| takes)
.filter(|v| v.as_str() == "-" || !v.starts_with('-'))
{
match args[i].as_str() {
"--gate-json" => gate = Some(v),
"--policy" => policy = Some(v),
_ => report = Some(v),
}
i += 1;
}
i += 1;
}
let named_sinks = distinct_gate_sinks(&all_gate_sinks(args));
if let Some(gp) = gate {
let env_policy = std::env::var("CANDOR_POLICY").ok();
let env_config = std::env::var("CANDOR_CONFIG").ok();
let report_set = gate_report_input_files(report);
for s_named in named_sinks.iter().filter(|s| s.as_str() != "-") {
for (other, flag) in [(policy, "--policy"), (report, "--report"),
(env_policy.as_deref(), "CANDOR_POLICY"),
(env_config.as_deref(), "CANDOR_CONFIG")] {
if let Some(other) = other.filter(|o| same_artifact(s_named, o)) {
eprintln!("candor-query gate: --gate-json {s_named} names the SAME FILE as {flag} {other} — refusing (exit 2).");
eprintln!(" Nothing was written; give the verdict its own path.");
return 2;
}
}
for f in &report_set {
if same_artifact(s_named, f) {
eprintln!("candor-query gate: --gate-json {s_named} names a file this gate reads — {f} — refusing (exit 2).");
eprintln!(" Nothing was written; give the verdict its own path.");
return 2;
}
}
if is_candor_config(s_named) {
eprintln!("candor-query gate: --gate-json {s_named} is a .candor/config — refusing (exit 2). Nothing was written.");
return 2;
}
}
for (other, flag) in [(policy, "--policy"), (report, "--report"),
(env_policy.as_deref(), "CANDOR_POLICY"),
(env_config.as_deref(), "CANDOR_CONFIG")] {
if let Some(other) = other.filter(|o| same_artifact(gp, o)) {
eprintln!("candor-query gate: --gate-json {gp} names the SAME FILE as {flag} {other} — refusing (exit 2).");
eprintln!(" The verdict is armed before the policy is read, so this would overwrite your");
eprintln!(" policy and then gate on the wreckage. Nothing was written.");
return 2;
}
}
for f in &report_set {
if same_artifact(gp, f) {
eprintln!("candor-query gate: --gate-json {gp} names a file this gate reads — {f} — refusing (exit 2).");
eprintln!(" The verdict is armed before the run reads its inputs, so this would overwrite");
eprintln!(" that input and then gate on the wreckage. Nothing was written; give the verdict");
eprintln!(" its own path.");
return 2;
}
}
if is_candor_config(gp) {
eprintln!("candor-query gate: --gate-json {gp} is a .candor/config — refusing (exit 2). This would");
eprintln!(" destroy the config that configures this run. Nothing was written.");
return 2;
}
if named_sinks.len() > 1 {
let list = named_sinks.join(", ");
eprintln!("candor-query gate: --gate-json given more than once ({list}) — refusing (exit 2).");
eprintln!(" A gate publishes ONE verdict. Naming two sinks says where it goes twice, and the");
eprintln!(" reader of the path that loses cannot tell it lost. Name one, or run the gate twice.");
let doc = candor_report::gate_refusal_json(&format!(
"--gate-json was given more than once ({list}) — a run publishes one verdict to one sink"
))
.unwrap_or_else(|_| "{\"ok\":false,\"refused\":true}".to_string());
for t in &named_sinks {
if t == "-" {
println!("{doc}");
} else if let Err(e) = std::fs::write(t, format!("{doc}\n")) {
eprintln!("candor-query gate: could not write the refusal to --gate-json {t} ({e})");
}
}
return 2;
}
let _ = QUERY_GATE_JSON.set(gp.to_string());
candor_classify::policy::set_refusal_sink(refuse_via_registered_sink);
if let Some((cfg_path, text)) = candor_classify::policy::discover_config(std::path::Path::new(".")) {
let home = {
let parent = cfg_path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
if parent.file_name().and_then(|n| n.to_str()) == Some(".candor") {
parent.parent().map(std::path::Path::to_path_buf).unwrap_or(parent)
} else {
parent
}
};
if same_artifact(gp, &cfg_path.display().to_string()) {
eprintln!("candor-query gate: --gate-json {gp} is the .candor/config this run reads — refusing (exit 2).");
return 2;
}
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
let mut it = line.splitn(2, char::is_whitespace);
if it.next().map(str::to_ascii_lowercase).as_deref() != Some("policy") {
continue;
}
let Some(v) = it.next().map(str::trim).filter(|v| !v.is_empty()) else { continue };
let abs = if std::path::Path::new(v).is_absolute() {
std::path::PathBuf::from(v)
} else {
home.join(v)
};
if same_artifact(gp, &abs.display().to_string()) {
eprintln!("candor-query gate: --gate-json {gp} names the policy this run reads via");
eprintln!(" {}'s `policy` key — refusing (exit 2). Nothing was written.", cfg_path.display());
return 2;
}
}
}
if gp != "-" {
let armed = format!(
"{{\n \"spec\": \"{}\",\n \"ok\": false,\n \"refused\": true,\n \"reason\": \"the gate did not complete — this document was written when the run STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is NOT a verdict about the code; see the run's stderr for the cause.\"\n}}\n",
candor_report::SPEC_VERSION
);
if let Err(e) = std::fs::write(gp, armed) {
eprintln!("candor-query: could not arm --gate-json {gp} fail-closed ({e}) — if this run does not complete, that path may still hold a PREVIOUS run's verdict");
}
}
}
}
let mut report_flag: Option<String> = None;
let mut policy_flag: Option<String> = None;
let mut gate_json: Option<String> = None;
let mut want_json = false;
let mut usage_error: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--json" => want_json = true,
"--text" | "--human" => {}
"--report" => match args.get(i + 1) {
Some(v) if v == "-" || !v.starts_with('-') => {
report_flag = Some(resolve_locator(v));
i += 1;
}
Some(v) => {
usage_error.get_or_insert_with(|| {
format!("--report was given no value — the next token `{v}` is a flag, not a locator (a path really named that is spelled ./{v})")
});
}
None => {
usage_error
.get_or_insert_with(|| format!("--report requires a <locator> argument ({GATE_USAGE})"));
break;
}
},
"--policy" => match args.get(i + 1) {
Some(v) if v == "-" || !v.starts_with('-') => {
policy_flag = Some(v.clone());
i += 1;
}
Some(v) => {
usage_error.get_or_insert_with(|| {
format!("--policy was given no value — the next token `{v}` is a flag, not a path (a file really named that is spelled ./{v})")
});
}
None => {
usage_error
.get_or_insert_with(|| format!("--policy requires a <file> argument ({GATE_USAGE})"));
break;
}
},
"--gate-json" => {
match args.get(i + 1) {
Some(v) if v == "-" || !v.starts_with('-') => {
gate_json = Some(v.clone());
i += 1;
}
_ => {
usage_error.get_or_insert_with(|| {
"--gate-json requires a value (a path, or `-` for stdout)".to_string()
});
}
}
}
other => {
usage_error.get_or_insert_with(|| {
if other.starts_with('-') && other.len() > 1 {
format!("unknown flag `{other}` ({GATE_USAGE})")
} else {
format!("unexpected argument `{other}` ({GATE_USAGE})")
}
});
}
}
i += 1;
}
if let Some(why) = usage_error {
eprintln!("candor-query gate: {why}");
return refuse(&why, want_json, gate_json.as_deref());
}
let policy_path = policy_flag
.or_else(|| std::env::var("CANDOR_POLICY").ok().filter(|s| !s.is_empty()))
.or_else(|| {
candor_classify::policy::discover_config_text(Path::new("."))
.and_then(|t| config_value(&t, "policy"))
});
let Some(policy_path) = policy_path else {
let why = "a policy is required — pass `--policy <file>`, set CANDOR_POLICY, or add a `policy` \
key to .candor/config. `gate` applies a policy to an existing report; with no policy \
there is no verdict to give."
.to_string();
eprintln!("candor-query gate: {why}");
return refuse(&why, want_json, gate_json.as_deref());
};
let Ok(policy_text) = std::fs::read_to_string(&policy_path) else {
let why = format!(
"policy file {policy_path} could not be read — failing (exit 2), policy NOT evaluated"
);
eprintln!("candor-query gate: {why}");
return refuse(&why, want_json, gate_json.as_deref());
};
let cfg = candor_classify::policy::discover_config(Path::new(&policy_path));
let aliases =
cfg.as_ref().map(|(_, t)| candor_classify::policy::parse_unknown_aliases(t)).unwrap_or_default();
let p = candor_classify::policy::parse_policy_with_aliases(&policy_text, &aliases);
let vocabulary = (!p.used_aliases.is_empty())
.then(|| {
cfg.as_ref().map(|(path, _)| candor_report::GateVocabulary {
config: path.display().to_string(),
aliases: p.used_aliases.clone(),
})
})
.flatten();
let fatal = p.fatal_messages();
if !fatal.is_empty() {
for e in &fatal {
eprintln!("candor-query gate: policy error — {e}");
}
let why = format!(
"refusing to evaluate a policy that cannot be honoured AS WRITTEN (exit 2, policy NOT \
evaluated). Fix the token, or define it as an `unknown-alias` in the `.candor/config` \
beside {policy_path}. Policy error(s): {}",
fatal.join(" · ")
);
eprintln!("candor-query gate: {why}");
return refuse(&why, want_json, gate_json.as_deref());
}
let ignored: Vec<candor_report::IgnoredLine> = p
.errors
.iter()
.filter(|e| !e.fatal)
.map(|e| candor_report::IgnoredLine {
line: e.line,
text: e.text.clone(),
reason: e.message.clone(),
})
.collect();
if p.rules.is_empty() && p.allow_rules.is_empty() && p.layer_rules.is_empty() && p.only_rules.is_empty() {
let why = format!(
"the policy at {policy_path} yielded NO RULES — refusing (exit 2, policy NOT evaluated). \
Every line was ignored, the file is empty, or it holds only comments. A gate with no rules \
cannot have caught anything, and reporting `ok: true` here would be indistinguishable from \
a gate that ran and found nothing. If you did not mean to gate, do not configure a policy."
);
eprintln!("candor-query gate: {why}");
return refuse_disclosing(
&why,
&[candor_report::Unevaluated {
rule: format!("(entire policy {policy_path} — no rules parsed)"),
why: "the configured policy yielded zero rules, so nothing was evaluated and no rule \
can have passed"
.to_string(),
}],
want_json,
gate_json.as_deref(),
);
}
let policy_refusals = whole_policy_refusals(&p, &policy_path);
let say = |u: &candor_report::Unevaluated| format!("`{}` — {}", u.rule, u.why);
if !policy_refusals.is_empty() && p.rules.is_empty() {
for u in &policy_refusals {
eprintln!("candor-query gate: {}", say(u));
}
return refuse_disclosing(
&policy_refusals.iter().map(&say).collect::<Vec<_>>().join(" · "),
&policy_refusals,
want_json,
gate_json.as_deref(),
);
}
let mut p = p;
p.allow_rules.clear();
p.layer_rules.clear();
p.only_rules.clear();
let p = p;
let Some(prefix) = report_flag.or_else(discover_report_prefix) else {
let why = "no report — pass --report <locator> or run from a repo with a .candor/ dir (scan: \
candor-scan . --out .candor/report)"
.to_string();
eprintln!("candor-query gate: {why}");
return refuse(&why, want_json, gate_json.as_deref());
};
let rep = match load_gate_report(&prefix) {
Ok(r) => r,
Err(why) => {
return refuse(&why, want_json, gate_json.as_deref());
}
};
for pkg in &rep.judged_nothing_pkgs {
eprintln!(
"candor-query gate: NOTE — `{pkg}` says it JUDGED NOTHING (⟨0.24⟩ `analyzed.count` is 0, or \
absent with no entries), so the verdict below is about no unit at all: an absent entry is \
candor's purity claim only where something was judged. This is usually a facade or \
re-export-only package — gate what it re-exports, or scan its source \
(candor-scan <dir> --policy {policy_path}). The verdict and exit code are unchanged: this \
report makes no claim, and inventing one for it would be the opposite defect."
);
}
let sig = report_signature(&rep.entries);
let mut refused = policy_refusals;
refused.extend(unanswerable_scoped_filters(&p, &sig));
let outcome = candor_classify::gate::gate(&p, &sig.as_input());
debug_assert!(
outcome.withheld.is_empty() || !refused.is_empty(),
"gate() withheld {:?} but no rule was refused — a withheld rule must always be disclosed",
outcome.withheld
);
for raw in &outcome.zero_match {
eprintln!(
"candor: policy rule matched NO function — `{raw}`. It was evaluated and bound nothing, \
so it cannot have caught anything. Legitimate when one policy is shared across repos; \
a typo'd layer name otherwise."
);
}
let zero_match = outcome.zero_match;
let mut violations = outcome.violations;
if violations.is_empty() && !refused.is_empty() {
let sole: Vec<String> = refused
.iter()
.map(|u| {
format!(
"{} Refusing (exit 2) — no rule fired on evidence this report carries, so there \
is no verdict to stand beside this.",
say(u)
)
})
.collect();
for why in &sole {
eprintln!("candor-query gate: {why}");
}
if !rep.unanalyzed.is_empty() {
eprintln!(
"candor-query gate: (the report ALSO declares {} unanalyzed unit(s) — that alone would \
have been exit 2)",
rep.unanalyzed.len()
);
}
return refuse_disclosing(&sole.join(" · "), &refused, want_json, gate_json.as_deref());
}
if !refused.is_empty() && !violations.is_empty() {
eprintln!(
"candor-query gate: NOTE — {} policy rule(s) could not be evaluated over this report and are \
NOT answered by the verdict below. The verdict stands anyway: the {} violation(s) reported \
below FIRED on evidence this report carries, and no resolution of an unanswered rule can \
un-reject an already-rejected policy (SPEC §3.1, PAPER3 Lemma 2). The exit code below \
answers those, and NOT these:",
refused.len(),
violations.len(),
);
for u in &refused {
eprintln!(" {}", say(u));
}
}
let stdout_is_json = want_json || gate_json.as_deref() == Some("-");
for gv in &violations {
let line = format!("[{}] {}", gv.rule, gv.detail);
if stdout_is_json {
eprintln!("{line}");
} else {
println!("{line}");
}
}
let coverage = (!rep.coverage_packages.is_empty()).then(|| candor_report::GateCoverage {
uncovered: rep.coverage_packages.len(),
packages: rep.coverage_packages.iter().cloned().collect(),
});
if !write_verdict(
&mut violations,
coverage.as_ref(),
rep.analyzed_count,
&rep.unanalyzed,
vocabulary.as_ref(),
&refused,
&zero_match,
&ignored,
&rep.out_of_scope,
&rep.net_partners,
want_json,
gate_json.as_deref(),
) {
return 2;
}
if !violations.is_empty() {
eprintln!("candor-query gate: {} policy violation(s)", violations.len());
eprintln!("→ candor-query fix-gate names the remedy for each (or `candor fix <fn> <Effect>` for one)");
1
} else if !rep.unanalyzed.is_empty() {
eprintln!(
"candor-query gate: NOT certified — the report declares {} unit(s) candor could not analyze; \
a gate cannot be green over unanalyzed code",
rep.unanalyzed.len()
);
2
} else if !rep.out_of_scope.is_empty() {
eprintln!(
"candor-query gate: NOT certified — the report names {} function(s) OUTSIDE the scan's scope \
performing an effect this policy denies; the gate did not judge them, so the verdict is \
incomplete rather than a pass",
rep.out_of_scope.len()
);
2
} else {
eprintln!("candor-query gate: policy ✓ (the report's own signature — no re-scan, no re-derivation)");
0
}
}
#[allow(clippy::too_many_arguments)]
fn write_verdict(
violations: &mut [candor_report::GateViolation],
coverage: Option<&candor_report::GateCoverage>,
analyzed_count: usize,
unanalyzed: &[candor_report::UnanalyzedUnit],
vocabulary: Option<&candor_report::GateVocabulary>,
unevaluated: &[candor_report::Unevaluated],
zero_match: &[String],
ignored: &[candor_report::IgnoredLine],
out_of_scope: &[candor_report::OutOfScopeFinding],
net_partners: &[candor_report::NetPartners],
want_json: bool,
gate_json: Option<&str>,
) -> bool {
let mut targets: Vec<&str> = Vec::new();
if want_json {
targets.push("-");
}
if let Some(p) = gate_json
&& !(want_json && p == "-")
{
targets.push(p);
}
if targets.is_empty() {
return true;
}
let json = match candor_report::gate_verdict_json_v31(
violations,
coverage,
analyzed_count,
unanalyzed,
vocabulary,
unevaluated,
zero_match,
ignored,
out_of_scope,
net_partners,
) {
Ok(j) => j,
Err(e) => {
eprintln!("candor-query gate: could not serialize the gate verdict ({e})");
return false;
}
};
for t in targets {
if t == "-" {
println!("{json}");
} else if let Err(e) = candor_report::write_atomic(Path::new(t), format!("{json}\n").as_bytes()) {
eprintln!("candor-query gate: could not write --gate-json {t} ({e})");
return false;
}
}
true
}
fn config_value(text: &str, key: &str) -> Option<String> {
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.splitn(2, char::is_whitespace);
if it.next().is_some_and(|k| k.eq_ignore_ascii_case(key)) {
let v = it.next().unwrap_or("").trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}