use crate::*;
use candor_classify::policy::ReasonClass;
struct GateReport {
entries: Vec<ReportEntry>,
analyzed_count: usize,
unanalyzed: Vec<candor_report::UnanalyzedUnit>,
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(),
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()
));
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)
}
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 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
}
pub(crate) fn cmd_gate(args: &[String]) -> i32 {
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" => {
let Some(v) = args.get(i + 1) else {
usage_error
.get_or_insert_with(|| format!("--report requires a <locator> argument ({GATE_USAGE})"));
break;
};
report_flag = Some(resolve_locator(v));
i += 1;
}
"--policy" => {
let Some(v) = args.get(i + 1) else {
usage_error
.get_or_insert_with(|| format!("--policy requires a <file> argument ({GATE_USAGE})"));
break;
};
policy_flag = Some(v.clone());
i += 1;
}
"--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 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.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 does not ride the report wire as a gate-usable fact, so 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(),
}));
}
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();
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
);
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,
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 {
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],
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_v24_refused(
violations,
coverage,
analyzed_count,
unanalyzed,
vocabulary,
None,
unevaluated,
) {
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
}