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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

### Fixed

- **`TraceSandboxAdapter`'s documentation claimed a guarantee it does not provide: that a caller cannot claim hardware it does not have.** `SandboxAttestation` validates shape only -- `platform` against the enum on `RuntimeInfo`, `measurement` against the `sha256:`/`sha384:` digest pattern -- and has never checked a quote, a signature, or a nonce. `_runtime()` then copies `platform` and `measurement` from the attestation into the record unchanged. Nothing stops the same process that constructs a `SandboxAttestation` from inventing both values, e.g. `SandboxAttestation(platform="amd-sev-snp", measurement="sha256:" + "0" * 64)`, and `build_trust_record()` accepts it, `TrustRecord.model_validate()` accepts the result, and `sign_record()` signs it -- producing a Level 1-shaped record with no hardware evidence behind it. The module docstring's "**It will not let a caller claim hardware it does not have**" and its closing claim that "a record that says `tpm2` therefore carries a measurement that something other than this process produced" were both false as written: they described appraisal this code does not perform. This isn't a gap unique to the sandbox adapter -- `docs/trust-levels.md`'s Level 1 section already states the same boundary for the format generally ("Merely changing `runtime.platform`, copying a nonzero digest, or setting `appraisal.status="affirming"` does not establish that evidence" and "`agentrust_trace.verify_record` does not itself appraise hardware quotes") -- but `sandbox.py`'s docstring and `docs/integration/sandbox-runtime.md` asserted the opposite for this adapter specifically, which is what made it a documentation defect rather than a restatement of a known limitation. Fabricating an attestation was never a bypass of anything this adapter checks; the check that was missing had never existed and was never implemented, only claimed. Both docs are corrected to say what is actually enforced (accepted-platform and digest-shape validation) and to state plainly that verifying genuine evidence from the named platform, before constructing a `SandboxAttestation`, is the caller's responsibility -- consistent with how every other Level 1 producer in this codebase is documented. The same unconditional phrasing also remained in `build_trust_record()`'s docstring and in the integration guide's "Adding a root of trust" opening line and Levels table; corrected there too, with the guide's table gaining an explicit assurance column so record shape and verified hardware assurance are no longer collapsed together. No runtime behavior changes: `SandboxAttestation` and `TraceSandboxAdapter` accept exactly the input they always accepted. A new regression test, `test_a_fabricated_but_well_shaped_attestation_is_accepted_verbatim`, pins the actual contract so it cannot silently drift toward either a false sense of verification or an undocumented new rejection.

- **`cnf.jwk` accepted an RSA confirmation key carrying no key material.** The schema says "Keys must carry actual key material" and enforced it for `OKP` and `EC` only, so a `cnf.jwk` of `{"kty": "RSA"}` with no `n` and no `e` validated, and the record then failed inside the verifier, where `sign.jwk_thumbprint` reports the missing thumbprint member. Nothing was accepted that should have been refused, since every path downstream fails closed. What was wrong is which instrument spoke: the schema is the artifact an implementation in any language validates against, and it was not the thing that told the producer the key was unusable. `RSA` now requires `n` and `e`, which states what the description already claimed and refuses nothing that verifies. A `kty` enum is deliberately not added, because section 3.2.1 states signing algorithms per envelope context and fixes no set for the embedded-signature form of section 3.2.2, so narrowing `kty` here would add a constraint the specification does not make. `models.JWK`, which is exported and is what a Python caller reaches, carried the same `OKP`/`EC`-only table and is corrected with it; `n` and `e` are declared members there too, so a non-string modulus is refused rather than stored as an untyped extra. A parametrized test now checks the schema and the model against each other on every case, since a key one takes and the other refuses fails somewhere the producer did not choose. Both copies of the schema move together, and a test asserts they are the same bytes.

