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
38 changes: 38 additions & 0 deletions .github/workflows/shadow-ai-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Shadow AI tests

on:
pull_request:
paths: ['shadow-ai/**', 'noxfile.py', 'requirements/validate.txt', '.github/workflows/shadow-ai-tests.yml']
push:
branches: [main]
paths: ['shadow-ai/**', 'noxfile.py', 'requirements/validate.txt', '.github/workflows/shadow-ai-tests.yml']

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
cmcp-version: ['0.2.0', '0.7.0']
env:
PYTHONPATH: shadow-ai/src
SHADOW_AI_REQUIRE_CMCP: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Install tests
run: pip install --require-hashes -r requirements/validate.txt
- name: Install released cMCP audit API
# AuditChain and SqliteAuditStore use only the standard library. This
# exercises their wire format, not a gateway/TEE or its dependencies.
run: pip install --no-deps "cmcp-runtime==${{ matrix.cmcp-version }}"
- name: Test scanner and cMCP adapter
run: python -m pytest shadow-ai/tests -q
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ select an `integrates_with` value from the current schema. See the note below.

[Shadow AI Discovery](shadow-ai/) is also standalone tooling outside the index
and Marketplace. It scans enriched records against an agent-to-tools registry;
it has no direct cMCP adapter and does not read or write Agent Manifest records.
its cMCP audit adapter requires operator-controlled source/session bindings. It
does not read or write Agent Manifest records.

All seven engines share [`agentrust-capture-core`](packages/agentrust-capture-core),
which owns fingerprinting, comparison, baseline sealing and the report honesty rules.
Expand Down
13 changes: 12 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


nox.options.default_venv_backend = "venv"
nox.options.sessions = ["capture_core", "trace_adapters", "capture_engines", "framework_adapters", "openai_agents_adapter", "google_adk_adapter", "shadow_ai", "wcm_integrations"]
nox.options.sessions = ["capture_core", "trace_adapters", "capture_engines", "framework_adapters", "openai_agents_adapter", "google_adk_adapter", "shadow_ai", "shadow_ai_cmcp", "wcm_integrations"]


def pytest(session: nox.Session, *paths: str) -> None:
Expand Down Expand Up @@ -92,6 +92,17 @@ def shadow_ai(session: nox.Session) -> None:
pytest(session, "shadow-ai/tests")


@nox.session(python="3.12")
@nox.parametrize("cmcp_version", ["0.2.0", "0.7.0"])
def shadow_ai_cmcp(session: nox.Session, cmcp_version: str) -> None:
"""Published cMCP audit APIs only; these modules use the standard library."""
session.install("--require-hashes", "-r", "requirements/validate.txt")
session.install("--no-deps", f"cmcp-runtime=={cmcp_version}")
session.env["PYTHONPATH"] = "shadow-ai/src"
session.env["SHADOW_AI_REQUIRE_CMCP"] = "1"
pytest(session, "shadow-ai/tests")


