Skip to content

Commit 5a89148

Browse files
committed
fix(mcp-proxy): broaden destructive coverage and harden env passthrough
Close P10.5 security hardening gaps by blocking AVP_* env passthrough, broadening destructive classifier and built-in policy pack coverage, and annotating known-safe evidence SQL construction with B608-specific nosec comments. Implemented with assistance from Codex.
1 parent 5c14f37 commit 5a89148

7 files changed

Lines changed: 298 additions & 48 deletions

File tree

agentveil_mcp_proxy/classification.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,22 @@
5555
"open",
5656
"close",
5757
)
58-
_DESTRUCTIVE_PREFIXES = ("delete", "remove", "destroy", "drop", "revoke", "terminate")
58+
_DESTRUCTIVE_PREFIXES = (
59+
"delete",
60+
"remove",
61+
"destroy",
62+
"drop",
63+
"revoke",
64+
"terminate",
65+
"purge",
66+
"truncate",
67+
"wipe",
68+
"format",
69+
"rm",
70+
"rmdir",
71+
"unlink",
72+
"clean",
73+
)
5974
_PRODUCTION_WORDS = ("prod", "production", "deploy", "release", "rollback", "infra", "cluster")
6075
_FINANCIAL_WORDS = ("payment", "transfer", "invoice", "billing", "payroll", "purchase", "refund")
6176

agentveil_mcp_proxy/evidence/store.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,9 @@ def write_pending(self, record: PendingApproval) -> None:
238238
record = replace(record, prev_event_hash=prev_event_hash)
239239
values = _record_values(record)
240240
placeholders = ", ".join("?" for _ in _COLUMNS)
241+
# SQL is safe: _COLUMNS is static and values are bound.
241242
self._conn.execute(
242-
f"INSERT INTO pending_approvals ({', '.join(_COLUMNS)}) VALUES ({placeholders})",
243+
f"INSERT INTO pending_approvals ({', '.join(_COLUMNS)}) VALUES ({placeholders})", # nosec B608
243244
values,
244245
)
245246
if not append_only:
@@ -254,8 +255,9 @@ def get_pending(self, request_id: str) -> PendingApproval | None:
254255
"""Return the approval record for a request ID, if present."""
255256

