Skip to content

Address issues encountered during new provider turn up - #722

Merged
wagoodman merged 57 commits into
mainfrom
address-turnup-feedback
Oct 29, 2026
Merged

wagoodman merged 57 commits into
mainfrom
address-turnup-feedback

Conversation

@willmurphyscode

Copy link
Copy Markdown
Contributor

This branch was tested out on anchore/vunnel#1114 to see whether it has enough features that it can be used to turn up a new provider. It addresses a few issues:

  1. Additional claims dirs can now record additions to the vocabulary.yaml
  2. If a research task fails because of hitting an unfamiliar vuln ID, the UI will offer the user to create the vocabulary addition. This could be clearer, because the user has to decide whether it's an advisory ID or a disclosure ID.
  3. Research that fails to record something is now easier to see and diagnose.

Long description:

  1. A new namespace couldn't be labeled. propose refused the cleanstart IDs as an unknown namespace, and the UI only flashed a toast.
  2. Headless research couldn't record. Every write was denied, and the UI said "researched, no hint" with nothing to show for it.

I verified the fix with the real claude on a copy of the CleanStart workspace: two findings, both recorded, about $0.20 each.

Changes

  1. Vocabulary: vocabulary.yaml merges across claims sources. For an unlisted namespace, the UI offers to add it and writes the file. It suggests a list by counting the vulnerabilities each ID names: vulnerability-namespaces or advisory-ids.
  2. Research writes: Claude Code resolves a relative Edit(...) rule against the session's current directory, so one cd broke every later write. The rule is now absolute, and the skill says never to cd.
  3. Research results: a job that records nothing ends as not recorded, with the reason. The finding page shows the verdict, report and open questions, plus a Record as hint button. "Research all" picks not-recorded rows up again.
  4. Branch builds: a branch build runs research against a plugin with the same base version. When research is off, the UI says why. --research-plugin-dir loads a branch's plugin for research runs only. DEVELOPING.md says how to run a branch in another repo's gate.
  5. Smaller fixes: match-path lanes, the namespace dialog layout, and e2e specs that had never run. The suite now passes in docker.

Trust decisions

The research agent reads pages anyone can write, so it's treated as untrusted. Each commit message has the full reasoning.

  1. The agent can't write job records, which the server trusts. A deny rule holds even after a cd. The job ID comes from the job's directory, and author.researchJob from the job's environment.
  2. Record as hint submits only what the page shows: a proposal from this run, about this job's rows.
  3. curl and gh stay blocked. The agent uses WebFetch.
  4. Research keeps the user's Claude Code settings. Isolating it would also drop the user's own deny rules.
  5. Advisory IDs aren't banned as vulnerability keys yet. The line between advisory and disclosure is still vague, and some distro may only publish advisories.

Known gaps (low)

  1. A row that keeps recording nothing is re-run, and charged again, on every "research all". The price is shown first.
  2. A proposal that names a subject instead of a row can't be recorded from the UI.
  3. Comments next to a bare --- in vocabulary.yaml may be lost when a namespace is added.

Testing

  1. go test, golangci-lint, bun test and tsc pass.
  2. task e2e:container passes 246 of 246.
  3. Two adversarial review rounds, one for security and one for correctness. Everything they confirmed is fixed except the gaps above.
  4. Turn up tests for feat: add RapidFort vulnerability provider vunnel#1114 were built using this branch (that is, a local quality gate was iterated on until it passed).