- **`provenance.verify_record()` and `intent_bridge.verify_bridge()` raised exceptions their own modules do not document when the untrusted `signature` field was malformed.** Both functions decode a caller-supplied signature before its shape has been established. `provenance.verify_record()` never checked that `record["signature"]` was a string at all: `signature + "=" * (-len(signature) % 4)` ran directly on whatever JSON value sat under the key, and a non-string (an int, a bool, a list, a nested object) raised a bare `TypeError` (`object of type 'int' has no len()` for an int; a `TypeError` on `+` for a dict or list) rather than the `ProvenanceError` this function documents for every other malformed input, including its own signature-presence check three lines above. `intent_bridge.verify_bridge()` did check the type, but called `sign._b64url_decode()` unwrapped: that function raises the bare `ValueError` `sign` documents for itself, not an `IntentBridgeError`, so a correctly-typed but undecodable string (too short to pad to a whole byte, or carrying a non-ASCII character) escaped as that `ValueError`. Same shape as the `rfc8785.CanonicalizationError` leak the "Six public functions" fix (below) already closed in this module; that sweep did not cover this call site. Neither is a signature-verification bypass: a malformed signature was always rejected, only with the wrong exception type, so a caller written against the module's own documented exception (as both modules' docstrings instruct) would see an uncaught crash instead of a handled refusal. Both now reuse `sign._b64url_decode()`, already `sign.verify_record()`'s own guard for this exact field, and wrap its `ValueError` in the calling module's documented type. In `provenance.verify_record()` the guard is placed where the crash it replaces was, after the `cnf.jwk` checks, so a record with more than one defect still reports them in the same order it did before. 13 regression tests added across both modules' non-string and malformed-base64-string cases, plus one pinning that check order.
Expand Down
55 changes: 43 additions & 12 deletions docs/integration/sandbox-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ it. `runtime.platform` reads `software-only`.

## Adding a root of trust

Pass a `SandboxAttestation` and the same call emits Level 1. Nothing else changes.
Pass a `SandboxAttestation` and the same call emits a Level 1-*shaped* record:
`runtime.platform` and `runtime.measurement` carry the supplied evidence verbatim.
Nothing else about the call changes. Actual Level 1 assurance requires that evidence
to have been independently verified -- by your own attestation verifier, against
genuine hardware from the named platform -- *before* you construct the
`SandboxAttestation` below. See the boundary spelled out just after the example.