256257
with self._lock:
258+
# SQL is safe: _COLUMNS is static and request_id is bound.
257259
row = self._conn.execute(
258-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE request_id = ?",
260+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE request_id = ?", # nosec B608
259261
(request_id,),
260262
).fetchone()
261263
return None if row is None else _row_to_record(row)
@@ -265,14 +267,16 @@ def list_pending(self, *, since_timestamp: int | None = None) -> list[PendingApp
265267

266268
with self._lock:
267269
if since_timestamp is None:
270+
# SQL is safe: _COLUMNS is static and status is bound.
268271
rows = self._conn.execute(
269-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE status = ? "
272+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE status = ? " # nosec B608
270273
"ORDER BY created_at, request_id",
271274
(ApprovalStatus.PENDING.value,),
272275
).fetchall()
273276
else:
277+
# SQL is safe: _COLUMNS is static and filters are bound.
274278
rows = self._conn.execute(
275-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE status = ? "
279+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE status = ? " # nosec B608
276280
"AND created_at >= ? ORDER BY created_at, request_id",
277281
(ApprovalStatus.PENDING.value, since_timestamp),
278282
).fetchall()
@@ -290,8 +294,9 @@ def transition(self, request_id: str, new_status: str, **fields: Any) -> Pending
290294
with self._lock:
291295
self._begin()
292296
try:
297+
# SQL is safe: _COLUMNS is static and request_id is bound.
293298
row = self._conn.execute(
294-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE request_id = ?",
299+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals WHERE request_id = ?", # nosec B608
295300
(request_id,),
296301
).fetchone()
297302
if row is None:
@@ -317,8 +322,9 @@ def transition(self, request_id: str, new_status: str, **fields: Any) -> Pending
317322
updates["status"] = normalized
318323
self._validate_transition_fields(updates)
319324
assignments = ", ".join(f"{column} = ?" for column in updates)
325+
# SQL is safe: assignment columns are restricted and values are bound.
320326
self._conn.execute(
321-
f"UPDATE pending_approvals SET {assignments} WHERE request_id = ?",
327+
f"UPDATE pending_approvals SET {assignments} WHERE request_id = ?", # nosec B608
322328
(*updates.values(), request_id),
323329
)
324330
self._rebuild_chain_locked()
@@ -438,8 +444,9 @@ def list_records(
438444
params.extend(ids)
439445
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
440446
with self._lock:
447+
# SQL is safe: _COLUMNS is static and where clauses are validated.
441448
rows = self._conn.execute(
442-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals "
449+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals " # nosec B608
443450
f"{where} ORDER BY created_at, request_id",
444451
params,
445452
).fetchall()
@@ -465,8 +472,9 @@ def find_active_similar_grant(
465472
)
466473
placeholders = ", ".join("?" for _ in reusable_statuses)
467474
with self._lock:
475+
# SQL is safe: _COLUMNS is static and filters are bound.
468476
row = self._conn.execute(
469-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals "
477+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals " # nosec B608
470478
f"WHERE status IN ({placeholders}) "
471479
"AND approval_scope = ? AND granted_scope_expires_at > ? "
472480
"AND downstream_server = ? AND tool_name = ? AND risk_class = ? "
@@ -494,16 +502,18 @@ def vacuum_terminal_records(self, *, before_timestamp: int) -> int:
494502
with self._lock:
495503
self._begin()
496504
try:
505+
# SQL is safe: terminal status placeholders and cutoff are controlled.
497506
row = self._conn.execute(
498507
"SELECT COUNT(*) FROM pending_approvals "
499-
f"WHERE status IN ({placeholders}) AND created_at < ?",
508+
f"WHERE status IN ({placeholders}) AND created_at < ?", # nosec B608
500509
(*terminal, int(before_timestamp)),
501510
).fetchone()
502511
deleted = int(row[0])
503512
if deleted:
513+
# SQL is safe: terminal status placeholders and cutoff are controlled.
504514
self._conn.execute(
505515
"DELETE FROM pending_approvals "
506-
f"WHERE status IN ({placeholders}) AND created_at < ?",
516+
f"WHERE status IN ({placeholders}) AND created_at < ?", # nosec B608
507517
(*terminal, int(before_timestamp)),
508518
)
509519
self._rebuild_chain_locked()
@@ -607,8 +617,9 @@ def _migrate_v3_to_v4_locked(self) -> None:
607617
# SQLite cannot relax a NOT NULL column constraint in place; rebuild the table.
608618
columns = ", ".join(_COLUMNS)
609619
self._conn.execute(_CREATE_PENDING_APPROVALS_MIGRATION_SQL)
620+
# SQL is safe: migration columns are restricted to static _COLUMNS.
610621
self._conn.execute(
611-
f"INSERT INTO pending_approvals_new ({columns}) "
622+
f"INSERT INTO pending_approvals_new ({columns}) " # nosec B608
612623
f"SELECT {columns} FROM pending_approvals"
613624
)
614625
self._conn.execute("DROP TABLE pending_approvals")
@@ -624,8 +635,9 @@ def _set_schema_version_locked(self, version: int) -> None:
624635
)
625636

626637
def _compute_chain_link_for_insert_locked(self, record: PendingApproval) -> tuple[str, bool]:
638+
# SQL is safe: _COLUMNS is static.
627639
row = self._conn.execute(
628-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals "
640+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals " # nosec B608
629641
"ORDER BY created_at DESC, request_id DESC LIMIT 1"
630642
).fetchone()
631643
if row is None:
@@ -639,8 +651,9 @@ def _compute_chain_link_for_insert_locked(self, record: PendingApproval) -> tupl
639651
return GENESIS_PREV_EVENT_HASH, False
640652

641653
def _rebuild_chain_locked(self) -> None:
654+
# SQL is safe: _COLUMNS is static.
642655
rows = self._conn.execute(
643-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals ORDER BY created_at, request_id"
656+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals ORDER BY created_at, request_id" # nosec B608
644657
).fetchall()
645658
prev_hash = GENESIS_PREV_EVENT_HASH
646659
for row in rows:
@@ -654,8 +667,9 @@ def _rebuild_chain_locked(self) -> None:
654667
prev_hash = record_hash(data)
655668

656669
def _validate_chain_locked(self) -> None:
670+
# SQL is safe: _COLUMNS is static.
657671
rows = self._conn.execute(
658-
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals ORDER BY created_at, request_id"
672+
f"SELECT {', '.join(_COLUMNS)} FROM pending_approvals ORDER BY created_at, request_id" # nosec B608
659673
).fetchall()
660674
prev_hash = GENESIS_PREV_EVENT_HASH
661675
for row in rows:

agentveil_mcp_proxy/passthrough.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
MAX_CLIENT_MESSAGE_BYTES = 1 * 1024 * 1024
6464
MAX_PENDING_RESPONSES = 1000
6565
DEFAULT_TIMED_OUT_ID_RETENTION_SECONDS = 600.0
66+
_ENV_PASSTHROUGH_BLOCKED_PREFIXES = ("AVP_",)
6667
SAFE_ENV_KEYS = (
6768
"PATH",
6869
"HOME",
@@ -233,6 +234,12 @@ def from_proxy_config(cls, config: ProxyConfig) -> "DownstreamConfig":
233234
for item in env_passthrough
234235
):
235236
raise PassthroughError("downstream.env_passthrough must be a list of strings")
237+
for item in env_passthrough:
238+
if any(item.startswith(prefix) for prefix in _ENV_PASSTHROUGH_BLOCKED_PREFIXES):
239+
raise PassthroughError(
240+
f"downstream.env_passthrough cannot forward {item!r}: "
241+
"AVP_* prefix is reserved for proxy-internal secrets"
242+
)
236243

237244
response_timeout = data.get(
238245
"response_timeout_seconds",

agentveil_mcp_proxy/policy.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,14 @@ def builtin_policy_pack(name: str) -> PolicyConfig:
752752
"risk_class": "destructive",
753753
"match": {
754754
"server": ["github", "github-*", "github_*", "*github*"],
755-
"tool": ["delete_*", "remove_*"],
755+
"tool": [
756+
"delete_*",
757+
"remove_*",
758+
"purge_*",
759+
"drop_*",
760+
"destroy_*",
761+
"revoke_*",
762+
],
756763
},
757764
},
758765
],
@@ -784,7 +791,19 @@ def builtin_policy_pack(name: str) -> PolicyConfig:
784791
"risk_class": "destructive",
785792
"match": {
786793
"server": ["filesystem", "fs", "*filesystem*"],
787-
"tool": ["delete_*", "remove_*"],
794+
"tool": [
795+
"delete_*",
796+
"remove_*",
797+
"purge_*",
798+
"truncate_*",
799+
"wipe_*",
800+
"format_*",
801+
"rm",
802+
"rm_*",
803+
"rmdir_*",
804+
"unlink_*",
805+
"clean_*",
806+
],
788807
},
789808
},
790809
],