@nox.session(python=["3.11", "3.12", "3.13"])
def wcm_integrations(session: nox.Session) -> None:
"""The Weight Custody Manifest integrations, against the published SDK.
Expand Down
87 changes: 82 additions & 5 deletions shadow-ai/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Shadow AI Discovery

Finds AI agents and tool use that nobody registered. It reads tool-call audit records (with agent IDs added) and compares them with a separate registry that lists each agent and the tools it is allowed to use, then reports agents that are not in the registry and tools an agent was never declared to use. It does not read cMCP output directly; see [Inputs and limitations](#inputs-and-limitations).
Finds AI agents and tool use that nobody registered. It compares tool-call audit records with a separate registry that lists each agent and the tools it may use, then reports agents that are not in the registry and undeclared tool calls. The base scanner needs enriched records with agent IDs; the [cMCP adapter](#cmcp-adapter) can attribute cMCP audit records using explicit operator-controlled session bindings.

This is standalone tooling, outside the integration index and Marketplace. It
has no working cMCP or Agent Manifest adapter and claims neither integration.
includes a cMCP audit adapter tested against the published 0.2.0 and 0.7.0 audit
APIs. It has no Agent Manifest adapter and makes no gateway/TEE verification claim.
The source moved from `integrations/shadow-ai/` to `shadow-ai/`; update local
`PYTHONPATH` settings to `shadow-ai/src`. Run its tests with `nox -s shadow_ai`.

Expand All @@ -23,7 +24,7 @@ A tool-call record without a usable `agent_id` is never classified. The scanner

| Stack component | How |
|---|---|
| **cMCP** | Intended source of tool-call audit records, once they are enriched with a trustworthy agent identity. Not consumed directly today; see [Inputs and limitations](#inputs-and-limitations). |
| **cMCP** | `CmcpAuditAdapter` consumes `AuditChain` / `SqliteAuditStore` JSON records using a separate trusted source/session-to-agent mapping. |
| **Agent Manifest** | `suggested_manifest_id` is a naming hint for registering a discovered agent. The scanner does not read, write or validate Agent Manifest records. |

## Inputs and limitations
Expand Down Expand Up @@ -52,7 +53,7 @@ JSON objects, passed as a list or read from a JSONL file (one object per line):
| `agent_id` | Required on tool-call records. Usable only if it is a string that is not empty or whitespace-only; used verbatim, never trimmed, lowercased or converted from another type. |
| `timestamp` | Optional. Copied as-is into results, `""` if absent. Not parsed, validated or normalized, and its type is not checked at runtime: a non-string value is copied unchanged. |

These are enriched records: whatever produces them must supply a trustworthy `agent_id`. cMCP v0.2.0 audit entries carry no `agent_id` and name their timestamp `timestamp_utc`, so they cannot be scanned directly. The scanner does not map `session_id` to an agent, does not read `timestamp_utc`, and does not infer identity from the tool catalog. Direct cMCP compatibility is not claimed until an adapter with a trustworthy session-to-agent mapping exists and is tested ([integrations#215](https://github.com/agentrust-io/integrations/issues/215)).
These are enriched records: whatever produces them must supply a trustworthy `agent_id`. cMCP audit entries carry `session_id` instead of `agent_id` and name their timestamp `timestamp_utc`. `ShadowAIScanner` still does not map session identity or read `timestamp_utc`; use the separate adapter below for identity attribution. Neither class infers identity from a tool catalog. The adapter addresses the remaining identity-mapping work in [integrations#215](https://github.com/agentrust-io/integrations/issues/215).

JSONL files are read with Python's default text encoding (`Path.read_text()`). Lines are split only on `\n`, `\r\n` and `\r`; blank lines are skipped but still counted, so reported line numbers are physical line numbers. A line that is not valid JSON raises `json.JSONDecodeError`, and a record that is not a JSON object raises `AttributeError`; both stop the scan, from every method.

Expand All @@ -75,6 +76,75 @@ if not report.complete:
print(f"unclassified tool call at {item.location_kind} {item.location}: {item.reason}")
```

## cMCP adapter

`CmcpAuditAdapter` inherits the same four scan methods and result types. Supply
bindings from an operator-controlled configuration or an authenticated session
control plane. For example, after that control plane authenticates the agent and
records the session ID returned by the selected gateway:

```python
from cmcp_shadow_ai import CmcpAuditAdapter, SessionAgentBinding

source = "billing-gateway/boot-42"
bindings = [
SessionAgentBinding(source, "session-id-from-that-gateway", "billing-agent"),
]
scanner = CmcpAuditAdapter(
"agents.json",
source_id=source,
trusted_bindings=bindings,
)
report = scanner.scan_audit_log_report("verified-billing-gateway-audit.jsonl")
```

The caller must establish that the audit stream belongs to that source and that
the session belongs to the named agent. A successful constructor does not
authenticate those assertions. The adapter does not authenticate a caller,
verify an audit signature/chain, perform attestation or read a manifest. Do not
build bindings from a tool catalog, an unverified manifest, audit fields, or an
agent's own identity assertion. Keep the agent registry and bindings outside the
audited agent's write access.

`source_id` identifies the gateway and its session namespace/epoch. Use a new
source ID if session IDs can be reused after a restart, reset or import. Keep
each input stream within its selected source; a `source_id` inside a record
cannot choose a binding. The adapter matches `(source_id, session_id)` exactly,
without trimming, case folding or prefix matching. It snapshots the supplied
bindings at construction and rejects duplicate source/session keys, including
duplicates with the same agent, so an entry cannot overwrite another identity.

For tool-call records, the adapter reports these additional unavailable reasons:

| Reason | Meaning |
|---|---|
| `session_missing` | No `session_id` field |
| `session_null` | Session ID is `null` |
| `session_blank` | Session ID is empty or whitespace-only |
| `session_non_string` | Session ID is not a string; `observed_type` reports its JSON type |
| `session_unmapped` | No binding for the selected source and exact session ID |
| `agent_conflict` | An extra `agent_id` in the record disagrees with the trusted binding |

These diagnostics have no agent ID or suggested manifest ID. Reports retain
valid findings and mark classification incomplete; strict list methods raise
`IdentityUnavailableError`. A record-supplied agent ID is never a fallback for a
missing binding. The adapter leaves input records unchanged and rejects a
non-empty malformed tool name rather than treating it as a clean scan.

Timestamp mapping remains separate from identity attribution: `timestamp` is
copied with the existing scanner semantics, and `timestamp_utc` is not mapped or
normalized. Findings from native cMCP records therefore have `timestamp=""`.
Records with non-empty tool names include denied attempts; a finding does not
establish that a tool executed or that an effect occurred.

The tested contract is JSON serialization from the **published**
`cmcp-runtime==0.2.0` and `cmcp-runtime==0.7.0` `AuditChain` and
`SqliteAuditStore` APIs. Tests install those packages with `--no-deps` because
these audit modules use the standard library. They exercise neither the full
gateway dependency stack nor a live gateway, TEE or authenticated identity
provider. Other releases are not claimed as tested. Genuine catalog/audit
fixtures and their provenance are under [tests/fixtures](tests/fixtures/README.md).

## Methods

| Method | Returns | Tool-call record without a usable `agent_id` |
Expand Down Expand Up @@ -135,10 +205,17 @@ JSONL lines are now split only on line breaks. Previously a character such as U+

## Running tests

From the repository root:

```bash
python -m pytest tests/ -v
nox -s shadow_ai shadow_ai_cmcp
```

`shadow_ai` tests the dependency-free scanner and adapter; producer tests skip
when cMCP is not installed. `shadow_ai_cmcp` runs the suite with each pinned
published cMCP release, requiring the producer tests to run. GitHub Actions runs
both releases on Python 3.11, 3.12 and 3.13.

## License

Apache 2.0
118 changes: 118 additions & 0 deletions shadow-ai/src/cmcp_shadow_ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Attribute cMCP audit records using operator-supplied session bindings.

Bindings and the audit source identifier are trusted policy inputs. This module
does not authenticate logs, attest a gateway, or derive identity from its catalog.
"""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Iterable, Iterator

