Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/workflows/spendguard-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# agentrust-io conformance workflow for the Agentic SpendGuard integration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move conformance workflow to the repository root

Because this workflow is added under integrations/spendguard/.github/workflows, GitHub Actions will not discover it for this repository; GitHub's workflow syntax docs state that workflow files must be stored in the repository's root .github/workflows directory. In the current location, the push/PR/scheduled conformance job described by this file and the README never runs, so the integration's TRACE conformance is not continuously exercised.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 15d0dc6. Moved to the repository root as .github/workflows/spendguard-conformance.yml (named per-integration to avoid collisions), kept the integrations/spendguard/** path scoping, and added the workflow file itself to the path filters. README link updated.

Heads-up for maintainers: integrations/_template/ ships the workflow in the same undiscoverable location, so every future integration copying the template will hit this. Happy to send a small follow-up PR against the template if useful.

# Lives in the repository root because GitHub Actions only discovers workflows
# in the root .github/workflows directory; scoped to this integration via paths.
name: spendguard conformance
on:
push:
paths:
- "integrations/spendguard/**"
- ".github/workflows/spendguard-conformance.yml"
pull_request:
paths:
- "integrations/spendguard/**"
- ".github/workflows/spendguard-conformance.yml"
schedule:
- cron: "0 6 * * 1" # weekly: catch drift against the latest released packages
workflow_dispatch:

permissions:
contents: read

jobs:
conformance:
strategy:
fail-fast: false
matrix:
python: ["3.11", "3.12", "3.13"]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python }}
- name: Install released agentrust-io packages
run: |
python -m pip install --upgrade pip
pip install agentrust-trace agentrust-trace-tests
- name: Install this integration
run: pip install -e "integrations/spendguard[test]"
- name: Integration tests
run: pytest integrations/spendguard/tests -q
- name: Emit a sample TRACE record
run: python integrations/spendguard/examples/emit_record.py --out trust-record.jwt
- name: TRACE conformance
run: trace-tests verify --record trust-record.jwt --level 0
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: conformance-${{ matrix.os }}-py${{ matrix.python }}
path: |
trust-record.jwt
trust-record.jwt.signed.json
2 changes: 2 additions & 0 deletions integrations/spendguard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.egg-info/
62 changes: 62 additions & 0 deletions integrations/spendguard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Agentic SpendGuard integration with TRACE

Agentic SpendGuard is a spend firewall for LLM agents: it reserves budget and
gates tool calls before the provider is called, and records each decision in a
signed, hash-chained ledger. This integration maps one SpendGuard evidence
bundle — the signed audit output of a single spend/gate decision — onto a TRACE
Trust Record, using the same field mapping as SpendGuard's own fixture verifier.

## Run it

Against released packages:

```bash
pip install -e "integrations/spendguard[test]" # pulls agentrust-trace
pip install agentrust-trace-tests
pytest integrations/spendguard/tests -q
python integrations/spendguard/examples/emit_record.py --out trust-record.jwt
trace-tests verify --record trust-record.jwt --level 0
```

## What is verified

- `spendguard_trace.build_trace_record` maps the committed example evidence
bundle (`examples/fixtures/allow/`, a signed SpendGuard allow decision) onto
TRACE fields: `policy.bundle_hash`, `tool_transcript.hash`,
`runtime.measurement` (the evidence-bundle hash), SPIFFE `subject`, model and
build provenance.
- `agentrust_trace.sign_record` signs the record with an ephemeral Ed25519 key
and `agentrust_trace.verify_record(..., allow_embedded_key=True)` verifies the
round-trip; `tests/` includes a tamper probe that must fail verification.
- `trace-tests verify --level 0` passes on the emitted record (8 checks).

## What it does NOT claim

See rules 2 and 4 in [CONTRIBUTING.md](../../CONTRIBUTING.md).

