From 00224211798bd10f59f898413020a40287813b65 Mon Sep 17 00:00:00 2001 From: Toby Kershaw Date: Wed, 11 Mar 2026 16:51:29 +0000 Subject: [PATCH 1/5] docs: add IFC reintegration proposal --- .../ifc-reintegration-proposal.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/architecture/ifc-reintegration-proposal.md diff --git a/docs/architecture/ifc-reintegration-proposal.md b/docs/architecture/ifc-reintegration-proposal.md new file mode 100644 index 0000000..9b41811 --- /dev/null +++ b/docs/architecture/ifc-reintegration-proposal.md @@ -0,0 +1,299 @@ +# IFC Reintegration Proposal + +> Status: Draft architecture note +> Related: [protocol-spec.md](../protocol-spec.md), [a2a-integration-spec.md](a2a-integration-spec.md), [precontract-negotiation-notes.md](precontract-negotiation-notes.md), `vfc/packages/ifc-engine`, `vfc/packages/message-envelope` + +## 1. Role of IFC in the Modern Stack + +This note proposes how to reintegrate IFC into the current AgentVault architecture without blurring its role with AFAL or vault execution. + +The intended layering is: + +- **A2A** — optional transport and interoperability carrier +- **AFAL** — admission and session formation +- **Vault contract** — bounded computation +- **IFC** — bounded communication and context-flow control outside sessions + +The key correction is that IFC should no longer be treated as an old experimental subsystem or an optional wrapper around a few messages. It should be the default policy membrane for out-of-vault agent communication. + +That gives the system two complementary controls: + +- **bounded computation** inside a vault session +- **bounded communication** outside a vault session + +This restores a clean architecture. Without IFC, AgentVault risks having a highly disciplined in-vault path and a comparatively ungoverned out-of-vault path. + +### 1.1 Distinct responsibilities + +AFAL and IFC should remain separate: + +- **AFAL asks:** should these agents open a bounded session, and under what agreement? +- **IFC asks:** may this concrete message flow between these agents outside a session, and if so under what constraints? + +The vault contract then governs the actual execution once a session exists. + +### 1.2 IFC as a membrane, not message decoration + +An IFC envelope is not just a safer message wrapper. It is a signed, policy-evaluable claim about permissible information flow. + +That claim binds: + +- sender and recipient +- message payload +- IFC label +- policy hash +- label receipt +- optional grant context + +The receiver does not merely "read metadata." The receiver evaluates the flow mechanically and obtains one of a small number of outcomes. + +### 1.3 Where IFC applies + +IFC should sit laterally to vault sessions rather than strictly before or after them. + +Valid paths include: + +- `IFC message` +- `IFC -> Escalate -> AFAL -> Vault` +- `Vault -> receipt/grant -> IFC follow-up` + +This means IFC can govern: + +- pre-session low-risk coordination +- inter-session follow-up +- post-session acknowledgements, logistics, and controlled artifact transfer + +It should not be used as a replacement for bounded vault computation. + +### 1.4 Non-IFC messages + +If IFC is the default membrane, the architecture needs an explicit rule for plain out-of-vault messages that are not IFC-wrapped. + +The long-term target should be: + +- production: blocked by default unless sent via an explicitly non-sensitive channel policy +- development: optional bypass for iteration + +The important thing is to make this policy explicit. Otherwise "default membrane" degrades into "membrane for only the flows we remembered to wrap." + +## 2. Modern IFC Envelope and Policy Model + +The existing IFC machinery in `vfc` remains strong in concept: + +- signed message envelopes +- label algebra over confidentiality, integrity, and boundedness +- label receipts and policy hashes +- capability grants +- HIDE semantics + +What needs updating is the semantic surface. The old IFC policy model still reflects earlier purpose buckets such as `MEDIATION` and `NEGOTIATION`. Current AgentVault has moved toward more explicit contract fields and richer session semantics, so IFC should do the same. + +### 2.1 Envelope model + +Keep the existing cryptographic envelope model, but modernize the policy inputs around it. + +Recommended message fields: + +- `message_id` +- `sender` +- `recipient` +- `payload` +- `label` +- `ifc_policy_hash` +- `label_receipt` +- `message_class` +- `topic_code` +- `session_relation` +- `related_session_id` (optional) +- `related_receipt_id` (optional) +- `grant_id` (optional) + +### 2.2 Message classification + +`message_class` should be functional, not contextual. Context belongs in `session_relation`. + +Recommended initial `message_class` values: + +- `LOGISTICS` +- `CONSENT` +- `REFERENCE` +- `ARTIFACT_TRANSFER` +- `CLARIFICATION` +- `ESCALATION_TRIGGER` + +Recommended initial `session_relation` values: + +- `PRE_SESSION` +- `POST_SESSION` +- `STANDALONE` + +This separation makes policy evaluation cleaner than using relational labels like `FOLLOW_UP` or `SESSION_ADJACENT` inside `message_class`. + +### 2.3 Policy inputs + +The IFC policy engine should evaluate at least: + +- label confidentiality / integrity / boundedness +- `message_class` +- `topic_code` +- `session_relation` +- current context label +- grant scope and expiry, if a grant is present +- operator-selected policy bundle + +Policy outputs remain: + +- `Allow` +- `Hide` +- `Escalate` +- `Block` + +Interpretation: + +- **Allow** — deliver into active agent context +- **Hide** — quarantine as a hidden variable; do not deliver into active reasoning context +- **Escalate** — this message should be converted into session formation +- **Block** — reject outright + +### 2.4 HIDE semantics + +HIDE is worth preserving. It is one of the strongest ideas in the original IFC design because it gives a third path between permissive delivery and simple rejection. + +However, the design constraint needs to be explicit: + +**HIDE must not become silent semantic smuggling.** + +Hidden material must remain quarantined strongly enough that it cannot influence active reasoning except through: + +- explicit bounded inspection, or +- explicit escalation into a more appropriate protocol path + +In practice, HIDE should remain mostly an internal control primitive, not a user-facing concept that leaks into most product surfaces. + +### 2.5 Escalation as a proto-agreement seed + +`Escalate` should not be a vague recommendation. It should return a structured seed that AFAL can consume. + +Recommended shape: + +- `recommended_topic_code` +- `recommended_signal_family` +- `recommended_policy_constraints` +- `reason_code` +- `source_message_id` +- `grant_context` + +This keeps the system from falling back into prose at the exact moment a message becomes too sensitive for out-of-vault handling. + +### 2.6 Grants + +Capability grants should become the main authorization primitive for non-session flows. + +Conceptually: + +- **contracts** authorize bounded computation +- **grants** authorize bounded communication + +A grant should be narrowly scoped and short-lived. Recommended fields: + +- issuer +- audience +- allowed `topic_code` or topic family +- allowed `message_class` set +- label ceiling +- optional related session or receipt provenance +- expiry +- use count + +Bias strongly toward provenance-bound grants tied to an existing session or receipt. Avoid broad standing authority, which would recreate ambient trust through accumulated permissions. + +## 3. First Implementation Slice + +The first reintegration slice should be deliberately narrow. The goal is to prove the membrane concept in a place where IFC is obviously useful, not to revive the entire historical IFC surface at once. + +### 3.1 Scope + +Implement IFC as the default path for: + +- post-session follow-up +- scheduling / logistics tied to an existing session or receipt +- grant issuance and grant consumption for those flows + +Include an escalation stub in the API shape, but do not require a full end-to-end `Escalate -> AFAL -> Vault` path in the first slice. + +### 3.2 Why start here + +This is the easiest place for IFC to be clearly valuable: + +- there is already provenance from the prior session +- the topic context is already bounded +- the communication need is real but smaller than reopening a vault +- grants can be minted naturally from a completed session or receipt + +This avoids speculative "general safe agent chat" and keeps the first slice grounded. + +### 3.3 Initial flow + +Recommended first flow: + +1. Two agents complete a vault session and receive a receipt. +2. One side mints a narrow capability grant for approved follow-up or logistics. +3. Subsequent out-of-vault messages are sent as IFC envelopes referencing the related receipt or session. +4. The receiver evaluates each message and gets `Allow`, `Hide`, `Escalate`, or `Block`. +5. If `Escalate` occurs, the system returns a structured escalation seed that can later feed AFAL session formation. + +### 3.4 Candidate use cases + +Good first-slice use cases: + +- "I acknowledge receipt of the bounded result." +- "Here are three candidate times for the follow-up." +- "I consent to the next bounded step." +- "Here is a pointer to the artifact we agreed to exchange." + +Bad first-slice use cases: + +- substantive compatibility or mediation reasoning +- open-ended bargaining +- any communication that is effectively trying to continue the vault session in prose + +Those should either block or escalate. + +### 3.5 Minimal product and protocol requirements + +The first slice should add: + +- an architecture note defining IFC's modern role +- a minimal message envelope profile for post-session and logistics messages +- grant issuance and verification for those messages +- a clear policy for plain non-IFC messages in the selected surfaces +- logging / receipts sufficient to debug `Allow` / `Hide` / `Escalate` / `Block` + +It should not yet add: + +- broad pre-session IFC chat +- free-form negotiation inside IFC +- a full replacement for AFAL or vault session semantics + +## 4. Open Design Questions + +The following questions remain, but they do not block the first slice: + +- Should some very low-risk channels auto-wrap plain messages into a lowest-tier IFC envelope, or should all production flows require explicit IFC wrapping? +- What exact `topic_code` ontology should IFC use when no active session exists? +- Should grants be minted only by completed receipts, or also by explicit user/operator action? +- How much of HIDE should be surfaced in developer tooling versus kept entirely internal? +- When escalation is wired end-to-end, should AFAL treat the escalation seed as a proposal hint or as a more formal proto-agreement object? + +## 5. Recommendation + +Reintegrate IFC as the default out-of-vault communication membrane, but do so through a narrow, session-adjacent first slice. + +The architectural target is: + +- **AFAL** for session admission +- **Vault contracts** for bounded computation +- **IFC** for bounded communication and context-flow control outside sessions +- **A2A** as an optional carrier for either AFAL or IFC payloads + +This restores IFC as a structurally necessary part of the system rather than a dormant historical subsystem. From 9d52c269ffcc3e7c43f361e7afe535cd5afb370e Mon Sep 17 00:00:00 2001 From: Toby Kershaw Date: Wed, 11 Mar 2026 17:06:49 +0000 Subject: [PATCH 2/5] docs: add negotiation architecture sketches --- .../agentvault-negotiation-protocol.md | 369 ++++++++++++++++++ docs/architecture/negotiation-fixtures.md | 98 +++++ .../negotiation-registry-artefacts.md | 155 ++++++++ docs/architecture/negotiation-wire-format.md | 146 +++++++ 4 files changed, 768 insertions(+) create mode 100644 docs/architecture/agentvault-negotiation-protocol.md create mode 100644 docs/architecture/negotiation-fixtures.md create mode 100644 docs/architecture/negotiation-registry-artefacts.md create mode 100644 docs/architecture/negotiation-wire-format.md diff --git a/docs/architecture/agentvault-negotiation-protocol.md b/docs/architecture/agentvault-negotiation-protocol.md new file mode 100644 index 0000000..25f1ef0 --- /dev/null +++ b/docs/architecture/agentvault-negotiation-protocol.md @@ -0,0 +1,369 @@ +# AgentVault Negotiation Protocol + +> Status: Draft protocol sketch +> Related: [protocol-spec.md](../protocol-spec.md), [precontract-negotiation-notes.md](precontract-negotiation-notes.md), [ifc-reintegration-proposal.md](ifc-reintegration-proposal.md) + +## 1. North Star + +AgentVault should allow delegated agents to negotiate bespoke bounded-computation agreements without allowing freeform contract generation. + +The intended middle ground is: + +- most sessions use standard named offers from the registry +- advanced sessions negotiate bespoke agreements by composing admitted artefacts +- execution always happens through a rigid, verifiable contract derived from that agreement + +The key distinction is between two layers: + +- **agreement layer** — negotiable, structured, and expressive within bounds +- **execution layer** — content-addressed, machine-verifiable, and relay-admissible + +Agents do not invent raw execution contracts. They negotiate a bounded computation agreement and deterministically compile it into an execution contract. + +This split is also an evolution boundary. Negotiation semantics can change over time without changing the execution contract format, as long as compilation still produces the same relay-verifiable contract shape. The same boundary runs the other direction: execution assurance can evolve, for example from relay-asserted to TEE-attested, without changing the shape of agreement negotiation. + +## 2. Negotiation Threat Model + +Negotiation is not a benign coordination helper. It is an information channel and must be treated as such. + +The main risk is that agents can learn about each other from: + +- proposal choices +- counterproposal choices +- reject reasons +- convergence failure +- timing and number of rounds + +So the system must decide what trust boundary applies during negotiation. + +### 2.1 Default assumption + +The safest default model is: + +- negotiation messages are not treated as disclosure-free +- negotiation reveals some bounded metadata about acceptable computation shapes +- private substantive facts must not be exchanged during negotiation + +This means negotiation should be understood as a tightly constrained metadata exchange, not as a private pre-session conversation. + +### 2.2 Stronger future model + +A stronger model may later place negotiation inside a relay- or enclave-mediated trust boundary so that proposal contents are not directly revealed agent-to-agent. + +That is not assumed by this sketch. + +For now, the protocol should be designed conservatively for the direct-exchange case. + +There are at least two future variants: + +- **trusted relay mediation** + - the relay can observe negotiation contents and mediate convergence, but the counterparties do not see each proposal directly +- **TEE-mediated negotiation** + - negotiation contents are hidden from both counterparties and the relay operator, subject to the enclave trust model + +Both variants would materially reduce direct agent-to-agent leakage, but they also introduce stronger trust assumptions than the current sketch. + +### 2.3 Design consequence + +Because negotiation is itself a side channel: + +- message types must be fixed and small +- reason codes must be coarse and finite +- round count must be bounded and explicit +- the protocol should minimize repeated exploratory bargaining + +## 3. Agreement Object + +The agreement object is the main object of negotiation. It describes what bounded computation should be run, not arbitrary runtime behavior. + +### 3.1 Agreement fields + +An initial agreement object should contain: + +- `topic_code` +- `signal_family` +- `acceptable_schema_refs` + - must all be compatible with the chosen `signal_family` +- `required_policy_refs` +- `acceptable_profile_refs` +- `acceptable_program_refs` or a deterministic derivation rule +- `bounded_parameters` +- `preference_order` +- `related_session_context` (optional) + +### 3.2 Field intent + +- `topic_code` + - real-world coordination domain + - examples: `salary_alignment`, `meeting_scheduling`, `project_scope` + +- `signal_family` + - semantic class of the bounded result + - examples: `overlap_signal`, `feasibility_signal`, `mediation_triage` + +- `acceptable_schema_refs` + - one or more admitted schema identifiers or hashes compatible with the chosen signal family + +- `required_policy_refs` + - policy bundles that must apply to any acceptable agreement + +- `acceptable_profile_refs` + - acceptable reasoning/model profile artefacts + +- `acceptable_program_refs` + - admitted prompt/program artefacts, unless program choice is fully derived from the other fields + +- `bounded_parameters` + - tightly limited negotiable parameters, such as: + - entropy tier + - timing class + - allowed follow-up scope + +- `preference_order` + - ranked ordering over acceptable choices, so negotiation can converge without prose + +- `related_session_context` + - optional prior receipt/session/topic references when negotiation is not starting from zero + +### 3.3 Hard constraints and ranked preferences + +The agreement object should distinguish between: + +- hard constraints +- ranked preferences + +Hard constraints define what is acceptable at all. + +Ranked preferences define how to choose among mutually acceptable options. + +For example, an agent may require: + +- `strict_privacy_mode` +- one of `[overlap_signal_v1, overlap_signal_v2]` + +And prefer: + +- `balanced_reasoning` before `fast_low_compute` +- `overlap_signal_v2` before `overlap_signal_v1` + +This is more precise than loose "preferred" semantics and gives convergence logic a deterministic basis. + +### 3.4 Compatibility validation + +The agreement layer should reject incoherent combinations early. + +At minimum: + +- every `acceptable_schema_ref` must be admitted for the selected `signal_family` +- every `acceptable_program_ref` must be compatible with the selected schema and policy set +- bounded parameters must be valid for the selected signal family and schema + +The preferred way to enforce this is through registry-declared compatibility mappings rather than late compilation failure. + +## 4. Negotiation Message Types + +Negotiation should use a small typed protocol, not free-text bargaining. + +The initial message set should be: + +- `PROPOSE_AGREEMENT` +- `COUNTER_AGREEMENT` +- `ACCEPT_AGREEMENT` +- `REJECT_AGREEMENT` + +Every negotiation envelope should also include: + +- `negotiation_id` +- `round_index` +- `round_budget` + +`round_budget` should be an explicit protocol parameter visible to both sides at negotiation start. A small default such as 3 rounds is likely sufficient for most flows. + +### 4.1 PROPOSE_AGREEMENT + +Sent when one side proposes an initial agreement object. + +Fields: + +- `negotiation_id` +- `proposal_id` +- `round_index` +- `round_budget` +- `agreement` +- `sender` +- `created_at` + +### 4.2 COUNTER_AGREEMENT + +Sent when the counterparty can accept the general direction but needs different admissible choices. + +Fields: + +- `negotiation_id` +- `proposal_id` +- `counterproposal_id` +- `round_index` +- `round_budget` +- `agreement` +- `reason_codes` +- `sender` +- `created_at` + +`reason_codes` should be structured and finite, for example: + +- `INCOMPATIBLE_TERMS` +- `NO_ACCEPTABLE_AGREEMENT` +- `ROUND_BUDGET_EXHAUSTED` + +Reason codes should stay as coarse as the convergence goal allows. They are useful for convergence, but they are also part of the negotiation side channel. + +There is a real tradeoff: + +- **coarser codes** leak less but make convergence harder +- **finer codes** help convergence but reveal more about posture and acceptable terms + +The initial protocol should make this tradeoff explicit. One plausible first model is: + +- agent-visible codes remain coarse +- more specific failure details are visible only to the relay or audit log + +### 4.3 ACCEPT_AGREEMENT + +Sent when one side accepts a fully resolved agreement object. + +Fields: + +- `negotiation_id` +- `proposal_id` +- `round_index` +- `round_budget` +- `resolved_agreement` +- `resolved_agreement_hash` +- `sender` +- `created_at` + +Acceptance must bind a single resolved choice for each execution-relevant dimension. It must not bind only a still-ambiguous set of acceptable options. + +### 4.4 REJECT_AGREEMENT + +Sent when no acceptable convergence exists. + +Fields: + +- `negotiation_id` +- `proposal_id` +- `round_index` +- `round_budget` +- `reason_codes` +- `sender` +- `created_at` + +The reject path should explain failure in structured coarse terms rather than natural language. + +## 5. Compilation Rule to Execution Contract + +Once both sides accept the same resolved agreement object, AgentVault compiles it deterministically into an execution contract. + +The compilation rule should: + +1. resolve the selected schema ref to one concrete schema artefact +2. resolve the selected policy refs to one concrete policy set +3. resolve the selected profile ref +4. resolve or derive one concrete program ref +5. resolve bounded parameters into final concrete values +6. synthesize one canonical execution contract +7. compute the resulting contract hash + +The execution contract should then bind the relay-relevant artefacts, such as: + +- `schema_hash` +- `policy_hash` +- `profile_hash` +- `program_hash` +- concrete bounded parameter values + +This compilation must be deterministic. Two agents holding the same accepted agreement must derive the same execution contract bytes and the same contract hash. + +### 5.1 Standard offers + +Standard offers should be immutable, content-addressed agreement artefacts in the registry. + +For example: + +- `salary_overlap_offer` +- `compatibility_check_offer` +- `mediation_triage_offer` + +Using a standard offer should still produce an agreement object. The difference is only that the object is largely pre-filled rather than negotiated from scratch. + +Referencing a standard offer should be unambiguous and versioned, for example via an offer ref or offer hash. + +## 6. Constraints on the Negotiation Process + +The negotiation protocol itself must be bounded, not just the artefacts. + +Initial constraints should include: + +- a fixed message grammar +- no free-text semantic bargaining +- an explicit small round budget +- explicit reject and counterproposal reason codes +- no exchange of private substantive facts during negotiation +- no unregistered schemas, policies, profiles, or programs + +Negotiation is for formal parameter convergence, not for moving the real coordination problem outside the vault. + +## 7. Worked Examples + +### 7.1 Salary alignment + +Two agents want to determine whether acceptable compensation ranges overlap. + +Agreement shape: + +- `topic_code = salary_alignment` +- `signal_family = overlap_signal` +- `acceptable_schema_refs = [overlap_signal_v1, overlap_signal_v2]` +- `required_policy_refs = [corporate_confidentiality]` +- `acceptable_profile_refs = [balanced_reasoning, conservative_reasoning]` + +If both sides converge on `overlap_signal_v1 + corporate_confidentiality + balanced_reasoning`, the system compiles a concrete execution contract and runs the vault session. + +### 7.2 Ambiguous mediation / compatibility case + +Two agents know they need bounded help on a sensitive coordination problem, but do not initially agree whether the right signal is compatibility assessment or mediation triage. + +Agreement shape: + +- `topic_code = project_scope` +- `signal_family = [compatibility_signal, mediation_triage]` +- `acceptable_schema_refs = [...]` +- `required_policy_refs = [strict_privacy_mode]` + +The negotiation protocol can narrow this to one admissible signal family and one compatible schema without forcing both sides to guess a brittle top-level `purpose_code` upfront. + +### 7.3 Failure at round limit + +Two agents negotiate under `round_budget = 3` and fail to converge on a mutually acceptable resolved agreement. + +The protocol terminates with `REJECT_AGREEMENT` and coarse structured reasons such as: + +- `NO_COMMON_SCHEMA` +- `POLICY_TOO_WEAK` + +The system should report the failure to the principal in a way that does not reveal more than the negotiated protocol already exposed. In the initial model, a safe default is: + +- "No acceptable bounded agreement was reached." + +Richer reporting can be added later, but it should be treated as another disclosure surface. + +## 8. Direction + +The intended direction is: + +- keep standard offers as the default product path +- allow richer structured negotiation when standard offers do not fit +- treat `purpose_code` as a coarse derived label or family tag, not the deepest semantic primitive +- keep the registry as the innovation surface for new capabilities + +This note is a protocol sketch, not yet a normative specification. Its role is to define the target shape of agreement negotiation before individual fields and wire formats are frozen. diff --git a/docs/architecture/negotiation-fixtures.md b/docs/architecture/negotiation-fixtures.md new file mode 100644 index 0000000..da2bb95 --- /dev/null +++ b/docs/architecture/negotiation-fixtures.md @@ -0,0 +1,98 @@ +# Negotiation Fixtures + +> Status: Draft fixture note +> Related: [agentvault-negotiation-protocol.md](agentvault-negotiation-protocol.md), [negotiation-wire-format.md](negotiation-wire-format.md) + +## 1. Purpose + +This note records three lightweight negotiation fixtures that can later become protocol vectors or tests. + +## 2. Fixture A: Convergent Salary Alignment + +### Initial proposal + +- `topic_code = salary_alignment` +- `signal_family = overlap_signal` +- `acceptable_schema_refs = [overlap_signal_v1, overlap_signal_v2]` +- `required_policy_refs = [corporate_confidentiality]` +- `acceptable_profile_refs = [balanced_reasoning, conservative_reasoning]` +- `round_budget = 3` + +### Counterproposal + +Counterparty narrows to: + +- `acceptable_schema_refs = [overlap_signal_v1]` +- `acceptable_profile_refs = [balanced_reasoning]` + +### Acceptance + +Resolved agreement: + +- `schema_ref = overlap_signal_v1` +- `policy_refs = [corporate_confidentiality]` +- `profile_ref = balanced_reasoning` +- `program_ref = overlap_estimator_v2` + +Expected result: + +- agreement accepted within round budget +- deterministic execution contract compiled + +## 3. Fixture B: Incompatible Terms + +### Initial proposal + +- `topic_code = project_scope` +- `signal_family = mediation_triage` +- `required_policy_refs = [strict_privacy_mode]` +- `round_budget = 3` + +### Counterparty response + +Counterparty can only accept: + +- `signal_family = compatibility_signal` +- `required_policy_refs = [corporate_confidentiality]` + +### Expected result + +- no resolved agreement +- protocol terminates with `REJECT_AGREEMENT` +- agent-visible reason codes remain coarse, for example: + - `INCOMPATIBLE_TERMS` + +## 4. Fixture C: Round Budget Exhausted + +### Initial proposal + +- `topic_code = relationship_future` +- `signal_family = compatibility_signal` +- `acceptable_schema_refs = [compatibility_signal_v1, compatibility_signal_v2]` +- `round_budget = 3` + +### Negotiation path + +1. proposal sent +2. counterproposal sent +3. second counterproposal still does not converge on one resolved agreement + +### Expected result + +- negotiation halts when `round_index == round_budget` +- protocol terminates with `REJECT_AGREEMENT` +- principal-visible outcome can safely be: + - "No acceptable bounded agreement was reached." + +## 5. Direction + +These fixtures are intentionally simple. + +Their role is to pressure-test: + +- agreement object shape +- resolved acceptance semantics +- coarse reject behavior +- explicit round-budget behavior + +Once the wire format and registry artefacts stabilize, these can become concrete JSON test vectors. diff --git a/docs/architecture/negotiation-registry-artefacts.md b/docs/architecture/negotiation-registry-artefacts.md new file mode 100644 index 0000000..cb4eac2 --- /dev/null +++ b/docs/architecture/negotiation-registry-artefacts.md @@ -0,0 +1,155 @@ +# Negotiation Registry Artefacts + +> Status: Draft architecture note +> Related: [agentvault-negotiation-protocol.md](agentvault-negotiation-protocol.md), [protocol-spec.md](../protocol-spec.md) + +## 1. Purpose + +This note sketches the registry artefacts needed to support structured bounded-computation negotiation in AgentVault. + +It is intentionally lightweight. The goal is to define the shape of the artefacts and their compatibility links before governance and wire formats are frozen. + +## 2. Artefact Types + +The minimum negotiation registry should contain four artefact types: + +- `signal_family` +- `schema` +- `policy_bundle` +- `standard_offer` + +Model profiles and prompt/program artefacts already exist conceptually in the system and can be referenced directly by negotiation. + +## 3. Signal Family Artefact + +A `signal_family` defines the semantic class of bounded result the session is meant to produce. + +Suggested fields: + +- `signal_family_id` +- `version` +- `semantic_intent` +- `admitted_schema_refs` +- `admitted_program_refs` +- `bounded_parameter_kinds` + +Example families: + +- `overlap_signal` +- `compatibility_signal` +- `mediation_triage` +- `feasibility_signal` + +### 3.1 Role + +`signal_family` is the semantic anchor of negotiation. + +It answers: + +- what kind of bounded computation is this? +- how should the output be interpreted? +- which schemas and programs are even valid choices? + +## 4. Schema Artefact + +A `schema` defines one concrete bounded realization of a signal family. + +Suggested fields: + +- `schema_id` +- `version` +- `schema_hash` +- `signal_family_id` +- `json_schema` +- `entropy_class` +- `output_notes` + +### 4.1 Role + +The schema fixes: + +- output structure +- output field set +- boundedness / entropy shape +- machine validation surface + +Each schema should belong to exactly one signal family. + +## 5. Policy Bundle Artefact + +A `policy_bundle` defines execution and disclosure constraints relevant to negotiation and execution. + +Suggested fields: + +- `policy_bundle_id` +- `version` +- `policy_hash` +- `policy_scope` +- `constraints` +- `compatible_signal_families` (optional) + +Example bundles: + +- `corporate_confidentiality` +- `strict_privacy_mode` +- `relationship_sensitive_mode` + +## 6. Standard Offer Artefact + +A `standard_offer` is a content-addressed pre-composed agreement template. + +Suggested fields: + +- `offer_id` +- `version` +- `offer_hash` +- `topic_code` +- `signal_family` +- `default_schema_ref` +- `required_policy_refs` +- `acceptable_profile_refs` +- `program_ref` or derivation rule +- `default_bounded_parameters` + +### 6.1 Role + +A standard offer gives agents a simple default path. + +Most sessions should start from a standard offer and only fall back to richer negotiation when the standard offer does not fit. + +## 7. Compatibility Rules + +Compatibility should be declared in the registry, not inferred ad hoc at runtime. + +Minimum compatibility rules: + +- a `signal_family` declares its admitted schemas +- a `signal_family` declares its admitted programs, unless programs are fully derived +- a `schema` belongs to exactly one `signal_family` +- a `standard_offer` references only compatible artefacts +- bounded parameter kinds must be admitted by the selected `signal_family` + +This allows incoherent combinations to fail early, before execution contract compilation. + +## 8. Governance Surface + +These compatibility mappings are also a governance surface. + +Adding a new schema to a signal family or a new standard offer to the registry changes what negotiations can successfully produce. + +This note does not define governance policy, but the system will eventually need a clear ownership and review model for: + +- who may define new signal families +- who may attach schemas/programs/policies to them +- how compatibility mappings are versioned and reviewed + +## 9. Direction + +The intended shape is: + +- signal families define semantic classes +- schemas define concrete bounded realizations +- policy bundles define execution constraints +- standard offers define default pre-composed agreement templates + +These artefacts should all be content-addressed or otherwise unambiguously versioned, so that negotiation and execution remain machine-verifiable. diff --git a/docs/architecture/negotiation-wire-format.md b/docs/architecture/negotiation-wire-format.md new file mode 100644 index 0000000..646a229 --- /dev/null +++ b/docs/architecture/negotiation-wire-format.md @@ -0,0 +1,146 @@ +# Negotiation Wire Format + +> Status: Draft wire-format sketch +> Related: [agentvault-negotiation-protocol.md](agentvault-negotiation-protocol.md), [negotiation-registry-artefacts.md](negotiation-registry-artefacts.md) + +## 1. Purpose + +This note sketches an initial JSON wire format for structured bounded-computation negotiation in AgentVault. + +It is intentionally draft and should not yet be treated as normative. + +## 2. Envelope Shape + +Every negotiation message should share a common envelope: + +```json +{ + "version": "AV-NEGOTIATE-V1", + "negotiation_id": "uuid", + "message_type": "PROPOSE_AGREEMENT", + "proposal_id": "uuid", + "round_index": 1, + "round_budget": 3, + "sender": "alice", + "created_at": "2026-03-11T16:00:00Z", + "body": {} +} +``` + +Common fields: + +- `version` +- `negotiation_id` +- `message_type` +- `proposal_id` +- `round_index` +- `round_budget` +- `sender` +- `created_at` +- `body` + +## 3. Agreement Shape + +The draft agreement object should look like: + +```json +{ + "topic_code": "salary_alignment", + "signal_family": "overlap_signal", + "acceptable_schema_refs": [ + "schema:overlap_signal_v1", + "schema:overlap_signal_v2" + ], + "required_policy_refs": [ + "policy:corporate_confidentiality" + ], + "acceptable_profile_refs": [ + "profile:balanced_reasoning", + "profile:conservative_reasoning" + ], + "acceptable_program_refs": [ + "program:overlap_estimator_v2" + ], + "bounded_parameters": { + "entropy_tier": ["E8", "E12"] + }, + "preference_order": { + "schema_refs": [ + "schema:overlap_signal_v2", + "schema:overlap_signal_v1" + ], + "profile_refs": [ + "profile:balanced_reasoning", + "profile:conservative_reasoning" + ] + } +} +``` + +## 4. Message Bodies + +### 4.1 PROPOSE_AGREEMENT + +```json +{ + "agreement": { "...": "..." } +} +``` + +### 4.2 COUNTER_AGREEMENT + +```json +{ + "agreement": { "...": "..." }, + "reason_codes": ["INCOMPATIBLE_TERMS"] +} +``` + +The initial wire format should bias toward coarse agent-visible reason codes. + +More specific diagnostics can be relay-visible without being counterpart-visible. + +### 4.3 ACCEPT_AGREEMENT + +```json +{ + "resolved_agreement": { + "topic_code": "salary_alignment", + "signal_family": "overlap_signal", + "schema_ref": "schema:overlap_signal_v1", + "policy_refs": ["policy:corporate_confidentiality"], + "profile_ref": "profile:balanced_reasoning", + "program_ref": "program:overlap_estimator_v2", + "bounded_parameters": { + "entropy_tier": "E8" + } + }, + "resolved_agreement_hash": "64hex" +} +``` + +`resolved_agreement` must contain one concrete selection for every execution-relevant dimension. + +### 4.4 REJECT_AGREEMENT + +```json +{ + "reason_codes": ["NO_ACCEPTABLE_AGREEMENT"] +} +``` + +## 5. Direction + +This format is intended to stay: + +- small +- typed +- deterministic +- compatible with future signing and hashing rules + +The next stage would be to freeze: + +- exact field requirements +- canonicalization rules +- signature rules +- the allowed reason code set From 98dd2e8746978b15e010160b87ea6253ae7987a3 Mon Sep 17 00:00:00 2001 From: Toby Kershaw Date: Wed, 11 Mar 2026 17:31:19 +0000 Subject: [PATCH 3/5] feat: add first IFC messaging slice --- .../src/__tests__/ifc-e2e.test.ts | 135 ++++++ .../src/__tests__/ifc.test.ts | 172 +++++++ .../src/__tests__/tool-registry.test.ts | 25 + .../agentvault-mcp-server/src/a2a-messages.ts | 4 + .../src/afal-http-server.ts | 54 +++ .../agentvault-mcp-server/src/afal-signing.ts | 2 + .../src/direct-afal-transport.ts | 13 + .../agentvault-mcp-server/src/dispatch.ts | 16 +- packages/agentvault-mcp-server/src/ifc.ts | 446 ++++++++++++++++++ packages/agentvault-mcp-server/src/index.ts | 36 +- .../src/tool-registry.ts | 49 +- .../agentvault-mcp-server/src/toolDefs.ts | 71 +++ .../src/tools/create-ifc-grant.ts | 19 + .../src/tools/getIdentity.ts | 12 + .../src/tools/read-ifc-messages.ts | 19 + .../src/tools/send-ifc-message.ts | 19 + 16 files changed, 1079 insertions(+), 13 deletions(-) create mode 100644 packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts create mode 100644 packages/agentvault-mcp-server/src/__tests__/ifc.test.ts create mode 100644 packages/agentvault-mcp-server/src/ifc.ts create mode 100644 packages/agentvault-mcp-server/src/tools/create-ifc-grant.ts create mode 100644 packages/agentvault-mcp-server/src/tools/read-ifc-messages.ts create mode 100644 packages/agentvault-mcp-server/src/tools/send-ifc-message.ts diff --git a/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts b/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts new file mode 100644 index 0000000..71922fd --- /dev/null +++ b/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { ed25519 } from '@noble/curves/ed25519'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +import { DirectAfalTransport, type AgentDescriptor } from '../direct-afal-transport.js'; +import { signMessage, DOMAIN_PREFIXES } from '../afal-signing.js'; +import type { AdmissionPolicy } from '../afal-responder.js'; +import { IfcService } from '../ifc.js'; +import { createToolRegistry } from '../tool-registry.js'; + +const ALICE_SEED = '0101010101010101010101010101010101010101010101010101010101010101'; +const BOB_SEED = '0202020202020202020202020202020202020202020202020202020202020202'; +const ALICE_PUB = bytesToHex(ed25519.getPublicKey(hexToBytes(ALICE_SEED))); +const BOB_PUB = bytesToHex(ed25519.getPublicKey(hexToBytes(BOB_SEED))); + +function makeDescriptor( + agentId: string, + pubkeyHex: string, + seedHex: string, + port: number, +): AgentDescriptor { + const unsigned = { + descriptor_version: '1', + agent_id: agentId, + issued_at: '2026-01-01T00:00:00Z', + expires_at: '2099-12-31T23:59:59Z', + identity_key: { algorithm: 'ed25519', public_key_hex: pubkeyHex }, + envelope_key: { algorithm: 'ed25519', public_key_hex: pubkeyHex }, + endpoints: { + propose: `http://127.0.0.1:${port}/afal/propose`, + commit: `http://127.0.0.1:${port}/afal/commit`, + negotiate: `http://127.0.0.1:${port}/afal/negotiate`, + }, + capabilities: {}, + policy_commitments: {}, + }; + return signMessage(DOMAIN_PREFIXES.DESCRIPTOR, unsigned, seedHex) as unknown as AgentDescriptor; +} + +const policy: AdmissionPolicy = { + trustedAgents: [], + allowedPurposeCodes: ['MEDIATION', 'COMPATIBILITY'], + allowedLaneIds: ['API_MEDIATED'], + maxEntropyBits: 32, + defaultTier: 'LOW_TRUST', +}; + +describe('IFC first slice e2e', () => { + const transports: DirectAfalTransport[] = []; + + afterEach(async () => { + await Promise.all(transports.map((t) => t.stop())); + transports.length = 0; + }); + + it('sends a post-session logistics message over A2A and exposes it via read_ifc_messages', async () => { + const bobTransport = new DirectAfalTransport({ + agentId: 'bob-test', + seedHex: BOB_SEED, + localDescriptor: makeDescriptor('bob-test', BOB_PUB, BOB_SEED, 0), + respondMode: { + httpPort: 0, + policy, + }, + }); + transports.push(bobTransport); + await bobTransport.start(); + + const bobUrl = bobTransport.a2aSendMessageUrl; + expect(bobUrl).toBeTruthy(); + + const aliceTransport = new DirectAfalTransport({ + agentId: 'alice-test', + seedHex: ALICE_SEED, + localDescriptor: makeDescriptor('alice-test', ALICE_PUB, ALICE_SEED, 0), + respondMode: { + httpPort: 0, + policy, + }, + }); + transports.push(aliceTransport); + + const bobIfc = new IfcService({ + agentId: 'bob-test', + seedHex: BOB_SEED, + verifyingKeyHex: BOB_PUB, + knownAgents: [], + }); + bobTransport.setIfcService(bobIfc); + + const aliceRegistry = createToolRegistry({ + transport: aliceTransport, + knownAgents: [{ agent_id: 'bob-test', aliases: ['Bob'], a2a_send_message_url: bobUrl ?? undefined }], + ifcSeedHex: ALICE_SEED, + }); + const bobRegistry = createToolRegistry({ + transport: bobTransport, + knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'] }], + ifcService: bobIfc, + }); + + const grantResult = await aliceRegistry.handleCreateIfcGrant({ + audience: 'bob-test', + receipt_id: 'd'.repeat(64), + session_id: '44444444-4444-4444-4444-444444444444', + message_classes: ['LOGISTICS'], + max_uses: 1, + expires_in_seconds: 60, + }); + expect(grantResult.ok).toBe(true); + + const sendResult = await aliceRegistry.handleSendIfcMessage({ + counterparty: 'bob-test', + grant: (grantResult.data as { grant: unknown }).grant as never, + message_class: 'LOGISTICS', + payload: 'Meet at 10:30 UTC tomorrow.', + related_receipt_id: 'd'.repeat(64), + related_session_id: '44444444-4444-4444-4444-444444444444', + }); + expect(sendResult.ok).toBe(true); + expect((sendResult.data as { decision: string }).decision).toBe('ALLOW'); + + const identityBefore = await bobRegistry.handleGetIdentity(); + expect(identityBefore.data?.pending_ifc_messages).toBe(1); + + const readResult = await bobRegistry.handleReadIfcMessages({}); + expect(readResult.ok).toBe(true); + const messages = (readResult.data as { messages: Array> }).messages; + expect(messages).toHaveLength(1); + expect(messages[0]?.['payload']).toBe('Meet at 10:30 UTC tomorrow.'); + + const identityAfter = await bobRegistry.handleGetIdentity(); + expect(identityAfter.data?.pending_ifc_messages).toBe(0); + }); +}); diff --git a/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts b/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts new file mode 100644 index 0000000..ed3b09b --- /dev/null +++ b/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { ed25519 } from '@noble/curves/ed25519'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +import { contentHash, DOMAIN_PREFIXES, signMessage } from '../afal-signing.js'; +import { IfcService, type IfcEnvelope, type IfcGrant } from '../ifc.js'; + +const ALICE_SEED = '0101010101010101010101010101010101010101010101010101010101010101'; +const ALICE_PUB = bytesToHex(ed25519.getPublicKey(hexToBytes(ALICE_SEED))); + +function createService(agentId = 'alice-test') { + return new IfcService({ + agentId, + seedHex: ALICE_SEED, + verifyingKeyHex: ALICE_PUB, + knownAgents: [], + }); +} + +describe('IfcService', () => { + it('creates and verifies a valid grant', () => { + const service = createService(); + const result = service.createGrant({ + audience: 'bob-test', + receipt_id: 'a'.repeat(64), + session_id: '11111111-1111-1111-1111-111111111111', + message_classes: ['LOGISTICS', 'CONSENT'], + max_uses: 2, + expires_in_seconds: 60, + }); + + expect(result.grant_id).toHaveLength(64); + expect(result.grant.scope.message_classes).toEqual(['LOGISTICS', 'CONSENT']); + expect(() => service.verifyGrant(result.grant)).not.toThrow(); + }); + + it('rejects an expired grant', () => { + const service = createService(); + const result = service.createGrant({ + audience: 'bob-test', + receipt_id: 'a'.repeat(64), + session_id: '11111111-1111-1111-1111-111111111111', + message_classes: ['LOGISTICS'], + max_uses: 1, + expires_in_seconds: 60, + }); + const { signature: _sig, grant_id: _grantId, ...unsigned } = result.grant; + const expired = signMessage( + DOMAIN_PREFIXES.IFC_GRANT, + { + ...unsigned, + expires_at: '2000-01-01T00:00:00.000Z', + grant_id: contentHash({ + ...unsigned, + expires_at: '2000-01-01T00:00:00.000Z', + }), + }, + ALICE_SEED, + ) as IfcGrant; + + expect(() => service.verifyGrant(expired)).toThrow('grant expired'); + }); + + it('returns HIDE for ARTIFACT_TRANSFER and stores a hidden reference', () => { + const alice = createService('alice-test'); + const bob = new IfcService({ + agentId: 'bob-test', + seedHex: '0202020202020202020202020202020202020202020202020202020202020202', + verifyingKeyHex: bytesToHex( + ed25519.getPublicKey( + hexToBytes('0202020202020202020202020202020202020202020202020202020202020202'), + ), + ), + knownAgents: [], + }); + + const { grant } = alice.createGrant({ + audience: 'bob-test', + receipt_id: 'b'.repeat(64), + session_id: '22222222-2222-2222-2222-222222222222', + message_classes: ['ARTIFACT_TRANSFER'], + max_uses: 1, + expires_in_seconds: 60, + }); + + const envelope = signMessage( + DOMAIN_PREFIXES.IFC_ENVELOPE, + { + version: 'AV-IFC-MSG-V1', + message_id: '33333333-3333-3333-3333-333333333333', + created_at: new Date().toISOString(), + sender: 'alice-test', + recipient: 'bob-test', + message_class: 'ARTIFACT_TRANSFER', + session_relation: 'POST_SESSION', + payload: 'artifact-pointer', + related_receipt_id: 'b'.repeat(64), + related_session_id: '22222222-2222-2222-2222-222222222222', + grant_id: grant.grant_id, + ifc_policy_hash: 'c'.repeat(64), + label_receipt: { + policy_version: 'POST_SESSION_V1', + message_class: 'ARTIFACT_TRANSFER', + session_relation: 'POST_SESSION', + }, + }, + ALICE_SEED, + ) as IfcEnvelope; + + const delivery = bob.receiveEnvelope({ + grant, + envelope, + }); + + expect(delivery.decision).toBe('HIDE'); + expect(delivery.hidden_variable_id).toMatch(/^ifc_var_/); + + const readResult = bob.readMessages(); + expect(readResult.messages).toHaveLength(1); + expect(readResult.messages[0]?.['decision']).toBe('HIDE'); + expect(readResult.messages[0]?.['hidden_variable_id']).toMatch(/^ifc_var_/); + }); + + it('blocks an envelope with an invalid signature', () => { + const alice = createService('alice-test'); + const bob = new IfcService({ + agentId: 'bob-test', + seedHex: '0202020202020202020202020202020202020202020202020202020202020202', + verifyingKeyHex: bytesToHex( + ed25519.getPublicKey( + hexToBytes('0202020202020202020202020202020202020202020202020202020202020202'), + ), + ), + knownAgents: [], + }); + + const { grant } = alice.createGrant({ + audience: 'bob-test', + receipt_id: 'b'.repeat(64), + session_id: '22222222-2222-2222-2222-222222222222', + message_classes: ['LOGISTICS'], + max_uses: 1, + expires_in_seconds: 60, + }); + + const delivery = bob.receiveEnvelope({ + grant, + envelope: { + version: 'AV-IFC-MSG-V1', + message_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-03-11T18:00:00.000Z', + sender: 'alice-test', + recipient: 'bob-test', + message_class: 'LOGISTICS', + session_relation: 'POST_SESSION', + payload: 'Meet at 10:30 UTC tomorrow.', + related_receipt_id: 'b'.repeat(64), + related_session_id: '22222222-2222-2222-2222-222222222222', + grant_id: grant.grant_id, + ifc_policy_hash: 'c'.repeat(64), + label_receipt: { + policy_version: 'POST_SESSION_V1', + message_class: 'LOGISTICS', + session_relation: 'POST_SESSION', + }, + signature: '', + } as IfcEnvelope, + }); + + expect(delivery.decision).toBe('BLOCK'); + }); +}); diff --git a/packages/agentvault-mcp-server/src/__tests__/tool-registry.test.ts b/packages/agentvault-mcp-server/src/__tests__/tool-registry.test.ts index 9a5033e..ba2b9c0 100644 --- a/packages/agentvault-mcp-server/src/__tests__/tool-registry.test.ts +++ b/packages/agentvault-mcp-server/src/__tests__/tool-registry.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createToolRegistry, getToolDefs } from '../tool-registry.js'; import type { AfalTransport, AfalInviteMessage } from '../afal-transport.js'; +import { IfcService } from '../ifc.js'; import { _setDiscoverPollConfigForTesting } from '../tools/relaySignal.js'; import { _resetHandlesForTesting } from '../tools/relayHandles.js'; @@ -66,6 +67,9 @@ describe('getToolDefs', () => { const names = defs.map((d) => d.name); expect(names).toContain('agentvault.get_identity'); expect(names).toContain('agentvault.relay_signal'); + expect(names).toContain('agentvault.create_ifc_grant'); + expect(names).toContain('agentvault.send_ifc_message'); + expect(names).toContain('agentvault.read_ifc_messages'); }); it('each tool def has name, description, and inputSchema', () => { @@ -177,4 +181,25 @@ describe('createToolRegistry', () => { expect(() => registry.dispatch('unknown.tool', {})).toThrow('Unknown tool: unknown.tool'); }); + + it('handleGetIdentity includes pending IFC messages when configured', async () => { + const transport = createMockTransport(); + const ifcService = new IfcService({ + agentId: 'test-agent', + seedHex: '0101010101010101010101010101010101010101010101010101010101010101', + verifyingKeyHex: + '8a88e3dd7409f195fd52db2d3cba5d72ca670bf1d94121bf3748801b40f6f5c2', + knownAgents: [], + }); + vi.spyOn(ifcService, 'pendingCount').mockReturnValue(2); + const registry = createToolRegistry({ + transport, + knownAgents: [], + ifcService, + }); + + const identity = await registry.handleGetIdentity(); + expect(identity.data?.pending_ifc_messages).toBe(2); + expect(identity.data?.next_action?.tool).toBe('agentvault.read_ifc_messages'); + }); }); diff --git a/packages/agentvault-mcp-server/src/a2a-messages.ts b/packages/agentvault-mcp-server/src/a2a-messages.ts index 0f38354..6f8b2c0 100644 --- a/packages/agentvault-mcp-server/src/a2a-messages.ts +++ b/packages/agentvault-mcp-server/src/a2a-messages.ts @@ -16,6 +16,10 @@ export const AGENTVAULT_TOPIC_ALIGNMENT_PROPOSAL_MEDIA_TYPE = 'application/vnd.agentvault.topic-alignment-proposal+json'; export const AGENTVAULT_TOPIC_ALIGNMENT_SELECTION_MEDIA_TYPE = 'application/vnd.agentvault.topic-alignment-selection+json'; +export const AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE = + 'application/vnd.agentvault.ifc-envelope+json'; +export const AGENTVAULT_IFC_RESULT_MEDIA_TYPE = + 'application/vnd.agentvault.ifc-result+json'; interface A2AMessagePart { data: unknown; diff --git a/packages/agentvault-mcp-server/src/afal-http-server.ts b/packages/agentvault-mcp-server/src/afal-http-server.ts index 4635557..6f9a57c 100644 --- a/packages/agentvault-mcp-server/src/afal-http-server.ts +++ b/packages/agentvault-mcp-server/src/afal-http-server.ts @@ -19,6 +19,7 @@ import { createServer } from 'node:http'; import type { Server, IncomingMessage, ServerResponse } from 'node:http'; import type { AfalResponder } from './afal-responder.js'; import type { AgentDescriptor } from './direct-afal-transport.js'; +import type { IfcService } from './ifc.js'; import { buildAgentCard } from './a2a-agent-card.js'; import { A2A_SEND_MESSAGE_PATH, @@ -26,6 +27,8 @@ import { AGENTVAULT_CONTRACT_OFFER_PROPOSAL_MEDIA_TYPE, AGENTVAULT_CONTRACT_OFFER_SELECTION_MEDIA_TYPE, AGENTVAULT_DENY_MEDIA_TYPE, + AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE, + AGENTVAULT_IFC_RESULT_MEDIA_TYPE, AGENTVAULT_PROPOSE_MEDIA_TYPE, AGENTVAULT_SESSION_TOKENS_MEDIA_TYPE, AGENTVAULT_TOPIC_ALIGNMENT_PROPOSAL_MEDIA_TYPE, @@ -70,6 +73,7 @@ export interface AfalHttpServerConfig { supportedPurposes?: string[]; advertiseAfalEndpoint?: boolean; seedHex?: string; + ifcService?: IfcService; } export class AfalHttpServer { @@ -79,10 +83,12 @@ export class AfalHttpServer { private _actualPort: number | null = null; private _localDescriptor: AgentDescriptor; private readonly _inFlightTasks = new Map(); + private ifcService?: IfcService; constructor(config: AfalHttpServerConfig) { this.config = config; this._localDescriptor = config.localDescriptor; + this.ifcService = config.ifcService; } get port(): number { @@ -99,6 +105,10 @@ export class AfalHttpServer { this._localDescriptor = descriptor; } + setIfcService(ifcService: IfcService): void { + this.ifcService = ifcService; + } + /** Remove expired in-flight task entries. Called on each A2A request. */ private gcInFlightTasks(): void { const now = Date.now(); @@ -276,6 +286,7 @@ export class AfalHttpServer { AGENTVAULT_SESSION_TOKENS_MEDIA_TYPE, AGENTVAULT_CONTRACT_OFFER_PROPOSAL_MEDIA_TYPE, AGENTVAULT_TOPIC_ALIGNMENT_PROPOSAL_MEDIA_TYPE, + AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE, ]); if (!parsed) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -370,6 +381,49 @@ export class AfalHttpServer { ), ); } + } else if (parsed.mediaType === AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE) { + if (!this.ifcService) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + buildA2ATaskResponse({ + mediaType: AGENTVAULT_IFC_RESULT_MEDIA_TYPE, + data: { + decision: 'BLOCK', + error: 'IFC service is not configured', + }, + taskId: parsed.taskId, + state: 'completed', + }), + ), + ); + } else if ( + !parsed.data || + typeof parsed.data !== 'object' || + !('envelope' in (parsed.data as Record)) || + !('grant' in (parsed.data as Record)) + ) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid IFC envelope body' })); + } else { + const result = this.ifcService.receiveEnvelope( + parsed.data as { + envelope: import('./ifc.js').IfcEnvelope; + grant: import('./ifc.js').IfcGrant; + }, + ); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + buildA2ATaskResponse({ + mediaType: AGENTVAULT_IFC_RESULT_MEDIA_TYPE, + data: result, + taskId: parsed.taskId, + state: 'completed', + }), + ), + ); + } } else { // session-tokens (COMMIT) — completes the lifecycle // Enforce task correlation: if client sends a task_id, it must match diff --git a/packages/agentvault-mcp-server/src/afal-signing.ts b/packages/agentvault-mcp-server/src/afal-signing.ts index 8c9994f..ff22a9f 100644 --- a/packages/agentvault-mcp-server/src/afal-signing.ts +++ b/packages/agentvault-mcp-server/src/afal-signing.ts @@ -27,6 +27,8 @@ export const DOMAIN_PREFIXES = { MESSAGE: 'VCAV-MESSAGE-V1:', REQUEST: 'VCAV-REQUEST-V1:', AGENT_CARD: 'VCAV-AGENT-CARD-V1:', + IFC_GRANT: 'VCAV-IFC-GRANT-V1:', + IFC_ENVELOPE: 'VCAV-IFC-ENVELOPE-V1:', } as const; export type DomainPrefix = (typeof DOMAIN_PREFIXES)[keyof typeof DOMAIN_PREFIXES]; diff --git a/packages/agentvault-mcp-server/src/direct-afal-transport.ts b/packages/agentvault-mcp-server/src/direct-afal-transport.ts index 7347da8..9a9e7c8 100644 --- a/packages/agentvault-mcp-server/src/direct-afal-transport.ts +++ b/packages/agentvault-mcp-server/src/direct-afal-transport.ts @@ -18,6 +18,7 @@ import { AfalHttpServer } from './afal-http-server.js'; import { AGENTVAULT_A2A_EXTENSION_URI, verifyAgentCardSignature } from './a2a-agent-card.js'; import type { AgentVaultA2AExtensionParams } from './a2a-agent-card.js'; import type { ModelProfileRef } from './model-profiles.js'; +import type { IfcService } from './ifc.js'; import { A2A_SEND_MESSAGE_PATH, AGENTVAULT_ADMIT_MEDIA_TYPE, @@ -201,6 +202,18 @@ export class DirectAfalTransport implements AfalTransport { return this.config.agentId; } + get ifcSeedHex(): string { + return this.config.seedHex; + } + + get a2aSendMessageUrl(): string | null { + return this.httpServer ? `${this.httpServer.baseUrl}${A2A_SEND_MESSAGE_PATH}` : null; + } + + setIfcService(ifcService: IfcService): void { + this.httpServer?.setIfcService(ifcService); + } + async start(): Promise { if (!this.httpServer) return; diff --git a/packages/agentvault-mcp-server/src/dispatch.ts b/packages/agentvault-mcp-server/src/dispatch.ts index 372a31b..9ac0c46 100644 --- a/packages/agentvault-mcp-server/src/dispatch.ts +++ b/packages/agentvault-mcp-server/src/dispatch.ts @@ -5,6 +5,7 @@ */ import type { AfalTransport } from './afal-transport.js'; +import type { IfcService } from './ifc.js'; import type { NormalizedKnownAgent } from './tools/relaySignal.js'; export async function dispatch( @@ -13,11 +14,12 @@ export async function dispatch( transport?: AfalTransport, knownAgents: NormalizedKnownAgent[] = [], agentId?: string, + ifcService?: IfcService, ): Promise { switch (toolName) { case 'agentvault.get_identity': { const { handleGetIdentity } = await import('./tools/getIdentity.js'); - return handleGetIdentity(agentId, knownAgents, transport); + return handleGetIdentity(agentId, knownAgents, transport, ifcService?.pendingCount() ?? 0); } case 'agentvault.relay_signal': { const { handleRelaySignal } = await import('./tools/relaySignal.js'); @@ -31,6 +33,18 @@ export async function dispatch( const { handleVerifyReceipt } = await import('./tools/verify-receipt.js'); return handleVerifyReceipt(args as unknown as Parameters[0]); } + case 'agentvault.create_ifc_grant': { + const { handleCreateIfcGrant } = await import('./tools/create-ifc-grant.js'); + return handleCreateIfcGrant(args as unknown as Parameters[0], ifcService); + } + case 'agentvault.send_ifc_message': { + const { handleSendIfcMessage } = await import('./tools/send-ifc-message.js'); + return handleSendIfcMessage(args as unknown as Parameters[0], ifcService); + } + case 'agentvault.read_ifc_messages': { + const { handleReadIfcMessages } = await import('./tools/read-ifc-messages.js'); + return handleReadIfcMessages(args as Parameters[0], ifcService); + } default: throw new Error(`Unknown tool: ${toolName}`); } diff --git a/packages/agentvault-mcp-server/src/ifc.ts b/packages/agentvault-mcp-server/src/ifc.ts new file mode 100644 index 0000000..3eab1ed --- /dev/null +++ b/packages/agentvault-mcp-server/src/ifc.ts @@ -0,0 +1,446 @@ +import { randomUUID } from 'node:crypto'; + +import { contentHash, DOMAIN_PREFIXES, signMessage, verifyMessage } from './afal-signing.js'; +import { + AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE, + AGENTVAULT_IFC_RESULT_MEDIA_TYPE, + buildA2ASendMessageRequest, + parseA2ATaskPart, +} from './a2a-messages.js'; +import type { NormalizedKnownAgent } from './tools/relaySignal.js'; + +export type IfcMessageClass = 'LOGISTICS' | 'CONSENT' | 'REFERENCE' | 'ARTIFACT_TRANSFER'; +export type IfcSessionRelation = 'POST_SESSION'; +export type IfcDecision = 'ALLOW' | 'HIDE' | 'ESCALATE' | 'BLOCK'; + +export interface IfcKnownAgent extends NormalizedKnownAgent { + a2a_send_message_url?: string; +} + +export interface IfcGrantScope { + message_classes: IfcMessageClass[]; + session_relation: IfcSessionRelation; +} + +export interface IfcGrantPermissions { + max_uses: number; +} + +export interface IfcGrantProvenance { + receipt_id: string; + session_id: string; +} + +export interface IfcGrantUnsigned { + version: 'AV-IFC-GRANT-V1'; + issuer: string; + issuer_public_key_hex: string; + audience: string; + label_ceiling: 'POST_SESSION_BOUND'; + scope: IfcGrantScope; + permissions: IfcGrantPermissions; + provenance: IfcGrantProvenance; + issued_at: string; + expires_at: string; +} + +export interface IfcGrant extends IfcGrantUnsigned { + grant_id: string; + signature: string; +} + +export interface IfcEnvelopeUnsigned { + version: 'AV-IFC-MSG-V1'; + message_id: string; + created_at: string; + sender: string; + recipient: string; + message_class: IfcMessageClass; + session_relation: IfcSessionRelation; + payload: string; + related_receipt_id: string; + related_session_id: string; + grant_id: string; + ifc_policy_hash: string; + label_receipt: { + policy_version: 'POST_SESSION_V1'; + message_class: IfcMessageClass; + session_relation: IfcSessionRelation; + }; +} + +export interface IfcEnvelope extends IfcEnvelopeUnsigned { + signature: string; +} + +export interface IfcEscalationStub { + recommended_topic_code: 'post_session_follow_up'; + recommended_signal_family: 'session_follow_up'; + recommended_policy_constraints: ['POST_SESSION_ONLY']; + reason_code: 'REFERENCE_NEEDS_SESSION'; + source_message_id: string; + grant_context: { + grant_id: string; + related_receipt_id: string; + related_session_id: string; + }; +} + +export interface IfcStoredMessage { + message_id: string; + sender: string; + recipient: string; + message_class: IfcMessageClass; + decision: IfcDecision; + related_receipt_id: string; + related_session_id: string; + created_at: string; + payload?: string; + hidden_variable_id?: string; + escalation_stub?: IfcEscalationStub; + read: boolean; +} + +export interface CreateIfcGrantArgs { + audience: string; + receipt_id: string; + session_id: string; + message_classes: IfcMessageClass[]; + max_uses: number; + expires_in_seconds: number; +} + +export interface SendIfcMessageArgs { + counterparty: string; + grant: IfcGrant; + message_class: IfcMessageClass; + payload: string; + related_receipt_id: string; + related_session_id: string; +} + +export interface ReadIfcMessagesArgs { + limit?: number; +} + +export interface IfcDeliveryResult { + decision: IfcDecision; + message_id: string; + related_receipt_id: string; + related_session_id: string; + hidden_variable_id?: string; + escalation_stub?: IfcEscalationStub; + error?: string; +} + +interface ReceivedGrantState { + grant: IfcGrant; + uses: number; +} + +function isHex(value: string, len: number): boolean { + return value.length === len && /^[0-9a-f]+$/.test(value); +} + +function assertUuidLower(value: string, field: string): void { + const parts = value.split('-'); + if ( + parts.length !== 5 || + parts[0].length !== 8 || + parts[1].length !== 4 || + parts[2].length !== 4 || + parts[3].length !== 4 || + parts[4].length !== 12 || + !parts.every((p) => /^[0-9a-f]+$/.test(p)) + ) { + throw new Error(`${field} must be a lowercase UUID`); + } +} + +function assertReceiptId(value: string): void { + if (!isHex(value, 64)) throw new Error('receipt_id must be 64 lowercase hex characters'); +} + +function assertMessageClass(value: string): asserts value is IfcMessageClass { + if (!['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'].includes(value)) { + throw new Error(`unsupported message_class: ${value}`); + } +} + +function classifyDecision(messageClass: IfcMessageClass): Exclude { + switch (messageClass) { + case 'LOGISTICS': + case 'CONSENT': + return 'ALLOW'; + case 'ARTIFACT_TRANSFER': + return 'HIDE'; + case 'REFERENCE': + return 'ESCALATE'; + } +} + +function aliasesContain(agent: IfcKnownAgent, hint: string): boolean { + const lowered = hint.toLowerCase(); + return agent.agent_id.toLowerCase() === lowered || agent.aliases.some((a) => a.toLowerCase() === lowered); +} + +export class IfcService { + private readonly agentId: string; + private readonly seedHex: string; + private readonly verifyingKeyHex: string; + private knownAgents: IfcKnownAgent[]; + private readonly receivedGrantUses = new Map(); + private readonly inbox: IfcStoredMessage[] = []; + private hiddenCounter = 0; + + constructor(params: { + agentId: string; + seedHex: string; + knownAgents?: IfcKnownAgent[]; + verifyingKeyHex: string; + }) { + this.agentId = params.agentId; + this.seedHex = params.seedHex; + this.knownAgents = params.knownAgents ?? []; + this.verifyingKeyHex = params.verifyingKeyHex; + } + + setKnownAgents(knownAgents: IfcKnownAgent[]): void { + this.knownAgents = knownAgents; + } + + pendingCount(): number { + return this.inbox.filter((m) => !m.read).length; + } + + createGrant(args: CreateIfcGrantArgs): { grant: IfcGrant; grant_id: string; expires_at: string; scope: IfcGrantScope } { + assertReceiptId(args.receipt_id); + assertUuidLower(args.session_id, 'session_id'); + if (args.max_uses < 1 || args.max_uses > 100) { + throw new Error('max_uses must be between 1 and 100'); + } + if (args.expires_in_seconds < 1 || args.expires_in_seconds > 86400) { + throw new Error('expires_in_seconds must be between 1 and 86400'); + } + if (args.message_classes.length < 1) { + throw new Error('message_classes must contain at least one value'); + } + const deduped = [...new Set(args.message_classes)]; + deduped.forEach((value) => assertMessageClass(value)); + + const now = new Date(); + const unsigned: IfcGrantUnsigned = { + version: 'AV-IFC-GRANT-V1', + issuer: this.agentId, + issuer_public_key_hex: this.verifyingKeyHex, + audience: args.audience, + label_ceiling: 'POST_SESSION_BOUND', + scope: { + message_classes: deduped, + session_relation: 'POST_SESSION', + }, + permissions: { + max_uses: args.max_uses, + }, + provenance: { + receipt_id: args.receipt_id, + session_id: args.session_id, + }, + issued_at: now.toISOString(), + expires_at: new Date(now.getTime() + args.expires_in_seconds * 1000).toISOString(), + }; + const grant_id = contentHash(unsigned); + const signed = signMessage( + DOMAIN_PREFIXES.IFC_GRANT, + { ...unsigned, grant_id }, + this.seedHex, + ) as IfcGrant; + return { + grant: signed, + grant_id, + expires_at: signed.expires_at, + scope: signed.scope, + }; + } + + verifyGrant(grant: IfcGrant): void { + assertReceiptId(grant.provenance.receipt_id); + assertUuidLower(grant.provenance.session_id, 'session_id'); + const { signature: _sig, grant_id, ...unsigned } = grant; + const recomputed = contentHash(unsigned); + if (recomputed !== grant_id) throw new Error('grant_id mismatch'); + if (!verifyMessage(DOMAIN_PREFIXES.IFC_GRANT, grant as unknown as Record, grant.issuer_public_key_hex)) { + throw new Error('grant signature verification failed'); + } + if (new Date(grant.expires_at).getTime() < Date.now()) throw new Error('grant expired'); + } + + async sendMessage(args: SendIfcMessageArgs): Promise { + assertMessageClass(args.message_class); + assertReceiptId(args.related_receipt_id); + assertUuidLower(args.related_session_id, 'related_session_id'); + this.verifyGrant(args.grant); + if (args.grant.audience !== args.counterparty && !this.knownAgents.find((a) => aliasesContain(a, args.counterparty) && a.agent_id === args.grant.audience)) { + throw new Error('grant audience does not match counterparty'); + } + if (args.grant.provenance.receipt_id !== args.related_receipt_id) { + throw new Error('grant receipt provenance mismatch'); + } + if (args.grant.provenance.session_id !== args.related_session_id) { + throw new Error('grant session provenance mismatch'); + } + if (!args.grant.scope.message_classes.includes(args.message_class)) { + throw new Error('grant does not allow this message_class'); + } + + const peer = this.knownAgents.find((agent) => aliasesContain(agent, args.counterparty)); + if (!peer?.a2a_send_message_url) { + throw new Error('counterparty has no a2a_send_message_url'); + } + + const unsigned: IfcEnvelopeUnsigned = { + version: 'AV-IFC-MSG-V1', + message_id: randomUUID(), + created_at: new Date().toISOString(), + sender: this.agentId, + recipient: args.grant.audience, + message_class: args.message_class, + session_relation: 'POST_SESSION', + payload: args.payload, + related_receipt_id: args.related_receipt_id, + related_session_id: args.related_session_id, + grant_id: args.grant.grant_id, + ifc_policy_hash: contentHash({ + policy_version: 'POST_SESSION_V1', + allowed_classes: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], + }), + label_receipt: { + policy_version: 'POST_SESSION_V1', + message_class: args.message_class, + session_relation: 'POST_SESSION', + }, + }; + + const envelope = signMessage( + DOMAIN_PREFIXES.IFC_ENVELOPE, + unsigned as unknown as Record, + this.seedHex, + ) as unknown as IfcEnvelope; + + const response = await fetch(peer.a2a_send_message_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + buildA2ASendMessageRequest({ + mediaType: AGENTVAULT_IFC_ENVELOPE_MEDIA_TYPE, + data: { envelope, grant: args.grant }, + acceptedOutputModes: [AGENTVAULT_IFC_RESULT_MEDIA_TYPE], + }), + ), + }); + const payload = await response.json(); + const parsed = parseA2ATaskPart(payload, [AGENTVAULT_IFC_RESULT_MEDIA_TYPE]); + if (!parsed) { + throw new Error('A2A SendMessage response did not contain an IFC result part'); + } + return parsed.data as IfcDeliveryResult; + } + + receiveEnvelope(input: { envelope: IfcEnvelope; grant: IfcGrant }): IfcDeliveryResult { + const { envelope, grant } = input; + try { + this.verifyGrant(grant); + if (grant.audience !== this.agentId) throw new Error('grant audience mismatch'); + if (grant.grant_id !== envelope.grant_id) throw new Error('grant_id mismatch'); + if (grant.provenance.receipt_id !== envelope.related_receipt_id) throw new Error('receipt provenance mismatch'); + if (grant.provenance.session_id !== envelope.related_session_id) throw new Error('session provenance mismatch'); + if (!grant.scope.message_classes.includes(envelope.message_class)) { + throw new Error('grant scope mismatch'); + } + if (envelope.recipient !== this.agentId) throw new Error('recipient mismatch'); + if (envelope.session_relation !== 'POST_SESSION') throw new Error('unsupported session_relation'); + if (!verifyMessage(DOMAIN_PREFIXES.IFC_ENVELOPE, envelope as unknown as Record, grant.issuer_public_key_hex)) { + throw new Error('envelope signature verification failed'); + } + + const grantState = this.receivedGrantUses.get(grant.grant_id) ?? { grant, uses: 0 }; + if (grantState.uses >= grant.permissions.max_uses) { + throw new Error('grant use limit exceeded'); + } + grantState.uses += 1; + this.receivedGrantUses.set(grant.grant_id, grantState); + + const decision = classifyDecision(envelope.message_class); + const stored: IfcStoredMessage = { + message_id: envelope.message_id, + sender: envelope.sender, + recipient: envelope.recipient, + message_class: envelope.message_class, + decision, + related_receipt_id: envelope.related_receipt_id, + related_session_id: envelope.related_session_id, + created_at: envelope.created_at, + read: false, + }; + if (decision === 'ALLOW') { + stored.payload = envelope.payload; + } else if (decision === 'HIDE') { + this.hiddenCounter += 1; + stored.hidden_variable_id = `ifc_var_${this.hiddenCounter}`; + } else if (decision === 'ESCALATE') { + stored.escalation_stub = { + recommended_topic_code: 'post_session_follow_up', + recommended_signal_family: 'session_follow_up', + recommended_policy_constraints: ['POST_SESSION_ONLY'], + reason_code: 'REFERENCE_NEEDS_SESSION', + source_message_id: envelope.message_id, + grant_context: { + grant_id: grant.grant_id, + related_receipt_id: envelope.related_receipt_id, + related_session_id: envelope.related_session_id, + }, + }; + } + this.inbox.push(stored); + + return { + decision, + message_id: envelope.message_id, + related_receipt_id: envelope.related_receipt_id, + related_session_id: envelope.related_session_id, + ...(stored.hidden_variable_id ? { hidden_variable_id: stored.hidden_variable_id } : {}), + ...(stored.escalation_stub ? { escalation_stub: stored.escalation_stub } : {}), + }; + } catch (error) { + return { + decision: 'BLOCK', + message_id: input.envelope.message_id, + related_receipt_id: input.envelope.related_receipt_id, + related_session_id: input.envelope.related_session_id, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + readMessages(args: ReadIfcMessagesArgs = {}): { pending_count: number; messages: Array> } { + const unread = this.inbox.filter((m) => !m.read); + const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : unread.length; + const selected = unread.slice(0, limit); + for (const item of selected) item.read = true; + return { + pending_count: this.pendingCount(), + messages: selected.map((item) => ({ + message_id: item.message_id, + sender: item.sender, + message_class: item.message_class, + decision: item.decision, + related_receipt_id: item.related_receipt_id, + related_session_id: item.related_session_id, + created_at: item.created_at, + ...(item.decision === 'ALLOW' ? { payload: item.payload } : {}), + ...(item.decision === 'HIDE' ? { hidden_variable_id: item.hidden_variable_id } : {}), + ...(item.decision === 'ESCALATE' ? { escalation_stub: item.escalation_stub } : {}), + })), + }; + } +} diff --git a/packages/agentvault-mcp-server/src/index.ts b/packages/agentvault-mcp-server/src/index.ts index 9f8aaa2..34a49b2 100644 --- a/packages/agentvault-mcp-server/src/index.ts +++ b/packages/agentvault-mcp-server/src/index.ts @@ -32,7 +32,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { buildError } from './envelope.js'; -import { RELAY_TOOLS, IDENTITY_TOOLS, VERIFY_TOOLS } from './toolDefs.js'; +import { RELAY_TOOLS, IDENTITY_TOOLS, VERIFY_TOOLS, IFC_TOOLS } from './toolDefs.js'; import { dispatch } from './dispatch.js'; import type { InviteTransport } from './invite-transport.js'; import { OrchestratorInboxAdapter } from './afal-transport.js'; @@ -47,6 +47,7 @@ import { listKnownModelProfiles } from './model-profiles.js'; import { listSupportedContractOffers } from './contract-offers.js'; import { supportsBespokePrecontractNegotiation } from './bespoke-contracts.js'; import { listSupportedTopicCodes, supportsTopicAlignment } from './topic-codes.js'; +import { IfcService, type IfcKnownAgent } from './ifc.js'; import { ed25519 } from '@noble/curves/ed25519'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; @@ -68,6 +69,23 @@ export function createAgentVaultServer( const afalTransport: AfalTransport | undefined = directTransport ?? (inviteTransport ? new OrchestratorInboxAdapter(inviteTransport) : undefined); + const agentId = afalTransport?.agentId ?? process.env['AV_AGENT_ID']; + const ifcSeedHex = + directTransport instanceof DirectAfalTransport + ? directTransport.ifcSeedHex + : process.env['AV_AFAL_SEED_HEX']; + const ifcService = + agentId && ifcSeedHex + ? new IfcService({ + agentId, + seedHex: ifcSeedHex, + knownAgents: knownAgents as IfcKnownAgent[], + verifyingKeyHex: bytesToHex(ed25519.getPublicKey(hexToBytes(ifcSeedHex))), + }) + : undefined; + if (directTransport instanceof DirectAfalTransport && ifcService) { + directTransport.setIfcService(ifcService); + } const server = new Server( { @@ -82,22 +100,20 @@ export function createAgentVaultServer( ); server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: [...IDENTITY_TOOLS, ...RELAY_TOOLS, ...VERIFY_TOOLS] }; + return { tools: [...IDENTITY_TOOLS, ...RELAY_TOOLS, ...VERIFY_TOOLS, ...IFC_TOOLS] }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { - // Resolve agent identity from the transport when available; - // fall back to env only for standalone (no-transport) mode. - const agentId = afalTransport?.agentId ?? process.env['AV_AGENT_ID']; const result = await dispatch( name, args as Record, afalTransport, knownAgents, agentId, + ifcService, ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } catch (error) { @@ -241,7 +257,7 @@ function buildDirectTransportFromEnv(): DirectAfalTransport | null { /** * Parse AV_KNOWN_AGENTS environment variable. - * Expected format: JSON array of {agent_id: string, aliases: string[]}. + * Expected format: JSON array of {agent_id: string, aliases: string[], a2a_send_message_url?: string}. */ function parseKnownAgentsFromEnv(): NormalizedKnownAgent[] { const raw = process.env['AV_KNOWN_AGENTS']; @@ -253,8 +269,12 @@ function parseKnownAgentsFromEnv(): NormalizedKnownAgent[] { return []; } for (const entry of parsed) { - if (typeof entry?.agent_id !== 'string' || !Array.isArray(entry?.aliases)) { - console.error('AV_KNOWN_AGENTS entries must have string agent_id and aliases array'); + if ( + typeof entry?.agent_id !== 'string' || + !Array.isArray(entry?.aliases) || + (entry?.a2a_send_message_url !== undefined && typeof entry?.a2a_send_message_url !== 'string') + ) { + console.error('AV_KNOWN_AGENTS entries must have string agent_id, aliases array, and optional string a2a_send_message_url'); return []; } } diff --git a/packages/agentvault-mcp-server/src/tool-registry.ts b/packages/agentvault-mcp-server/src/tool-registry.ts index 60fda4c..7973956 100644 --- a/packages/agentvault-mcp-server/src/tool-registry.ts +++ b/packages/agentvault-mcp-server/src/tool-registry.ts @@ -9,14 +9,20 @@ */ import type { AfalTransport } from './afal-transport.js'; +import { ed25519 } from '@noble/curves/ed25519'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import type { NormalizedKnownAgent, RelaySignalArgs } from './tools/relaySignal.js'; import type { InboxService, GetIdentityOutput } from './tools/getIdentity.js'; import type { VerifyReceiptArgs, VerifyReceiptOutput } from './tools/verify-receipt.js'; import type { ToolResponse } from './envelope.js'; +import { IfcService, type CreateIfcGrantArgs, type IfcKnownAgent, type ReadIfcMessagesArgs, type SendIfcMessageArgs } from './ifc.js'; import { handleGetIdentity } from './tools/getIdentity.js'; import { handleRelaySignal } from './tools/relaySignal.js'; import { handleVerifyReceipt } from './tools/verify-receipt.js'; -import { IDENTITY_TOOLS, RELAY_TOOLS, VERIFY_TOOLS } from './toolDefs.js'; +import { handleCreateIfcGrant } from './tools/create-ifc-grant.js'; +import { handleSendIfcMessage } from './tools/send-ifc-message.js'; +import { handleReadIfcMessages } from './tools/read-ifc-messages.js'; +import { IDENTITY_TOOLS, RELAY_TOOLS, VERIFY_TOOLS, IFC_TOOLS } from './toolDefs.js'; // ── Configuration ──────────────────────────────────────────────────────── @@ -36,6 +42,8 @@ export interface ToolRegistryConfig { * When set, overrides the template's default model_profile_id. */ relayProfileId?: string; + ifcSeedHex?: string; + ifcService?: IfcService; } // ── Tool definition shape ──────────────────────────────────────────────── @@ -56,6 +64,9 @@ export interface ToolRegistry { handleGetIdentity(): Promise>; handleRelaySignal(args: RelaySignalArgs): Promise>; handleVerifyReceipt(args: VerifyReceiptArgs): Promise>; + handleCreateIfcGrant(args: CreateIfcGrantArgs): Promise>; + handleSendIfcMessage(args: SendIfcMessageArgs): Promise>; + handleReadIfcMessages(args: ReadIfcMessagesArgs): Promise>; dispatch(toolName: string, args: Record): Promise>; toolDefs: ToolDefinition[]; } @@ -71,10 +82,21 @@ export interface ToolRegistry { export function createToolRegistry(config: ToolRegistryConfig): ToolRegistry { const { transport, knownAgents, inboxService } = config; const agentId = config.agentId ?? transport.agentId; + const ifcService = + config.ifcService ?? + (config.ifcSeedHex + ? new IfcService({ + agentId, + seedHex: config.ifcSeedHex, + knownAgents: knownAgents as IfcKnownAgent[], + verifyingKeyHex: bytesToHex(ed25519.getPublicKey(hexToBytes(config.ifcSeedHex))), + }) + : undefined); + ifcService?.setKnownAgents(knownAgents as IfcKnownAgent[]); const registry: ToolRegistry = { handleGetIdentity() { - return handleGetIdentity(agentId, knownAgents, inboxService ?? transport); + return handleGetIdentity(agentId, knownAgents, inboxService ?? transport, ifcService?.pendingCount() ?? 0); }, handleRelaySignal(args: RelaySignalArgs) { @@ -85,6 +107,18 @@ export function createToolRegistry(config: ToolRegistryConfig): ToolRegistry { return handleVerifyReceipt(args); }, + handleCreateIfcGrant(args: CreateIfcGrantArgs) { + return handleCreateIfcGrant(args, ifcService); + }, + + handleSendIfcMessage(args: SendIfcMessageArgs) { + return handleSendIfcMessage(args, ifcService); + }, + + handleReadIfcMessages(args: ReadIfcMessagesArgs) { + return handleReadIfcMessages(args, ifcService); + }, + dispatch(toolName: string, args: Record) { switch (toolName) { case 'agentvault.get_identity': @@ -93,9 +127,15 @@ export function createToolRegistry(config: ToolRegistryConfig): ToolRegistry { return registry.handleRelaySignal(args as RelaySignalArgs); case 'agentvault.verify_receipt': return registry.handleVerifyReceipt(args as unknown as VerifyReceiptArgs); + case 'agentvault.create_ifc_grant': + return registry.handleCreateIfcGrant(args as unknown as CreateIfcGrantArgs); + case 'agentvault.send_ifc_message': + return registry.handleSendIfcMessage(args as unknown as SendIfcMessageArgs); + case 'agentvault.read_ifc_messages': + return registry.handleReadIfcMessages(args as ReadIfcMessagesArgs); default: throw new Error( - `Unknown tool: ${toolName}. Available: agentvault.get_identity, agentvault.relay_signal, agentvault.verify_receipt`, + `Unknown tool: ${toolName}. Available: agentvault.get_identity, agentvault.relay_signal, agentvault.verify_receipt, agentvault.create_ifc_grant, agentvault.send_ifc_message, agentvault.read_ifc_messages`, ); } }, @@ -111,7 +151,7 @@ export function createToolRegistry(config: ToolRegistryConfig): ToolRegistry { * Useful for registering tools with an LLM provider. */ export function getToolDefs(): ToolDefinition[] { - return [...IDENTITY_TOOLS, ...RELAY_TOOLS, ...VERIFY_TOOLS] as ToolDefinition[]; + return [...IDENTITY_TOOLS, ...RELAY_TOOLS, ...VERIFY_TOOLS, ...IFC_TOOLS] as ToolDefinition[]; } // ── Re-exports for consumer convenience ────────────────────────────────── @@ -122,3 +162,4 @@ export type { NormalizedKnownAgent, RelaySignalArgs } from './tools/relaySignal. export type { InboxService, GetIdentityOutput } from './tools/getIdentity.js'; export type { VerifyReceiptArgs, VerifyReceiptOutput } from './tools/verify-receipt.js'; export type { ToolResponse, StatusCode, ErrorCode } from './envelope.js'; +export type { CreateIfcGrantArgs, ReadIfcMessagesArgs, SendIfcMessageArgs } from './ifc.js'; diff --git a/packages/agentvault-mcp-server/src/toolDefs.ts b/packages/agentvault-mcp-server/src/toolDefs.ts index 2b6c3fb..0f914b5 100644 --- a/packages/agentvault-mcp-server/src/toolDefs.ts +++ b/packages/agentvault-mcp-server/src/toolDefs.ts @@ -48,6 +48,77 @@ export const VERIFY_TOOLS = [ }, ]; +export const IFC_TOOLS = [ + { + name: 'agentvault.create_ifc_grant', + description: + 'Create a short-lived IFC follow-up grant tied to an existing receipt and session. ' + + 'Use this for post-session logistics, consent, references, and controlled artifact transfer.', + inputSchema: { + type: 'object' as const, + properties: { + audience: { type: 'string', description: 'Receiving agent_id.' }, + receipt_id: { type: 'string', description: 'Related receipt_id (64 lowercase hex).' }, + session_id: { type: 'string', description: 'Related session_id (lowercase UUID).' }, + message_classes: { + type: 'array', + items: { + type: 'string', + enum: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], + }, + description: 'Allowed IFC message classes for this grant.', + }, + max_uses: { type: 'number', description: 'Maximum permitted uses for this grant.' }, + expires_in_seconds: { + type: 'number', + description: 'Grant validity duration in seconds (max 86400).', + }, + }, + required: ['audience', 'receipt_id', 'session_id', 'message_classes', 'max_uses', 'expires_in_seconds'], + }, + }, + { + name: 'agentvault.send_ifc_message', + description: + 'Send one IFC-wrapped post-session message to a known peer over the AgentVault A2A send-message path. ' + + 'Plain non-IFC messages are not allowed on this surface.', + inputSchema: { + type: 'object' as const, + properties: { + counterparty: { + type: 'string', + description: 'Known agent_id or alias of the counterparty.', + }, + grant: { + type: 'object', + description: 'Signed grant created by agentvault.create_ifc_grant.', + }, + message_class: { + type: 'string', + enum: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], + description: 'Post-session IFC message class.', + }, + payload: { type: 'string', description: 'Bounded message payload.' }, + related_receipt_id: { type: 'string', description: 'Related receipt_id (64 lowercase hex).' }, + related_session_id: { type: 'string', description: 'Related session_id (lowercase UUID).' }, + }, + required: ['counterparty', 'grant', 'message_class', 'payload', 'related_receipt_id', 'related_session_id'], + }, + }, + { + name: 'agentvault.read_ifc_messages', + description: + 'Read pending IFC messages that have been allowed, hidden, escalated, or blocked for this agent.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { type: 'number', description: 'Optional maximum number of pending messages to read.' }, + }, + required: [], + }, + }, +]; + export const RELAY_TOOLS = [ { name: 'agentvault.relay_signal', diff --git a/packages/agentvault-mcp-server/src/tools/create-ifc-grant.ts b/packages/agentvault-mcp-server/src/tools/create-ifc-grant.ts new file mode 100644 index 0000000..0513336 --- /dev/null +++ b/packages/agentvault-mcp-server/src/tools/create-ifc-grant.ts @@ -0,0 +1,19 @@ +import { buildError, buildSuccess, type ToolResponse } from '../envelope.js'; +import type { CreateIfcGrantArgs, IfcService } from '../ifc.js'; + +export async function handleCreateIfcGrant( + args: CreateIfcGrantArgs, + ifcService?: IfcService, +): Promise> { + if (!ifcService) { + return buildError('PRECONDITION_FAILED', 'IFC support is not configured for this server.'); + } + try { + return buildSuccess('SUCCESS', ifcService.createGrant(args)); + } catch (error) { + return buildError( + 'INVALID_INPUT', + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/packages/agentvault-mcp-server/src/tools/getIdentity.ts b/packages/agentvault-mcp-server/src/tools/getIdentity.ts index c0694fb..226cc69 100644 --- a/packages/agentvault-mcp-server/src/tools/getIdentity.ts +++ b/packages/agentvault-mcp-server/src/tools/getIdentity.ts @@ -26,6 +26,7 @@ export interface GetIdentityOutput { agent_id: string | undefined; known_agents: NormalizedKnownAgent[]; pending_invites?: number; + pending_ifc_messages?: number; next_action?: NextAction; inbox_hint?: string; } @@ -34,6 +35,7 @@ export async function handleGetIdentity( agentId: string | undefined, knownAgents: NormalizedKnownAgent[], inboxService?: InboxService, + ifcPendingCount = 0, ): Promise> { const result: GetIdentityOutput = { agent_id: agentId, known_agents: knownAgents }; @@ -70,5 +72,15 @@ export async function handleGetIdentity( } } + result.pending_ifc_messages = ifcPendingCount; + if ((!result.pending_invites || result.pending_invites === 0) && ifcPendingCount > 0) { + result.next_action = { + tool: 'agentvault.read_ifc_messages', + args: {}, + reason: 'pending_ifc_messages', + }; + result.inbox_hint = `You have ${ifcPendingCount} pending IFC message(s).`; + } + return buildSuccess('SUCCESS', result); } diff --git a/packages/agentvault-mcp-server/src/tools/read-ifc-messages.ts b/packages/agentvault-mcp-server/src/tools/read-ifc-messages.ts new file mode 100644 index 0000000..c77a28a --- /dev/null +++ b/packages/agentvault-mcp-server/src/tools/read-ifc-messages.ts @@ -0,0 +1,19 @@ +import { buildError, buildSuccess, type ToolResponse } from '../envelope.js'; +import type { IfcService, ReadIfcMessagesArgs } from '../ifc.js'; + +export async function handleReadIfcMessages( + args: ReadIfcMessagesArgs, + ifcService?: IfcService, +): Promise> { + if (!ifcService) { + return buildError('PRECONDITION_FAILED', 'IFC support is not configured for this server.'); + } + try { + return buildSuccess('SUCCESS', ifcService.readMessages(args)); + } catch (error) { + return buildError( + 'UNKNOWN_ERROR', + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/packages/agentvault-mcp-server/src/tools/send-ifc-message.ts b/packages/agentvault-mcp-server/src/tools/send-ifc-message.ts new file mode 100644 index 0000000..74c85e2 --- /dev/null +++ b/packages/agentvault-mcp-server/src/tools/send-ifc-message.ts @@ -0,0 +1,19 @@ +import { buildError, buildSuccess, type ToolResponse } from '../envelope.js'; +import type { IfcService, SendIfcMessageArgs } from '../ifc.js'; + +export async function handleSendIfcMessage( + args: SendIfcMessageArgs, + ifcService?: IfcService, +): Promise> { + if (!ifcService) { + return buildError('PRECONDITION_FAILED', 'IFC support is not configured for this server.'); + } + try { + return buildSuccess('COMPLETE', await ifcService.sendMessage(args)); + } catch (error) { + return buildError( + 'PRECONDITION_FAILED', + error instanceof Error ? error.message : String(error), + ); + } +} From 0371f0f7e5f3eaec2b0d7d407763e513d45a3df0 Mon Sep 17 00:00:00 2001 From: Toby Kershaw Date: Wed, 11 Mar 2026 18:11:27 +0000 Subject: [PATCH 4/5] fix: trust IFC sender keys from known agents --- .../src/__tests__/ifc-e2e.test.ts | 11 +++- .../src/__tests__/ifc.test.ts | 63 +++++++++++++++++-- packages/agentvault-mcp-server/src/ifc.ts | 54 +++++++++++++--- packages/agentvault-mcp-server/src/index.ts | 8 ++- .../src/tools/relaySignal.ts | 2 + 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts b/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts index 71922fd..69cf7dd 100644 --- a/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts +++ b/packages/agentvault-mcp-server/src/__tests__/ifc-e2e.test.ts @@ -90,12 +90,19 @@ describe('IFC first slice e2e', () => { const aliceRegistry = createToolRegistry({ transport: aliceTransport, - knownAgents: [{ agent_id: 'bob-test', aliases: ['Bob'], a2a_send_message_url: bobUrl ?? undefined }], + knownAgents: [ + { + agent_id: 'bob-test', + aliases: ['Bob'], + public_key_hex: BOB_PUB, + a2a_send_message_url: bobUrl ?? undefined, + }, + ], ifcSeedHex: ALICE_SEED, }); const bobRegistry = createToolRegistry({ transport: bobTransport, - knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'] }], + knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'], public_key_hex: ALICE_PUB }], ifcService: bobIfc, }); diff --git a/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts b/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts index ed3b09b..f4c2024 100644 --- a/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts +++ b/packages/agentvault-mcp-server/src/__tests__/ifc.test.ts @@ -7,6 +7,10 @@ import { IfcService, type IfcEnvelope, type IfcGrant } from '../ifc.js'; const ALICE_SEED = '0101010101010101010101010101010101010101010101010101010101010101'; const ALICE_PUB = bytesToHex(ed25519.getPublicKey(hexToBytes(ALICE_SEED))); +const POST_SESSION_POLICY_HASH = contentHash({ + policy_version: 'POST_SESSION_V1', + allowed_classes: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], +}); function createService(agentId = 'alice-test') { return new IfcService({ @@ -71,7 +75,7 @@ describe('IfcService', () => { hexToBytes('0202020202020202020202020202020202020202020202020202020202020202'), ), ), - knownAgents: [], + knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'], public_key_hex: ALICE_PUB }], }); const { grant } = alice.createGrant({ @@ -97,7 +101,7 @@ describe('IfcService', () => { related_receipt_id: 'b'.repeat(64), related_session_id: '22222222-2222-2222-2222-222222222222', grant_id: grant.grant_id, - ifc_policy_hash: 'c'.repeat(64), + ifc_policy_hash: POST_SESSION_POLICY_HASH, label_receipt: { policy_version: 'POST_SESSION_V1', message_class: 'ARTIFACT_TRANSFER', @@ -131,7 +135,7 @@ describe('IfcService', () => { hexToBytes('0202020202020202020202020202020202020202020202020202020202020202'), ), ), - knownAgents: [], + knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'], public_key_hex: ALICE_PUB }], }); const { grant } = alice.createGrant({ @@ -157,7 +161,7 @@ describe('IfcService', () => { related_receipt_id: 'b'.repeat(64), related_session_id: '22222222-2222-2222-2222-222222222222', grant_id: grant.grant_id, - ifc_policy_hash: 'c'.repeat(64), + ifc_policy_hash: POST_SESSION_POLICY_HASH, label_receipt: { policy_version: 'POST_SESSION_V1', message_class: 'LOGISTICS', @@ -169,4 +173,55 @@ describe('IfcService', () => { expect(delivery.decision).toBe('BLOCK'); }); + + it('blocks a self-asserted sender key that is not trusted locally', () => { + const alice = createService('alice-test'); + const bob = new IfcService({ + agentId: 'bob-test', + seedHex: '0202020202020202020202020202020202020202020202020202020202020202', + verifyingKeyHex: bytesToHex( + ed25519.getPublicKey( + hexToBytes('0202020202020202020202020202020202020202020202020202020202020202'), + ), + ), + knownAgents: [{ agent_id: 'alice-test', aliases: ['Alice'], public_key_hex: 'f'.repeat(64) }], + }); + + const { grant } = alice.createGrant({ + audience: 'bob-test', + receipt_id: 'b'.repeat(64), + session_id: '22222222-2222-2222-2222-222222222222', + message_classes: ['LOGISTICS'], + max_uses: 1, + expires_in_seconds: 60, + }); + + const envelope = signMessage( + DOMAIN_PREFIXES.IFC_ENVELOPE, + { + version: 'AV-IFC-MSG-V1', + message_id: '33333333-3333-3333-3333-333333333333', + created_at: new Date().toISOString(), + sender: 'alice-test', + recipient: 'bob-test', + message_class: 'LOGISTICS', + session_relation: 'POST_SESSION', + payload: 'Meet at 10:30 UTC tomorrow.', + related_receipt_id: 'b'.repeat(64), + related_session_id: '22222222-2222-2222-2222-222222222222', + grant_id: grant.grant_id, + ifc_policy_hash: POST_SESSION_POLICY_HASH, + label_receipt: { + policy_version: 'POST_SESSION_V1', + message_class: 'LOGISTICS', + session_relation: 'POST_SESSION', + }, + }, + ALICE_SEED, + ) as IfcEnvelope; + + const delivery = bob.receiveEnvelope({ grant, envelope }); + expect(delivery.decision).toBe('BLOCK'); + expect(delivery.error).toContain('grant issuer key mismatch'); + }); }); diff --git a/packages/agentvault-mcp-server/src/ifc.ts b/packages/agentvault-mcp-server/src/ifc.ts index 3eab1ed..f67d38b 100644 --- a/packages/agentvault-mcp-server/src/ifc.ts +++ b/packages/agentvault-mcp-server/src/ifc.ts @@ -15,6 +15,7 @@ export type IfcDecision = 'ALLOW' | 'HIDE' | 'ESCALATE' | 'BLOCK'; export interface IfcKnownAgent extends NormalizedKnownAgent { a2a_send_message_url?: string; + public_key_hex?: string; } export interface IfcGrantScope { @@ -138,6 +139,11 @@ interface ReceivedGrantState { uses: number; } +const POST_SESSION_POLICY_HASH = contentHash({ + policy_version: 'POST_SESSION_V1', + allowed_classes: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], +}); + function isHex(value: string, len: number): boolean { return value.length === len && /^[0-9a-f]+$/.test(value); } @@ -209,6 +215,18 @@ export class IfcService { this.knownAgents = knownAgents; } + private resolveKnownAgent(hint: string): IfcKnownAgent | undefined { + return this.knownAgents.find((agent) => aliasesContain(agent, hint)); + } + + private resolveTrustedSenderKey(sender: string): string { + const knownAgent = this.resolveKnownAgent(sender); + if (!knownAgent?.public_key_hex) { + throw new Error(`no trusted public key for sender: ${sender}`); + } + return knownAgent.public_key_hex; + } + pendingCount(): number { return this.inbox.filter((m) => !m.read).length; } @@ -280,7 +298,10 @@ export class IfcService { assertReceiptId(args.related_receipt_id); assertUuidLower(args.related_session_id, 'related_session_id'); this.verifyGrant(args.grant); - if (args.grant.audience !== args.counterparty && !this.knownAgents.find((a) => aliasesContain(a, args.counterparty) && a.agent_id === args.grant.audience)) { + if ( + args.grant.audience !== args.counterparty && + !this.knownAgents.find((a) => aliasesContain(a, args.counterparty) && a.agent_id === args.grant.audience) + ) { throw new Error('grant audience does not match counterparty'); } if (args.grant.provenance.receipt_id !== args.related_receipt_id) { @@ -293,7 +314,7 @@ export class IfcService { throw new Error('grant does not allow this message_class'); } - const peer = this.knownAgents.find((agent) => aliasesContain(agent, args.counterparty)); + const peer = this.resolveKnownAgent(args.counterparty); if (!peer?.a2a_send_message_url) { throw new Error('counterparty has no a2a_send_message_url'); } @@ -310,10 +331,7 @@ export class IfcService { related_receipt_id: args.related_receipt_id, related_session_id: args.related_session_id, grant_id: args.grant.grant_id, - ifc_policy_hash: contentHash({ - policy_version: 'POST_SESSION_V1', - allowed_classes: ['LOGISTICS', 'CONSENT', 'REFERENCE', 'ARTIFACT_TRANSFER'], - }), + ifc_policy_hash: POST_SESSION_POLICY_HASH, label_receipt: { policy_version: 'POST_SESSION_V1', message_class: args.message_class, @@ -349,7 +367,18 @@ export class IfcService { receiveEnvelope(input: { envelope: IfcEnvelope; grant: IfcGrant }): IfcDeliveryResult { const { envelope, grant } = input; try { - this.verifyGrant(grant); + const trustedSenderKeyHex = this.resolveTrustedSenderKey(envelope.sender); + if (grant.issuer !== envelope.sender) throw new Error('grant issuer mismatch'); + if (grant.issuer_public_key_hex !== trustedSenderKeyHex) { + throw new Error('grant issuer key mismatch'); + } + const { signature: _sig, grant_id, ...unsignedGrant } = grant; + const recomputedGrantId = contentHash(unsignedGrant); + if (recomputedGrantId !== grant_id) throw new Error('grant_id mismatch'); + if (!verifyMessage(DOMAIN_PREFIXES.IFC_GRANT, grant as unknown as Record, trustedSenderKeyHex)) { + throw new Error('grant signature verification failed'); + } + if (new Date(grant.expires_at).getTime() < Date.now()) throw new Error('grant expired'); if (grant.audience !== this.agentId) throw new Error('grant audience mismatch'); if (grant.grant_id !== envelope.grant_id) throw new Error('grant_id mismatch'); if (grant.provenance.receipt_id !== envelope.related_receipt_id) throw new Error('receipt provenance mismatch'); @@ -359,7 +388,16 @@ export class IfcService { } if (envelope.recipient !== this.agentId) throw new Error('recipient mismatch'); if (envelope.session_relation !== 'POST_SESSION') throw new Error('unsupported session_relation'); - if (!verifyMessage(DOMAIN_PREFIXES.IFC_ENVELOPE, envelope as unknown as Record, grant.issuer_public_key_hex)) { + if (envelope.ifc_policy_hash !== POST_SESSION_POLICY_HASH) { + throw new Error('ifc_policy_hash mismatch'); + } + if ( + !verifyMessage( + DOMAIN_PREFIXES.IFC_ENVELOPE, + envelope as unknown as Record, + trustedSenderKeyHex, + ) + ) { throw new Error('envelope signature verification failed'); } diff --git a/packages/agentvault-mcp-server/src/index.ts b/packages/agentvault-mcp-server/src/index.ts index 34a49b2..1250195 100644 --- a/packages/agentvault-mcp-server/src/index.ts +++ b/packages/agentvault-mcp-server/src/index.ts @@ -257,7 +257,8 @@ function buildDirectTransportFromEnv(): DirectAfalTransport | null { /** * Parse AV_KNOWN_AGENTS environment variable. - * Expected format: JSON array of {agent_id: string, aliases: string[], a2a_send_message_url?: string}. + * Expected format: JSON array of + * {agent_id: string, aliases: string[], public_key_hex?: string, a2a_send_message_url?: string}. */ function parseKnownAgentsFromEnv(): NormalizedKnownAgent[] { const raw = process.env['AV_KNOWN_AGENTS']; @@ -272,9 +273,12 @@ function parseKnownAgentsFromEnv(): NormalizedKnownAgent[] { if ( typeof entry?.agent_id !== 'string' || !Array.isArray(entry?.aliases) || + (entry?.public_key_hex !== undefined && typeof entry?.public_key_hex !== 'string') || (entry?.a2a_send_message_url !== undefined && typeof entry?.a2a_send_message_url !== 'string') ) { - console.error('AV_KNOWN_AGENTS entries must have string agent_id, aliases array, and optional string a2a_send_message_url'); + console.error( + 'AV_KNOWN_AGENTS entries must have string agent_id, aliases array, and optional string public_key_hex/a2a_send_message_url', + ); return []; } } diff --git a/packages/agentvault-mcp-server/src/tools/relaySignal.ts b/packages/agentvault-mcp-server/src/tools/relaySignal.ts index a16e0ac..7ab08f8 100644 --- a/packages/agentvault-mcp-server/src/tools/relaySignal.ts +++ b/packages/agentvault-mcp-server/src/tools/relaySignal.ts @@ -60,6 +60,8 @@ import { export interface NormalizedKnownAgent { agent_id: string; aliases: string[]; + public_key_hex?: string; + a2a_send_message_url?: string; } function isRelayInvitePayload( From 158d6cd33b72694ac643aaf807ed72cd4b56667f Mon Sep 17 00:00:00 2001 From: Toby Kershaw Date: Thu, 12 Mar 2026 10:02:04 +0000 Subject: [PATCH 5/5] fix: polish demo favicon and replay navigation --- packages/agentvault-demo-ui/public/app.js | 18 ++++++++++++++++++ packages/agentvault-demo-ui/public/index.html | 4 ++-- packages/agentvault-demo-ui/public/replay.html | 1 + 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/agentvault-demo-ui/public/app.js b/packages/agentvault-demo-ui/public/app.js index d585ff3..7d03312 100644 --- a/packages/agentvault-demo-ui/public/app.js +++ b/packages/agentvault-demo-ui/public/app.js @@ -17,6 +17,7 @@ stopBtn: $('stop-btn'), resetBtn: $('reset-btn'), newRunBtn: $('new-run-btn'), + replayLink: $('replay-link'), statusChip: $('status-chip'), statusText: $('status-text'), // Chat panels @@ -64,6 +65,7 @@ var totalEvents = 0; var reconnectNotice = null; var terminalAgents = {}; + var suppressSseReconnectWarning = false; // ── Init vault card manager ──────────────────────────────── VaultCardManager.init(els.vaultEvents); @@ -385,18 +387,34 @@ } // ── SSE ──────────────────────────────────────────────────── + function closeLiveEventStream() { + suppressSseReconnectWarning = true; + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + function connectSSE() { if (eventSource) eventSource.close(); + suppressSseReconnectWarning = false; eventSource = new EventSource('/api/events'); eventSource.onmessage = function (e) { try { handleEvent(JSON.parse(e.data)); } catch (err) { console.error('SSE parse error:', err); } }; eventSource.onerror = function () { + if (suppressSseReconnectWarning) return; console.warn('SSE reconnecting...'); }; } + if (els.replayLink) { + els.replayLink.addEventListener('click', closeLiveEventStream); + } + window.addEventListener('pagehide', closeLiveEventStream); + window.addEventListener('beforeunload', closeLiveEventStream); + // ── Status polling ───────────────────────────────────────── async function pollStatus() { try { diff --git a/packages/agentvault-demo-ui/public/index.html b/packages/agentvault-demo-ui/public/index.html index 446a7ac..520cca9 100644 --- a/packages/agentvault-demo-ui/public/index.html +++ b/packages/agentvault-demo-ui/public/index.html @@ -8,7 +8,7 @@ - +
@@ -30,7 +30,7 @@ - Replay + Replay diff --git a/packages/agentvault-demo-ui/public/replay.html b/packages/agentvault-demo-ui/public/replay.html index 9f6a48e..039e142 100644 --- a/packages/agentvault-demo-ui/public/replay.html +++ b/packages/agentvault-demo-ui/public/replay.html @@ -8,6 +8,7 @@ +