feat(skill-use): a compliance check for web-search-triage, from what the tap recorded
deploy / test (push) Successful in 5m19s
deploy / build (push) Successful in 5m44s

The scorer could see that agents OPENED web-search-triage (trigger=pass on both
files-arm runs) and nothing about whether they followed it — compliance was
not_applicable because no mechanical check existed. The evidence was in the
recorded arguments the whole time. On the runs that read the skill, the parent
decomposed the sweep into per-source fetches and sent each to a subagent; on
01a09b42 two of those spawn prompts read "Return the URL, date if visible, and
the key content". The task never asked for a date. The skill's "undated is a
finding" did. On the runs that did not read it: inline curls, no subagents, no
date.

Two of the skill's rules leave a mark in arguments, and the check scores
exactly those two. The ranking rule: every URL a fetch was sent to is classified
against a short allow-list of primary hosts (rank 0) and a short skip-list of
aggregators (rank 3+); fetching an aggregator is the visible violation, fetching
primary sources the visible compliance, and anything unrecognised is unranked
and decides nothing. The date rule: reported as extra evidence on a pass, never
required for one, because a curl to an abstract page has no prompt to ask in.

`Agent` is a fetching tool here on purpose. The URLs on the files-arm runs live
in the spawn PROMPT; a check that only read curl lines would have scored those
runs as fetching nothing.

`Verdict::PassWith(String)` carries the evidence and serialises under the same
"pass" tag, so no reader grows a fourth branch and the one that looks finds
the date fingerprint in `why`.