from shadow_ai_discovery import (
IdentityUnavailable,
ScanReport,
ShadowAIScanner,
_json_type_name,
)


def _require_identifier(value: str, field: str) -> None:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-blank string")


@dataclass(frozen=True)
class SessionAgentBinding:
"""One authenticated session's identity, supplied by an operator/control plane.

Use a source_id that identifies the gateway and its session namespace/epoch.
Do not construct these from audit fields or a tool catalog. Identifiers are
exact strings, with no trimming, case folding or session-prefix matching.
"""

source_id: str
session_id: str
agent_id: str

def __post_init__(self) -> None:
for field in ("source_id", "session_id", "agent_id"):
_require_identifier(getattr(self, field), field)


class CmcpAuditAdapter(ShadowAIScanner):
"""The scanner's four scan methods, with explicit cMCP session attribution.

source_id comes from the trusted caller's audit-source selection, never a
record. trusted_bindings are a snapshot: subsequent caller mutations cannot
rebind a session. Any duplicate source/session key is a configuration error.

Input records stay unchanged. timestamp retains the scanner's existing
behavior; timestamp_utc mapping/normalization is deliberately separate.
"""

def __init__(
self,
catalog_path: str | Path,
*,
source_id: str,
trusted_bindings: Iterable[SessionAgentBinding],
) -> None:
_require_identifier(source_id, "source_id")
bindings: dict[tuple[str, str], str] = {}
for binding in trusted_bindings:
if not isinstance(binding, SessionAgentBinding):
raise TypeError("trusted_bindings must contain SessionAgentBinding objects")
key = (binding.source_id, binding.session_id)
if key in bindings:
raise ValueError("duplicate source/session binding; attribution is ambiguous")
bindings[key] = binding.agent_id
self._source_id = source_id
self._bindings = MappingProxyType(bindings)
super().__init__(catalog_path)

def _classify(
self, located_records: Iterable[tuple[int, dict[str, Any]]], location_kind: str
) -> ScanReport:
unavailable: list[IdentityUnavailable] = []

