From 3de18f56f858d5789d01f62aef7a27111de49465 Mon Sep 17 00:00:00 2001 From: Sam Hyland Date: Mon, 6 Jul 2026 08:13:19 +0000 Subject: [PATCH] sync: update from monorepo Synced via scripts/sync-to-public.sh from MANIFEST.public. All files verified against allowlist. Publication scans passed. --- CHANGELOG.md | 30 ++++++ README.md | 20 ++-- pyproject.toml | 2 +- server.json | 4 +- src/synpareia_trust_mcp/__init__.py | 2 +- src/synpareia_trust_mcp/app.py | 32 +++++++ src/synpareia_trust_mcp/journal.py | 45 ++++++++- src/synpareia_trust_mcp/profile.py | 6 ++ src/synpareia_trust_mcp/tools/orient.py | 19 ++++ src/synpareia_trust_mcp/tools/recall.py | 76 +++++++++++++++ tests/lifecycle/test_lifecycle.py | 45 +++++++++ .../scenarios/test_01_orient_and_discover.py | 22 +++++ tests/scenarios/test_09_erase_counterparty.py | 86 +++++++++++++++++ tests/test_journal.py | 62 ++++++++++++ tests/test_mcp_integration.py | 10 +- tests/test_profile.py | 35 +++++++ tests/test_tools_recall.py | 95 +++++++++++++++++++ uv.lock | 2 +- 18 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 tests/scenarios/test_09_erase_counterparty.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2fe08..65bfbf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to `synpareia-trust-mcp` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-07-03 + +Privacy-completion release: makes the data-protection posture the prove/vet/bind +copy already promises actually true in the product. Closes the two GDPR §6 gaps +the 0.6.3 publish-gate `legal` perspective surfaced (both amplified by +network-on-by-default). Adds one tool (33 total) — backward-compatible; minor +bump. + +### Added + +- **`forget_counterparty(identifier)`** — first-class Tier-1 erasure. Permanently + removes a counterparty record and *all* your evaluations of them from the local + **journal**; the local-data counterpart to the directory-side `delete_profile`. + Idempotent (forgetting an absent identifier returns `forgotten: false`, no + error). This is the concrete mechanism behind "erasure stays under your + control" **for the counterparty journal** (GDPR Art. 17, on the data subject's + own machine) — previously the README could only point agents at hand-editing + `counterparties.json`. Scope is deliberately the journal: signed + conversation/recording chains (`conversations/conv_.json`) are + tamper-evident audit trails and are not erased by this tool (the response says + so), preserving the audit-integrity-vs-erasure trade the data-protection design + already names. +- **First-run identity disclosure (GDPR §6).** When the server mints a brand-new + identity, it now discloses — on stderr (never stdout, the stdio-MCP protocol + channel) — the DID, the on-disk location, and that *nothing has been sent + anywhere* (publishing and witnessing are always explicit calls). `orient` also + carries an `identity.first_run` notice for the session in which the identity + was created. This matters more since 0.6 defaulted the network ON: the operator + should know a fresh identity is local-only until they make an explicit call. + ## [0.6.3] - 2026-07-03 Positioning + copy release ahead of the MCP-marketplace listings, and the first diff --git a/README.md b/README.md index 3ae5fb5..75e5ae9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Start by calling `orient` — it maps your situation to the right tools and poin | `recall_counterparty` | Look up what you know about a counterparty | Yes | | `add_evaluation` | Attach your own note/score to a counterparty | Yes | | `find_evaluations` | Search your evaluations by tag | Yes | +| `forget_counterparty` | Erase a counterparty + all your evaluations of them | Yes | | `witness_info` | Witness identity, public key, service URL | No | | `witness_seal_timestamp` | Timestamp seal over a block hash | No | | `witness_seal_state` | State seal over a chain head | No | @@ -73,7 +74,7 @@ Start by calling `orient` — it maps your situation to the right tools and poin | `delete_profile_history` | Delete a prior published card version | No | | `delete_profile` | Tombstone your published card | No | -17 of the 32 tools work fully offline (identity, signing, recording, commitments, local counterparty memory, and offline seal verification). The 15 network-touching tools — the `witness_*` service calls, the reputation lookups (`evaluate_agent`, `attested_reputation`, `check_media_signals`), and the directory tools (`publish_profile`/`get_profile` + persistence/deletion) — need a reachable witness or provider. +18 of the 33 tools work fully offline (identity, signing, recording, commitments, local counterparty memory including erasure, and offline seal verification). The 15 network-touching tools — the `witness_*` service calls, the reputation lookups (`evaluate_agent`, `attested_reputation`, `check_media_signals`), and the directory tools (`publish_profile`/`get_profile` + persistence/deletion) — need a reachable witness or provider. ### Upgrading from 0.2.0 @@ -183,9 +184,10 @@ What's stored: When you record an evaluation about a counterparty, that observation stays on your disk — there is no automatic upload, no shared reputation database, no cross-agent broadcast. -- **Recordings** (`recordings//`) — full message-by-message logs of - conversations you explicitly asked the toolkit to record. Same locality - guarantees. +- **Conversation/recording chains** (`conversations/conv_.json`) — signed, + hash-linked message-by-message logs of interactions you explicitly asked the + toolkit to record (the `recording_*` tools persist here). Tamper-evident and + local; same locality guarantees. What flows off-machine (only when the corresponding tool is invoked): @@ -212,8 +214,14 @@ Subject-rights / GDPR notes (where the GDPR applies to your agent's operations): - All journal data lives on the data subject's own machine. Erasure is - achieved by deleting the relevant record — there is no `forget_counterparty` - tool yet; today, edit `counterparties.json` directly. + achieved with `forget_counterparty(identifier)`, which permanently removes a + counterparty and all your evaluations of them from the local journal (the + Tier-1 counterpart to the directory-side `delete_profile`). You can also edit + `counterparties.json` directly. Scope note: this erases the **journal**; + signed conversation/recording chains (`conversations/conv_.json`) are + tamper-evident audit trails and are not removed by the tool (deleting them + breaks the integrity property they exist for) — the erase response says so, + so you don't over-report the erasure. - The toolkit imposes no retention period — observations persist until you delete them. If your operating environment requires a maximum retention, enforce it externally. diff --git a/pyproject.toml b/pyproject.toml index d6d70e1..cd4527e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "synpareia-trust-mcp" -version = "0.6.3" +version = "0.7.0" description = "Trust tools for agent-to-agent dealings — prove, vet, and bind with verifiable evidence (MCP server)" readme = "README.md" license = "Apache-2.0" diff --git a/server.json b/server.json index 4cb6211..0f9b1a6 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.synpareia/trust-mcp", "title": "Synpareia Trust Toolkit", "description": "Verifiable dealings with other agents: prove what you did, vet who you deal with, bind agreements", - "version": "0.6.3", + "version": "0.7.0", "websiteUrl": "https://synpareia.com", "repository": { "url": "https://github.com/synpareia/trust-mcp", @@ -13,7 +13,7 @@ { "registryType": "pypi", "identifier": "synpareia-trust-mcp", - "version": "0.6.3", + "version": "0.7.0", "transport": { "type": "stdio" }, diff --git a/src/synpareia_trust_mcp/__init__.py b/src/synpareia_trust_mcp/__init__.py index 9cdb201..b3872ac 100644 --- a/src/synpareia_trust_mcp/__init__.py +++ b/src/synpareia_trust_mcp/__init__.py @@ -1,3 +1,3 @@ """Synpareia Trust Toolkit — identity and trust tools for AI agents.""" -__version__ = "0.6.3" +__version__ = "0.7.0" diff --git a/src/synpareia_trust_mcp/app.py b/src/synpareia_trust_mcp/app.py index 1db8f8e..7a0e907 100644 --- a/src/synpareia_trust_mcp/app.py +++ b/src/synpareia_trust_mcp/app.py @@ -48,6 +48,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # Generate or load the agent's identity (first run creates a new keypair) profile_manager.ensure_profile() + # First-run disclosure (GDPR §6 data-protection-by-design). When a brand-new + # identity is minted, tell the operator — on stderr, never stdout, which is + # the stdio-MCP protocol channel — that an identity now exists locally and + # that nothing has been sent anywhere. This matters more since 0.6 defaulted + # the network ON: the operator should know the identity is local-only until + # they make an explicit publishing call. + if profile_manager.newly_generated: + _emit_first_run_disclosure(config, profile_manager.profile.id) + # Initialize witness client if URL is configured witness_client = _create_witness_client(config) @@ -64,6 +73,29 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: await witness_client.close() +def _emit_first_run_disclosure(config: Config, did: str) -> None: + """Print the first-run identity disclosure to stderr (GDPR §6). + + stderr, not stdout: stdout carries the stdio-MCP JSON-RPC stream and must + not be polluted. MCP hosts surface a server's stderr in their logs, so this + reaches the operator. + """ + import sys + + profile_path = config.data_dir / "profile.json" + print( + "[synpareia-trust-mcp] Generated a new agent identity.\n" + f" DID: {did}\n" + f" Stored: {profile_path} (private key, mode 0600 — back it up; " + "losing it loses the identity)\n" + " Network: nothing has been sent anywhere. Publishing to the " + "synpareia directory is an explicit publish_profile call; witnessing " + "is an explicit witness_* call.", + file=sys.stderr, + flush=True, + ) + + def _create_witness_client(config: Config) -> WitnessClient | None: """Create a WitnessClient if the witness URL is configured and httpx is available.""" if not config.witness_url: diff --git a/src/synpareia_trust_mcp/journal.py b/src/synpareia_trust_mcp/journal.py index f726ae1..cb36349 100644 --- a/src/synpareia_trust_mcp/journal.py +++ b/src/synpareia_trust_mcp/journal.py @@ -28,6 +28,7 @@ import json import math import os +import sys import uuid from dataclasses import asdict, dataclass, field from datetime import UTC, datetime @@ -268,6 +269,29 @@ def find_evaluations(self, tag: str) -> list[dict[str, Any]]: ) return results + def delete(self, identifier: str) -> AgentRecord | None: + """Erase a counterparty record (and all its evaluations) by identifier. + + Matches on the primary `identifier` or any alias (a DID, etc.). Removes + the whole record — its display-name history, custom fields, and every + evaluation you attached — and rewrites the journal. Returns the removed + record so the caller can report what was erased, or ``None`` if no + record matched (erasure is idempotent: forgetting something already + gone is not an error). + + This is the local-journal counterpart to the directory-side + `delete_profile`: it is the concrete mechanism behind "erasure stays + under your control" for Tier-1 data (GDPR Art. 17 on the data subject's + own machine). + """ + records = self._load() + target = _find_by_identifier(records, identifier) + if target is None: + return None + remaining = [r for r in records if r is not target] + self._save(remaining) + return target + def all(self) -> list[AgentRecord]: return self._load() @@ -278,7 +302,26 @@ def _load(self) -> list[AgentRecord]: data = json.loads(self._path.read_text()) except (json.JSONDecodeError, UnicodeDecodeError): return [] - return [AgentRecord.from_dict(item) for item in data] + if not isinstance(data, list): + return [] + # Skip individual malformed rows (a hand-edited or partially-written + # counterparties.json) rather than raising out of a read tool: a single + # bad row must not crash recall/remember/add_evaluation/find/forget. The + # skip is logged to stderr (never stdout — the stdio-MCP JSON-RPC + # channel). Whole-file corruption is handled above (returns empty); + # deeper quarantine-vs-drop durability is tracked separately (task #66). + records: list[AgentRecord] = [] + for item in data: + try: + records.append(AgentRecord.from_dict(item)) + except (KeyError, TypeError, ValueError) as exc: + print( + f"[synpareia-trust-mcp] skipping malformed journal record " + f"({type(exc).__name__}: {exc}) in {self._path}", + file=sys.stderr, + flush=True, + ) + return records def _save(self, records: list[AgentRecord]) -> None: self._data_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/synpareia_trust_mcp/profile.py b/src/synpareia_trust_mcp/profile.py index 635cca8..69151f1 100644 --- a/src/synpareia_trust_mcp/profile.py +++ b/src/synpareia_trust_mcp/profile.py @@ -34,6 +34,11 @@ def __init__(self, data_dir: Path, private_key_b64: str | None = None) -> None: self._data_dir = data_dir self._private_key_b64 = private_key_b64 self._profile: synpareia.Profile | None = None + # True iff ensure_profile() minted a brand-new keypair this session + # (as opposed to loading a persisted one or importing an env key). + # Drives the first-run "nothing has been sent to the network yet" + # disclosure (GDPR §6 data-protection-by-design). + self.newly_generated: bool = False @property def profile(self) -> synpareia.Profile: @@ -148,4 +153,5 @@ def _save_profile(self, path: Path, profile: synpareia.Profile) -> None: def _generate_and_save(self, path: Path) -> synpareia.Profile: profile = synpareia.generate() self._save_profile(path, profile) + self.newly_generated = True return profile diff --git a/src/synpareia_trust_mcp/tools/orient.py b/src/synpareia_trust_mcp/tools/orient.py index e10c564..a0bdd91 100644 --- a/src/synpareia_trust_mcp/tools/orient.py +++ b/src/synpareia_trust_mcp/tools/orient.py @@ -101,6 +101,25 @@ def orient(ctx: Context) -> dict[str, Any]: "has_private_key": profile_data["has_private_key"], "directory": directory_state, } + # First-run disclosure (GDPR §6): when this identity was minted in the + # current session, tell the caller it's local-only and nothing has been + # sent — the agent-discoverable twin of the stderr message emitted at + # startup. Only present on a fresh identity; absent once loaded from disk. + if app.profile_manager.newly_generated: + # Durable-true phrasing: the flag persists for the whole session, so + # the notice must stay accurate even after the agent publishes or + # witnesses. It states the standing property (local-until-you-act + + # opt-in) rather than an absolute "nothing has been sent", which would + # go stale the moment a network call is made. + identity["first_run"] = { + "new_identity": True, + "notice": ( + "This identity was generated locally this session. It lives only " + "on this machine unless you make it otherwise — publishing to the " + "directory (publish_profile) and witnessing (witness_*) are always " + "explicit, opt-in calls, never automatic." + ), + } # Configuration status services = { diff --git a/src/synpareia_trust_mcp/tools/recall.py b/src/synpareia_trust_mcp/tools/recall.py index 4eb1414..2cdd1e6 100644 --- a/src/synpareia_trust_mcp/tools/recall.py +++ b/src/synpareia_trust_mcp/tools/recall.py @@ -7,6 +7,7 @@ - `recall_counterparty` — read-only lookup by identifier or display name. - `add_evaluation` — write an agent-authored note/tags/score to a record. - `find_evaluations` — search evaluations across all records by tag. +- `forget_counterparty` — erase a record and all its evaluations (Art. 17). These tools form a closed Tier-1 loop: nothing leaves the local data dir and no external services are contacted. Agents can build durable, @@ -138,6 +139,81 @@ def add_evaluation( } +@mcp.tool() +def forget_counterparty( + identifier: str, + ctx: Context, +) -> dict[str, Any]: + """Erase a counterparty and all your evaluations of them from the local journal. + + Permanently removes the Tier-1 record matched by `identifier` (a + `local:...` id or a DID alias) — its display-name history, custom fields, + and every note/score you attached. This is the local-data counterpart to + the directory-side `delete_profile`: it is how "erasure stays under your + control" is enforced for **the counterparty journal** (GDPR Art. 17, on + your own machine). Your private notes were never uploaded, so there is no + journal copy elsewhere to recall. + + Scope — read this before reporting an erasure to a data subject: this + erases the **journal** record only. Signed conversation/recording chains + (stored in ``conversations/`` as ``conv_.json``) that reference the + same counterparty are NOT touched by this tool (deleting them would break + the tamper-evidence property they exist for); the response says so on every + successful erase so you don't over-report. + + Erasure is idempotent: forgetting an identifier that isn't (or is no + longer) in the journal returns ``forgotten: false`` without error — the + end state (no such record) is what erasure guarantees. Call + `recall_counterparty` first if you want to confirm the identifier before + erasing. + """ + app: AppContext = ctx.request_context.lifespan_context + # _load now skips malformed rows centrally (journal.py), so a corrupt row + # no longer raises here. This guards the remaining write-path failure (an + # OSError from _save: disk full / permission) so the documented + # idempotent-no-error erasure contract holds even then. Narrow, not bare — + # a programming error in delete() still surfaces loudly (reviewer nit). + try: + removed = app.journal_store.delete(identifier) + except OSError as exc: + return { + "ok": False, + "forgotten": False, + "identifier": identifier, + "error": f"{type(exc).__name__}: {exc}"[:200], + "message": ( + "Could not complete erasure — the local journal could not be " + "rewritten (disk full or permissions?). No record was removed. " + "Inspect the counterparties.json data file." + ), + } + if removed is None: + return { + "ok": True, + "forgotten": False, + "identifier": identifier, + "message": ( + f"No journal record matched '{identifier}' — nothing to erase. " + "It may already be gone, or the identifier is wrong " + "(recall_counterparty to check)." + ), + } + return { + "ok": True, + "forgotten": True, + "identifier": removed.identifier, + "display_names": list(removed.display_names), + "evaluations_erased": len(removed.evaluations), + "scope": "local_journal_only", + "message": ( + "Erased from your local journal — this counterparty record and all " + "your evaluations of them are permanently removed. Note: any signed " + "conversation/recording chains (in conversations/) reference the " + "counterparty by DID and are NOT erased by this tool." + ), + } + + @mcp.tool() def find_evaluations( tag: str, diff --git a/tests/lifecycle/test_lifecycle.py b/tests/lifecycle/test_lifecycle.py index 774ce91..3c08db8 100644 --- a/tests/lifecycle/test_lifecycle.py +++ b/tests/lifecycle/test_lifecycle.py @@ -234,6 +234,51 @@ def test_conversation_lifecycle_is_silent(self, tmp_path: Path) -> None: assert buf.getvalue() == "", f"stray stdout: {buf.getvalue()!r}" +class TestFirstRunDisclosure: + """The GDPR §6 first-run disclosure MUST go to stderr, never stdout — + stdout is the stdio-MCP JSON-RPC channel and a stray write there breaks + every host. (Publish-gate close-read M1 / coverage G1 / pentest INFO-1.)""" + + def test_disclosure_goes_to_stderr_not_stdout(self, tmp_path: Path, capsys) -> None: + from synpareia_trust_mcp.app import _emit_first_run_disclosure + from synpareia_trust_mcp.config import Config + + did = "did:synpareia:testdisclosure" + cfg = Config( + data_dir=tmp_path / "data", + display_name="t", + private_key_b64=None, + network_url=None, + auto_register=False, + witness_url=None, + witness_token=None, + moltbook_api_url=None, + moltrust_api_key=None, + ) + _emit_first_run_disclosure(cfg, did) + captured = capsys.readouterr() + # Load-bearing: nothing on the JSON-RPC channel. + assert captured.out == "", f"disclosure leaked to stdout: {captured.out!r}" + # Content lands on stderr: DID, path, and the 'nothing sent' assurance. + assert did in captured.err + assert "profile.json" in captured.err + assert "nothing has been sent" in captured.err + + def test_disclosure_only_fires_on_fresh_mint(self, tmp_path: Path, capsys) -> None: + # A fresh mint sets newly_generated True; a reload leaves it False and + # the lifespan branch (if newly_generated: emit) must stay silent. + from synpareia_trust_mcp.profile import ProfileManager + + data_dir = tmp_path / "data" + first = ProfileManager(data_dir) + first.ensure_profile() + assert first.newly_generated is True + capsys.readouterr() # clear + reloaded = ProfileManager(data_dir) + reloaded.ensure_profile() + assert reloaded.newly_generated is False # so the emit branch is skipped + + class TestPersistedProfileShape: """Guard against profile.json schema drift.""" diff --git a/tests/scenarios/test_01_orient_and_discover.py b/tests/scenarios/test_01_orient_and_discover.py index f14e812..e8785e3 100644 --- a/tests/scenarios/test_01_orient_and_discover.py +++ b/tests/scenarios/test_01_orient_and_discover.py @@ -76,6 +76,28 @@ def test_learn_guides_include_tool_names_and_examples(self, app_ctx) -> None: # and mention at least one tool name or configuration hint. assert guide, f"Empty guide for area '{area_entry['area']}'" + def test_orient_surfaces_first_run_disclosure_on_fresh_identity(self, app_ctx) -> None: + """A freshly-minted identity carries the GDPR §6 first-run notice + ('nothing sent to the network'); a loaded one does not.""" + ctx, app = app_ctx + # app_ctx generates a fresh profile, so newly_generated is True. + assert app.profile_manager.newly_generated is True + result = orient(ctx) + first_run = result["identity"].get("first_run") + assert first_run is not None + assert first_run["new_identity"] is True + # Durable-true: states the standing local-until-you-act property + the + # opt-in nature of publishing/witnessing (must not go stale if the + # agent publishes later in the same session). + notice = first_run["notice"].lower() + assert "locally" in notice + assert "publish_profile" in notice + assert "opt-in" in notice + + # Simulate a subsequent session (identity loaded, not minted). + app.profile_manager.newly_generated = False + assert "first_run" not in orient(ctx)["identity"] + def test_orient_includes_next_steps(self, app_ctx) -> None: ctx, _ = app_ctx result = orient(ctx) diff --git a/tests/scenarios/test_09_erase_counterparty.py b/tests/scenarios/test_09_erase_counterparty.py new file mode 100644 index 0000000..6b852c5 --- /dev/null +++ b/tests/scenarios/test_09_erase_counterparty.py @@ -0,0 +1,86 @@ +"""Scenario 09: Erase a counterparty from the local journal (GDPR Art. 17). + +See scenarios/trust-toolkit/09-erase-counterparty.md. + +Tier-1 local-only flow: remember -> evaluate -> recall-to-confirm -> +forget -> verify-gone. Asserts the honest-scope confirmation and the +fail-closed behaviour on a corrupt journal (both landed in the 0.7.0 +publish-gate response). +""" + +from __future__ import annotations + +from synpareia_trust_mcp.tools.recall import ( + add_evaluation, + find_evaluations, + forget_counterparty, + recall_counterparty, + remember_counterparty, +) + + +class TestEraseCounterpartyFlow: + def test_full_erasure_flow(self, app_ctx) -> None: + ctx, _ = app_ctx + # 1. remember + evaluate + rec = remember_counterparty( + namespace="slack", namespace_id="U-erase", display_name="subject", ctx=ctx + ) + add_evaluation( + identifier=rec["identifier"], text="left mid-deal", tags=["ghosted"], ctx=ctx + ) + # 2. confirm it exists + assert ( + recall_counterparty(identifier_or_name=rec["identifier"], ctx=ctx)["match_count"] == 1 + ) + # 3. erase + result = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + # 4. honest confirmation + assert result["forgotten"] is True + assert result["evaluations_erased"] == 1 + assert result["scope"] == "local_journal_only" + assert "NOT erased" in result["message"] + # 5. verify gone + assert ( + recall_counterparty(identifier_or_name=rec["identifier"], ctx=ctx)["match_count"] == 0 + ) + assert find_evaluations(tag="ghosted", ctx=ctx)["match_count"] == 0 + + def test_idempotent_second_erase(self, app_ctx) -> None: + ctx, _ = app_ctx + rec = remember_counterparty( + namespace="slack", namespace_id="U-idem", display_name="x", ctx=ctx + ) + assert forget_counterparty(identifier=rec["identifier"], ctx=ctx)["forgotten"] is True + second = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + assert second["ok"] is True + assert second["forgotten"] is False + + def test_erasure_does_not_touch_other_counterparties(self, app_ctx) -> None: + ctx, _ = app_ctx + keep = remember_counterparty( + namespace="slack", namespace_id="K", display_name="keeper", ctx=ctx + ) + add_evaluation(identifier=keep["identifier"], text="solid", tags=["reliable"], ctx=ctx) + drop = remember_counterparty( + namespace="slack", namespace_id="D", display_name="dropme", ctx=ctx + ) + forget_counterparty(identifier=drop["identifier"], ctx=ctx) + assert ( + recall_counterparty(identifier_or_name=keep["identifier"], ctx=ctx)["match_count"] == 1 + ) + assert find_evaluations(tag="reliable", ctx=ctx)["match_count"] == 1 + + def test_save_failure_fails_closed(self, app_ctx, monkeypatch) -> None: + # A write-path OSError (disk full / permissions) returns the structured + # idempotent-no-error failure rather than raising out of the tool. + ctx, app = app_ctx + + def boom(identifier): + raise OSError("read-only file system") + + monkeypatch.setattr(app.journal_store, "delete", boom) + result = forget_counterparty(identifier="local:anything", ctx=ctx) + assert result["ok"] is False + assert result["forgotten"] is False + assert "error" in result diff --git a/tests/test_journal.py b/tests/test_journal.py index 9363df2..e30f767 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -12,6 +13,29 @@ ) +class TestLoadRobustness: + """A corrupt counterparties.json must degrade gracefully, not crash the + read tools that call _load (PR #329 review: the fix is central to _load, + so recall/remember/add/find/forget all benefit).""" + + def test_malformed_row_is_skipped_not_raised(self, store: JournalStore, capsys) -> None: + good = store.upsert(namespace="slack", namespace_id="G", display_name="good") + # Append a row missing required fields — would KeyError in from_dict. + data = json.loads(store._path.read_text()) + data.append({"identifier": "local:broken", "display_names": ["x"]}) + store._path.write_text(json.dumps(data)) + # A fresh store over the same file loads only the valid record. + reloaded = JournalStore(store._data_dir) + records = reloaded.all() + assert [r.identifier for r in records] == [good.identifier] + assert "skipping malformed journal record" in capsys.readouterr().err + + def test_non_list_top_level_is_empty_not_raised(self, store: JournalStore) -> None: + store._path.parent.mkdir(parents=True, exist_ok=True) + store._path.write_text(json.dumps({"not": "a list"})) + assert JournalStore(store._data_dir).all() == [] + + @pytest.fixture() def store(tmp_path: Path) -> JournalStore: return JournalStore(tmp_path / "journal") @@ -219,3 +243,41 @@ def test_serialize_and_restore(self, tmp_path: Path) -> None: assert restored is not None assert restored.evaluations[0].text == "test eval" assert restored.evaluations[0].score == 0.5 + + +class TestDelete: + def test_delete_removes_record_and_returns_it(self, store: JournalStore) -> None: + r = store.upsert(namespace="slack", namespace_id="U1", display_name="alice") + store.add_evaluation(r.identifier, text="note", tags=["t"], score=0.5) + removed = store.delete(r.identifier) + assert removed is not None + assert removed.identifier == r.identifier + assert len(removed.evaluations) == 1 + assert store.get(r.identifier) is None + assert store.all() == [] + + def test_delete_missing_returns_none(self, store: JournalStore) -> None: + assert store.delete("local:nope") is None + + def test_delete_by_did_alias(self, store: JournalStore) -> None: + r = store.upsert(namespace="slack", namespace_id="U1", display_name="alice") + store.add_did(r.identifier, "did:synpareia:deadbeef") + removed = store.delete("did:synpareia:deadbeef") + assert removed is not None + assert store.get(r.identifier) is None + + def test_delete_leaves_other_records(self, store: JournalStore) -> None: + keep = store.upsert(namespace="slack", namespace_id="K", display_name="keep") + drop = store.upsert(namespace="slack", namespace_id="D", display_name="drop") + store.delete(drop.identifier) + assert store.get(keep.identifier) is not None + assert store.get(drop.identifier) is None + + def test_delete_persists_to_disk(self, tmp_path: Path) -> None: + d = tmp_path / "journal" + s1 = JournalStore(d) + r = s1.upsert(namespace="slack", namespace_id="U1", display_name="alice") + s1.delete(r.identifier) + # Fresh store reading the same file must not see the deleted record. + s2 = JournalStore(d) + assert s2.get(r.identifier) is None diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index a072f1c..d9bc80b 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -87,13 +87,13 @@ def test_tool_count(self) -> None: tool_count = len(mcp._tool_manager._tools) # 2 (orient/learn) + 2 (make_claim/verify_claim) + 1 (evaluate_agent) # + 1 (prove_independence) + 5 (recording) + 6 (witness) - # + 4 (remember/recall/add_evaluation/find_evaluations) + # + 5 (remember/recall/add_evaluation/find_evaluations/forget_counterparty) # + 1 (check_media_signals) + 1 (attested_reputation) - # + 2 (encode_signed/decode_signed) = 25 + # + 2 (encode_signed/decode_signed) = 26 # + 7 (Phase 1g directory: publish_profile, get_profile, # update_profile_policy, enable_persistence, disable_persistence, - # delete_profile_history, delete_profile) = 32 - assert tool_count == 32, f"Expected 32 tools, got {tool_count}" + # delete_profile_history, delete_profile) = 33 + assert tool_count == 33, f"Expected 33 tools, got {tool_count}" class TestMCPLifespan: @@ -130,4 +130,4 @@ def test_package_metadata(self) -> None: """Package metadata should be accessible.""" import synpareia_trust_mcp - assert synpareia_trust_mcp.__version__ == "0.6.3" + assert synpareia_trust_mcp.__version__ == "0.7.0" diff --git a/tests/test_profile.py b/tests/test_profile.py index 8f02a1e..fa5b347 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -60,6 +60,41 @@ def test_reloads_same_identity(self, tmp_data_dir: Path) -> None: assert profile1.public_key == profile2.public_key assert profile1.private_key == profile2.private_key + def test_newly_generated_flag_true_only_on_fresh_mint(self, tmp_data_dir: Path) -> None: + # First run mints a keypair — the first-run disclosure (GDPR §6) fires. + pm1 = ProfileManager(tmp_data_dir) + assert pm1.newly_generated is False # not until ensure_profile runs + pm1.ensure_profile() + assert pm1.newly_generated is True + + # Second run loads the persisted key — no disclosure, no fresh mint. + pm2 = ProfileManager(tmp_data_dir) + pm2.ensure_profile() + assert pm2.newly_generated is False + + def test_newly_generated_false_when_importing_env_key(self, tmp_data_dir: Path) -> None: + import base64 + + key_b64 = base64.b64encode(synpareia.generate().private_key).decode() + pm = ProfileManager(tmp_data_dir, private_key_b64=key_b64) + pm.ensure_profile() + # Importing an existing key is not minting a new identity. + assert pm.newly_generated is False + + def test_newly_generated_true_on_corrupt_auto_recover( + self, tmp_data_dir: Path, monkeypatch + ) -> None: + # A corrupt profile regenerated under AUTO_RECOVER is a fresh identity + # the operator should be told about — the disclosure must fire. + pm1 = ProfileManager(tmp_data_dir) + pm1.ensure_profile() + (tmp_data_dir / "profile.json").write_text("{ not valid json", encoding="utf-8") + + monkeypatch.setenv("SYNPAREIA_AUTO_RECOVER_PROFILE", "true") + pm2 = ProfileManager(tmp_data_dir) + pm2.ensure_profile() + assert pm2.newly_generated is True + def test_does_not_overwrite_existing_profile(self, tmp_data_dir: Path) -> None: pm1 = ProfileManager(tmp_data_dir) pm1.ensure_profile() diff --git a/tests/test_tools_recall.py b/tests/test_tools_recall.py index 44e5e79..b0cc3af 100644 --- a/tests/test_tools_recall.py +++ b/tests/test_tools_recall.py @@ -2,9 +2,12 @@ from __future__ import annotations +import json + from synpareia_trust_mcp.tools.recall import ( add_evaluation, find_evaluations, + forget_counterparty, recall_counterparty, remember_counterparty, ) @@ -227,3 +230,95 @@ def test_returns_empty_for_unknown_tag(self, app_ctx) -> None: result = find_evaluations(tag="nonexistent", ctx=ctx) assert result["match_count"] == 0 assert result["results"] == [] + + +class TestForgetCounterparty: + def test_erases_record_and_evaluations(self, app_ctx) -> None: + ctx, app = app_ctx + rec = remember_counterparty( + namespace="slack", namespace_id="U9", display_name="mallory", ctx=ctx + ) + add_evaluation(identifier=rec["identifier"], text="unreliable", tags=["late"], ctx=ctx) + result = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + assert result["ok"] is True + assert result["forgotten"] is True + assert result["evaluations_erased"] == 1 + assert "mallory" in result["display_names"] + # Gone from the journal. + after = recall_counterparty(identifier_or_name=rec["identifier"], ctx=ctx) + assert after["match_count"] == 0 + # And gone from evaluation search. + assert find_evaluations(tag="late", ctx=ctx)["match_count"] == 0 + + def test_erasure_is_idempotent(self, app_ctx) -> None: + ctx, app = app_ctx + # Never-seen identifier: no error, forgotten=False. + result = forget_counterparty(identifier="local:does-not-exist", ctx=ctx) + assert result["ok"] is True + assert result["forgotten"] is False + + def test_erases_by_did_alias(self, app_ctx) -> None: + ctx, app = app_ctx + rec = remember_counterparty( + namespace="synpareia", namespace_id="did:synpareia:abc", display_name="bob", ctx=ctx + ) + # remember_counterparty stores a local:... id; erase by that id. + result = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + assert result["forgotten"] is True + assert recall_counterparty(identifier_or_name="bob", ctx=ctx)["match_count"] == 0 + + def test_only_erases_the_named_record(self, app_ctx) -> None: + ctx, app = app_ctx + keep = remember_counterparty( + namespace="slack", namespace_id="K1", display_name="keeper", ctx=ctx + ) + drop = remember_counterparty( + namespace="slack", namespace_id="D1", display_name="dropme", ctx=ctx + ) + forget_counterparty(identifier=drop["identifier"], ctx=ctx) + assert ( + recall_counterparty(identifier_or_name=keep["identifier"], ctx=ctx)["match_count"] == 1 + ) + assert ( + recall_counterparty(identifier_or_name=drop["identifier"], ctx=ctx)["match_count"] == 0 + ) + + def test_success_message_scopes_erasure_to_journal(self, app_ctx) -> None: + # Publish-gate legal: the return must not let an agent over-report + # erasure — it names the journal scope and flags untouched audit trails. + ctx, app = app_ctx + rec = remember_counterparty(namespace="slack", namespace_id="S1", display_name="s", ctx=ctx) + result = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + assert result["scope"] == "local_journal_only" + assert "NOT erased" in result["message"] + + def test_malformed_row_does_not_crash_forget(self, app_ctx) -> None: + # Publish-gate pentest LOW-1 + PR #329 review: a malformed row is + # skipped centrally in _load, so forget over a journal that contains a + # bad row still succeeds on the clean records rather than raising. + ctx, app = app_ctx + rec = remember_counterparty( + namespace="slack", namespace_id="clean", display_name="ok", ctx=ctx + ) + # Inject a malformed row alongside the clean one. + path = app.journal_store._path + data = json.loads(path.read_text()) + data.append({"identifier": "local:broken"}) # missing required fields + path.write_text(json.dumps(data)) + result = forget_counterparty(identifier=rec["identifier"], ctx=ctx) + assert result["ok"] is True + assert result["forgotten"] is True + + def test_save_failure_fails_closed_not_raises(self, app_ctx, monkeypatch) -> None: + # The write-path guard: an OSError from _save must return the + # idempotent-no-error structured failure, not raise out of the tool. + ctx, app = app_ctx + + def boom(identifier): + raise OSError("disk full") + + monkeypatch.setattr(app.journal_store, "delete", boom) + result = forget_counterparty(identifier="local:whatever", ctx=ctx) + assert result["ok"] is False + assert result["forgotten"] is False + assert "error" in result and "OSError" in result["error"] diff --git a/uv.lock b/uv.lock index a9d86d5..6d2852a 100644 --- a/uv.lock +++ b/uv.lock @@ -1205,7 +1205,7 @@ provides-extras = ["sqlite", "witness", "profile", "dev"] [[package]] name = "synpareia-trust-mcp" -version = "0.6.3" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "idna" },