- **Level 0 carries an explicit `TR-SIG-005 UNVERIFIED` finding.** The released
`agentrust-trace-tests` 0.1.0 loader rejects any plain trace record carrying a
top-level `signature` field (anti-downgrade: it reads as a partial cMCP
envelope), and its TR-SIG module verifies signatures only on cMCP RuntimeClaim
envelopes. So the graded record is the unsigned payload and is **not
cryptographically verified by trace-tests**. The signed form is written next
to it (`<out>.signed.json`) and verifies with `agentrust_trace.verify_record`.
- The ephemeral signing key proves the sign/verify path works; it does **not**
chain to a trusted issuer. SpendGuard's production decisions are signed with
KMS-held keys; that trust path is not exercised here.
- The example bundle's SpendGuard-side CloudEvent signatures are verified by the
conformance harness in the [upstream repo](https://github.com/m24927605/agentic-spendguard)
(fixture-only today), not re-verified by this integration.
- `runtime.platform` is `software-only`. No TEE, hardware root of trust, or
attested-execution claim is made.
- No cMCP integration is included yet. Cedar spend-policy patterns for the cMCP
tool boundary (amount thresholds, HITL-above-X, per-workflow limits) are a
planned follow-up; `integrates_with` will gain `cmcp` when they land.

## Conformance CI

The repository-root workflow
[`.github/workflows/spendguard-conformance.yml`](../../.github/workflows/spendguard-conformance.yml)
(path-scoped to this directory) installs the released agentrust-io packages,
runs the mapping tests, emits a record, and runs `trace-tests verify --level 0`
across Python 3.11–3.13. A clean matrix run is the basis for the Verified tier.
63 changes: 63 additions & 0 deletions integrations/spendguard/examples/emit_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Emit a TRACE Trust Record from a SpendGuard evidence bundle.

Maps the committed example bundle (a signed SpendGuard allow decision) onto a
TRACE Trust Record, signs it with an ephemeral Ed25519 key via
`agentrust_trace.sign_record`, and verifies the signature round-trip with
`agentrust_trace.verify_record`.

Two files are written:

<out> unsigned record for `trace-tests verify` (the released
trace-tests 0.1.0 loader rejects any plain record carrying
a top-level `signature` field, so the graded record is the
unsigned payload; TR-SIG reports it UNVERIFIED at level 0)
<out>.signed.json the signed record, verifiable with
`agentrust_trace.verify_record(..., allow_embedded_key=True)`

The ephemeral key is generated per run and never persisted: this proves the
sign/verify path is real, not that the record chains to a trusted issuer.
"""

from __future__ import annotations

import argparse
import json
import sys
import time
from pathlib import Path

import agentrust_trace

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from spendguard_trace import build_trace_record # noqa: E402

DEFAULT_BUNDLE = Path(__file__).resolve().parent / "fixtures" / "allow"


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", required=True, help="Path for the trace-tests-gradable record")
parser.add_argument("--bundle", default=str(DEFAULT_BUNDLE), help="SpendGuard evidence bundle directory")
args = parser.parse_args()

key = agentrust_trace.generate_key()
jwk = agentrust_trace.key_to_jwk(key)
record = build_trace_record(Path(args.bundle), iat=int(time.time()), jwk=jwk)

signed = agentrust_trace.sign_record(dict(record), key)
agentrust_trace.verify_record(signed, allow_embedded_key=True)

out = Path(args.out)
out.write_text(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
signed_out = out.with_name(out.name + ".signed.json")
signed_out.write_text(json.dumps(signed, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")

print(f"subject: {record['subject']}")
print(f"unsigned (for trace-tests): {out}")
print(f"signed (verify_record OK): {signed_out}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"builder":"spendguard-fixture-builder","container_image_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444","release_bundle_digest":null,"sbom_digest":null,"source_commit_digest":null}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"bundle_id":"demo-allow","created_at":"2026-07-01T00:00:00.000Z","decision_id":"019f1af9-b400-7000-8000-000000000102","decision_path":"allow","evidence_bundle_hash":"sha256:242768282ddb2f490b73ccc2ae10be70d27ef9f9bbf5a3da698c717e9c7d3b85","files":[{"media_type":"application/vnd.spendguard.audit.decision+json","path":"decision.json","sha256":"ebce35a1ddb7d97dc9da1b1fde8ebf7a6c0ad9d32c7635483edfc725db49c348"},{"media_type":"application/vnd.spendguard.audit.outcome+json","path":"outcome.json","sha256":"4e608e0864097ee0126709ae0d55c8aafdf06ab39595c88abe5952357af396ab"},{"media_type":"application/vnd.spendguard.policy-bundle+json","path":"policy-bundle.json","sha256":"0e1571e5094c61079d94369fe60b2f5c281f45d725767b05998b792bf13abf9d"},{"media_type":"application/vnd.spendguard.tool-catalog+json","path":"tool-catalog.json","sha256":"7aae617ada3b90227204e289500a3acd030ec304882b52df08a1878f55736518"},{"media_type":"application/vnd.spendguard.build-provenance+json","path":"build-provenance.json","sha256":"4c78d89411bc0d53bc9983ffb029d46fe7ddcb58c1348558bca2b5d6beae2427"}],"run_id":"run-demo","tenant_id":"00000000-0000-4000-8000-000000000001","version":"spendguard-agentrust-evidence/v1"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"canonical_bytes_sha256":"bbf68197f2372ffd31f6d92f6de943ff97004af4ec3dbfd70a85401cf107d9d3","cloudevent":{"data_b64":"eyJkYXRhX2NsYXNzIjoiaW50ZXJuYWwiLCJtYXRjaGVkX3J1bGVzIjpbXSwibW9kZWwiOnsibW9kZWxfaWQiOiJncHQtNG8tbWluaSIsInByb3ZpZGVyIjoic3BlbmRndWFyZCIsInZlcnNpb24iOiJ1bmtub3duIn0sInJlYXNvbl9jb2RlcyI6WyJzcGVuZGd1YXJkLmFsbG93Il0sInNuYXBzaG90X2hhc2giOiJzaGEyNTY6YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsInNwZW5kZ3VhcmQiOnsiYnVpbGQiOnsiY29udGFpbmVyX2ltYWdlX2RpZ2VzdCI6InNoYTI1Njo0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0IiwicmVsZWFzZV9idW5kbGVfZGlnZXN0IjpudWxsLCJzYm9tX2RpZ2VzdCI6bnVsbCwic291cmNlX2NvbW1pdF9kaWdlc3QiOm51bGx9LCJwb2xpY3lfYnVuZGxlX2hhc2giOiJzaGEyNTY6MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMiIsInRvb2xfY2F0YWxvZ19oYXNoIjoic2hhMjU2OjMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMifX0=","data_json":{"data_class":"internal","matched_rules":[],"model":{"model_id":"gpt-4o-mini","provider":"spendguard","version":"unknown"},"reason_codes":["spendguard.allow"],"snapshot_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","spendguard":{"build":{"container_image_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444","release_bundle_digest":null,"sbom_digest":null,"source_commit_digest":null},"policy_bundle_hash":"sha256:2222222222222222222222222222222222222222222222222222222222222222","tool_catalog_hash":"sha256:3333333333333333333333333333333333333333333333333333333333333333"}},"datacontenttype":"application/json","decision_id":"019f1af9-b400-7000-8000-000000000102","id":"019f1af9-b400-7000-8000-000000000101","producer_id":"ledger:server-mint","producer_sequence":7,"run_id":"run-demo","schema_bundle_id":"spendguard-agentrust-v1","source":"spiffe://spendguard.local/exporter/test","specversion":"1.0","tenant_id":"00000000-0000-4000-8000-000000000001","time":"2026-07-01T00:00:00.000Z","type":"spendguard.audit.decision"},"schema":"spendguard-agentrust-decision-event/v1","signature":{"algorithm":"Ed25519","key_id":"sg-ed25519-2026-06","value_base64url":"B62Y2ld8TEuUhleJ6tulxhnBGdJzmVB_N5NuTKZeyzl6r7CrBXyxP0s9JomtTCsjiDzQl-whf3czqi6HSdEFDg"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"canonical_bytes_sha256":"d2e8c1b99e5e857d41c70c2a28a41aead926d3575a03d916f345607b11603483","cloudevent":{"data_b64":"eyJkZWNpc2lvbl9pZCI6IjAxOWYxYWY5LWI0MDAtNzAwMC04MDAwLTAwMDAwMDAwMDEwMiIsImVzdGltYXRlZF9hbW91bnRfYXRvbWljIjoiMTIzNDUiLCJraW5kIjoicmVzZXJ2YXRpb25fY3JlYXRlZCIsInJlc2VydmF0aW9uX2lkIjoiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMjAxIn0=","data_json":{"decision_id":"019f1af9-b400-7000-8000-000000000102","estimated_amount_atomic":"12345","kind":"reservation_created","reservation_id":"00000000-0000-4000-8000-000000000201"},"datacontenttype":"application/json","decision_id":"019f1af9-b400-7000-8000-000000000102","id":"019f1af9-b400-7000-8000-000000000201","producer_id":"ledger:server-mint","producer_sequence":8,"run_id":"run-demo","schema_bundle_id":"spendguard-agentrust-v1","source":"spiffe://spendguard.local/exporter/test","specversion":"1.0","tenant_id":"00000000-0000-4000-8000-000000000001","time":"2026-07-01T00:00:00.000Z","type":"spendguard.audit.outcome"},"schema":"spendguard-agentrust-outcome-event/v1","signature":{"algorithm":"Ed25519","key_id":"sg-ed25519-2026-06","value_base64url":"BzqeqbMi1FcYnhAP4uKSsci7JE7BFQIzV0_JxhK_LIpM1aKeuV08RhiD0HrmaGYw4P1XQZC_JUTTpefLP3UuDA"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"enforcement_mode":"enforce","policy_bundle_hash":"sha256:2222222222222222222222222222222222222222222222222222222222222222","schema":"spendguard-policy-bundle/v1","system_prompt_hash":null}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"catalog_hash":"sha256:3333333333333333333333333333333333333333333333333333333333333333","schema":"spendguard-tool-catalog/v1","tools_hash":null}
17 changes: 17 additions & 0 deletions integrations/spendguard/integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Agentic SpendGuard
vendor: SpendGuard
integrates_with:
- trace
description: >-
Spend firewall for LLM agents that reserves budget and gates tool calls before
the provider is called, with a signed, hash-chained decision record.
maintainer:
github: m24927605
repository: https://github.com/m24927605/agentic-spendguard
license: Apache-2.0
tier: community
# Level 0 passes with the explicit TR-SIG-005 UNVERIFIED finding: trace-tests
# 0.1.0 does not grade signatures on plain trace records (see README).
trace_conformance_level: 0
tested_against:
agentrust-trace: "0.3.0"
17 changes: 17 additions & 0 deletions integrations/spendguard/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "spendguard-agentrust-integration"
version = "0.1.0"
description = "Maps SpendGuard signed spend/gate decisions onto TRACE Trust Records"
requires-python = ">=3.11"
license = "Apache-2.0"
dependencies = ["agentrust-trace"]

[project.optional-dependencies]
test = ["pytest"]

[tool.setuptools]
py-modules = ["spendguard_trace"]
90 changes: 90 additions & 0 deletions integrations/spendguard/spendguard_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Map a SpendGuard evidence bundle onto a TRACE Trust Record.

A SpendGuard evidence bundle is the signed audit output of one spend/gate
decision (see the upstream repo's `spendguard-agentrust-evidence/v1` layout):

bundle.json manifest: bundle_id, evidence_bundle_hash, file digests
decision.json signed CloudEvent: the allow/deny decision
outcome.json signed CloudEvent: reservation outcome (allow only)
policy-bundle.json policy bundle the decision was evaluated under
tool-catalog.json tool catalog in force at decision time
build-provenance.json build digests of the deciding SpendGuard build

This module maps those fields onto a TRACE Trust Record. Field mapping matches
the golden outputs of SpendGuard's own fixture verifier (`spendguard_agentrust`
in the upstream repo), so both sides derive the identical record shape from the
same evidence.

Verification of the SpendGuard-side CloudEvent signatures lives in the upstream
repo's conformance harness, not here; this integration demonstrates the mapping
and the agentrust-trace sign/verify round-trip.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

EAT_PROFILE = "tag:agentrust.io,2026:trace-v0.1"
VERIFIER = "agentic-spendguard-agentrust-exporter"
# SpendGuard's exporter asserts SLSA level 1 for its fixture builder; carried
# through unchanged so this record matches the upstream golden outputs.
FIXTURE_SLSA_LEVEL = 1


def _load(bundle_dir: Path, name: str) -> dict[str, Any]:
return json.loads((bundle_dir / name).read_text(encoding="utf-8"))


def build_trace_record(bundle_dir: Path, *, iat: int, jwk: dict[str, str]) -> dict[str, Any]:
"""Build an unsigned TRACE Trust Record from a SpendGuard evidence bundle.

`jwk` is the public JWK bound into `cnf`; the caller signs the returned
record with the matching private key (`agentrust_trace.sign_record`).
"""
bundle = _load(bundle_dir, "bundle.json")
decision = _load(bundle_dir, "decision.json")
build = _load(bundle_dir, "build-provenance.json")
has_outcome = (bundle_dir / "outcome.json").exists()

ce = decision["cloudevent"]
data = ce["data_json"]
sg = data["spendguard"]
bundle_id = bundle["bundle_id"]

return {
"eat_profile": EAT_PROFILE,
"iat": iat,
"subject": f"spiffe://spendguard.local/tenant/{ce['tenant_id']}/run/{ce['run_id']}",
"cnf": {"jwk": jwk},
"data_class": data["data_class"],
"model": data["model"],
"policy": {
"bundle_hash": sg["policy_bundle_hash"],
"enforcement_mode": "enforce",
"version": ce["schema_bundle_id"],
},
"runtime": {
"measurement": bundle["evidence_bundle_hash"],
"platform": "software-only",
},
"tool_transcript": {
"call_count": 1 if has_outcome else 0,
"hash": sg["tool_catalog_hash"],
"transcript_uri": f"urn:spendguard:evidence:{bundle_id}",
},
"build_provenance": {
"builder": build["builder"],
"digest": build["container_image_digest"],
"provenance_uri": f"urn:spendguard:build-provenance:{bundle_id}",
"slsa_level": FIXTURE_SLSA_LEVEL,
},
"appraisal": {
"policy_ref": ce["schema_bundle_id"],
"status": "none",
"timestamp": iat,
"verifier": VERIFIER,
},
"transparency": "pending",
}
56 changes: 56 additions & 0 deletions integrations/spendguard/tests/test_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""SpendGuard evidence bundle -> TRACE Trust Record mapping tests."""

import json
from pathlib import Path

import agentrust_trace
import pytest

from spendguard_trace import build_trace_record

BUNDLE = Path(__file__).resolve().parents[1] / "examples" / "fixtures" / "allow"
IAT = 1782864000


@pytest.fixture(scope="module")
def key():
return agentrust_trace.generate_key()


@pytest.fixture(scope="module")
def record(key):
return build_trace_record(BUNDLE, iat=IAT, jwk=agentrust_trace.key_to_jwk(key))


def test_fields_map_from_signed_decision(record):
decision = json.loads((BUNDLE / "decision.json").read_text())
bundle = json.loads((BUNDLE / "bundle.json").read_text())
ce = decision["cloudevent"]
sg = ce["data_json"]["spendguard"]

assert record["policy"]["bundle_hash"] == sg["policy_bundle_hash"]
assert record["tool_transcript"]["hash"] == sg["tool_catalog_hash"]
assert record["runtime"]["measurement"] == bundle["evidence_bundle_hash"]
assert record["subject"] == (
f"spiffe://spendguard.local/tenant/{ce['tenant_id']}/run/{ce['run_id']}"
)
assert record["model"] == ce["data_json"]["model"]
# allow bundle has an outcome event -> one reserved call
assert record["tool_transcript"]["call_count"] == 1


def test_record_has_no_cmcp_envelope_markers(record):
# trace-tests 0.1.0 rejects plain records carrying any of these keys
assert not {"signature", "trace", "gateway"} & record.keys()


def test_sign_verify_roundtrip(record, key):
signed = agentrust_trace.sign_record(dict(record), key)
agentrust_trace.verify_record(signed, allow_embedded_key=True, max_age_seconds=None)


def test_tampered_record_fails_verification(record, key):
signed = agentrust_trace.sign_record(dict(record), key)
signed["policy"]["bundle_hash"] = "sha256:" + "f" * 64
with pytest.raises(Exception):
agentrust_trace.verify_record(signed, allow_embedded_key=True, max_age_seconds=None)
Loading