tests/test_mcp_proxy_classification.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,54 @@ def test_risk_inference_destructive_wins_over_production_compounds():
229229
assert infer_risk_class("auth.revoke_prod_access", tool="revoke_prod_access") is RiskClass.DESTRUCTIVE
230230

231231

232+
def test_infer_risk_class_recognizes_purge_as_destructive():
233+
assert infer_risk_class("database.purge_database", tool="purge_database") is (
234+
RiskClass.DESTRUCTIVE
235+
)
236+
237+
238+
def test_infer_risk_class_recognizes_truncate_as_destructive():
239+
assert infer_risk_class("database.truncate_table", tool="truncate_table") is (
240+
RiskClass.DESTRUCTIVE
241+
)
242+
243+
244+
def test_infer_risk_class_recognizes_wipe_as_destructive():
245+
assert infer_risk_class("storage.wipe_disk", tool="wipe_disk") is RiskClass.DESTRUCTIVE
246+
247+
248+
def test_infer_risk_class_recognizes_format_as_destructive():
249+
assert infer_risk_class("storage.format_volume", tool="format_volume") is (
250+
RiskClass.DESTRUCTIVE
251+
)
252+
253+
254+
def test_infer_risk_class_recognizes_rm_as_destructive():
255+
assert infer_risk_class("filesystem.rm", tool="rm") is RiskClass.DESTRUCTIVE
256+
257+
258+
def test_infer_risk_class_recognizes_rmdir_as_destructive():
259+
assert infer_risk_class("filesystem.rmdir_tree", tool="rmdir_tree") is (
260+
RiskClass.DESTRUCTIVE
261+
)
262+
263+
264+
def test_infer_risk_class_recognizes_unlink_as_destructive():
265+
assert infer_risk_class("filesystem.unlink_file", tool="unlink_file") is (
266+
RiskClass.DESTRUCTIVE
267+
)
268+
269+
270+
def test_infer_risk_class_recognizes_clean_as_destructive():
271+
assert infer_risk_class("filesystem.clean_temp", tool="clean_temp") is RiskClass.DESTRUCTIVE
272+
273+
274+
def test_infer_risk_class_destructive_wins_over_read_on_compound():
275+
assert infer_risk_class("filesystem.purge_files", tool="purge_files") is (
276+
RiskClass.DESTRUCTIVE
277+
)
278+
279+
232280
def test_risk_inference_does_not_over_classify_substring_collisions():
233281
assert infer_risk_class("github.get_infrastructure", tool="get_infrastructure") is RiskClass.READ
234282
assert infer_risk_class("github.list_endpoints", tool="list_endpoints") is RiskClass.READ

tests/test_mcp_proxy_passthrough.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ def _write_json(path: Path, data: dict) -> None:
5858
os.chmod(path, 0o600)
5959

6060

61+
def _config_with_env_passthrough(env_passthrough: list[str]) -> ProxyConfig:
62+
return ProxyConfig.from_dict({
63+
"proxy_config_schema_version": 1,
64+
"avp": {
65+
"base_url": "https://agentveil.dev",
66+
"agent_name": "agentveil-mcp-proxy",
67+
"trusted_signer_dids": ["did:key:z6MktrustedSigner"],
68+
},
69+
"mode": "protect",
70+
"privacy": {
71+
"action": "redacted",
72+
"resource": "hash",
73+
"payload": "hash_only",
74+
"evidence_upload": False,
75+
},
76+
"fallback": {},
77+
"approval": {},
78+
"policy": {
79+
"id": "test-policy",
80+
"policy_schema_version": 1,
81+
"default_decision": "ask_backend",
82+
"default_risk_class": "unknown",
83+
"rules": [],
84+
},
85+
"downstream": {
86+
"name": "env-test",
87+
"command": sys.executable,
88+
"args": [],
89+
"env_passthrough": env_passthrough,
90+
},
91+
})
92+
93+
6194
def _set_downstream(config_path: Path, script: Path, *, log_path: Path | None = None) -> None:
6295
config = json.loads(config_path.read_text(encoding="utf-8"))
6396
env = {}
@@ -167,7 +200,7 @@ def _env_downstream(tmp_path: Path) -> Path:
167200
result = {
168201
"secret": os.environ.get("AWS_SECRET_ACCESS_KEY"),
169202
"explicit": os.environ.get("EXPLICIT_DOWNSTREAM_ENV"),
170-
"passthrough": os.environ.get("AVP_TEST_ALLOWED_ENV"),
203+
"passthrough": os.environ.get("MY_TOOL_ALLOWED_ENV"),
171204
}
172205
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}), flush=True)
173206
""".lstrip(),
@@ -547,7 +580,7 @@ def __init__(self, *args, **kwargs):
547580

548581
def test_downstream_env_is_minimal_by_default_and_explicit_only(tmp_path, monkeypatch):
549582
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", SECRET)
550-
monkeypatch.setenv("AVP_TEST_ALLOWED_ENV", "allowed")
583+
monkeypatch.setenv("MY_TOOL_ALLOWED_ENV", "allowed")
551584
home = tmp_path / "avp-home"
552585
init = init_proxy(home=home, agent_name="proxy", plaintext=True)
553586
config = json.loads(init.config_path.read_text(encoding="utf-8"))
@@ -556,7 +589,7 @@ def test_downstream_env_is_minimal_by_default_and_explicit_only(tmp_path, monkey
556589
"command": sys.executable,
557590
"args": ["-u", str(_env_downstream(tmp_path))],
558591
"env": {"EXPLICIT_DOWNSTREAM_ENV": "explicit"},
559-
"env_passthrough": ["AVP_TEST_ALLOWED_ENV"],
592+
"env_passthrough": ["MY_TOOL_ALLOWED_ENV"],
560593
}
561594
_write_json(init.config_path, config)
562595

@@ -574,6 +607,43 @@ def test_downstream_env_is_minimal_by_default_and_explicit_only(tmp_path, monkey
574607
}
575608

576609

610+
def test_downstream_config_rejects_avp_passphrase_in_env_passthrough():
611+
config = _config_with_env_passthrough(["AVP_PROXY_PASSPHRASE"])
612+
613+
with pytest.raises(passthrough_module.PassthroughError, match="AVP_\\* prefix"):
614+
DownstreamConfig.from_proxy_config(config)
615+
616+
617+
def test_downstream_config_rejects_other_avp_var_in_env_passthrough():
618+
config = _config_with_env_passthrough(["AVP_HOME"])
619+
620+
with pytest.raises(passthrough_module.PassthroughError, match="AVP_\\* prefix"):
621+
DownstreamConfig.from_proxy_config(config)
622+
623+
624+
def test_downstream_config_accepts_non_avp_env_passthrough():
625+
config = _config_with_env_passthrough(["MY_TOOL_VAR", "HOME"])
626+
627+
parsed = DownstreamConfig.from_proxy_config(config)
628+
629+
assert parsed.env_passthrough == ("MY_TOOL_VAR", "HOME")
630+
631+
632+
def test_downstream_config_accepts_lowercase_avp_var():
633+
config = _config_with_env_passthrough(["avp_internal"])
634+
635+
parsed = DownstreamConfig.from_proxy_config(config)
636+
637+
assert parsed.env_passthrough == ("avp_internal",)
638+
639+
640+
def test_downstream_config_rejects_avp_var_among_safe_vars():
641+
config = _config_with_env_passthrough(["HOME", "AVP_PROXY_PASSPHRASE", "PATH"])
642+
643+
with pytest.raises(passthrough_module.PassthroughError, match="AVP_PROXY_PASSPHRASE"):
644+
DownstreamConfig.from_proxy_config(config)
645+
646+
577647
def test_downstream_notifications_are_forwarded_before_matching_response(tmp_path):
578648
home = tmp_path / "avp-home"
579649
init = init_proxy(home=home, agent_name="proxy", plaintext=True)

0 commit comments

Comments
 (0)