One-sided like every check in this module: no tools is not observable, no fetch
is not applicable, an unrankable fetch is not a violation.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-13 17:01:36 -05:00
co-authored by Claude Opus 5
parent 7dd3aa0965
commit 758760cedb
+283 -1
View File
@@ -55,6 +55,15 @@ use serde::Serialize;
#[serde(rename_all = "snake_case", tag = "verdict", content = "why")]
pub enum Verdict {
Pass,
/// A pass that can say what it saw. Same tag as [`Verdict::Pass`] on the
/// wire — `{"verdict":"pass","why":…}` — so every reader that keys on the
/// tag is unaffected and the evidence is there for the one that looks.
///
/// Exists because a bare pass on `web-search-triage` would have hidden
/// the only finding worth having: the agent's spawn prompts asked for the
/// page's date, which the task never did and the skill does.
#[serde(rename = "pass")]
PassWith(String),
Fail(String),
/// The skill says nothing this axis can check.
NotApplicable,
@@ -68,7 +77,7 @@ pub enum Verdict {
impl Verdict {
pub fn label(&self) -> &'static str {
match self {
Verdict::Pass => "pass",
Verdict::Pass | Verdict::PassWith(_) => "pass",
Verdict::Fail(_) => "FAIL",
Verdict::NotApplicable => "n/a",
Verdict::NotObservable(_) => "not observable",
@@ -313,6 +322,7 @@ fn check(skill: &str, ev: &Evidence<'_>) -> (Verdict, Verdict) {
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(ev)),
"workspace-repo-commit-protocol" => (Verdict::NotApplicable, workspace_boundary(ev)),
"small-focused-commits" => (Verdict::NotApplicable, commit_subject_boundary(ev)),
"web-search-triage" => (triage_compliance(ev), Verdict::NotApplicable),
// One check for both: `cargo-test-driven-development` is the Rust
// flavour of the same loop, and its own text says so. Scoring them by
// separate rules would mean two rules for one procedure, which is how
@@ -775,6 +785,164 @@ fn dash_m_value(cmd: &str) -> Option<String> {
None
}
/// The tools whose arguments carry a URL the agent reached for.
///
/// `Agent` is here on purpose. On both `files`-arm runs the parent decomposed
/// the sweep into per-source fetches and sent each to a subagent — the URLs
/// live in the spawn PROMPT, and a check that only read `curl` lines would
/// have scored those runs as fetching nothing.
const FETCH_TOOLS: &[&str] = &["Bash", "Agent", "WebFetch", "web_fetch"];
/// Every `http(s)` URL in the arguments of a fetching tool, in call order.
fn fetched_urls(ev: &Evidence<'_>) -> Vec<String> {
let mut out = Vec::new();
for t in ev.tools {
if !FETCH_TOOLS.contains(&t.tool.as_str()) {
continue;
}
// A `Bash` that never fetches is most of what agents run.
if t.tool == "Bash"
&& !t
.command()
.is_some_and(|c| c.contains("curl") || c.contains("wget"))
{
continue;
}
let text = t.input.to_string();
let mut rest = text.as_str();
while let Some(i) = rest.find("http") {
let cand = &rest[i..];
let end = cand
.find(|c: char| {
c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ')' | ']' | '\\')
})
.unwrap_or(cand.len());
let url = cand[..end].trim_end_matches(|c| matches!(c, '.' | ',' | ';' | ':'));
if url.starts_with("http://") || url.starts_with("https://") {
out.push(url.to_string());
}
rest = &cand[end.max(4)..];
}
}
out
}
/// Rank 0 in `web-search-triage`'s ladder: the paper, the spec, the release.
///
/// A conservative allow-list. Anything not on it is UNRANKED, not rank 3 — a
/// vendor's docs, an author's own blog and a lab page are all rank 0 or 1 and
/// none of them can be recognised by hostname.
fn is_primary_source(url: &str) -> bool {
const HOSTS: &[&str] = &[
"arxiv.org/abs/",
"arxiv.org/pdf/",
"doi.org/",
"aclanthology.org/",
"openreview.net/",
"proceedings.neurips.cc/",
"proceedings.mlr.press/",
"dl.acm.org/doi/",
"ieeexplore.ieee.org/",
"github.com/",
"nature.com/articles/",
"science.org/doi/",
];
HOSTS.iter().any(|h| url.contains(h))
}
/// Rank 3 and below: a restatement of a restatement. The skill's own words are
/// "the same item and should be recorded once, if at all", and its skip signals
/// — "a numbered list of tools", no date — describe these hosts.
///
/// Also conservative. Substack, X and personal blogs are NOT here: an author's
/// own post is rank 1 and the skill says to read it.
fn is_aggregator(url: &str) -> bool {
const HOSTS: &[&str] = &[
"medium.com/",
"towardsdatascience.com/",
"reddit.com/",
"news.ycombinator.com/",
"quora.com/",
"dev.to/",
"linkedin.com/",
"wikipedia.org/",
];
HOSTS.iter().any(|h| url.contains(h))
}
/// `web-search-triage`: read the primary source, and treat undated as a finding.
///
/// Two of the skill's rules leave a mark in the recorded ARGUMENTS, and this
/// scores exactly those two — the rest of the skill is judgement about page
/// content the tap never sees, and a heuristic over it would be a number that
/// looks like a measurement and is not one.
///
/// - The ranking rule. Every URL a fetch was sent to is classified against a
/// short allow-list of primary hosts and a short skip-list of aggregators.
/// Fetching an aggregator is the visible violation; fetching primary sources
/// is the visible compliance. Anything unrecognised is unranked and decides
/// nothing.
/// - The date rule. On mission `01a09b42` the parent's spawn prompts read
/// "Return the URL, date if visible, and the key content" — the task never
/// asked for a date; the skill's "undated is a finding" did. Reported as
/// extra evidence on a pass, never required for one: a curl to an abstract
/// page has no prompt to ask in.
///
/// One-sided like every check here: it reports a violation it can see and never
/// infers compliance from silence.
fn triage_compliance(ev: &Evidence<'_>) -> Verdict {
if ev.tools.is_empty() {
return Verdict::NotObservable(
"no tool calls were recorded for this mission — which URLs were \
fetched is what this check reads, and that is not in the narrative"
.into(),
);
}
let urls = fetched_urls(ev);
if urls.is_empty() {
// Never swept the web, so there was nothing to triage.
return Verdict::NotApplicable;
}
if let Some(u) = urls.iter().find(|u| is_aggregator(u)) {
return Verdict::Fail(format!(
"fetched {u} — an aggregator, rank 3 or below on the skill's ladder; \
the procedure is to find the primary source and record the rest as \
one item, not to read them"
));
}
let primary = urls.iter().filter(|u| is_primary_source(u)).count();
if primary == 0 {
return Verdict::NotObservable(format!(
"fetched {} URL(s), none on the primary-source list and none on the \
aggregator list — the check cannot rank them, and a rank it cannot \
see is not a violation",
urls.len()
));
}
let asked_for_date = ev
.tools
.iter()
.filter(|t| t.tool == "Agent")
.filter(|t| {
t.input
.get("prompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|p| p.to_ascii_lowercase().contains("date"))
})
.count();
let mut why = format!(
"fetched {primary} primary source(s) out of {} URL(s) and no aggregator",
urls.len()
);
if asked_for_date > 0 {
why.push_str(&format!(
"; {asked_for_date} fetch(es) delegated to a subagent asked for the \
page's date, which the task did not and the skill does"
));
}
Verdict::PassWith(why)
}
/// `arxiv-daily` forbids searching arXiv — the harvest already ran.
///
/// This is the one boundary we have watched an agent cross in production, so it
@@ -1047,6 +1215,120 @@ mod tests {
);
}
fn bash(cmd: &str) -> ToolEvidence {
ToolEvidence {
tool: "Bash".into(),
path: None,
input: json!({ "command": cmd }),
response: serde_json::Value::Null,
}
}
fn spawn(prompt: &str) -> ToolEvidence {
ToolEvidence {
tool: "Agent".into(),
path: None,
input: json!({ "prompt": prompt, "subagent_type": "general-purpose" }),
response: serde_json::Value::Null,
}
}
/// The shape mission 01a09b42 recorded: the parent read the skill, then
/// sent each primary source to a subagent and asked for the date — which
/// the task never did and the skill's "undated is a finding" does.
#[test]
fn triage_passes_on_primary_sources_and_reports_the_date_fingerprint() {
let tools = vec![
spawn(
"Fetch the following URLs and return their full text content. Return the \
URL, date if visible, and the key content.\n1. https://arxiv.org/abs/2309.15217 \
(RAGAS paper)\n2. https://arxiv.org/abs/2311.09476",
),
bash("curl -s \"https://arxiv.org/abs/2309.01431\" | head -200"),
];
let ev = Evidence::new("", &tools);
match triage_compliance(&ev) {
Verdict::PassWith(why) => {
assert!(why.contains("3 primary source(s)"), "{why}");
assert!(why.contains("asked for the page's date"), "{why}");
}
other => panic!("expected a pass with evidence, got {other:?}"),
}
}
/// The shape the `index` runs recorded — inline curls, no delegation. The
/// agent still read primary sources, so it still complied; the date
/// fingerprint is extra evidence, never a requirement.
#[test]
fn triage_passes_on_inline_curls_without_the_date_clause() {
let tools = vec![
bash("curl -sL https://arxiv.org/abs/2204.04745"),
bash("ls -la research/"),
];
match triage_compliance(&Evidence::new("", &tools)) {
Verdict::PassWith(why) => assert!(!why.contains("date"), "{why}"),
other => panic!("{other:?}"),
}
}
/// The one violation the check can see: reading a restatement of a
/// restatement instead of the source it restates.
#[test]
fn triage_fails_on_an_aggregator() {
let tools = vec![
bash("curl -s https://arxiv.org/abs/2309.15217"),
spawn("Fetch https://medium.com/@someone/rag-eval-explained-2024 and summarise"),
];
match triage_compliance(&Evidence::new("", &tools)) {
Verdict::Fail(why) => assert!(why.contains("medium.com"), "{why}"),
other => panic!("{other:?}"),
}
}
/// One-sided, both ways. Nothing fetched is nothing to triage; something
/// fetched that the lists cannot rank is unranked, not a violation.
#[test]
fn triage_is_silent_where_it_cannot_see() {
let none: Vec<ToolEvidence> = vec![];
assert!(matches!(
triage_compliance(&Evidence::new("", &none)),
Verdict::NotObservable(_)
));
let no_fetch = vec![bash("cargo test"), bash("git status")];
assert!(matches!(
triage_compliance(&Evidence::new("", &no_fetch)),
Verdict::NotApplicable
));
let unranked = vec![bash("curl -s https://docs.example-vendor.io/eval/guide")];
assert!(matches!(
triage_compliance(&Evidence::new("", &unranked)),
Verdict::NotObservable(_)
));
}
/// A URL inside JSON is followed by a quote, and one at the end of a
/// sentence by a full stop. Neither is part of the URL.
#[test]
fn fetched_urls_stop_at_the_right_character() {
let tools = vec![spawn(
"Fetch \"https://arxiv.org/abs/1\" then https://doi.org/10.1/x. Done.",
)];
assert_eq!(
fetched_urls(&Evidence::new("", &tools)),
vec!["https://arxiv.org/abs/1", "https://doi.org/10.1/x"]
);
}
/// `PassWith` must be indistinguishable from `Pass` to a reader keyed on
/// the tag, or every consumer of the report grows a fourth branch.
#[test]
fn a_pass_with_evidence_serialises_under_the_pass_tag() {
let v = serde_json::to_value(Verdict::PassWith("saw it".into())).unwrap();
assert_eq!(v["verdict"], "pass");
assert_eq!(v["why"], "saw it");
assert_eq!(Verdict::PassWith("x".into()).label(), Verdict::Pass.label());
}
/// The whole loop, end to end: the index writes a uri, the agent reads that
/// exact uri back, and the scorer recovers the skill's name from it.
///