```python
from agentrust_trace.adapters import SandboxAttestation
Expand All @@ -71,11 +76,30 @@ This is the point of the adapter spanning both levels. A sandbox runs wherever t
customer runs it, and the deployments that most need evidence are often the ones with
the least hardware. One code path covers a developer laptop and a confidential VM.

**The adapter will not let you claim hardware you do not have.** `platform` is only ever
set from a supplied attestation; an attestation may not name `software-only`; the
**The adapter checks the shape of an attestation, not its truth.** `platform` is only
ever set from a supplied attestation; an attestation may not name `software-only`; the
platform is checked against the enum on `RuntimeInfo` rather than a copy of it; and the
measurement must be a `sha256:` or `sha384:` digest. A record that says `tpm2` therefore
carries a measurement something other than this process produced.
measurement must be a `sha256:` or `sha384:` digest. None of that is cryptographic
verification: nothing here checks a quote, a signature, or a nonce, so code in your own
process can construct a `SandboxAttestation` with an invented platform and an invented
digest and the adapter will emit a record that says `tpm2` (or any other platform)
anyway. Call this adapter only after your own attestation verifier has checked genuine
evidence from the named platform. This is the same boundary [trust levels](../trust-levels.md#level-1-hardware-evidence)
states for every Level 1 producer: `agentrust_trace.verify_record` does not appraise
hardware quotes, and this adapter does not either.

**Verified evidence is still not the whole requirement.** [Trust levels](../trust-levels.md#level-1-hardware-evidence)
says Level 1 needs "authenticated evidence binding the record-signing key to the expected
environment," and [verification](../verification.md#verifying-hardware-rooted-records)
puts the responsibility for that binding on the producing profile -- this adapter, for a
sandbox runtime. It does not define one: nothing here ties `measurement` or `nonce` to the
key you eventually pass to `sign_record`, and that key is chosen independently of, and
after, whatever attestation you verified. Verifying a genuine quote and then signing with
an unrelated key is still not Level 1 assurance; this code cannot distinguish that case
from a fabricated one. If your platform's quote supports a caller-supplied challenge
(TPM qualifying data, SEV-SNP `REPORT_DATA`, TDX `REPORTDATA`), request it with that field
set to a value derived from the signing key's RFC 7638 thumbprint, verify the binding
yourself, and only then carry the challenge through as `nonce`.

## Field mapping

Expand Down Expand Up @@ -112,11 +136,11 @@ when an appraisal actually happened.

## Levels

| Level | What you pass | `runtime.platform` |
|---|---|---|
| 0 | nothing extra | `software-only` |
| 1 | a `SandboxAttestation` | the attested platform |
| 2 | Level 1 plus `transparency=` a SCITT receipt URI | the attested platform |
| Level | What you pass | `runtime.platform` | Assurance this establishes on its own |
| ----- | ------------------------------------------------ | ---------------------- | -------------------------------------- |
| 0 | nothing extra | `software-only` | None claimed -- honestly unattested |
| 1 | a `SandboxAttestation` | the attested platform | Record shape only. Real Level 1 assurance requires the supplied evidence to have been independently verified by your own attestation verifier before construction, *and* that verification to bind the record-signing key to the attested environment -- this adapter defines no such binding |
| 2 | Level 1 plus `transparency=` a SCITT receipt URI | the attested platform | Same as Level 1, plus a transparency receipt -- the receipt does not itself verify the hardware evidence |

`transparency` defaults to `None`, which leaves the key out of the record. That is
correct below Level 2: an unanchored record has no receipt to name.
Expand All @@ -128,8 +152,15 @@ this adapter.

## Worked example

`examples/sandbox-runtime.json` is a TPM 2.0 rooted record produced by this adapter,
validating as-is against the schema.
`examples/sandbox-runtime.json` shows the record *shape* this adapter emits for a
`tpm2` attestation, and it validates as-is against the schema. It is not a TPM-rooted
record: `runtime.measurement`, `runtime.nonce`, and `cnf.jwk` in that file are
illustrative placeholder values with no relationship to each other or to any real quote
-- decoding the `nonce` shows it is literally the string `sandbox-runtime-nonce`, not a
challenge bound to the `cnf.jwk` beside it. Do not copy this file as a template for a
genuinely attested record without replacing every one of those fields with values your
own attestation verifier produced, including a `nonce` actually bound to your signing
key if your platform supports that (see "Adding a root of trust" above).

## Related

Expand Down
7 changes: 4 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ checking any archived record, including these.
- `amd-sev-snp.json`: AMD SEV-SNP example.
- `nvidia-h100.json`: NVIDIA H100 Confidential Computing example.
- `tpm2.json`: TPM 2.0 example.
- `sandbox-runtime.json`: a sandboxed agent runtime, TPM 2.0 rooted. Produced by
`TraceSandboxAdapter`; the decision log is a kernel-sandbox policy trace rather
than MCP tool calls.
- `sandbox-runtime.json`: a sandboxed agent runtime in the shape a `tpm2` attestation
produces. Its measurement, nonce and key are placeholders; see
`docs/integration/sandbox-runtime.md`. Produced by `TraceSandboxAdapter`; the decision
log is a kernel-sandbox policy trace rather than MCP tool calls.
- `action-receipts/`: informative fixture shapes for action-level receipt
verification. These are not TRACE Trust Records and are not validated against
`schema/trace-claim.json`.
Expand Down
75 changes: 69 additions & 6 deletions src/agentrust_trace/adapters/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,42 @@
the call changes. An adapter that could only emit Level 0 would force a second code
path for the deployments that matter most.

**It will not let a caller claim hardware it does not have.** ``platform`` is only ever
set from a supplied attestation, an attestation may not name ``software-only``, and the
platform is checked against the enum on :class:`~agentrust_trace.models.RuntimeInfo`
rather than a copy of it. A record that says ``tpm2`` therefore carries a measurement
that something other than this process produced.
**It rejects the ways a caller can misname unattested evidence; it does not appraise the
evidence itself.** ``platform`` is only ever set from a supplied attestation, an
attestation may not name ``software-only``, the platform is checked against the enum on
:class:`~agentrust_trace.models.RuntimeInfo` rather than a copy of it, and
``measurement`` must be a ``sha256:``/``sha384:`` digest. That is shape validation, not
cryptographic verification: nothing here checks a quote, a signature, or a nonce, so
constructing a :class:`SandboxAttestation` with an invented platform and an invented
digest is accepted and reaches the record unchanged. This is the same boundary
docs/trust-levels.md states for every Level 1 producer --
``agentrust_trace.sign.verify_record`` does not appraise hardware quotes either -- and it
applies here for the same reason: appraising the evidence requires the platform's own
verification path (a TDX/SEV-SNP quote check against the vendor's key, a TPM quote check
against a known PCR policy, and so on), which is outside what a record-shaped library can
do generically. Pass a :class:`SandboxAttestation` only after your own attestation
verifier has checked genuine evidence from the named platform; passing one built from
data your process invented produces a Level 1-shaped record with no Level 1 assurance
behind it.

**Independently verified evidence is necessary but not sufficient.** docs/trust-levels.md
is explicit that Level 1 needs "authenticated evidence binding the record-signing key to
the expected environment," and docs/verification.md puts the responsibility for that
binding on "the producing profile." This adapter is that producing profile for a sandbox
runtime, and it defines no binding: nothing here ties ``measurement`` (or ``nonce``) to
the specific key that ends up in the record's ``cnf.jwk`` -- the key :func:`sign_record`
is called with is chosen independently of, and after, whatever attestation was verified.
A caller who verifies a genuine quote and then signs with an unrelated key produces a
record that is no more bound to hardware than a fabricated one; :class:`SandboxAttestation`
and :func:`~agentrust_trace.sign.sign_record` cannot tell the two cases apart. If your
attestation flow supports a caller-supplied challenge (a TPM quote's qualifying data, an
SNP report's ``REPORT_DATA``, a TDX quote's ``REPORTDATA``), bind it yourself: request the
quote with that field set to a value derived from the signing key you are about to pass to
``sign_record`` (for example its RFC 7638 thumbprint), verify the quote's binding to that
value in your own verifier, and only then carry it through as :attr:`SandboxAttestation.nonce`
so a downstream verifier that knows your convention can check it too.
:func:`~agentrust_trace.sign.verify_record` does not check this binding either -- nothing
in this codebase does; it is on you and on whatever verifier you point at these records.

Sandbox identity and image are carried in the existing v0.2 fields (``subject`` and
``build_provenance.digest``). A dedicated ``sandbox`` object belongs in a later profile;
Expand Down Expand Up @@ -80,6 +111,21 @@ class SandboxAttestation:
Supply this when the host produced attestation evidence. Omit it and the record is
Level 0, marked ``software-only``, which is the honest description of a sandbox on
a machine with no root of trust.

This dataclass validates shape only (an accepted platform name, a digest-shaped
measurement); it does not verify that the evidence is genuine. Construct one only
from a measurement your own attestation verifier obtained from the named platform,
not from a value your process computed or invented -- nothing downstream of this
constructor can tell the difference.

Verifying the evidence is genuine is still not the same as Level 1 assurance: TRACE
also requires that evidence to bind the record-signing key to the attested
environment (see the module docstring). This dataclass has no field that expresses
that binding on its own -- ``nonce`` is carried through to the record verbatim, on
trust, and is not checked against ``measurement``, against any key, or against
anything else. If you bind your quote's challenge to your signing key yourself,
``nonce`` is where that value goes; if you do not, leaving it unset is more honest
than filling it with a value that implies a binding nobody verified.
"""

platform: str
Expand All @@ -94,6 +140,12 @@ class SandboxAttestation:
rim_uri: str | None = None
firmware_version: str | None = None
nonce: str | None = None
"""Opaque, carried into ``runtime.nonce`` unchanged. If your attestation verifier
bound the quote's challenge to the key you will sign this record with, this is
where that challenge goes; nothing here binds it to the key, and downstream
``verify_record(expected_nonce=...)`` compares it only with a value the verifier
already knows. Omit it rather than fill it with a value that was never actually
bound to anything."""

def __post_init__(self) -> None:
if not isinstance(self.platform, str):
Expand Down Expand Up @@ -248,7 +300,18 @@ def __init__(
def build_trust_record(self, session: SandboxSessionResult) -> dict[str, Any]:
"""Return an unsigned Trust Record for *session*.

Level 0 when ``session.attestation`` is ``None``, Level 1 when it is supplied.
Level 0 when ``session.attestation`` is ``None``. Otherwise the record is
Level 1-*shaped*: ``runtime.platform`` and ``runtime.measurement`` carry the
supplied evidence verbatim, with no cryptographic check performed on it here.
Actual Level 1 assurance requires that evidence to have been independently
verified -- by your own attestation verifier, against the named platform --
before you construct the :class:`SandboxAttestation`, *and* it requires that
verification to bind the key :func:`~agentrust_trace.sign.sign_record` is
called with to the attested environment. This method has no way to check
either: the ``cnf`` it returns is a placeholder, and the real key arrives
later, at ``sign_record``, decided independently of whatever attestation was
passed here. See the module docstring and :class:`SandboxAttestation` for this
boundary.
"""
bundle_hash = self.bundle_hash(session.policy_bundle_bytes)
runtime = self._runtime(session, bundle_hash)
Expand Down
Loading
Loading