Skip to content

feat: verify agent job responses against the stored key - #517

Open
retr0h wants to merge 1 commit into
mainfrom
feat/agent-key-store-us1
Open

retr0h wants to merge 1 commit into
mainfrom
feat/agent-key-store-us1

Conversation

@retr0h

@retr0h retr0h commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

US1 (T014-T019) of components/osapi/specs/002-agent-key-store, on top of the
store merged in #516. Closes GHSA-3jh4's deferred half.

Why verification was inert

The controller verified responses with pkiSigner.ControllerPublicKey(). That
field holds the controller's key as an agent records it at enrollment, so
on the controller it is nil — every response took the "no public key available"
branch and was returned unverified. The signing added for GHSA-3jh4 was
therefore decorative on the receiving end.

The problem the task list missed

T014 says "look up by the responding agent's machine ID" — but a response
carries no machine ID. job.Response has a self-reported Hostname, the
envelope a self-reported Fingerprint, and the KV key is
responses.<jobID>.<hostname>.<nano>. The store is keyed by machine ID.

The registry does map hostname to machine ID, but that is exactly the
unauthenticated surface GHSA-j73r describes: an accepted-but-hostile agent
could repoint a hostname at its own key and sail through verification. Using it
would defeat the purpose of this change.

Resolution: the signer stamps its machine ID into SignedEnvelope. The
field is self-reported and is treated purely as an index — it chooses which
stored record to verify against, and a signature that doesn't match that
record's key is rejected. The payload's hostname must also equal the hostname
recorded at acceptance, so an accepted agent cannot answer for a host it never
enrolled as. Old agents omit the field and are refused while enforcing, which
is the staged rollout FR-009 describes.

Shape

  • AgentKeyStore + AgentKey are declared in internal/job/client, mirroring
    PKISigner, and enrollment adapts to them. The dependency runs
    controller -> shared client, never the reverse, and the error mapping lives in
    a package held at 100% coverage rather than in cmd/.
  • Five distinct causes: not signed, no stored key, store unavailable, signature
    invalid, hostname mismatch.
  • All three response paths fail closed — single-target fails the job, broadcast
    and the stored-response path drop it.
  • SetAgentKeyStore(nil) is the pre-enforcement behaviour, byte for byte.

Gate

just react-build && just ready && just test green: coverage 100.0% against
the 99.9% target. The only zero-count blocks in the filtered profile are the
eight pre-existing documented-unreachable ones; none are in this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c

Agents have signed their responses since the GHSA-3jh4 fix, but nothing
checked those signatures. The controller verified with
pkiSigner.ControllerPublicKey(), which on the controller is the field an
agent fills in at enrollment and is therefore nil there — so every
response took the "no key available" path and was returned unverified.

Verification needs to know whose key to check against, and a response
carried no identity a verifier could trust: job.Response has only a
self-reported hostname, the envelope only a self-reported fingerprint,
and the KV key is derived from the hostname. The registry maps hostnames
to machine IDs, but that is the unauthenticated surface GHSA-j73r
describes, so using it would let an accepted agent repoint a hostname at
its own key and pass verification — defeating the thing this fixes.

So the signer now stamps its machine ID on the envelope. That field is
self-reported and is treated as nothing more than an index: it selects
which stored record to verify against, and a signature that does not
match that record's key is rejected. The hostname in the payload must
also match the one recorded at acceptance, so an accepted agent cannot
answer for a host it did not enrol as.

All three response paths fail closed: a single-target job reports
failure, and a broadcast drops the response so that agent shows as not
having answered rather than as answered by whoever sent it. Every
rejection carries a distinct cause.

Enforcement is opt-in: with no store wired the old path runs unchanged.

Closes the deferred half of GHSA-3jh4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #517   +/-   ##
=======================================
  Coverage   99.95%   99.95%           
=======================================
  Files         490      491    +1     
  Lines       23249    23334   +85     
=======================================
+ Hits        23239    23324   +85     
  Misses         10       10           
Files with missing lines Coverage Δ
internal/agent/enrollment.go 100.00% <100.00%> (ø)
internal/controller/enrollment/keystore_adapter.go 100.00% <100.00%> (ø)
internal/job/client/agent.go 100.00% <100.00%> (ø)
internal/job/client/client.go 100.00% <100.00%> (ø)
internal/job/client/jobs.go 100.00% <100.00%> (ø)
internal/job/client/signing.go 100.00% <100.00%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 86e7852...2802ddd. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant