Skip to content

Commit 06265f9

Browse files
committed
feat(mcp-proxy): send install clone advisory context
Add bounded install_clone_context validation for Runtime Gate package install and clone advisory metadata. Wire MCP package mutation tools to send only bounded refs and cover direct SDK privacy rejection plus signed advisory receipt verification. Implemented with assistance from Codex.
1 parent bdcde5b commit 06265f9

7 files changed

Lines changed: 736 additions & 10 deletions

File tree

agentveil/agent.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
AVPValidationError,
3636
AVPServerError,
3737
)
38+
from agentveil.runtime_install_clone import validate_install_clone_context
3839

3940
log = logging.getLogger("agentveil")
4041

@@ -1335,13 +1336,18 @@ def runtime_evaluate(
13351336
payload_hash: Optional[str] = None,
13361337
risk_class: Optional[str] = None,
13371338
policy_context_hash: Optional[str] = None,
1339+
install_clone_context: Optional[dict] = None,
13381340
) -> dict:
13391341
"""
13401342
Evaluate whether this agent may perform one action right now.
13411343
13421344
The agent DID is always derived from this SDK identity; callers cannot
13431345
override it. The returned decision is one of ALLOW, BLOCK, or
13441346
WAITING_FOR_HUMAN_APPROVAL.
1347+
1348+
Optional ``install_clone_context`` is bounded advisory metadata for
1349+
package install/clone tools. It must not contain raw paths, URLs,
1350+
prompts, secrets, or package-manager command payloads.
13451351
"""
13461352
body_data = {
13471353
"agent_did": self._did,
@@ -1360,6 +1366,10 @@ def runtime_evaluate(
13601366
body_data["risk_class"] = risk_class
13611367
if policy_context_hash is not None:
13621368
body_data["policy_context_hash"] = policy_context_hash
1369+
if install_clone_context is not None:
1370+
body_data["install_clone_context"] = validate_install_clone_context(
1371+
install_clone_context
1372+
)
13631373
return self._post_json("/v1/runtime/evaluate", body_data)
13641374

13651375
def get_runtime_decision(self, audit_id: str) -> dict:

agentveil/runtime_install_clone.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Bounded install/clone advisory context for Runtime Gate requests.
2+
3+
Public clients must not forward raw paths, URLs, prompts, package-manager
4+
commands, or secrets in ``install_clone_context``. This module validates a
5+
caller-supplied dict against the private-compatible bounded vocabulary before
6+
any HTTP body is built.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import re
13+
from typing import Any, Mapping
14+
15+
from agentveil.exceptions import AVPValidationError
16+
17+
_SOURCE_REF_PATTERN = re.compile(r"^src_[a-z0-9_-]{1,58}$")
18+
_NAMESPACE_REF_PATTERN = re.compile(r"^ns_[a-z0-9_-]{1,58}$")
19+
_PACKAGE_REF_PATTERN = re.compile(r"^pkg_[a-z0-9_-]{1,58}$")
20+
_HASH_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
21+
22+
_SOURCE_REF_KINDS = frozenset({
23+
"user_pinned",
24+
"workspace_registry",
25+
"internal_registry",
26+
"approved_namespace",
27+
"model_suggested",
28+
"metadata_suggested",
29+
"tool_output_suggested",
30+
"unknown",
31+
})
32+
_INTENT_SOURCES = frozenset({
33+
"user_direct",
34+
"user_pinned",
35+
"workspace_policy",
36+
"agent_plan",
37+
"metadata",
38+
"tool_output",
39+
"remote_context",
40+
"unknown",
41+
})
42+
_TARGET_SOURCES = frozenset({
43+
"user_pinned",
44+
"workspace_registry",
45+
"internal_registry",
46+
"tool_output",
47+
"metadata",
48+
"model_suggested",
49+
"remote_context",
50+
"unknown",
51+
})
52+
_TOOL_SOURCES = frozenset({
53+
"approved_registry",
54+
"workspace_registry",
55+
"local_manifest",
56+
"mcp_server_schema",
57+
"model_suggested",
58+
"unknown",
59+
})
60+
_METADATA_INFLUENCES = frozenset({
61+
"none",
62+
"low",
63+
"medium",
64+
"high",
65+
"unknown",
66+
})
67+
68+
_REQUIRED_KEYS = frozenset({
69+
"operation",
70+
"source_ref",
71+
"source_ref_kind",
72+
"user_pinned_source",
73+
"intent_source",
74+
"target_source",
75+
"tool_source",
76+
"metadata_influence",
77+
})
78+
_OPTIONAL_KEYS = frozenset({
79+
"package_namespace",
80+
"requested_package",
81+
"expected_package",
82+
"expected_hash",
83+
"resource_hash",
84+
"payload_hash",
85+
})
86+
_ALLOWED_KEYS = _REQUIRED_KEYS | _OPTIONAL_KEYS
87+
88+
_FORBIDDEN_SUBSTRINGS = (
89+
"://",
90+
"/Users/",
91+
"/private/",
92+
"/var/folders/",
93+
"/home/",
94+
"\\Users\\",
95+
"X-Amz-",
96+
"sk_live_",
97+
"sk_test_",
98+
"ghp_",
99+
"-----BEGIN",
100+
)
101+
102+
103+
def validate_install_clone_context(context: Mapping[str, Any]) -> dict[str, Any]:
104+
"""Return a bounded copy of ``install_clone_context`` or raise.
105+
106+
Rejects unknown keys, invalid vocabulary, and values that look like raw
107+
paths, URLs, or secret markers. Returns a fresh bounded copy.
108+
"""
109+
110+
if not isinstance(context, Mapping):
111+
raise AVPValidationError("install_clone_context must be an object")
112+
113+
keys = set(context)
114+
extra = keys - _ALLOWED_KEYS
115+
if extra:
116+
raise AVPValidationError(
117+
f"install_clone_context contains forbidden keys: {sorted(extra)}"
118+
)
119+
missing = _REQUIRED_KEYS - keys
120+
if missing:
121+
raise AVPValidationError(
122+
f"install_clone_context missing required keys: {sorted(missing)}"
123+
)
124+
125+
operation = context["operation"]
126+
if operation not in {"install", "clone"}:
127+
raise AVPValidationError("install_clone_context.operation invalid")
128+
129+
source_ref = _require_str(context["source_ref"], "source_ref")
130+
if not _SOURCE_REF_PATTERN.fullmatch(source_ref):
131+
raise AVPValidationError("install_clone_context.source_ref invalid")
132+
133+
source_ref_kind = _require_str(context["source_ref_kind"], "source_ref_kind")
134+
if source_ref_kind not in _SOURCE_REF_KINDS:
135+
raise AVPValidationError("install_clone_context.source_ref_kind invalid")
136+
137+
if not isinstance(context["user_pinned_source"], bool):
138+
raise AVPValidationError("install_clone_context.user_pinned_source must be bool")
139+
140+
intent_source = _require_str(context["intent_source"], "intent_source")
141+
if intent_source not in _INTENT_SOURCES:
142+
raise AVPValidationError("install_clone_context.intent_source invalid")
143+
144+
target_source = _require_str(context["target_source"], "target_source")
145+
if target_source not in _TARGET_SOURCES:
146+
raise AVPValidationError("install_clone_context.target_source invalid")
147+
148+
tool_source = _require_str(context["tool_source"], "tool_source")
149+
if tool_source not in _TOOL_SOURCES:
150+
raise AVPValidationError("install_clone_context.tool_source invalid")
151+
152+
metadata_influence = _require_str(context["metadata_influence"], "metadata_influence")
153+
if metadata_influence not in _METADATA_INFLUENCES:
154+
raise AVPValidationError("install_clone_context.metadata_influence invalid")
155+
156+
bounded: dict[str, Any] = {
157+
"operation": operation,
158+
"source_ref": source_ref,
159+
"source_ref_kind": source_ref_kind,
160+
"user_pinned_source": context["user_pinned_source"],
161+
"intent_source": intent_source,
162+
"target_source": target_source,
163+
"tool_source": tool_source,
164+
"metadata_influence": metadata_influence,
165+
}
166+
167+
for key in ("package_namespace", "requested_package", "expected_package"):
168+
if key not in context or context[key] is None:
169+
continue
170+
value = _require_str(context[key], key)
171+
pattern = _NAMESPACE_REF_PATTERN if key == "package_namespace" else _PACKAGE_REF_PATTERN
172+
if not pattern.fullmatch(value):
173+
raise AVPValidationError(f"install_clone_context.{key} invalid")
174+
bounded[key] = value
175+
176+
for key in ("expected_hash", "resource_hash", "payload_hash"):
177+
if key not in context or context[key] is None:
178+
continue
179+
value = _require_str(context[key], key)
180+
if not _HASH_PATTERN.fullmatch(value):
181+
raise AVPValidationError(f"install_clone_context.{key} invalid")
182+
bounded[key] = value
183+
184+
serialized = json.dumps(bounded, sort_keys=True)
185+
lowered = serialized.lower()
186+
for marker in _FORBIDDEN_SUBSTRINGS:
187+
if marker.lower() in lowered:
188+
raise AVPValidationError(
189+
"install_clone_context contains forbidden raw path/url/secret marker"
190+
)
191+
return bounded
192+
193+
194+
def _require_str(value: Any, field: str) -> str:
195+
if not isinstance(value, str):
196+
raise AVPValidationError(f"install_clone_context.{field} must be a string")
197+
text = value.strip()
198+
if not text or len(text) > 71:
199+
raise AVPValidationError(f"install_clone_context.{field} invalid")
200+
return text

packages/agentveil-mcp-proxy/agentveil_mcp_proxy/classification.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@
171171
"pip_run_script": RiskClass.DESTRUCTIVE,
172172
}
173173

174+
# Package install/clone-relevant tools that may attach bounded Runtime Gate
175+
# advisory context. Read-only package status tools are intentionally excluded.
176+
_PACKAGE_INSTALL_CLONE_CONTEXT_TOOLS = frozenset({
177+
"pip_install",
178+
"pip_uninstall",
179+
"pip_update",
180+
"pip_run_script",
181+
})
182+
INSTALL_CLONE_SOURCE_REF = "src_package_route_builtin"
183+
INSTALL_CLONE_PACKAGE_REF = "pkg_package_route_builtin"
184+
174185
# Fetch/network MCP tools (e.g. the official MCP "fetch" server's `fetch` tool)
175186
# take a URL argument. The tool name `fetch` matches the generic _READ prefix,
176187
# so a benign public fetch already infers READ. The risk that this prefix misses
@@ -277,7 +288,7 @@ class ClassifiedToolCall:
277288
def backend_metadata(self) -> dict[str, Any]:
278289
"""Return privacy-filtered metadata intended for later backend calls."""
279290

280-
return {
291+
metadata = {
281292
"action": self.action,
282293
"action_hash": self.action_hash if self.action == self.action_hash else None,
283294
"resource": self.resource,
@@ -291,6 +302,10 @@ def backend_metadata(self) -> dict[str, Any]:
291302
else self.policy_evaluation.would_decision.value
292303
),
293304
}
305+
install_clone_context = build_install_clone_context(self.tool)
306+
if install_clone_context is not None:
307+
metadata["install_clone_context"] = install_clone_context
308+
return metadata
294309

295310
def local_evidence_metadata(self) -> dict[str, Any]:
296311
"""Return local-only metadata for future evidence slices."""
@@ -399,6 +414,29 @@ def extract_resource(arguments: Mapping[str, Any]) -> str | None:
399414
return None
400415

401416

417+
def build_install_clone_context(tool: str) -> dict[str, Any] | None:
418+
"""Return bounded install/clone advisory context for package mutation tools.
419+
420+
Returns ``None`` for non-package tools. Includes only stable bounded refs,
421+
without raw package names, paths, URLs, prompts, source, or secrets.
422+
"""
423+
424+
if tool not in _PACKAGE_INSTALL_CLONE_CONTEXT_TOOLS:
425+
return None
426+
return {
427+
"operation": "install",
428+
"source_ref": INSTALL_CLONE_SOURCE_REF,
429+
"source_ref_kind": "workspace_registry",
430+
"user_pinned_source": False,
431+
"intent_source": "user_direct",
432+
"target_source": "workspace_registry",
433+
"tool_source": "approved_registry",
434+
"metadata_influence": "none",
435+
"requested_package": INSTALL_CLONE_PACKAGE_REF,
436+
"expected_package": INSTALL_CLONE_PACKAGE_REF,
437+
}
438+
439+
402440
def infer_action_family(tool: str) -> str:
403441
# claim-check: allow "privacy-safe" describes a coarse label helper, not full data safety.
404442
"""Return a coarse, privacy-safe action family label for one MCP tool name."""
@@ -515,8 +553,11 @@ def _normalize_json(value: Any) -> Any:
515553
__all__ = [
516554
"ClassifiedToolCall",
517555
"HASH_PREFIX",
556+
"INSTALL_CLONE_PACKAGE_REF",
557+
"INSTALL_CLONE_SOURCE_REF",
518558
"REDACTED",
519559
"ToolCallClassifier",
560+
"build_install_clone_context",
520561
"extract_resource",
521562
"infer_action_family",
522563
"infer_risk_class",

packages/agentveil-mcp-proxy/agentveil_mcp_proxy/runtime_gate.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
from agentveil.agent import AVPAgent
2121
from agentveil.data_integrity import DataIntegrityError, verify_eddsa_jcs_2022
2222
from agentveil.delegation import DelegationInvalid, verify_delegation
23+
from agentveil.exceptions import AVPValidationError
2324
from agentveil.proof import ProofVerificationError, verify_signed_jcs
25+
from agentveil.runtime_install_clone import validate_install_clone_context
2426
from agentveil_mcp_proxy.circuit_breaker import (
2527
CircuitBreaker,
2628
CircuitBreakerOpenError,
@@ -88,6 +90,7 @@ class _RuntimeGateRequest:
8890
payload_hash: str
8991
risk_class: str
9092
policy_context_hash: str
93+
install_clone_context: Mapping[str, Any] | None = None
9194

9295

9396
class RuntimeGateClient:
@@ -191,15 +194,20 @@ def evaluate(self, classification: ClassifiedToolCall) -> RuntimeGateDecision:
191194
raise RuntimeGateUnavailableError("runtime gate circuit breaker open") from exc
192195
try:
193196
try:
194-
response = self.agent.runtime_evaluate(
195-
action=request.action,
196-
resource=request.resource,
197-
environment=request.environment,
198-
delegation_receipt=self.control_grant,
199-
payload_hash=request.payload_hash,
200-
risk_class=request.risk_class,
201-
policy_context_hash=request.policy_context_hash,
202-
)
197+
evaluate_kwargs: dict[str, Any] = {
198+
"action": request.action,
199+
"resource": request.resource,
200+
"environment": request.environment,
201+
"delegation_receipt": self.control_grant,
202+
"payload_hash": request.payload_hash,
203+
"risk_class": request.risk_class,
204+
"policy_context_hash": request.policy_context_hash,
205+
}
206+
if request.install_clone_context is not None:
207+
evaluate_kwargs["install_clone_context"] = dict(
208+
request.install_clone_context
209+
)
210+
response = self.agent.runtime_evaluate(**evaluate_kwargs)
203211
except Exception as exc:
204212
raise RuntimeGateUnavailableError("runtime gate request failed") from exc
205213
if not isinstance(response, Mapping):
@@ -243,6 +251,14 @@ def seen_receipt_cache_size(self) -> int:
243251

244252
def _build_request(self, classification: ClassifiedToolCall) -> _RuntimeGateRequest:
245253
metadata = classification.backend_metadata()
254+
install_clone_context = metadata.get("install_clone_context")
255+
if install_clone_context is not None:
256+
if not isinstance(install_clone_context, Mapping):
257+
raise RuntimeGateUnavailableError("install_clone_context invalid")
258+
try:
259+
install_clone_context = validate_install_clone_context(install_clone_context)
260+
except AVPValidationError as exc:
261+
raise RuntimeGateUnavailableError("install_clone_context invalid") from exc
246262
return _RuntimeGateRequest(
247263
action=_runtime_field(metadata.get("action")),
248264
resource=_runtime_field(metadata.get("resource")),
@@ -253,6 +269,7 @@ def _build_request(self, classification: ClassifiedToolCall) -> _RuntimeGateRequ
253269
metadata.get("policy_context_hash"),
254270
"policy_context_hash",
255271
),
272+
install_clone_context=install_clone_context,
256273
)
257274

258275
def _decision_receipt_jcs(self, response: Mapping[str, Any]) -> str:

0 commit comments

Comments
 (0)