A lane took its via cell from the row's pooled match types and upstreams
but its result from the first match detail, so binutils' exact-direct
match showed "via source package binutils". With a raw match, every
cell now reads its first detail, and "via" shows only for an
exact-indirect detail that searched by another package's name.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A run whose config gives grype by path records spec {name, path}, no
version, so the gate's setup lines raised KeyError. They now show the
version, else the path.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Research on a CLEANSTART-* id ran the whole agent, then propose refused
its call because CLEANSTART is in neither advisory-ids nor
vulnerability-namespaces. POST /api/research now runs the same check
against each job's gate vocabulary and refuses the request before any
job is queued. The refusal now says which list to add the namespace to.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Phase 4 kept openQuestions empty unless the verdict was unclear, so a tp
or fp report's open questions never reached the finding page, which
reads only proposal.json (CLEANSTART-2026-IC12241 lost two). Every open
question the report lists now goes into openQuestions, on any verdict.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A set read one source's vocabulary.yaml whole: the writable source's,
else the first that had one. So a provider's own claims couldn't list a
new namespace without copying the published vocabulary. Now every
source's vocabulary.yaml is optional and they merge in order: the lists
are unioned, and a later source may add justifications and rules. A key
two sources define differently is an error naming both sources, as is
a namespace in vulnerability-namespaces that an advisory-ids pattern
matches. claims validate and vex import, which take one corpus
directory, still read that directory's own vocabulary.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Refusing an id whose namespace is in neither list only said to add it
somewhere. Where the gate's rows are at hand (POST /api/research and
claims agent propose), the refusal now counts the vulnerabilities each
of the scan's ids in that namespace names: an id naming at most one is
a disclosure, so the namespace goes in vulnerability-namespaces; one
naming several is an advisory, so it goes in advisory-ids. The refusal
gives the count, one example, and the writable source's vocabulary.yaml
to edit. An id's same-namespace related ids are siblings, not names, and
its CVEs count when it has any, so a CVE and its GHSA count once. With
no scan, the refusal names both lists and an additional-claims
vocabulary.yaml.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
The research refusal for a namespace no vocabulary lists was plain text,
and only POST /api/research checked it: the per-gate research route,
which the finding page's Research button uses, started the job anyway.
Both routes now refuse with an UnlistedNamespace body (code
unlisted-namespace): the namespace, the list the scan suggests, how many
of its ids were checked, an example, and the vocabulary.yaml to edit,
relative to the workspace. A gate's state lists the same for its open
rows' namespaces.

