Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion packages/scanner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,84 @@ for (const check of result.checks) {
## Categories

Checks are grouped into:
- `safety` — confirmation gates, risk classification, idempotency
- `safety` — confirmation gates, risk classification, idempotency, **signed-manifest verification (v0.5.0)**
- `schema` — manifest shape, JSON Schema validity, examples
- `docs` — descriptions, summaries, contact info
- `developerExperience` — discoverability, latency, error responses

## Signed-manifest checks (v0.5.0, optional)

The scanner can verify a manifest's signature against a publisher
key set you supply. **The default scanner behavior is unchanged** —
unsigned manifests still score the same and signed manifests trigger
no signature check unless you opt in via the `signature` option.

```ts
import { scanUrl } from "@marmarlabs/agentbridge-scanner";

const keySet = await loadYourKeySetSomehow(); // e.g. from /.well-known/agentbridge-keys.json

const result = await scanUrl("https://orders.acme.example", {
signature: {
keySet, // required to verify
expectedIssuer: "https://orders.acme.example", // optional strict check
requireSignature: false, // default: missing signature → info, no deduction
now: new Date(), // optional override for testing
clockSkewSeconds: 60, // forwarded to the verifier
},
});

const verified = result.passed.find((c) => c.id === "manifest.signature.verified");
const invalid = result.checks.find((c) => c.id === "manifest.signature.invalid");
```

`scoreManifest(manifest, options?)` accepts the same `signature` block.

### Check IDs

Stable identifiers — once shipped, renaming any of them is a major
bump per
[`docs/v1-readiness.md` §13](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/v1-readiness.md#13-compatibility-guarantees).
All sit under category `safety`.

| Check | Severity (default) | Severity (`requireSignature`) | Deduction | When |
|---|---|---|---|---|
| `manifest.signature.verified` (passed) | info | info | 0 | Signature verified successfully |
| `manifest.signature.missing` | info | error | 0 / 15 | Manifest carries no `signature` block |
| `manifest.signature.unverified-no-key-set` | info | info | 0 | Signature present but no `keySet` was supplied — verification skipped |
| `manifest.signature.malformed` | error | error | 25 | Signature block fails schema validation, including inverted/zero-length time window |
| `manifest.signature.key-set-malformed` | warning | warning | 0 | Operator-supplied key set fails schema validation (manifest readiness unaffected) |
| `manifest.signature.unsupported-algorithm` | error | error | 20 | Algorithm outside `EdDSA` / `ES256` |
| `manifest.signature.unknown-kid` | error | error | 25 | `kid` not in `keySet.keys[]` |
| `manifest.signature.revoked-kid` | error | error | 30 | `kid` listed in `keySet.revokedKids[]` |
| `manifest.signature.issuer-mismatch` | error | error | 25 | `signature.iss` ≠ `keySet.issuer` / `manifest.baseUrl` origin / `expectedIssuer` |
| `manifest.signature.before-signed-at` | error | error | 20 | `now` < `signedAt − skew` |
| `manifest.signature.expired` | error | error | 20 | `now` > `expiresAt + skew` |
| `manifest.signature.canonicalization-failed` | error | error | 25 | Manifest contains values that cannot be canonicalized (circular references, etc.) |
| `manifest.signature.invalid` | error | error | 25 | Signature did not verify against the supplied public key |
| `manifest.signature.key-type-mismatch` | error | error | 20 | Key entry alg ≠ signature alg, or JWK kty/crv mismatch alg |

### Out of scope for this release

- **No remote key fetch.** The scanner does not fetch
`/.well-known/agentbridge-keys.json` — your code does that and
passes the result in via `signature.keySet`. A runtime helper for
remote fetch lands in a later v0.5.0 PR.
- **No MCP server / CLI enforcement.** This package only emits
scanner check IDs. The MCP server's `--require-signature` mode
and the CLI's verify / require-signature commands ship in
subsequent v0.5.0 PRs.
- **Verification is additive.** Even when a manifest verifies, the
existing safety controls — confirmation gate, origin pinning,
target-origin allowlist, audit redaction, stdio stdout hygiene,
HTTP transport auth/origin checks — all continue to enforce on
top.

> ⚠️ **Private keys never belong inside a manifest or a key set.**
> The scanner's key-set input is the **public** half only;
> `AgentBridgeKeySetSchema` rejects JWKs that include the private
> scalar `d`.

## Scanner regression fixtures

The repo includes scanner fixtures in
Expand Down
15 changes: 14 additions & 1 deletion packages/scanner/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
scoreManifest,
type ScannerCheck,
type RecommendationCategory,
type SignatureScoringOptions,
} from "./score";
import { probePage, type PageProbeResult } from "./playwright";

Expand Down Expand Up @@ -42,6 +43,15 @@ export interface ScanOptions {
fetcher?: typeof fetch;
/** Manifest fetch timeout (ms). Default 5000. */
timeoutMs?: number;
/**
* Optional signed-manifest checking (v0.5.0). When omitted, scanner
* output is identical to v0.4.x — unsigned manifests still score
* the same, signed manifests trigger no signature check. The
* scanner does NOT fetch `/.well-known/agentbridge-keys.json` for
* you; pass the key set in via `signature.keySet`. See
* `SignatureScoringOptions` for the full surface.
*/
signature?: SignatureScoringOptions;
}

const EMPTY_GROUPS: Record<RecommendationCategory, string[]> = {
Expand Down Expand Up @@ -151,7 +161,10 @@ export async function scanUrl(rawUrl: string, opts: ScanOptions = {}): Promise<S
}

const manifest = validation.manifest;
const scoring = scoreManifest(manifest);
const scoring = scoreManifest(
manifest,
opts.signature ? { signature: opts.signature } : undefined,
);
const checks = [...scoring.checks];
const passed = [...scoring.passed];

Expand Down
Loading
Loading