def attributed() -> Iterator[tuple[int, dict[str, Any]]]:
for location, record in located_records:
tool_name = record.get("tool_name")
if not tool_name:
continue
if not isinstance(tool_name, str) or not tool_name.strip():
raise ValueError(f"invalid tool_name at {location_kind} {location}")
reason, observed_type = self._session_reason(record)
if reason is None:
agent_id = self._bindings[(self._source_id, record["session_id"])]
# An extra claim in a record cannot replace the trusted
# binding or conceal a conflicting identity assertion.
if "agent_id" in record and record["agent_id"] != agent_id:
reason = "agent_conflict"
if reason is not None:
unavailable.append(IdentityUnavailable(
location_kind, location, tool_name, record.get("timestamp", ""),
reason, observed_type,
))
continue
yield location, {**record, "agent_id": agent_id}

report = super()._classify(attributed(), location_kind)
diagnostics = sorted((*unavailable, *report.unavailable), key=lambda item: item.location)
return ScanReport(report.findings, tuple(diagnostics))

def _session_reason(self, record: dict[str, Any]) -> tuple[str | None, str | None]:
if "session_id" not in record:
return "session_missing", None
session_id = record["session_id"]
if session_id is None:
return "session_null", None
if not isinstance(session_id, str):
return "session_non_string", _json_type_name(session_id)
if not session_id.strip():
return "session_blank", None
if (self._source_id, session_id) not in self._bindings:
return "session_unmapped", None
return None, None
8 changes: 4 additions & 4 deletions shadow-ai/src/shadow_ai_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def to_dict(self) -> dict[str, str]:
@dataclass(frozen=True)
class IdentityUnavailable:
"""
A tool-call record that could not be classified because it carries no usable
``agent_id``. The rejected agent_id is not stored as an identity field: there
A tool-call record that could not be attributed to a usable ``agent_id``.
The rejected identity is not stored as an identity field: there
is no agent_id or suggested_manifest_id. Other record fields (tool_name,
timestamp) are copied as-is, without redaction.
"""
Expand All @@ -48,8 +48,8 @@ class IdentityUnavailable:
location: int
tool_name: str
timestamp: str # copied from the record without type validation; "" if absent
reason: str # "missing" | "null" | "blank" | "non_string"
observed_type: str | None = None # JSON type name, set only for "non_string"
reason: str # scanner identity reasons, or the cMCP adapter's session reasons
observed_type: str | None = None # JSON type name for non-string identity/session IDs

def to_dict(self) -> dict[str, Any]:
return {
Expand Down
41 changes: 41 additions & 0 deletions shadow-ai/tests/cmcp_producer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Produce real cMCP SQLite payloads using an installed, released audit API."""
from __future__ import annotations

import json
import sqlite3
import sys
import tempfile
from importlib.metadata import version
from pathlib import Path

from cmcp_runtime.audit.chain import AuditChain
from cmcp_runtime.audit.store import SqliteAuditStore


def write_audit(path: Path) -> list[dict]:
"""Exercise AuditChain and the exact payload serialization in SqliteAuditStore.

No gateway, network, TEE or caller authentication is exercised here. Session
identity comes from the separate operator bindings supplied by the tests.
"""
if version("cmcp-runtime") not in {"0.2.0", "0.7.0"}:
raise ValueError("fixture producer requires cmcp-runtime 0.2.0 or 0.7.0")
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "audit.db"
store = SqliteAuditStore(database)
try:
for session in ("session-a", "session-b", "session-c"):
chain = AuditChain(session, store=store)
chain.append("tool_call", call_id=session + "/1", tool_name="echo", policy_decision="allow")
if session == "session-a":
chain.append("tool_call", call_id=session + "/2", tool_name="delete_all", policy_decision="deny")
finally:
store.close()
with sqlite3.connect(database) as connection:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

with sqlite3.connect(...) commits but never closes, so the TemporaryDirectory cleanup hits WinError 32 on Windows in both interop tests. contextlib.closing(sqlite3.connect(database)) fixes it.

payloads = [row[0] for row in connection.execute("SELECT payload FROM audit_entries ORDER BY rowid")]
path.write_text("\n".join(payloads) + "\n", encoding="utf-8")
return [json.loads(payload) for payload in payloads]


if __name__ == "__main__":
write_audit(Path(sys.argv[1]))
Loading
Loading