diff --git a/datasets/agentdataflow_toolexchange_traces/README.md b/datasets/agentdataflow_toolexchange_traces/README.md index 232d70b..71a8ccc 100644 --- a/datasets/agentdataflow_toolexchange_traces/README.md +++ b/datasets/agentdataflow_toolexchange_traces/README.md @@ -10,39 +10,113 @@ Structured traces capturing how data flows through agentic AI systems — betwee Categories include: -- **Tool call traces** — Sequences of tool invocations showing what data is sent to and received from external tools (APIs, databases, file systems, code execution environments) -- **Multi-agent delegation chains** — Traces showing how tasks and data are passed between agents, including what context is forwarded, filtered, or accumulated -- **Plugin data exchange logs** — Records of data shared with third-party plugins, including request/response payloads and metadata -- **Credential and permission flows** — How credentials, tokens, and permissions are scoped, delegated, and consumed across agent workflows -- **Context accumulation patterns** — Traces showing how agent context windows grow over multi-step tasks, what data persists across steps, and where over-sharing occurs -- **Memory read/write traces** — How agents interact with short-term and long-term memory stores, including what data is persisted and retrieved +- **Tool call traces** (`tool_call`) — Sequences of tool invocations showing what data is sent to and received from external tools (APIs, databases, file systems, code execution environments) +- **Multi-agent delegation chains** (`multi_agent_delegation`) — Traces showing how tasks and data are passed between agents, including what context is forwarded, filtered, or accumulated +- **Plugin data exchange logs** (`plugin_data_exchange`) — Records of data shared with third-party plugins, including request/response payloads and metadata +- **Credential and permission flows** (`credential_flow`) — How credentials, tokens, and permissions are scoped, delegated, and consumed across agent workflows +- **Context accumulation patterns** (`context_accumulation`) — Traces showing how agent context windows grow over multi-step tasks, what data persists across steps, and where over-sharing occurs +- **Memory read/write traces** (`memory_read_write`) — How agents interact with short-term and long-term memory stores, including what data is persisted and retrieved + +## Files + +| File | Purpose | +|---|---| +| `schema.json` | JSON Schema (draft 2020-12) every entry validates against | +| `example.json` | Worked example. Read it with the schema before writing an entry | +| `entries/` | One trace per file, named `.json` | +| `validate.py` | `python validate.py` - schema, provenance, span graph, and secret-scan checks | +| `build_index.py` | `python build_index.py` - regenerates `index.csv` | +| `index.csv` | Flat index of all entries, for filtering and citation | ## Data Format - +One trace per file in `entries/`, named after its `trace_id` (`DSGAI-TRACE-.json`). The authoritative definition is `schema.json`; the summary below is orientation. -Contributions should include: +Every entry carries: -- **Trace ID** -- **Category** — From the list above -- **DSGAI mapping** — Primary DSGAI entries relevant to this trace -- **Agent framework** — LangGraph, AutoGPT, CrewAI, custom, etc. (if disclosable) -- **Trace data** — The sequence of events, tool calls, and data exchanges in structured format (JSON, JSONL, or OpenTelemetry-compatible spans) -- **Data sensitivity annotations** — Flag any steps where sensitive data is present, over-shared, or inadequately scoped -- **Security observations** — What data security risks this trace illustrates -- **Benign / adversarial** — Whether this is a normal workflow trace or one demonstrating a security failure +- **`trace_id`, `title`, `description`** - what flows, and what makes it security-relevant +- **`category`** - one of the six above +- **`disposition`** - `benign`, `adversarial`, or `unintentional_failure` +- **`dsgai_mapping`** - DSGAI entries this trace illustrates, primary risk first +- **`provenance`** - where the trace came from, and what backs it (see below) +- **`sanitization`** - a hard attestation plus the techniques applied +- **`spans`** - the trace itself: an ordered sequence of events +- **`security_observations`** - what the trace demonstrates, one falsifiable claim per item + +Optional: `agent` (framework, protocol, topology, autonomy), `owasp_llm_top10_mapping`, `mitre_atlas_mapping`, `mitigations`, `contributor`, `tags`, `notes`. + +### Spans + +A span is one event. The shape is OpenTelemetry-compatible without requiring an OTel pipeline to produce it, so a trace can be exported from a real system or written by hand and still validate the same way. + +```json +{ + "span_id": "s7", + "parent_span_id": "s6", + "t_offset_ms": 176400910, + "actor": "tool", + "actor_id": "tool://server-b/lookup_record", + "operation": "tool.result", + "summary": "Tool returns the full record, three fields wider than the schema the agent bound to.", + "payload": { "contact_email": "", "internal_notes": "" }, + "data_classes": ["tool_output", "pii"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI01", + "note": "Entitlement was enforced once, against a schema that has since changed.", + "severity": "High" + } +} +``` + +Two conventions carry most of the weight: + +**Payloads are shapes, not content.** String values in `payload` should be typed placeholders - ``, ``, ``. Literal content belongs in a trace only where the literal *is* the published artifact, such as a prompt-injection string from a paper. This is not only a privacy rule: it is what makes traces from real systems shareable at all, because the finding almost always lives in the shape. + +**Findings hang off spans.** Attaching a risk to the span where it materializes lets a reader see exactly where a flow stops being safe, instead of inferring it from prose. `validate.py` requires every span-level `finding.dsgai_id` to appear in the trace's top-level `dsgai_mapping`. + +Prefer `t_offset_ms` over wall-clock timestamps. Relative offsets carry the ordering that matters without inventing precision or disclosing when a system was running. Use `timestamp` only where the absolute date is part of the finding. + +### Provenance tiers + +Most people who hold real agent traces cannot publish them, because production telemetry contains everything that must not be published. A dataset that accepts only production telemetry stays empty; a dataset that accepts anything fills with plausible fiction. The `provenance.tier` field is how this dataset takes both contributions without confusing them: + +| Tier | Meaning | +|---|---| +| `observed_production` | Sanitized telemetry from a real deployed system | +| `observed_lab` | Sandbox, test environment, CTF, or red-team exercise | +| `derived_from_public_observation` | The mechanism is rendered as a trace, and its precondition is a documented public fact carried in `evidence` | +| `hypothetical` | Illustrative only, no empirical backing | + +Every tier except `hypothetical` requires at least one `provenance.evidence` item, enforced by `validate.py`. Each evidence item states what it supports, narrowly - a DOI, advisory, CVE, or vendor changelog establishing that the trace's precondition happens in the world. Statistics computed over this dataset should be reported per tier, never pooled across them. + +Choose the tier honestly. `derived_from_public_observation` is not a lesser contribution; it is the tier that lets a measured phenomenon be studied as a data flow without anyone publishing their logs. + +### MITRE ATLAS mappings + +`mitre_atlas_mapping` is release-pinned by construction: an entry records the ATLAS release it was verified against and the name each identifier carried in that release. ATLAS identifiers and their names have both moved between releases, so an unpinned pair silently rots. Omit the property rather than guess. ## Sanitization Requirements All traces **must** be sanitized before submission: -- No real API keys, tokens, credentials, or secrets — replace with placeholder values (e.g., `sk-REDACTED`, `Bearer EXAMPLE_TOKEN`) +- No real API keys, tokens, credentials, or secrets — replace with placeholder values (e.g. ``) - No real PII, PHI, or proprietary data — use synthetic equivalents - No internal hostnames, IP addresses, or infrastructure details - Generalize organization-specific tool names if they could identify the source Traces from test environments, sandboxes, or CTF exercises are ideal. Production traces must be thoroughly sanitized. +Two things back this up rather than leaving it to good intentions. `sanitization.attestation` must be a literal `true`, so an entry cannot merge with the box quietly unticked. And `validate.py` scans every string in the entry, independently of that attestation: payloads, summaries, notes, evidence locators. Placeholders are exempt; real-looking values fail the run. The scan is deliberately wider than the payloads, because prose written while looking at a real trace is where a real hostname actually gets typed. + +Naming a third party as the subject of a security failure is a disclosure act, not a dataset contribution. Generalize the counterparty unless you have gone through disclosure and are prepared to say so in `notes`. + ## Contributing -Add traces as individual JSON, JSONL, or YAML files and submit a pull request. See the [main datasets README](../README.md) for general contribution guidelines. +1. Copy `example.json` and edit it, or export spans from your own system into the same shape +2. Save as `entries/.json` +3. Run `python validate.py` - it must print OK +4. Run `python build_index.py` to refresh `index.csv` +5. Open a pull request describing what the trace demonstrates and which DSGAI entries it maps to + +See the [main datasets README](../README.md) for general contribution guidelines, and `#team-genai-data-security-initiative` on the [OWASP Slack workspace](https://owasp.slack.com) for anything that needs a conversation first. diff --git a/datasets/agentdataflow_toolexchange_traces/build_index.py b/datasets/agentdataflow_toolexchange_traces/build_index.py new file mode 100644 index 0000000..169c10d --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/build_index.py @@ -0,0 +1,100 @@ +"""Generate index.csv from every entry in ./entries/. + +Usage: python build_index.py +Writes ./index.csv with key flat columns; arrays are joined with '|'. +Stdlib only. +""" +from __future__ import annotations + +import csv +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +ENTRIES_DIR = ROOT / "entries" +INDEX_PATH = ROOT / "index.csv" + +SENSITIVITY_ORDER = ["none", "low", "moderate", "high"] + +COLUMNS = [ + "trace_id", + "title", + "category", + "disposition", + "provenance_tier", + "primary_dsgai", + "dsgai_mapping", + "owasp_llm_top10_mapping", + "agent_framework", + "tool_protocol", + "span_count", + "finding_count", + "max_sensitivity", + "data_classes", + "evidence_count", + "primary_evidence", + "date_added", + "tags", +] + + +def max_sensitivity(spans: list[dict]) -> str: + best = -1 + for span in spans: + value = span.get("sensitivity") + if value in SENSITIVITY_ORDER: + best = max(best, SENSITIVITY_ORDER.index(value)) + return SENSITIVITY_ORDER[best] if best >= 0 else "" + + +def row_for(entry: dict) -> dict[str, str]: + spans = entry.get("spans", []) or [] + prov = entry.get("provenance", {}) or {} + agent = entry.get("agent", {}) or {} + evidence = prov.get("evidence", []) or [] + dsgai = entry.get("dsgai_mapping", []) or [] + + data_classes: list[str] = [] + for span in spans: + for cls in span.get("data_classes", []) or []: + if cls not in data_classes: + data_classes.append(cls) + + return { + "trace_id": entry.get("trace_id", ""), + "title": entry.get("title", ""), + "category": entry.get("category", ""), + "disposition": entry.get("disposition", ""), + "provenance_tier": prov.get("tier", ""), + "primary_dsgai": dsgai[0] if dsgai else "", + "dsgai_mapping": "|".join(dsgai), + "owasp_llm_top10_mapping": "|".join(entry.get("owasp_llm_top10_mapping", []) or []), + "agent_framework": agent.get("framework", ""), + "tool_protocol": agent.get("tool_protocol", ""), + "span_count": str(len(spans)), + "finding_count": str(sum(1 for s in spans if s.get("finding"))), + "max_sensitivity": max_sensitivity(spans), + "data_classes": "|".join(sorted(data_classes)), + "evidence_count": str(len(evidence)), + "primary_evidence": evidence[0].get("citation", "") if evidence else "", + "date_added": entry.get("date_added", ""), + "tags": "|".join(entry.get("tags", []) or []), + } + + +def main() -> None: + rows = [] + for path in sorted(ENTRIES_DIR.glob("*.json")): + entry = json.loads(path.read_text(encoding="utf-8")) + rows.append(row_for(entry)) + + with INDEX_PATH.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=COLUMNS) + writer.writeheader() + writer.writerows(rows) + + print(f"Wrote {INDEX_PATH.name} with {len(rows)} entries.") + + +if __name__ == "__main__": + main() diff --git a/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval.json b/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval.json new file mode 100644 index 0000000..751664c --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval.json @@ -0,0 +1,261 @@ +{ + "$schema": "../schema.json", + "trace_id": "DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval", + "title": "Tool re-declares itself destructive after an agent has already auto-approved it", + "description": "An agent binds a tool whose contract declares destructiveHint false, and an approval policy uses that declaration to place the tool in an auto-approve class. The server later re-publishes the same tool with destructiveHint true, without changing its declared server version. Because the approval decision was made once at bind time and keyed to the tool name, the next call runs a destructive operation under a classification the tool no longer satisfies, and no human approval is ever requested. The trace captures the full sequence including the contract change itself.", + "category": "tool_call", + "disposition": "unintentional_failure", + + "dsgai_mapping": ["DSGAI06", "DSGAI05", "DSGAI07"], + "owasp_llm_top10_mapping": ["LLM06:2025", "LLM03:2025"], + + "agent": { + "framework": "custom", + "tool_protocol": "mcp", + "topology": "single agent with an orchestrator-side approval policy over remote tools", + "autonomy": "human_approves_flagged_actions" + }, + + "provenance": { + "tier": "derived_from_public_observation", + "collection_method": "Constructed as a minimal agent session around a change class measured in the wild. The precondition - a tool contract re-declaring destructiveHint true after previously declaring it false, with the server's declared version unchanged - is a counted category in a public contract-drift corpus; the agent, orchestrator, policy engine, and all payloads around it are synthetic.", + "evidence": [ + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> aggregates.incidents_by_kind['annotation-flip-to-destructive'] = 123; unstable.headline_excluding_unstable.incidents_by_kind['annotation-flip-to-destructive'] = 117", + "supports": "Tool contracts re-declaring themselves destructive is an observed change class, not a hypothetical one: 123 deduped safety-relevant incidents across the observation window, 117 after excluding the unstable-tool class." + }, + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> aggregates.flip_segmentation['guarantee-change|same'] = 25", + "supports": "The specific case this trace models - a previously-declared guarantee changing while the server's declared version stayed the same - accounts for 25 of those incidents. The remaining flips were first-time labeling of a tool that had never declared the field." + }, + { + "type": "url", + "citation": "https://mcpindex.ai/drift-report", + "supports": "Live counterpart of the corpus, including the method by which contract changes are classified and deduplicated." + } + ], + "observation_window": { + "start": "2026-06-09", + "end": "2026-07-19", + "note": "23 snapshots across 22 consecutive pairs of the reachable remote population of the official MCP registry (approximately 2,090 servers and 44,000+ tool contracts per snapshot). The window includes one disclosed 21.1-day crawler outage; accumulated diff across the gap is attributed to the gap's endpoints rather than dated inside it." + } + }, + + "sanitization": { + "attestation": true, + "techniques": ["not_applicable_fully_synthetic", "name_generalization", "shape_only_payloads"], + "notes": "No real server, tool, operator, or tenant is named. The corpus this derives from publishes per-server incident counts, but attributing a security failure mechanism to a named third party is a disclosure act rather than a dataset contribution, so the trace uses a generic server identity." + }, + + "spans": [ + { + "span_id": "s1", + "parent_span_id": null, + "t_offset_ms": 0, + "actor": "orchestrator", + "actor_id": "orchestrator://session-1", + "operation": "tool.discover", + "summary": "Client lists tools from a remote server and reads the declared effect annotations.", + "payload": { + "server": "tool://server-a", + "tool": "remove_from_roster", + "declared_server_version": "", + "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false }, + "input_schema_digest": "" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none" + }, + { + "span_id": "s2", + "parent_span_id": "s1", + "t_offset_ms": 40, + "actor": "policy_engine", + "actor_id": "policy://approval", + "operation": "policy.decision", + "summary": "Approval policy reads destructiveHint false and places the tool in the auto-approve class. The decision is cached against the tool name.", + "payload": { + "rule": "write tools may auto-approve when annotations.destructiveHint is not true", + "decision": "auto_approve", + "decision_key": "server-a/remove_from_roster", + "cache_ttl": "session", + "revalidate_on_contract_change": false + }, + "data_classes": ["tool_schema"], + "sensitivity": "none", + "finding": { + "dsgai_id": "DSGAI07", + "note": "The authorization decision is derived from a mutable, self-declared attribute of the counterparty and is then cached with no binding to the contract it was derived from. Nothing in the record ties the decision to the specific contract that justified it.", + "severity": "Medium" + } + }, + { + "span_id": "s3", + "parent_span_id": "s2", + "t_offset_ms": 900, + "actor": "agent", + "actor_id": "agent://roster-assistant", + "operation": "tool.call", + "summary": "First call. Runs without a human approval prompt, correctly, under the contract as declared at bind time.", + "payload": { + "tool": "server-a/remove_from_roster", + "arguments": { "roster_id": "", "member_id": "", "dry_run": true } + }, + "data_classes": ["tool_schema"], + "sensitivity": "low" + }, + { + "span_id": "s4", + "parent_span_id": "s3", + "t_offset_ms": 1480, + "actor": "tool", + "actor_id": "tool://server-a/remove_from_roster", + "operation": "tool.result", + "summary": "Tool returns a preview of the change. No data is removed.", + "payload": { "status": "ok", "would_remove": 1, "removed": 0 }, + "data_classes": ["tool_output"], + "sensitivity": "low" + }, + { + "span_id": "s5", + "parent_span_id": null, + "t_offset_ms": 86400000, + "actor": "external_service", + "actor_id": "tool://server-a", + "operation": "contract.change", + "summary": "Server re-publishes the same tool with destructiveHint true. The declared server version is unchanged, so nothing downstream of a version check observes the change.", + "payload": { + "tool": "server-a/remove_from_roster", + "path": "annotations.destructiveHint", + "change_kind": "annotation-flip-to-destructive", + "before": false, + "after": true, + "declared_server_version_before": "", + "declared_server_version_after": "", + "version_delta": "same" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none", + "finding": { + "dsgai_id": "DSGAI05", + "note": "The counterparty's declared effect changed while every integrity signal available to the client - declared version, tool name, endpoint - held steady. A client that validates on version alone cannot detect this transition.", + "severity": "High" + } + }, + { + "span_id": "s6", + "parent_span_id": null, + "t_offset_ms": 90000000, + "actor": "orchestrator", + "actor_id": "orchestrator://session-2", + "operation": "tool.bind", + "summary": "A later session resumes from the cached binding. No re-discovery is performed, so the new contract is never fetched.", + "payload": { + "bound_from": "cache", + "decision_key": "server-a/remove_from_roster", + "contract_refetched": false + }, + "data_classes": ["tool_schema"], + "sensitivity": "none", + "finding": { + "dsgai_id": "DSGAI06", + "note": "The binding is keyed by tool name, so it survives a change to everything the name refers to. This is the step that converts a supplier-side change into a client-side authorization failure.", + "severity": "High" + } + }, + { + "span_id": "s7", + "parent_span_id": "s6", + "t_offset_ms": 90000600, + "actor": "agent", + "actor_id": "agent://roster-assistant", + "operation": "tool.call", + "summary": "Second call, this time for real. The tool now declares itself destructive; the agent does not know that.", + "payload": { + "tool": "server-a/remove_from_roster", + "arguments": { "roster_id": "", "member_id": "", "dry_run": false } + }, + "data_classes": ["tool_schema"], + "sensitivity": "moderate" + }, + { + "span_id": "s8", + "parent_span_id": "s7", + "t_offset_ms": 90000610, + "actor": "policy_engine", + "actor_id": "policy://approval", + "operation": "policy.decision", + "summary": "Policy hits the cached auto-approve decision from s2. No approval request is emitted to the human approver.", + "payload": { + "decision": "auto_approve", + "decision_source": "cache", + "decision_derived_from_contract": "s1", + "contract_in_effect": "s5", + "approval_requested": false + }, + "data_classes": ["tool_schema"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI06", + "note": "The auto-approve class was earned by a contract that is no longer in force. The gap between decision_derived_from_contract and contract_in_effect is the whole defect, and it is invisible to every party at runtime.", + "severity": "High" + } + }, + { + "span_id": "s9", + "parent_span_id": "s7", + "t_offset_ms": 90001250, + "actor": "tool", + "actor_id": "tool://server-a/remove_from_roster", + "operation": "tool.result", + "summary": "Destructive operation executes. Records are removed with no human in the loop.", + "payload": { "status": "ok", "removed": 1, "reversible": false }, + "data_classes": ["tool_output"], + "sensitivity": "high" + }, + { + "span_id": "s10", + "parent_span_id": "s9", + "t_offset_ms": 90001300, + "actor": "orchestrator", + "actor_id": "orchestrator://session-2", + "operation": "context.append", + "summary": "Result is appended to the model context and summarized back to the user as a completed action.", + "payload": { "appended_bytes": "", "presented_as": "completed action" }, + "data_classes": ["tool_output"], + "sensitivity": "low" + } + ], + + "security_observations": [ + "An approval decision derived from a self-declared, mutable attribute of a third party inherits that attribute's mutability, but caching hides the inheritance.", + "A declared version is not an integrity signal for a tool contract. In the source corpus the majority of safety-relevant contract changes carried no version change at all (62.4% of deduped incidents across the window).", + "Name-keyed tool bindings are the failure amplifier: they let a supplier-side change silently inherit a client-side authorization grant.", + "The failure is fully invisible in the trace as most systems record it. Without the contract.change span at s5, spans s6 through s9 look like a correct, policy-compliant run.", + "No adversary is required. This flow is what ordinary supplier maintenance does to a client that trusts declared effects." + ], + + "mitigations": [ + "Bind approval decisions to a contract digest, not a tool name. Re-derive the decision whenever the digest changes.", + "Re-fetch and re-diff tool contracts at session start rather than restoring a cached binding, and treat any change to declared effect annotations as an approval-invalidating event.", + "Escalate to human approval on the transition itself, independent of the destination state: a tool that becomes destructive is a stronger signal than one that always was.", + "Do not treat a declared server version as evidence that a contract is unchanged; compare the contract.", + "Record, in the action log, which contract version justified each authorization decision, so the gap at s8 is auditable after the fact." + ], + + "contributor": { + "name": "Bharti, Gautam", + "orcid": "0009-0001-4448-1438", + "affiliation": "Independent researcher" + }, + + "date_added": "2026-08-07", + + "tags": ["mcp", "tool-contract-drift", "declared-effect", "approval-bypass", "excessive-agency", "supply-chain"], + + "notes": "MITRE ATLAS mapping is deliberately omitted. Repository issues #59 and #60 show ATLAS identifiers and their names have both moved across releases, and asserting an unverified pair here would add to that debt. The schema supports a release-pinned mapping; this entry should gain one once the repository settles on a pinned release. This entry also does not claim intent on the part of any tool supplier: a contract diff is an observation about declared effect, not a safety verdict." +} diff --git a/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-output-schema-expansion-context-overshare.json b/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-output-schema-expansion-context-overshare.json new file mode 100644 index 0000000..84c7844 --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/entries/DSGAI-TRACE-mcp-output-schema-expansion-context-overshare.json @@ -0,0 +1,284 @@ +{ + "$schema": "../schema.json", + "trace_id": "DSGAI-TRACE-mcp-output-schema-expansion-context-overshare", + "title": "Tool output schema widens under a stable version, and the extra fields flow straight into model context", + "description": "An agent calls a lookup tool whose declared output schema returns a narrow projection of a record. The orchestrator appends whole tool results to the model context without projecting them against the schema it bound to. The server later widens the output schema to return the full record, without changing its declared version. The next identical call pulls contact, note, and billing fields into the context window, into the model's answer, and into the observability sink, with no change on the agent side and no error anywhere.", + "category": "context_accumulation", + "disposition": "unintentional_failure", + + "dsgai_mapping": ["DSGAI15", "DSGAI01", "DSGAI14", "DSGAI05"], + "owasp_llm_top10_mapping": ["LLM02:2025", "LLM05:2025"], + + "agent": { + "framework": "custom", + "tool_protocol": "mcp", + "topology": "single agent with schema-agnostic tool-result passthrough into context", + "autonomy": "fully_autonomous" + }, + + "provenance": { + "tier": "derived_from_public_observation", + "collection_method": "Constructed as a minimal agent session around the most frequent safety-relevant contract-change class in a public drift corpus. The precondition - a tool's declared output schema changing between snapshots, predominantly with no change to the server's declared version - is measured; the agent, the record contents, and the downstream sinks are synthetic.", + "evidence": [ + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> aggregates.incidents_by_kind['output-schema-changed'] = 992; unstable.headline_excluding_unstable.incidents_by_kind['output-schema-changed'] = 955", + "supports": "Output-schema change is the largest single class of safety-relevant tool-contract change observed in the window: 992 of 2,503 deduped safety-relevant incidents; excluding the unstable-tool class, 955 of 2,401." + }, + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> aggregates.silent_share_pct = 62.4; aggregates.version_delta_split = {same: 1561, changed: 942}", + "supports": "Across all safety-relevant classes in the window, 62.4% of deduped incidents occurred while the server's declared version was unchanged, so a version check is not a sufficient trigger to re-inspect an output schema." + }, + { + "type": "url", + "citation": "https://mcpindex.ai/methodology", + "supports": "Change-detection method: deterministic contract diff between consecutive snapshots, scoped to servers reachable in both, with safety relevance derived from the change taxonomy alone." + } + ], + "observation_window": { + "start": "2026-06-09", + "end": "2026-07-19", + "note": "Same corpus and window as DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval. 'Silent' is a claim about version evidence only and asserts nothing about intent." + } + }, + + "sanitization": { + "attestation": true, + "techniques": ["not_applicable_fully_synthetic", "shape_only_payloads", "name_generalization"], + "notes": "Record contents are represented as typed placeholders rather than values, which is also the point being made: a trace of this class is publishable precisely because the payload shape, not the payload, carries the finding." + }, + + "spans": [ + { + "span_id": "s1", + "parent_span_id": null, + "t_offset_ms": 0, + "actor": "orchestrator", + "actor_id": "orchestrator://support-flow", + "operation": "tool.discover", + "summary": "Agent binds a lookup tool. The declared output schema returns three fields.", + "payload": { + "tool": "server-b/lookup_record", + "declared_server_version": "", + "output_schema_fields": ["record_id", "status", "updated_at"], + "output_schema_digest": "" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none" + }, + { + "span_id": "s2", + "parent_span_id": "s1", + "t_offset_ms": 620, + "actor": "agent", + "actor_id": "agent://support-assistant", + "operation": "tool.call", + "summary": "Agent looks up a record on behalf of a tier-1 support user.", + "payload": { + "tool": "server-b/lookup_record", + "arguments": { "record_id": "" }, + "caller_entitlements": ["record.status.read"] + }, + "data_classes": ["user_prompt"], + "sensitivity": "low" + }, + { + "span_id": "s3", + "parent_span_id": "s2", + "t_offset_ms": 1140, + "actor": "tool", + "actor_id": "tool://server-b/lookup_record", + "operation": "tool.result", + "summary": "Tool returns the narrow projection the agent bound to.", + "payload": { + "record_id": "", + "status": "open", + "updated_at": "" + }, + "data_classes": ["tool_output"], + "sensitivity": "low" + }, + { + "span_id": "s4", + "parent_span_id": "s3", + "t_offset_ms": 1160, + "actor": "orchestrator", + "actor_id": "orchestrator://support-flow", + "operation": "context.append", + "summary": "Whole tool result is appended to context. No projection is applied against the bound schema; the result happens to be safe because the tool happens to be narrow.", + "payload": { + "projection_applied": false, + "fields_appended": 3, + "allowlist_configured": false + }, + "data_classes": ["tool_output"], + "sensitivity": "low", + "finding": { + "dsgai_id": "DSGAI15", + "note": "The control that keeps this flow safe is the supplier's schema, not the client's policy. That is a control the client does not own and cannot see change.", + "severity": "Medium" + } + }, + { + "span_id": "s5", + "parent_span_id": null, + "t_offset_ms": 172800000, + "actor": "external_service", + "actor_id": "tool://server-b", + "operation": "contract.change", + "summary": "Server widens the output schema from a projection to the full record. Declared server version is unchanged.", + "payload": { + "tool": "server-b/lookup_record", + "path": "outputSchema", + "change_kind": "output-schema-changed", + "fields_before": ["record_id", "status", "updated_at"], + "fields_after": ["record_id", "status", "updated_at", "contact_email", "internal_notes", "billing_account_ref"], + "declared_server_version_before": "", + "declared_server_version_after": "", + "version_delta": "same" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none", + "finding": { + "dsgai_id": "DSGAI05", + "note": "A widening output schema is a data-classification change disguised as a compatibility-preserving one. Nothing breaks, so nothing alerts.", + "severity": "High" + } + }, + { + "span_id": "s6", + "parent_span_id": null, + "t_offset_ms": 176400000, + "actor": "agent", + "actor_id": "agent://support-assistant", + "operation": "tool.call", + "summary": "Byte-identical call to s2. Same tier-1 caller, same entitlements, same arguments.", + "payload": { + "tool": "server-b/lookup_record", + "arguments": { "record_id": "" }, + "caller_entitlements": ["record.status.read"] + }, + "data_classes": ["user_prompt"], + "sensitivity": "low" + }, + { + "span_id": "s7", + "parent_span_id": "s6", + "t_offset_ms": 176400910, + "actor": "tool", + "actor_id": "tool://server-b/lookup_record", + "operation": "tool.result", + "summary": "Tool returns the full record. Three of the six fields are data the caller has no entitlement to.", + "payload": { + "record_id": "", + "status": "open", + "updated_at": "", + "contact_email": "", + "internal_notes": "", + "billing_account_ref": "" + }, + "data_classes": ["tool_output", "pii", "proprietary", "financial"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI01", + "note": "Entitlement was enforced at the caller, once, against a schema that has since changed. The tool now returns more than the caller is authorized to receive, and the authorization layer never re-runs.", + "severity": "High" + } + }, + { + "span_id": "s8", + "parent_span_id": "s7", + "t_offset_ms": 176400930, + "actor": "orchestrator", + "actor_id": "orchestrator://support-flow", + "operation": "context.append", + "summary": "Same schema-agnostic append as s4. Six fields now enter the context window instead of three.", + "payload": { + "projection_applied": false, + "fields_appended": 6, + "unexpected_fields": ["contact_email", "internal_notes", "billing_account_ref"], + "unexpected_field_alert": false + }, + "data_classes": ["tool_output", "pii", "proprietary", "financial"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI15", + "note": "The over-share happens here, in code that did not change, on a path that has no idea anything changed. A schema-agnostic passthrough turns a supplier's schema change into a context-window authorization failure.", + "severity": "High" + } + }, + { + "span_id": "s9", + "parent_span_id": "s7", + "t_offset_ms": 176400935, + "actor": "external_service", + "actor_id": "observability://trace-sink", + "operation": "egress", + "summary": "The same tool result is mirrored to the tracing backend as a span attribute, outside the AI security boundary.", + "payload": { + "sink": "third-party tracing backend", + "captured": "full tool result", + "retention_days": "", + "redaction_rules_cover_new_fields": false + }, + "data_classes": ["telemetry", "pii", "financial"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI14", + "note": "Redaction rules in the observability pipeline were written against the fields that existed when the tool was integrated. New fields arrive unredacted by default, and persist for the retention window.", + "severity": "High" + } + }, + { + "span_id": "s10", + "parent_span_id": "s8", + "t_offset_ms": 176403200, + "actor": "model", + "actor_id": "model://assistant", + "operation": "model.completion", + "summary": "Model answers the tier-1 user's question and, being helpful, includes the contact address it now has in context.", + "payload": { + "answer_includes_fields": ["status", "updated_at", "contact_email"], + "user_entitled_to": ["status", "updated_at"] + }, + "data_classes": ["pii"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI01", + "note": "Disclosure completes here. The model is behaving correctly with respect to its context; the context is what is wrong.", + "severity": "High" + } + } + ], + + "security_observations": [ + "Where a tool result is appended to context without projection, the supplier's output schema is the de facto data-classification boundary for the agent.", + "Output-schema widening is the most frequent safety-relevant contract change measured in the source corpus (992 of 2,503 deduped incidents), and it is the class least likely to raise an error, because adding fields breaks nothing.", + "Version pinning does not defend against this: 62.4% of deduped safety-relevant incidents in the window carried no change to the server's declared version.", + "One tool-result object reaches three sinks with different trust boundaries - model context, the user-facing answer, and the observability backend - and the redaction posture of each was set against a schema that no longer holds.", + "The client-side diff between the safe run and the unsafe run is zero lines of code and zero configuration changes." + ], + + "mitigations": [ + "Project tool results against an explicit client-side field allowlist before appending to context. Treat fields outside the allowlist as an alertable event, not as data to pass through.", + "Pin the bound output-schema digest and re-diff on each session; fail closed or re-approve when the digest moves.", + "Classify entitlements per field rather than per tool, so a widened result cannot inherit a narrower call's authorization.", + "Make observability redaction default-deny for tool-result attributes: allowlist the fields that may be captured rather than denylisting the ones that may not.", + "Alert on the arrival of previously unseen fields in a tool result, which is cheap, needs no schema fetch, and catches this class at s7." + ], + + "contributor": { + "name": "Bharti, Gautam", + "orcid": "0009-0001-4448-1438", + "affiliation": "Independent researcher" + }, + + "date_added": "2026-08-07", + + "tags": ["mcp", "tool-contract-drift", "output-schema", "context-window", "over-sharing", "telemetry-leakage"], + + "notes": "MITRE ATLAS mapping deliberately omitted pending a pinned ATLAS release in this repository; see issues #59 and #60. The trace asserts a mechanism, not a prevalence claim about any particular deployment: the corpus establishes that output schemas change frequently and usually without a version signal, not that any specific agent handled such a change badly." +} diff --git a/datasets/agentdataflow_toolexchange_traces/example.json b/datasets/agentdataflow_toolexchange_traces/example.json new file mode 100644 index 0000000..909322b --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/example.json @@ -0,0 +1,208 @@ +{ + "$schema": "./schema.json", + "trace_id": "DSGAI-TRACE-example-toolset-replacement-name-rebind", + "title": "Name-keyed binding survives a whole-toolset replacement and re-points at a different implementation", + "description": "This is the worked example for this dataset; read it alongside schema.json before writing an entry. A server replaces its entire toolset between two observations. The agent holds a binding keyed by tool name and a credential scoped to that name, so when a similarly named tool appears in the replacement set, the binding and the credential both survive a change to everything they referred to. The trace shows where the rebind happens and where the inherited scope is spent.", + "category": "tool_call", + "disposition": "unintentional_failure", + + "dsgai_mapping": ["DSGAI04", "DSGAI06", "DSGAI02"], + "owasp_llm_top10_mapping": ["LLM03:2025", "LLM06:2025"], + + "agent": { + "framework": "custom", + "tool_protocol": "mcp", + "topology": "single agent, name-keyed tool registry, per-tool credential scopes", + "autonomy": "fully_autonomous" + }, + + "provenance": { + "tier": "derived_from_public_observation", + "collection_method": "Constructed around a measured removal pattern. The precondition - tool removals arriving overwhelmingly as whole-toolset replacements rather than as single-tool retirements - is a counted split in a public drift corpus; the agent, registry, and credential model around it are synthetic.", + "evidence": [ + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> removals.removal_scope_split = {single: 138, 'toolset-replaced': 734}; aggregates.deduped_removed_tools = 872", + "supports": "Of 872 deduped tool removals in the window, 734 (84%) occurred as part of a whole-toolset replacement rather than as an isolated retirement, so a name reappearing in a later snapshot is a common rather than exotic condition." + }, + { + "type": "doi", + "citation": "10.5281/zenodo.21449149", + "locator": "aggregates.json -> notes[2]: 'Removal entries are historical observations; a same-named tool may have since returned.'", + "supports": "The corpus explicitly records that a removed name may return, which is the precondition for a name-keyed rebind." + } + ], + "observation_window": { + "start": "2026-06-09", + "end": "2026-07-19", + "note": "Removals are counted only across servers reachable in both snapshots of a pair, so a server going offline is never counted as a removal." + } + }, + + "sanitization": { + "attestation": true, + "techniques": ["not_applicable_fully_synthetic", "name_generalization", "shape_only_payloads"], + "notes": "Fully synthetic. Nothing here was taken from a real deployment." + }, + + "spans": [ + { + "span_id": "s1", + "parent_span_id": null, + "t_offset_ms": 0, + "actor": "orchestrator", + "actor_id": "orchestrator://ops-flow", + "operation": "tool.discover", + "summary": "Agent discovers a 12-tool set and registers each tool under its name.", + "payload": { + "server": "tool://server-c", + "tool_count": 12, + "registry_key": "name", + "toolset_digest": "" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none" + }, + { + "span_id": "s2", + "parent_span_id": "s1", + "t_offset_ms": 90, + "actor": "policy_engine", + "actor_id": "policy://credentials", + "operation": "credential.issue", + "summary": "A scoped credential is minted for one tool and cached against the tool name.", + "payload": { + "subject": "server-c/export_report", + "scopes": ["reports.read", "reports.export"], + "credential": "", + "bound_to": "tool name", + "bound_to_toolset_digest": false + }, + "data_classes": ["credential", "token"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI02", + "note": "The credential is bound to a string, not to the implementation the string resolved to at issue time. Anything that later answers to that string inherits the scope.", + "severity": "Medium" + } + }, + { + "span_id": "s3", + "parent_span_id": null, + "t_offset_ms": 259200000, + "actor": "external_service", + "actor_id": "tool://server-c", + "operation": "contract.change", + "summary": "Server replaces its entire toolset. All 12 original tools are gone; 9 new ones appear, one of which reuses a prior name.", + "payload": { + "change_kind": "tool-removed", + "removal_scope": "toolset-replaced", + "tools_removed": 12, + "tools_added": 9, + "name_collisions": ["export_report"], + "declared_server_version_delta": "same" + }, + "data_classes": ["tool_schema"], + "sensitivity": "none", + "finding": { + "dsgai_id": "DSGAI04", + "note": "A whole-toolset swap under a stable server identity is a supply-chain substitution event. The reused name is the vector; the stable endpoint and version are what make it look like continuity.", + "severity": "High" + } + }, + { + "span_id": "s4", + "parent_span_id": null, + "t_offset_ms": 262800000, + "actor": "orchestrator", + "actor_id": "orchestrator://ops-flow", + "operation": "tool.bind", + "summary": "Registry resolves the cached name to the new implementation. Because the lookup succeeds, no error, warning, or re-approval is produced.", + "payload": { + "requested": "server-c/export_report", + "resolved": "server-c/export_report", + "implementation_changed": true, + "input_schema_digest_changed": true, + "rebind_alert": false + }, + "data_classes": ["tool_schema"], + "sensitivity": "moderate", + "finding": { + "dsgai_id": "DSGAI06", + "note": "A successful name lookup is treated as evidence of continuity. It is only evidence that a name is in use.", + "severity": "High" + } + }, + { + "span_id": "s5", + "parent_span_id": "s4", + "t_offset_ms": 262801100, + "actor": "agent", + "actor_id": "agent://ops-assistant", + "operation": "credential.use", + "summary": "The cached credential from s2 is presented to the new implementation.", + "payload": { + "credential": "", + "issued_for_implementation": "s1", + "presented_to_implementation": "s3", + "scopes": ["reports.read", "reports.export"] + }, + "data_classes": ["credential", "token"], + "sensitivity": "high", + "finding": { + "dsgai_id": "DSGAI02", + "note": "Scope granted to one implementation is spent against another. The gap between issued_for_implementation and presented_to_implementation is the defect.", + "severity": "High" + } + }, + { + "span_id": "s6", + "parent_span_id": "s5", + "t_offset_ms": 262802400, + "actor": "tool", + "actor_id": "tool://server-c/export_report", + "operation": "tool.result", + "summary": "New implementation accepts the call and returns an export, with a different data reach than the tool the scope was granted for.", + "payload": { + "status": "ok", + "rows": "", + "includes_fields_outside_prior_reach": true + }, + "data_classes": ["tool_output", "proprietary"], + "sensitivity": "high" + }, + { + "span_id": "s7", + "parent_span_id": "s6", + "t_offset_ms": 262802500, + "actor": "orchestrator", + "actor_id": "orchestrator://ops-flow", + "operation": "context.append", + "summary": "Export summary is appended to context and the run completes as a success.", + "payload": { "appended_bytes": "", "presented_as": "completed action" }, + "data_classes": ["tool_output", "proprietary"], + "sensitivity": "moderate" + } + ], + + "security_observations": [ + "Tool identity in practice is a name plus an endpoint, and neither is stable enough to carry an authorization or credential binding.", + "Whole-toolset replacement is the dominant removal shape in the source corpus (734 of 872 deduped removals), so name reuse after removal is an ordinary event rather than an attack signature.", + "Every guard in this trace passed. The registry resolved, the credential validated, the tool returned success. Nothing in the run had a place to notice the substitution.", + "The same mechanism carries an adversarial variant, but the adversarial variant is not required for the failure." + ], + + "mitigations": [ + "Key the tool registry by a contract digest or a signed tool identity, and treat name-only resolution as a cache miss.", + "Bind credentials to the implementation digest they were issued against; refuse presentation when the digest has moved.", + "Emit a rebind event whenever a resolved name maps to a changed input-schema digest, and require re-approval before the first call.", + "Track toolset-level digests, not just per-tool ones, so a wholesale replacement is visible as one event rather than as a scatter of unrelated removals." + ], + + "date_added": "2026-08-07", + + "tags": ["mcp", "tool-contract-drift", "toolset-replacement", "name-rebind", "credential-scope", "supply-chain", "example"], + + "notes": "Template entry. Two conventions worth copying: (1) payload values are typed placeholders, never real content, which is what makes a trace of this class publishable at all; (2) findings hang off individual spans, so a reader can see exactly where a flow stops being safe rather than inferring it from a paragraph. The adversarial variant of this flow - an actor deliberately registering a name a known agent population is bound to - belongs in a separate entry at a different provenance tier, since the corpus backs the churn, not the intent. Keeping the two apart is the whole reason the tier field exists." +} diff --git a/datasets/agentdataflow_toolexchange_traces/index.csv b/datasets/agentdataflow_toolexchange_traces/index.csv new file mode 100644 index 0000000..edd2c02 --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/index.csv @@ -0,0 +1,3 @@ +trace_id,title,category,disposition,provenance_tier,primary_dsgai,dsgai_mapping,owasp_llm_top10_mapping,agent_framework,tool_protocol,span_count,finding_count,max_sensitivity,data_classes,evidence_count,primary_evidence,date_added,tags +DSGAI-TRACE-mcp-declared-effect-flip-stale-autoapproval,Tool re-declares itself destructive after an agent has already auto-approved it,tool_call,unintentional_failure,derived_from_public_observation,DSGAI06,DSGAI06|DSGAI05|DSGAI07,LLM06:2025|LLM03:2025,custom,mcp,10,4,high,tool_output|tool_schema,3,10.5281/zenodo.21449149,2026-08-07,mcp|tool-contract-drift|declared-effect|approval-bypass|excessive-agency|supply-chain +DSGAI-TRACE-mcp-output-schema-expansion-context-overshare,"Tool output schema widens under a stable version, and the extra fields flow straight into model context",context_accumulation,unintentional_failure,derived_from_public_observation,DSGAI15,DSGAI15|DSGAI01|DSGAI14|DSGAI05,LLM02:2025|LLM05:2025,custom,mcp,10,6,high,financial|pii|proprietary|telemetry|tool_output|tool_schema|user_prompt,3,10.5281/zenodo.21449149,2026-08-07,mcp|tool-contract-drift|output-schema|context-window|over-sharing|telemetry-leakage diff --git a/datasets/agentdataflow_toolexchange_traces/schema.json b/datasets/agentdataflow_toolexchange_traces/schema.json new file mode 100644 index 0000000..4324c64 --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/schema.json @@ -0,0 +1,435 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://owasp.org/dsgai/datasets/agentdataflow_toolexchange_traces/schema.json", + "title": "DSGAI Agent Data Flow & Tool Exchange Trace", + "description": "Schema for a single trace entry in the OWASP DSGAI agentdataflow_toolexchange_traces dataset. Each entry documents one data flow through an agentic AI system as an ordered span sequence, with an explicit provenance tier and a machine-checkable sanitization attestation. One JSON file per entry, validated against this schema.", + "type": "object", + "additionalProperties": false, + "required": [ + "trace_id", + "title", + "description", + "category", + "disposition", + "dsgai_mapping", + "provenance", + "sanitization", + "spans", + "security_observations", + "date_added" + ], + "properties": { + "$schema": { + "description": "Optional pointer to the schema file for editor tooling. Ignored by validators.", + "type": "string" + }, + + "trace_id": { + "description": "Internal identifier. 'DSGAI-TRACE-', lowercase kebab-case slug.", + "type": "string", + "pattern": "^DSGAI-TRACE-[a-z0-9]+(-[a-z0-9]+)*$" + }, + + "title": { + "description": "Short human-readable name of the data flow this trace captures.", + "type": "string", + "minLength": 4, + "maxLength": 200 + }, + + "description": { + "description": "What flows through this trace, and what makes it security-relevant. 2-6 sentences.", + "type": "string", + "minLength": 40 + }, + + "category": { + "description": "Primary trace category, from the categories listed in this dataset's README.", + "type": "string", + "enum": [ + "tool_call", + "multi_agent_delegation", + "plugin_data_exchange", + "credential_flow", + "context_accumulation", + "memory_read_write" + ] + }, + + "disposition": { + "description": "What kind of run this is. 'benign' = a normal workflow with no security failure. 'adversarial' = an attacker is present and acting. 'unintentional_failure' = no adversary, but data is exposed, over-shared, or mis-scoped anyway.", + "type": "string", + "enum": ["benign", "adversarial", "unintentional_failure"] + }, + + "dsgai_mapping": { + "description": "DSGAI risk entries this trace illustrates. First entry should be the primary risk.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "DSGAI01", "DSGAI02", "DSGAI03", "DSGAI04", "DSGAI05", + "DSGAI06", "DSGAI07", "DSGAI08", "DSGAI09", "DSGAI10", + "DSGAI11", "DSGAI12", "DSGAI13", "DSGAI14", "DSGAI15", + "DSGAI16", "DSGAI17", "DSGAI18", "DSGAI19", "DSGAI20", "DSGAI21" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + + "owasp_llm_top10_mapping": { + "description": "OWASP Top 10 for LLM Applications 2025 entries this trace aligns with.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "LLM01:2025", "LLM02:2025", "LLM03:2025", "LLM04:2025", "LLM05:2025", + "LLM06:2025", "LLM07:2025", "LLM08:2025", "LLM09:2025", "LLM10:2025" + ] + }, + "uniqueItems": true + }, + + "mitre_atlas_mapping": { + "description": "MITRE ATLAS alignment. Release-pinned by construction: ATLAS identifiers and their names have both moved across releases, so an entry must record which release it was verified against and the name the identifier carried in that release. Omit the property entirely rather than guess.", + "type": "object", + "additionalProperties": false, + "required": ["atlas_release", "techniques"], + "properties": { + "atlas_release": { + "description": "ATLAS data release the identifiers and names below were verified against, e.g. '4.9.0'.", + "type": "string", + "minLength": 1 + }, + "techniques": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name_at_release"], + "properties": { + "id": { + "type": "string", + "pattern": "^AML\\.T[0-9]{4}(\\.[0-9]{3})?$" + }, + "name_at_release": { + "description": "The technique name verbatim in the pinned release. Verified, not remembered.", + "type": "string", + "minLength": 3 + } + } + } + } + } + }, + + "agent": { + "description": "The agentic system the trace was taken from or models.", + "type": "object", + "additionalProperties": false, + "properties": { + "framework": { + "description": "LangGraph, CrewAI, AutoGen, OpenAI Agents SDK, custom, undisclosed, etc.", + "type": "string", + "minLength": 2 + }, + "tool_protocol": { + "description": "Protocol carrying the tool exchange.", + "type": "string", + "enum": ["mcp", "openai_tools", "anthropic_tools", "a2a", "openapi_plugin", "custom", "undisclosed"] + }, + "topology": { + "description": "Agent topology, e.g. 'single agent + tools', 'planner delegating to two workers'.", + "type": "string", + "minLength": 4 + }, + "autonomy": { + "description": "How much the system acts without a human in the loop for the actions in this trace.", + "type": "string", + "enum": ["human_approves_each_action", "human_approves_flagged_actions", "fully_autonomous", "undisclosed"] + } + } + }, + + "provenance": { + "description": "Where this trace came from. The tier is the load-bearing field: it lets consumers filter, and it lets contributors who cannot publish production telemetry still contribute without disguising extrapolation as observation.", + "type": "object", + "additionalProperties": false, + "required": ["tier", "collection_method"], + "properties": { + "tier": { + "description": "observed_production = sanitized telemetry from a real deployed system. observed_lab = sandbox, test environment, CTF, or red-team exercise. derived_from_public_observation = the mechanism is rendered as a trace, and its precondition is a documented public fact carried in `evidence`. hypothetical = illustrative only, no empirical backing; excluded from any dataset statistic.", + "type": "string", + "enum": [ + "observed_production", + "observed_lab", + "derived_from_public_observation", + "hypothetical" + ] + }, + "collection_method": { + "description": "How the trace was produced: exporter, harness, corpus, or construction method. One or two sentences.", + "type": "string", + "minLength": 8 + }, + "evidence": { + "description": "Citable public backing. Required for every tier except 'hypothetical' (enforced by validate.py).", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "citation"], + "properties": { + "type": { + "type": "string", + "enum": ["doi", "url", "cve", "ghsa", "advisory", "publication", "dataset", "registry_record", "vendor_changelog"] + }, + "citation": { + "description": "The identifier or URL. For DOIs use the concept DOI, not a versioned one.", + "type": "string", + "minLength": 4 + }, + "locator": { + "description": "Where inside the source the supporting fact sits, e.g. a file and field, a section, a table.", + "type": "string", + "minLength": 2 + }, + "supports": { + "description": "The specific claim in this trace that this source backs. Keep it narrow.", + "type": "string", + "minLength": 8 + } + } + } + }, + "observation_window": { + "description": "For observed and derived tiers: the period the underlying observation covers.", + "type": "object", + "additionalProperties": false, + "properties": { + "start": { "type": "string", "format": "date" }, + "end": { "type": "string", "format": "date" }, + "note": { "type": "string", "minLength": 4 } + } + } + } + }, + + "sanitization": { + "description": "Machine-checkable form of the CONTRIBUTING.md anonymization rule. The attestation is a hard `true`, so an entry cannot be merged with the box silently unticked, and validate.py scans payloads for secret-shaped and PII-shaped strings independently of it.", + "type": "object", + "additionalProperties": false, + "required": ["attestation", "techniques"], + "properties": { + "attestation": { + "description": "Contributor attests that no real credentials, tokens, PII, PHI, proprietary data, internal hostnames, or internal IP addresses remain in this entry.", + "const": true + }, + "techniques": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "synthetic_substitution", + "redaction_placeholder", + "shape_only_payloads", + "name_generalization", + "hash_truncation", + "field_dropped", + "not_applicable_fully_synthetic" + ] + } + }, + "notes": { + "description": "Anything a reviewer should know about what was removed and why.", + "type": "string", + "minLength": 4 + } + } + }, + + "spans": { + "description": "The trace itself: an ordered sequence of events. OpenTelemetry-compatible in shape without requiring an OTel pipeline to produce it.", + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["span_id", "actor", "operation"], + "properties": { + "span_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "parent_span_id": { + "type": ["string", "null"], + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "t_offset_ms": { + "description": "Milliseconds from the first span. Preferred over wall-clock: it carries the ordering that matters without inventing precision or leaking when a system was running.", + "type": "integer", + "minimum": 0 + }, + "timestamp": { + "description": "Wall-clock time, for observed traces where the absolute date is part of the finding.", + "type": "string", + "format": "date-time" + }, + "actor": { + "type": "string", + "enum": [ + "user", + "orchestrator", + "agent", + "model", + "tool", + "memory_store", + "policy_engine", + "human_approver", + "external_service", + "adversary" + ] + }, + "actor_id": { + "description": "Stable synthetic identifier, e.g. 'agent://planner' or 'tool://server-a/lookup_record'. Generalize anything that would identify a real deployment.", + "type": "string", + "minLength": 1 + }, + "operation": { + "type": "string", + "enum": [ + "tool.discover", + "tool.bind", + "tool.call", + "tool.result", + "tool.error", + "contract.change", + "model.prompt", + "model.completion", + "context.append", + "memory.read", + "memory.write", + "delegation.handoff", + "policy.decision", + "approval.request", + "approval.grant", + "approval.deny", + "credential.issue", + "credential.use", + "egress" + ] + }, + "summary": { + "description": "One line, plain language: what happened in this span.", + "type": "string", + "minLength": 4 + }, + "payload": { + "description": "The SHAPE of the data moving, not its content. String values should be typed placeholders such as '', '', or ''. Literal content is acceptable only where the literal is itself the published research artifact. validate.py scans the whole entry, this object included, for secret-shaped and PII-shaped strings.", + "type": "object" + }, + "data_classes": { + "description": "Classes of data present in this span.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "none", + "system_prompt", + "user_prompt", + "tool_schema", + "tool_output", + "source_code", + "file_path", + "credential", + "token", + "pii", + "phi", + "financial", + "proprietary", + "telemetry", + "memory_state", + "retrieved_document" + ] + } + }, + "sensitivity": { + "description": "Sensitivity of the data in this span, in the contributor's judgement.", + "type": "string", + "enum": ["none", "low", "moderate", "high"] + }, + "finding": { + "description": "Attach the risk to the exact span where it materializes, rather than only to the trace as a whole.", + "type": "object", + "additionalProperties": false, + "required": ["dsgai_id", "note"], + "properties": { + "dsgai_id": { + "type": "string", + "pattern": "^DSGAI(0[1-9]|1[0-9]|2[01])$" + }, + "note": { "type": "string", "minLength": 8 }, + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"] + } + } + } + } + } + }, + + "security_observations": { + "description": "What this trace demonstrates. One claim per item; keep each falsifiable.", + "type": "array", + "items": { "type": "string", "minLength": 12 }, + "minItems": 1 + }, + + "mitigations": { + "description": "Controls that would break this flow, stated concretely enough to implement.", + "type": "array", + "items": { "type": "string", "minLength": 8 } + }, + + "contributor": { + "description": "Optional attribution, for research datasets that need a citable creator.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 2 }, + "orcid": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$" }, + "affiliation": { "type": "string", "minLength": 2 } + } + }, + + "date_observed": { + "description": "Date the underlying flow was observed (ISO 8601 date). Omit for hypothetical traces.", + "type": "string", + "format": "date" + }, + + "date_added": { + "description": "Date this entry was added to the dataset (ISO 8601 date).", + "type": "string", + "format": "date" + }, + + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 2 }, + "uniqueItems": true + }, + + "notes": { + "description": "Scope caveats, what this trace deliberately does not claim, and what a follow-up entry would need to cover.", + "type": "string" + } + } +} diff --git a/datasets/agentdataflow_toolexchange_traces/validate.py b/datasets/agentdataflow_toolexchange_traces/validate.py new file mode 100644 index 0000000..4b4ab8b --- /dev/null +++ b/datasets/agentdataflow_toolexchange_traces/validate.py @@ -0,0 +1,245 @@ +"""Validate every entry in ./entries/ against ./schema.json. + +Usage: python validate.py +Exit code 0 on success, 1 on any validation failure. +Requires: jsonschema (pip install jsonschema) + +Beyond schema conformance this checks the things a schema cannot express: + * trace_id agrees with the filename + * a non-hypothetical provenance tier actually carries evidence + * the span graph is well formed and parents precede children + * every span-level finding maps to a risk the trace declares at the top level + * ISO dates are real dates, in every environment + * no string ANYWHERE in the entry looks like a real credential, key, + address, or host path + +The last check is the machine-readable half of the CONTRIBUTING.md anonymization +rule: `sanitization.attestation` is a claim by the contributor, and this scan is +what stops that claim from being the only thing standing between a real secret +and a public dataset. +""" +from __future__ import annotations + +import json +import re +import sys +from datetime import date +from pathlib import Path + +try: + from jsonschema import Draft202012Validator +except ImportError: + sys.stderr.write("jsonschema not installed. Run: pip install jsonschema\n") + sys.exit(2) + +ROOT = Path(__file__).resolve().parent +SCHEMA_PATH = ROOT / "schema.json" +ENTRIES_DIR = ROOT / "entries" +TAXONOMY_PATH = ROOT.parent / "_shared" / "dsgai_taxonomy.json" + +# A value that is entirely a placeholder is exempt from the secret scan. +PLACEHOLDER_RE = re.compile(r"^<[^<>]+>$") + +# Each pattern needs a context cue, not just entropy: a bare hex or base64 run +# over-matches on digests, ids, and hashes that are perfectly fine to publish. +SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("openai-style key", re.compile(r"\bsk-[A-Za-z0-9]{16,}")), + ("aws access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("github token", re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}")), + ("github fine-grained pat", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}")), + ("slack token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}")), + ("google api key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")), + ("json web token", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")), + ("private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), + ("bearer credential", re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{20,}")), + ("email address", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")), + ("rfc1918 address", re.compile(r"\b(10\.\d{1,3}|192\.168|172\.(1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b")), + ("user home path", re.compile(r"(/Users/|/home/|[A-Za-z]:\\\\Users\\\\)[A-Za-z0-9._-]+")), +] + + +def load_taxonomy_ids() -> set[str]: + if not TAXONOMY_PATH.exists(): + return set() + data = json.loads(TAXONOMY_PATH.read_text(encoding="utf-8")) + return {e["id"] for e in data.get("entries", [])} + + +def walk_strings(node, path: str = ""): + """Yield (json_path, string) for every string anywhere under `node`.""" + if isinstance(node, dict): + for key, value in node.items(): + yield from walk_strings(value, f"{path}.{key}" if path else key) + elif isinstance(node, list): + for i, value in enumerate(node): + yield from walk_strings(value, f"{path}[{i}]") + elif isinstance(node, str): + yield path, node + + +def scan_for_secrets(entry: dict, name: str, errors: list[str]) -> None: + """Scan the WHOLE entry, not just span payloads. + + Payloads are the obvious place for a leaked value, but they are not the + likely one. A contributor writing `summary`, `notes`, `collection_method`, + or an evidence `locator` is writing prose while looking at a real trace, + which is exactly when a real hostname or address gets typed out. A scan + narrower than the attestation it backs is worse than no scan, because it + reads as coverage. + """ + for jpath, value in walk_strings(entry): + if PLACEHOLDER_RE.match(value.strip()): + continue + for label, pattern in SECRET_PATTERNS: + if pattern.search(value): + errors.append( + f"{name}: {jpath}: value looks like a real {label}; " + f"replace with a typed placeholder such as " + ) + break + + +def check_spans(entry: dict, name: str, errors: list[str], warnings: list[str]) -> None: + spans = entry.get("spans", []) or [] + seen: set[str] = set() + last_offset: int | None = None + + for index, span in enumerate(spans): + span_id = span.get("span_id", "") + if span_id in seen: + errors.append(f"{name}: duplicate span_id {span_id!r}") + seen.add(span_id) + + parent = span.get("parent_span_id") + if parent is not None and parent not in seen: + errors.append( + f"{name}: span {span_id!r} has parent_span_id {parent!r}, " + f"which is not a preceding span" + ) + + offset = span.get("t_offset_ms") + if isinstance(offset, int): + if last_offset is not None and offset < last_offset: + warnings.append( + f"{name}: span {span_id!r} (index {index}) moves t_offset_ms backwards " + f"({last_offset} -> {offset}); spans should be listed in temporal order" + ) + last_offset = offset + + +def check_findings(entry: dict, name: str, errors: list[str]) -> None: + declared = set(entry.get("dsgai_mapping", []) or []) + for span in entry.get("spans", []) or []: + finding = span.get("finding") + if not finding: + continue + risk = finding.get("dsgai_id", "") + if risk not in declared: + errors.append( + f"{name}: span {span.get('span_id', '?')!r} has a finding for {risk}, " + f"which is not in the trace's dsgai_mapping {sorted(declared)}" + ) + + +def check_provenance(entry: dict, name: str, errors: list[str]) -> None: + prov = entry.get("provenance", {}) or {} + tier = prov.get("tier", "") + evidence = prov.get("evidence") or [] + if tier != "hypothetical" and not evidence: + errors.append( + f"{name}: provenance.tier is {tier!r}, which requires at least one " + f"entry in provenance.evidence. Use tier 'hypothetical' if the trace " + f"has no citable backing." + ) + for i, item in enumerate(evidence): + if item.get("type") == "doi": + citation = item.get("citation", "") + if not re.match(r"^10\.\d{4,9}/\S+$", citation): + errors.append( + f"{name}: provenance.evidence[{i}].citation {citation!r} is typed " + f"'doi' but is not a bare DOI (expected form: 10.xxxx/yyyy)" + ) + + +def check_dates(entry: dict, name: str, errors: list[str]) -> None: + """Check ISO dates with the stdlib. + + `"format": "date"` in the schema is an annotation, not a constraint, unless + a format checker is wired in - and the checkers for `date-time` and `uri` + need optional packages that may or may not be installed. FORMAT_CHECKER is + enabled below for whatever it can cover; this function guarantees the date + fields are checked in every environment regardless. + """ + for field in ("date_added", "date_observed"): + value = entry.get(field) + if value is None: + continue + try: + date.fromisoformat(value) + except (ValueError, TypeError): + errors.append(f"{name}: {field} {value!r} is not an ISO 8601 date (YYYY-MM-DD)") + + +def main() -> int: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + validator = Draft202012Validator( + schema, format_checker=Draft202012Validator.FORMAT_CHECKER + ) + taxonomy_ids = load_taxonomy_ids() + + errors: list[str] = [] + warnings: list[str] = [] + entry_files = sorted(ENTRIES_DIR.glob("*.json")) + if not entry_files: + sys.stderr.write(f"No entries found in {ENTRIES_DIR}\n") + return 1 + + # The worked example is validated too. An example that does not validate is + # a trap for the next contributor. + example_path = ROOT / "example.json" + checked = entry_files + ([example_path] if example_path.exists() else []) + + for path in checked: + name = path.name + try: + entry = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + errors.append(f"{name}: invalid JSON - {e}") + continue + + for err in validator.iter_errors(entry): + loc = "/".join(str(p) for p in err.absolute_path) or "" + errors.append(f"{name}: {loc}: {err.message}") + + trace_id = entry.get("trace_id", "") + if trace_id and path.parent == ENTRIES_DIR and trace_id != path.stem: + errors.append( + f"{name}: trace_id {trace_id!r} does not match the filename stem {path.stem!r}" + ) + + if taxonomy_ids: + for dsgai_id in entry.get("dsgai_mapping", []) or []: + if dsgai_id not in taxonomy_ids: + errors.append(f"{name}: dsgai_mapping {dsgai_id!r} not in taxonomy") + + check_provenance(entry, name, errors) + check_spans(entry, name, errors, warnings) + check_findings(entry, name, errors) + check_dates(entry, name, errors) + scan_for_secrets(entry, name, errors) + + for line in warnings: + print(f"WARN {line}") + + if errors: + for line in errors: + print(f"ERROR {line}") + print(f"\nFAIL: {len(errors)} issue(s) across {len(checked)} files.") + return 1 + + print(f"OK: {len(entry_files)} entries + example.json validated against schema.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())