POST /api/gates/{gate}/vocabulary/namespace adds a namespace to a list
of the writable claims source's vocabulary.yaml, under the label-write
lock. It creates the file when the source has none, and otherwise edits
the YAML tree in place, keeping comments, order and styles. It refuses
a namespace that would be listed as both kinds, and a gate with no
writable source. The next request's view reads the change, since a view
is rebuilt when a local source's files change.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Research refused for a namespace the claims vocabulary lacks showed as
a red toast that disappeared before it could be read, with nothing to
click. Every research start (the finding's Research, a table's, the
research page's, the unlabeled tab's and a job's Retry) now opens a
dialog that stays until answered. It says what the gate's scan shows
(e.g. CLEANSTART-2026-GU36052 → CVE-2025-11082), offers the suggested
list as the primary button and the other beside it, and names the
vocabulary.yaml it writes. Adding writes the file and starts the same
research again, with errors shown in the dialog.

A gate's page and the gates page's work banner show the same notice and
add buttons for open rows in an unlisted namespace, before anyone
starts research. A toast longer than a line now stays until its close
button is clicked.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
The footer's three buttons overflowed the dialog, pushing Cancel past its left edge. Wrap them, right-aligned, and shorten the add buttons to "Add as disclosure" / "Add as advisory"; the body already says namespace.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A research job whose command exited 0 was done, whether or not the
agent recorded a call. The CleanStart turnup's jobs read "researched,
no hint" after propose refused the namespace, or after headless claude
was denied every write, with nothing saying either.

A job that exits 0 is now done only when a proposal or cause stamped
with its id was recorded since it started; otherwise it is
not-recorded. Its error says why: propose's refusal, which propose now
leaves in the job's directory (propose-error.txt), else the tool uses
the agent was refused (the result line's permission_denials), else
that it never proposed. The job keeps the agent's last message. A job
recorded as done before this, which recorded nothing, reads as
not-recorded the same way.

A row's research summary carries the job's cost. The row's detail
carries what the job's agent wrote under .yardstick/research/<vuln>/<pkg>/:
its report, and its proposal when that holds a verdict. POST
research/record records that proposal through propose, in the job's
environment, without researching again.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
The finding page offered only "Research again", with nothing saying
research had run or why it left no hint. Its new research section heads
with when the job ran, on what model and for how much, then says what
came of it: recorded; concluded tp · fix-newer · high but not recorded,
and why; ran but wrote no result, and why; running; failed. It shows
REPORT.md, the open questions and the agent's last message. When the
research wrote a proposal it didn't record, Record as hint records it
without researching again, and a refusal stays in the section rather
than a toast.

Tables say "researched, not recorded", with why on hover, in place of
"researched, no hint", and the jobs page says not recorded with why.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A not-recorded job's reason listed the agent's first three refused tool
uses. In the CleanStart turnup those were gh api calls, which left the
denied writes to REPORT.md and proposal.json under "and 2 more". Writes
and propose calls now lead.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
The finding page looked for a job's report and proposal under a package
directory named by packageurl-go. The plugin names it with its own
parse_purl regexp, and the two disagree on Go modules: for golang.org/x/net
the plugin writes x_net, packageurl-go says net. They also disagree on a
subpath holding a '/'. OutputDir now uses the plugin's regexp and
unquote, so it names the same directory: one optional namespace segment,
then the name up to @, ? or #, falling back to the whole purl when it
doesn't parse, and to "all" when there is none. A table test pins the
plugin's own outputs for apk, deb, scoped npm, maven, Go modules,
subpaths and qualifiers.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Research jobs from the label UI did their research and then were refused
every write (friction log entry 10: CLEANSTART-2026-GU36052 and
-IC12241, about $0.29 each, nothing recorded).

The cause, reproduced with `claude -p` on haiku: Claude Code resolves a
relative path rule such as Edit(.yardstick/research/**) against the
session's current directory, and that moves when the agent runs `cd` in
Bash (allowed without a rule, as a read-only command). The CleanStart runs
did `cd .yardstick/research/<vuln>/<pkg> && cat SUMMARY.md`, and every
Write after that was refused. Without the cd the same rule allows the
write. It is not the guess in the log (rules resolved against the repo
root), and not the user settings rewrite: neither changes the result.

So the rule is absolute: Edit(/$YARDSTICK_ROOT/.yardstick/research/**),
which expands to Claude Code's "//<path>" form. Verified: it allows the
write after a cd, still refuses a write outside .yardstick/research, and
works with a space in the path.

Because the workspace path now lands inside the --allowedTools string,
Start refuses a path with characters that could end the rule early, widen
it as a glob, or break the shell quoting (a workspace named "x),Bash(*"
would otherwise grant a shell). The workspace is the user's own cwd, so
this is defence in depth rather than a reachable injection.

Deliberately not done: running research without the user's settings
(--setting-sources/--restricted). That would also drop the user's own deny
rules and the plugin's enablement. The grants yardstick passes now suffice
on their own, so the user's settings can only add to them. gh and curl
stay ungranted: the agent reads pages anyone can write, and WebFetch
covers what they were used for.

The skill now says to write only with Write/Edit by absolute path and
never to cd, so an interactive run (whose skill rule is still relative)
doesn't hit the same trap.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A branch build of yardstick is stamped like v0.17.0-my-branch, and the
plugin it ships can only be plain 0.17.0 (a plugin version is plain
semver). The exact-match check turned research off for every branch
build, and said why only in one line on the serve terminal, while the UI
just offered no research (friction log entry 8). The workaround was to
stamp the branch build as the release, which hides which build runs.

Now a build whose base version (without the -/+ suffix) matches the
installed plugin runs research, with a note on the terminal and the gates
page that research runs the installed plugin, which may not be this
build's. A build of another release is still refused: its plugin's
skills and the binary's CLI and policy may disagree, which is what the
check exists for. A dev build without a version still takes any plugin.

Why accept rather than warn-and-refuse: refusing left no supported way to
try a branch's research at all, and the risk (a plugin a patch behind its
binary) is the same one a dev build has always been allowed.

When research is off, the gates page now shows the reason and the command
to run, since the tables then have no research control to hover.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Trying a branch's research skills meant re-pointing the user's global
Claude Code plugin marketplace, which changes every session, and (as the
plugin's version can't differ from the release's) only an uninstall and
reinstall picked the change up (friction log entry 9).

claims serve --research-plugin-dir <dir> now gives research jobs
`--plugin-dir <dir>`, which Claude Code loads for that run only and which
wins over an installed plugin of the same name (checked with claude -p:
the directory's /yardstick:research-vuln ran, not the installed one).
The directory reaches the command through YARDSTICK_PLUGIN_DIR, quoted,
never through the command text, like every other job value. The version
check reads the directory's .claude-plugin/plugin.json instead of
`claude plugin list`, and refuses a directory that isn't a yardstick
plugin.

DEVELOPING.md now says how to try a branch in another repo's gate
(entry 7): build it with its UI via the existing make snapshot:single-target
(go-make's goreleaser task, rather than a new build task), copy it over
.tool/yardstick, and point research at the clone's plugin. Changing
binny or vunnel's make targets is vunnel's side and not done here.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
golangci-lint (v2.14.0, the repo's pin) found six issues on the branch:

- gosec G703 on propose's refusal note: the path comes from
  YARDSTICK_RESEARCH_JOB_DIR. The agent can't set that variable (its Bash
  rule only matches commands that start with `yardstick claims agent
  propose`), but a stray value would have let propose write or delete
  propose-error.txt anywhere. It now acts only on a directory shaped like
  .yardstick/research/jobs/<id>, and the nolint says why.
- funlen on Runner.Start: the request checks moved to Runner.check.
- goconst: the stream-json "result" line type is a constant.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
With --research-plugin-dir the startup note read "runs the yardstick
plugin in <dir> 0.17.0, which may not be this build's": a garbled
string replace, and a caveat that doesn't fit a directory someone named
on purpose. It now says which version runs from where, and the gates
page's notice title fits both cases.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A real headless run on CLEANSTART-2026-GU36052 (the branch build, the
branch's plugin, a copy of vunnel's workspace) now records its proposal:
Write and Edit under .yardstick/research go through. Its only refusals
were two curl calls, which are deliberately not granted (the agent reads
pages anyone can write). Saying so up front saves the turns spent on them.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
These Playwright specs were written on this branch while Chromium couldn't
launch on the host; make e2e:container (docker) runs them. Three fixes:

- namespace-dialog: Basecoat fixes a dialog's child in place, so the
  <dialog> itself has no box and Playwright calls it hidden. The testid
  moves to the box, as the command palette's already is.
- The e2e reset deleted the workspace while the previous test's research
  job was still writing into it ("restore workspace" 500). The seed now
  stops the old server and waits for its jobs first; testkit.NewServer
  returns the runner to make that possible.
- research-everywhere: a queued job's finding page polls, so a faked
  state route could be mid-flight as a test ended. Routes are dropped
  with ignoreErrors after each test, as Playwright advises.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Jobs() kept the id written inside job.json, and that id becomes a path:
the job's log, its saves, and Record. A record naming another id, or
a path, could point them elsewhere. A job's id is now its directory's
name, whatever its record says.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
propose filled author.researchJob from YARDSTICK_RESEARCH_JOB only when
the call left it empty. A job counts as having recorded something by
that stamp, so an agent could name another job and mark it recorded.
The stamp is now always the job propose runs in, and empty outside one.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A not-recorded job counted as researched (its run set LastDone), so
batch research and a table's Research skipped its rows. After someone
adds CLEANSTART to the vocabulary, "research all" has to pick up the
rows propose refused; the price shows before it starts. LastDone now
marks only a run that recorded a call, so a not-recorded row is
researched again, with the rest.

Jobs() read the agent store, and an old job's log, on every call, every
2s poll, to settle done jobs from before yardstick told done from not
recorded. Each job is now settled once and saved: a done job that
recorded a call is marked recorded, and one that didn't becomes
not-recorded, dropping the LastDone its own run set. Later reads ask
nothing and read no log.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
…'s way

Record could not record a job from before yardstick told done from not
recorded: it loaded the record from disk, which still said done, while
the page showed the reconciled status. It now judges the job as Jobs
reads it.

It recorded whatever proposal-*.json the folder held, else
proposal.json. The folder is the vulnerability's and package's, with
no subject, so another subject's job shares it, and an agent could plant
a proposal naming another row. Record now sends proposal.json alone,
and only when it is about one of the job's rows and written since this
run started. The page shows the same: a verdict, a report or Record as
hint only from this run, and a proposal on another row as another
finding's research.

It took no lock and didn't check for a live job, so a rerun queued
while propose ran could be saved over as done. It now refuses a live
job, and checks again under the runner's lock before saving. The route
also checks that the job researched under the gate asked about.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
With no refusal left by propose and no refused tool use, a
not-recorded job said "the agent ended without proposing". For a job
from before propose left its refusal, that is false: the CleanStart
runs did propose, and propose refused them. It now says "nothing was
recorded; the log says what happened", which is true either way.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
AddToList rebuilt a comment-only vocabulary.yaml from nothing, dropping
its comments: yaml.v3 keeps no comments for a document with no node.
It also refused a file that is only "---" as not a mapping, though
loading reads it as an empty vocabulary. Comments alone are now kept as
written with the list after them, and an empty document becomes a
mapping holding the list.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
9fa5048 made a lane read one match detail, so binutils' CleanStart match
no longer says "via source package binutils". Without a raw match the
lane falls back to the row's upstreams, and that path still took the
first one, the package's own name (found by the adversarial review). It
now takes the first upstream that names another package.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Two findings from an adversarial review of this branch:

1. The agent could write job records. The Edit grant covers
   .yardstick/research/**, which includes jobs/<id>/: job.json, which the
   server trusts for a job's status, rows and the paths it reads and
   writes, and propose-error.txt, which the UI shows as yardstick's own
   words. A prompt-injected agent could forge a "not recorded" job over
   any rows, offer a planted proposal to Record, or show a person
   instructions as if from yardstick. The command now also passes
   --disallowedTools "Edit(/$YARDSTICK_ROOT/.yardstick/research/jobs/**)".
   Checked with claude -p: a deny wins over the allow, also after a cd,
   and writes elsewhere under research still go through.
   propose-error.txt is still written by yardstick itself (propose), not
   the agent's tools.

2. The agent's propose didn't see claims sources given to claims serve by
   --claims. The UI adds a namespace to the last source, so with
   `claims serve --claims ./.claims` the dialog would add CLEANSTART, the
   server would accept it, and the agent's propose would refuse it.
   Jobs now get YARDSTICK_CLAIMS, the server's own list (clio reads it,
   comma-separated; checked against the CleanStart workspace's merged
   vocabulary), with local directories made absolute. A source with a
   comma is refused rather than split. Without sources nothing is set, so
   the workspace's .yardstick.yaml applies as before.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
The label-writing specs (hints, lists, refresh) flaked with ENOENT on a
claims file that readdir had just listed: 4 failures in 6 runs of those
three specs on origin/main (2e92f9d), and more on this branch. The server
replaces a file by renaming a new copy over it (corpus.WriteFile), and
through make e2e:container's bind mount the file can briefly look absent.
That last part is an inference: ENOENT on a file that is only ever
renamed over, in both trees, and only under docker.

diskLabels now waits up to half a second for such a file to come back
(and skips it if it really was removed), rather than turning the poll
into a failure. After the change: 4/4 clean runs of the three specs, and
2/2 full runs of 246.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Two regressions from the first review's fixes, found by a second
adversarial review:

1. Record passed only proposal.json to propose. For a finding over
   several binary packages the skill writes a copy per row
   (proposal-<row id>.json), so every row but the first got no hint,
   and the job counted all of them researched. Record now adds the
   copies too, but only this run's, about another of the job's rows,
   and with the verdict and justification the page shows, so it still
   records nothing the person didn't see (the first review's concern).

2. Record refused a request from a gate other than the job's. A row id
   is the same in every gate that scans its image, and the finding page
   shows the row's newest job whatever gate it ran under, so the button
   failed there. The check protected nothing: Record always proposes
   under the job's own gate. Dropped.

Checked and not changed: a case variant (.../research/JOBS/...) does not
get past the jobs deny rule; claude -p on macOS refused the Write.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
A double click on Record as hint ran propose twice, and the second came
back refused (the job was already done), showing an error over a record
that went through.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
The job id and job directory were bare os.Getenv calls beside clio's
options, with the names split between the server and propose. Both are
trust anchors: the agent runs propose with whatever arguments it likes,
so if either could come from a flag, it could name its own job.

- they are now one options struct on propose (and propose-cause,
  explain propose) with no flag: fangs reads a flagless field from
  YARDSTICK_<key> alone, no config file is read, and there is no
  --config
- the server writes YARDSTICK_RESEARCH_JOB through research.JobEnv,
  beside research.JobDirEnv

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
propose kept a record's own timestamp and author kind. In a research
job both come from an agent that reads pages anyone can write, and the
timestamp is the other half of the recorded check: a job counts as
recorded by a call stamped with it since the job started.

- a call dated ahead would mark every later run of the job recorded,
  so research-all would skip its rows for good; one dated back would
  have a job that did record re-run, and re-charged
- a call claiming to be a person's isn't one

In a job, timestamp is now and author.kind is agent, as the job id
already is. Outside a job nothing changes.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
The agent may run `yardstick claims agent propose` with any arguments,
and propose only checked that a row's package and vulnerability match
it. So a prompt-injected agent could propose under another --gate (and
its vocabulary), on rows the job wasn't asked about, or under another
subject, and each call would carry this job's id and show as requested
in the label UI. It could also skip Record and write that directly.

In a research job propose now refuses:

1. a gate other than the job's (--gate naming the job's gate is fine,
   as Record passes it)
2. a proposal with no row, or a row not in YARDSTICK_ROWS
3. a subject other than the row's (left off, it is the row's as before)

The job's gate comes from a new YARDSTICK_RESEARCH_GATE, the same value
as YARDSTICK_GATE under a name no flag reads, since --gate overrides
YARDSTICK_GATE. Rows and gate are read env only, with the job id.
Outside a job nothing changes.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Job records (job.json, log.txt, propose-error.txt) lived under
.yardstick/research/jobs/, inside the agent's Edit grant on
.yardstick/research/**. Only a deny rule kept the agent's Edit and Write
off them, and the plugin's helper script, which writes under
.yardstick/research/<vuln>/<pkg>/, could reach jobs/<id>/ with a vuln
named "jobs". That held only because none of its file names matched a
record's.

- records now live under .yardstick/jobs/<id>/, outside every write the
  agent is granted. A workspace's old .yardstick/research/jobs/ is moved
  there once, when the server starts and finds no .yardstick/jobs/
- the --disallowedTools rule stays, pointed at the new place. It no
  longer guards against yardstick's own allow rule, but the user's
  settings still merge their allow rules into research, and a broad one
  (Edit(**), say) would otherwise reach the records
- propose's refusal note follows: it writes only into a directory shaped
  like .yardstick/jobs/<id>

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
The agent wrote its report and proposal under
.yardstick/research/<vuln>/<pkg>/, with no job or subject in the path, so
two jobs on the same CVE and package (another image, another gate) shared
REPORT.md and proposal.json, and research-all runs two at a time. The
server stood in for ownership with heuristics: a file counted only when
written since the run started, and a proposal naming another row showed as
"another finding's research".

- a job now gets YARDSTICK_RESEARCH_OUT, .yardstick/research/job-<id>/,
  and the helper script and the skill write there. The helper refuses a
  value outside the workspace's .yardstick/research/. A run outside a job
  keeps the <vuln>/<pkg> layout
- a run clears its directory as it starts, so what is there is the last
  run's, and the mtime checks go
- a job that never started (cancelled while queued) shows no output,
  rather than the run before it
- the Go copy of the plugin's purl-to-directory naming goes too: the
  server names the directory now, instead of guessing the plugin's
- a proposal is still shown and recordable only when it names one of the
  job's rows

Another job's agent can still write into this directory, as it can
anywhere under .yardstick/research/; the directory is per job for
ownership, not as a boundary.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
… were

The move from .yardstick/research/jobs/ ran on any start that found no
.yardstick/jobs/, and that directory only appeared once a job was saved.
The old place is inside the agent's write grant, so a record an agent left
there would be taken in as a trusted job on a later start.

- the start that checks now makes .yardstick/jobs/ right after the move,
  found or not, so it happens at most once
- .yardstick/research/jobs/** is denied to the agent alongside the new
  place, absolute like the allow rule
- the helper script refuses to write under research/jobs/, in any case,
  since a vuln named "jobs" (or "JOBS" on a case-insensitive filesystem)
  would otherwise land there

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Record checked proposal.json through Output, then gave propose its path,
and propose read it again. Anything that rewrote the file in between (a
rerun, or another job's agent, which can write anywhere under
.yardstick/research/) got recorded under this job's id instead. The per-row
copies were matched to the shown proposal only on verdict and
justification, so their confidence, note or evidence could differ from
what the person saw.

- Record now pipes the proposal.json bytes Output read to propose on
  stdin, nothing by path
- a row's copy decides only which rows get the call (it has to be one of
  the job's rows, with the shown verdict and justification). What is
  recorded for that row is proposal.json with its row swapped in

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Research keeps the user's Claude Code settings so their deny rules still
apply. That also carried their defaultMode and MCP servers into a run
whose agent reads pages anyone can write: acceptEdits would hand it writes
anywhere, bypassPermissions everything, and an MCP server with an allow
rule a tool yardstick never granted.

- --permission-mode default: a headless run then refuses whatever isn't
  allowed, as yardstick's rules assume. On current Claude Code the mode is
  listed as "manual"; "default" is still accepted
- --strict-mcp-config with no --mcp-config: no MCP servers. The plugin
  ships none and the skill uses none

Not covered: the user's (and the workspace's) allow rules still merge
with yardstick's, so a user who allows `gh` or a broad Edit allows it here
too. Dropping them means --setting-sources without user and project, and
passing the user's deny rules through by hand, which is a bigger change.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
The skill's allowed-tools grants Edit(.yardstick/research/**), which
Claude Code resolves against the session's current directory, so it means
the workspace only in an interactive run started there. Frontmatter can't
name $YARDSTICK_ROOT, so the frontmatter now says that a job's
absolute rule (and its deny on job records) on the command line is the
one that holds.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
A refused tool use's input and the agent's last message were cut at a byte
offset, so text with non-ASCII in it could end in half a rune and show
U+FFFD. They now back up to the start of the rune, as the log tail does.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
readCapped took a single Read, which may return less than the file holds
(and a pipe or network filesystem often does). It now reads until the
cap or the end of the file.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Merging vocabulary.yaml across sources refused any difference in a
justification or rule, prose included. So the usual github.com/anchore/claims@main
plus a local checkout broke every gate the day a corpus PR reworded a rule's
title, for everyone whose checkout was behind.

- a justification's description and a rule's title, basis and decision: the later
  source wins, with a warning naming both
- a rule's verdict, justification or ceiling differing is still an error, as is a
  namespace on both lists

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
A vocabulary.yaml that only lists a namespace adds to another source's.
Merged with nothing under it, it turned strict checking on with nothing
allowed, so every label was refused, and the label UI would write one when
a gate had no vocabulary at all.

- `Vocabulary.CheckWritable` is the one rule: no vocabulary, or only
  fragments, is no vocabulary to write labels against, with a message saying
  a fragment needs a full one under it
- `FindVocabulary` and `Set.Writer` refuse on it, and adding a namespace
  is refused rather than writing a fragment with nothing under it
- a gate on fragments alone offers no namespace adds

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
import --to read only that directory's vocabulary.yaml, so a --to holding
one that only adds a namespace (as the label UI writes) refused every label.
It now takes --claims and opens the app config's sources, --claims and --to
in that order, writing to --to as a gate's writer does, so labels are checked
against the same merged vocabulary every other command uses.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
claims validate checks one directory as a whole corpus, the way corpus CI
does (catalog.yaml, SBOMs, the layout), so it doesn't take other sources to
merge onto: a directory whose vocabulary.yaml lists no justifications isn't
one. It now says so once, rather than refusing each label for a
justification nothing lists.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
A refusal for an unlisted namespace that named no file said to add it to
"an additional-claims vocabulary.yaml", so validating the published corpus
pointed contributors at the wrong place.

- `CheckVulnerability` takes the file: validate passes the corpus's own
  vocabulary.yaml, and a write the writing source's
- with no file, the refusal says "a claims source's vocabulary.yaml"

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Adding a namespace read the file with os.ReadFile, which follows a symlink
out of the claims source, while the set's loader and the write both stay
inside it through os.Root. All three now read by the same rule.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
POST /api/gates/{gate}/vocabulary/namespace reads like a per-gate change,
but it edits the writable source's vocabulary.yaml, which every gate on that
source picks up. The route's doc says so.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
The namespace check rebuilt pairing.RelatedIDs over every row on each
research request and each GET state, which the finding page polls during
research, even when every namespace was listed. A view now builds it on
first use and keeps it until the view is rebuilt.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
AddToList re-encoded the whole file as a YAML tree, which keeps comments
and order but drops blank lines, so on the published corpus's file adding
CLEANSTART also removed the blank line after the list.

- an item is now written after the list's last one, inline as ", NS" or as a
  line indented like the one above, and a missing list is appended at the end,
  so the diff is the one line
- the result is parsed back and checked; anything laid out otherwise (an empty
  list key, an inline mapping, an escaped last item) still goes through the
  tree
- adding a namespace that's already listed returns the file untouched
- an empty document (`---`) keeps its marker, with the list after it

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
An unlisted namespace whose ids in the scan each named one vulnerability was
called a disclosure namespace, and the dialog made "Add as disclosure" the
primary button. A single-CVE advisory (a DSA, an ALAS) looks the same, and
picking the wrong list changes pairing, since advisories never join alias
groups.

- `Unlisted.Suggest` is now only `advisory-ids`, when some id names several
  vulnerabilities; otherwise it's empty and the refusal says the scan can't
  tell, naming both lists
- the dialog and the gate's notice say the same and promote neither button

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
…t start

A research-all dry run skipped the namespace check, so the estimate looked
fine and Start then refused the whole batch on the first unlisted namespace,
leaving the dialog to find the rest one at a time.

- a dry run's response lists every namespace a start would be refused for
  (`unlisted`), and the research page shows them with their add buttons
- a refused start still answers with one namespace for the dialog, and its
  error names the others

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
not-recorded is already a justification (the reason most ported labels
carry), and both show on the finding page and ride the same wire types.
The job status is unrecorded now, so the two don't read as one.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
@wagoodman wagoodman self-assigned this Oct 29, 2026
The panel fades in from `visibility: hidden`, so a `focus()` right after the cog's click could miss and leave Esc to walk up a crumb.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
@wagoodman
wagoodman merged commit 83a7dde into main Oct 29, 2026
15 checks passed
@wagoodman
wagoodman deleted the address-turnup-feedback branch October 29, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants