agentx-security-sdk 0.4.0__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/PKG-INFO +18 -3
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/README.md +17 -2
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/__init__.py +1 -1
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/cli.py +3 -2
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/decorators.py +28 -10
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/mcp_proxy.py +133 -19
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/pulse.py +5 -3
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_agentx_sdk.py +24 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_mcp_proxy.py +174 -2
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/PKG-INFO +18 -3
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/setup.py +1 -1
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/LICENSE +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/client.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/conftest.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/db.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/envfile.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/overrides.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/rules.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_async_protect.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_autocontribute.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_block_category.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_block_result.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_build2_overrides.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_cli.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_client.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_console_encoding.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_db.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_fail_modes.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_nudge.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_pulse.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_rules.py +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/SOURCES.txt +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/dependency_links.txt +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/entry_points.txt +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/requires.txt +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/top_level.txt +0 -0
- {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-security-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Runtime firewall for AI agents - blocks catastrophic tool calls and self-heals the run.
|
|
5
5
|
Home-page: https://agentx-core.com
|
|
6
6
|
Author: AgentX Core Team
|
|
@@ -147,6 +147,21 @@ Don't own the tool's Python? Running a non-Python agent? Wrap any **MCP server**
|
|
|
147
147
|
|
|
148
148
|
`agentx-mcp <real server command>` spawns the real server and relays the protocol untouched, intercepting only tool calls. A blocked call comes back to the agent as a coaching tool error it can self-correct on, so the run keeps going and the dangerous call never reaches the server.
|
|
149
149
|
|
|
150
|
+
**No Python in your stack?** You don't need a persistent install. Run it on demand with [`uvx`](https://docs.astral.sh/uv/) (or `pipx run`), which fetches the package into a throwaway environment, so your `mcp.json` stays one line:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"mcpServers": {
|
|
155
|
+
"filesystem": {
|
|
156
|
+
"command": "uvx",
|
|
157
|
+
"args": ["--from", "agentx-security-sdk", "agentx-mcp", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
(`pipx run --spec agentx-security-sdk agentx-mcp <real server command>` works the same way.)
|
|
164
|
+
|
|
150
165
|
---
|
|
151
166
|
|
|
152
167
|
### Step 3: See your first block: no key, no gateway, no signup
|
|
@@ -204,7 +219,7 @@ Injected Input Payload: 'Customer requested normal update. Retain account histor
|
|
|
204
219
|
|
|
205
220
|
### Step 4: (Upgrade) Recover: turn hard blocks into recoverable challenges
|
|
206
221
|
|
|
207
|
-
Everything above is keyless **Shield
|
|
222
|
+
Everything above is keyless **Shield**: it blocks the blatant calls and coaches your agent to self-correct. The **reasoning axis** (orthogonal to `AGENTX_MODE`) is **Recover**: the gateway judge catches what the keyword floor can't see, writes the **task-fitting challenge** when your policy carries none, and runs the coach-and-retry for you, plus Discovery for novel-intent classification. The tier no `403`-style firewall has. It takes two things, and they go together:
|
|
208
223
|
|
|
209
224
|
**1. A `GEMINI_API_KEY`**, the cheap part. Copy `.env.example` to `.env` and set it:
|
|
210
225
|
|
|
@@ -350,7 +365,7 @@ Inside the examples/ folder, you will find a few standalone scripts proving the
|
|
|
350
365
|
* **04_circuit_breaker_demo.py:** AgentX catches and prevents an infinite apology loop, saving time and tokens.
|
|
351
366
|
* **06_hitl_escalation.py:** See how an agent safely pauses execution and pings a SOC analyst for approval using a 202 Accepted queue.
|
|
352
367
|
* **09_budget_ceiling_demo.py:** Watch AgentX meter a runaway agent's cumulative spend and halt the session the moment it crosses your budget ceiling, a deterministic, gateway-side escalation (no LLM judge). Needs the gateway running + a key.
|
|
353
|
-
* **10_self_correction_coaching.py:** The Recover tier,
|
|
368
|
+
* **10_self_correction_coaching.py:** The Recover tier, where the gateway judge does the coaching for you. AgentX blocks a dangerous action and returns a task-fitting challenge that names a safe path, so your agent finishes the job instead of dead-ending. Puts the AgentX challenge side by side with a plain guardrail's, then watches the agent recover on it. Needs the gateway running + your Gemini key (the keyless Shield already coaches in-band; Recover's judge catches more and runs the retry for you).
|
|
354
369
|
* **And many more...**
|
|
355
370
|
|
|
356
371
|
---
|
|
@@ -109,6 +109,21 @@ Don't own the tool's Python? Running a non-Python agent? Wrap any **MCP server**
|
|
|
109
109
|
|
|
110
110
|
`agentx-mcp <real server command>` spawns the real server and relays the protocol untouched, intercepting only tool calls. A blocked call comes back to the agent as a coaching tool error it can self-correct on, so the run keeps going and the dangerous call never reaches the server.
|
|
111
111
|
|
|
112
|
+
**No Python in your stack?** You don't need a persistent install. Run it on demand with [`uvx`](https://docs.astral.sh/uv/) (or `pipx run`), which fetches the package into a throwaway environment, so your `mcp.json` stays one line:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"mcpServers": {
|
|
117
|
+
"filesystem": {
|
|
118
|
+
"command": "uvx",
|
|
119
|
+
"args": ["--from", "agentx-security-sdk", "agentx-mcp", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
(`pipx run --spec agentx-security-sdk agentx-mcp <real server command>` works the same way.)
|
|
126
|
+
|
|
112
127
|
---
|
|
113
128
|
|
|
114
129
|
### Step 3: See your first block: no key, no gateway, no signup
|
|
@@ -166,7 +181,7 @@ Injected Input Payload: 'Customer requested normal update. Retain account histor
|
|
|
166
181
|
|
|
167
182
|
### Step 4: (Upgrade) Recover: turn hard blocks into recoverable challenges
|
|
168
183
|
|
|
169
|
-
Everything above is keyless **Shield
|
|
184
|
+
Everything above is keyless **Shield**: it blocks the blatant calls and coaches your agent to self-correct. The **reasoning axis** (orthogonal to `AGENTX_MODE`) is **Recover**: the gateway judge catches what the keyword floor can't see, writes the **task-fitting challenge** when your policy carries none, and runs the coach-and-retry for you, plus Discovery for novel-intent classification. The tier no `403`-style firewall has. It takes two things, and they go together:
|
|
170
185
|
|
|
171
186
|
**1. A `GEMINI_API_KEY`**, the cheap part. Copy `.env.example` to `.env` and set it:
|
|
172
187
|
|
|
@@ -312,7 +327,7 @@ Inside the examples/ folder, you will find a few standalone scripts proving the
|
|
|
312
327
|
* **04_circuit_breaker_demo.py:** AgentX catches and prevents an infinite apology loop, saving time and tokens.
|
|
313
328
|
* **06_hitl_escalation.py:** See how an agent safely pauses execution and pings a SOC analyst for approval using a 202 Accepted queue.
|
|
314
329
|
* **09_budget_ceiling_demo.py:** Watch AgentX meter a runaway agent's cumulative spend and halt the session the moment it crosses your budget ceiling, a deterministic, gateway-side escalation (no LLM judge). Needs the gateway running + a key.
|
|
315
|
-
* **10_self_correction_coaching.py:** The Recover tier,
|
|
330
|
+
* **10_self_correction_coaching.py:** The Recover tier, where the gateway judge does the coaching for you. AgentX blocks a dangerous action and returns a task-fitting challenge that names a safe path, so your agent finishes the job instead of dead-ending. Puts the AgentX challenge side by side with a plain guardrail's, then watches the agent recover on it. Needs the gateway running + your Gemini key (the keyless Shield already coaches in-band; Recover's judge catches more and runs the retry for you).
|
|
316
331
|
* **And many more...**
|
|
317
332
|
|
|
318
333
|
---
|
|
@@ -1157,8 +1157,9 @@ def execute_demo():
|
|
|
1157
1157
|
print(f" receipt: {result.receipt_id}")
|
|
1158
1158
|
print(" The DROP TABLE never reached your database.")
|
|
1159
1159
|
print("\n What this shows: one decorator stops a catastrophic tool call offline.")
|
|
1160
|
-
print(" That's SHIELD (keyless)
|
|
1161
|
-
print("
|
|
1160
|
+
print(" That's SHIELD (keyless): it blocks AND coaches your agent to self-correct.")
|
|
1161
|
+
print(" RECOVER (gateway + key) runs the coach-and-retry for you and catches what")
|
|
1162
|
+
print(" keyword rules can't see. Two steps get you there:")
|
|
1162
1163
|
print("=" * 75)
|
|
1163
1164
|
print(" 1 ▶ Protect your own agent. Wrap any tool, then coach and retry on a block:")
|
|
1164
1165
|
print(" from agentx_sdk import agentx_protect, is_block")
|
|
@@ -807,33 +807,41 @@ LOCAL_SHIELD_WEIGHTS, LOCAL_SHIELD_MANIFEST = load_local_vector_shield_cache()
|
|
|
807
807
|
# tool/function name or payload (see _BLOCK_CATEGORY_VOCAB + pulse.py). Off-vocab is
|
|
808
808
|
# dropped, so a pulled/cloud policy without a known category simply reports nothing.
|
|
809
809
|
_BUILTIN_POLICY_KEYWORDS = [
|
|
810
|
+
# socratic_prompt = the agent-facing CHALLENGE; preferred_alternative = the concrete
|
|
811
|
+
# SAFE PATH. Both are written for the caller's own model to self-correct on (the
|
|
812
|
+
# keyless MCP proxy + the offline keyword shield), so they lead with the issue and a
|
|
813
|
+
# usable next step, NOT internal taxonomy or judge-era "explain your reasoning" cruft.
|
|
810
814
|
{
|
|
811
815
|
"id": "11111111-1111-1111-1111-111111111101",
|
|
812
816
|
"name": "Mass Destructive Intent",
|
|
813
817
|
"category": "DESTRUCTIVE_ACTION",
|
|
814
818
|
"blocked_intents": ["DROP TABLE", "DELETE FROM", "TRUNCATE TABLE", "DROP DATABASE"],
|
|
815
|
-
"socratic_prompt": "
|
|
819
|
+
"socratic_prompt": "This is a destructive, irreversible write that drops or mass-deletes data.",
|
|
820
|
+
"preferred_alternative": "Scope the change to specific rows with a WHERE clause, take a backup or snapshot first, or use a reversible soft-delete instead of dropping the table.",
|
|
816
821
|
},
|
|
817
822
|
{
|
|
818
823
|
"id": "11111111-1111-1111-1111-111111111103",
|
|
819
824
|
"name": "Network Sandbox (SSRF)",
|
|
820
825
|
"category": "NETWORK_TRAVERSAL",
|
|
821
826
|
"blocked_intents": ["169.254.169.254", "localhost", "127.0.0.1", "0.0.0.0"],
|
|
822
|
-
"socratic_prompt": "
|
|
827
|
+
"socratic_prompt": "This target is a loopback or cloud-metadata address, a common SSRF path to internal credentials.",
|
|
828
|
+
"preferred_alternative": "Send the request to the intended external service hostname over HTTPS, not an internal IP, localhost, or 169.254.169.254.",
|
|
823
829
|
},
|
|
824
830
|
{
|
|
825
831
|
"id": "11111111-1111-1111-1111-111111111104",
|
|
826
832
|
"name": "Secrets and PII Exfiltration",
|
|
827
833
|
"category": "SECRETS_LEAK",
|
|
828
834
|
"blocked_intents": ["system_users", "pastebin", "SELECT email", "SELECT role"],
|
|
829
|
-
"socratic_prompt": "
|
|
835
|
+
"socratic_prompt": "This call reads credentials or routes data to an external paste sink, which is an exfiltration path.",
|
|
836
|
+
"preferred_alternative": "Read only the non-secret fields you actually need and keep the data inside the trusted system. Do not send it to an external paste endpoint.",
|
|
830
837
|
},
|
|
831
838
|
{
|
|
832
839
|
"id": "11111111-1111-1111-1111-111111111102",
|
|
833
840
|
"name": "Customer Privacy Shield",
|
|
834
841
|
"category": "PII_EXFILTRATION",
|
|
835
842
|
"blocked_intents": ["SELECT email", "SELECT phone", "SELECT address", "credit_card"],
|
|
836
|
-
"socratic_prompt": "
|
|
843
|
+
"socratic_prompt": "This query pulls raw customer PII (email, phone, address, or card data). Bulk access to unmasked PII is restricted.",
|
|
844
|
+
"preferred_alternative": "Select only the non-PII fields you need, aggregate instead of returning raw rows, or use masked or hashed columns.",
|
|
837
845
|
},
|
|
838
846
|
]
|
|
839
847
|
|
|
@@ -857,7 +865,10 @@ def _note_block_category(category, stats=None):
|
|
|
857
865
|
passes its OWN stats dict so it shares this exact vocab guard instead of
|
|
858
866
|
reimplementing it (the two can't drift)."""
|
|
859
867
|
target = _session_stats if stats is None else stats
|
|
860
|
-
|
|
868
|
+
# isinstance guard: a malformed pulled policy can carry a NON-string category, and
|
|
869
|
+
# `<list> in <frozenset>` raises TypeError (unhashable) — drop it rather than let it
|
|
870
|
+
# wedge the block path (incl. the agentx-mcp proxy's client routing loop).
|
|
871
|
+
if isinstance(category, str) and category in _BLOCK_CATEGORY_VOCAB:
|
|
861
872
|
target["block_category"] = category
|
|
862
873
|
|
|
863
874
|
def load_local_policy_keywords(seed_dir=".agentx"):
|
|
@@ -893,6 +904,9 @@ def load_local_policy_keywords(seed_dir=".agentx"):
|
|
|
893
904
|
"blocked_intents": p.get("blocked_intents", []),
|
|
894
905
|
"socratic_prompt": p.get("socratic_prompt")
|
|
895
906
|
or "Policy Violation. Revise your action to comply with security policy.",
|
|
907
|
+
# Carry the concrete safe path through if the pull supplies one, so the
|
|
908
|
+
# keyless coaching (A1a) can surface it for pulled/cloud policies too.
|
|
909
|
+
"preferred_alternative": p.get("preferred_alternative"),
|
|
896
910
|
})
|
|
897
911
|
if policies:
|
|
898
912
|
return policies
|
|
@@ -1359,13 +1373,17 @@ def agentx_protect(agent_id: str, extract_query_func=None, extract_cot_func=None
|
|
|
1359
1373
|
else:
|
|
1360
1374
|
print(f"📝 [LOCAL KEYWORD SHIELD] Offline (no API key) — using local receipt: {effective_receipt}")
|
|
1361
1375
|
|
|
1362
|
-
# ✅ SURGICAL FIX: Route via the delivery function
|
|
1376
|
+
# ✅ SURGICAL FIX: Route via the delivery function. Coaching mirrors the
|
|
1377
|
+
# keyless MCP path (A1a): lead with the challenge + the concrete safe path,
|
|
1378
|
+
# with no judge-era "explain your symbolic reasoning" / SAFE_WRITE taxonomy
|
|
1379
|
+
# (this OFFLINE keyword-shield path has no judge), so the two keyless surfaces
|
|
1380
|
+
# emit consistent coaching. The ONLINE/judge path below keeps its own wording.
|
|
1381
|
+
ls_safe_hint = f" Safe alternative: {ls_safe_path}" if ls_safe_path else ""
|
|
1363
1382
|
payload = (
|
|
1364
1383
|
f"🚨 [AgentX Security Block] | policy: '{policy_name}' | receipt_id: '{effective_receipt}' | "
|
|
1365
|
-
f"Challenge/Constraint: {challenge_text}
|
|
1366
|
-
f"System Instruction: Your request has been blocked.
|
|
1367
|
-
f"
|
|
1368
|
-
f"and retry your tool execution turn immediately."
|
|
1384
|
+
f"Challenge/Constraint: {challenge_text}{ls_safe_hint} "
|
|
1385
|
+
f"System Instruction: Your request has been blocked. Revise the action to a "
|
|
1386
|
+
f"safe form and retry your tool execution turn immediately."
|
|
1369
1387
|
)
|
|
1370
1388
|
return _deliver_challenge(
|
|
1371
1389
|
payload, effective_receipt, policy_name,
|
|
@@ -42,6 +42,7 @@ Known limitations (deliberate for v1):
|
|
|
42
42
|
block responses. That is correct (a stalled host stalls the proxy) but head-of-line.
|
|
43
43
|
"""
|
|
44
44
|
import json
|
|
45
|
+
import os
|
|
45
46
|
import subprocess
|
|
46
47
|
import sys
|
|
47
48
|
import threading
|
|
@@ -58,6 +59,8 @@ _real_stdout = sys.stdout
|
|
|
58
59
|
sys.stdout = sys.stderr
|
|
59
60
|
try:
|
|
60
61
|
from agentx_sdk.decorators import (
|
|
62
|
+
_BLOCK_CATEGORY_VOCAB,
|
|
63
|
+
_apply_org_override,
|
|
61
64
|
_coerce_arg_value,
|
|
62
65
|
_max_cognitive_turns,
|
|
63
66
|
_note_block_category,
|
|
@@ -98,22 +101,29 @@ def _flatten_call(name, arguments):
|
|
|
98
101
|
return " ".join(parts)
|
|
99
102
|
|
|
100
103
|
|
|
101
|
-
def _coaching_text(decision, tripped):
|
|
102
|
-
"""The agent-facing block message.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
def _coaching_text(decision, tripped, tool_name=None):
|
|
105
|
+
"""The agent-facing block message. Written for the CALLER'S own model to self-correct
|
|
106
|
+
on (no judge ever reads this), so it leads with the issue and the safe next step and
|
|
107
|
+
stays call-specific: it names the tool that was blocked and, when the policy carries
|
|
108
|
+
one, the concrete safe alternative (challenge-quality lever: task-fitting > generic).
|
|
109
|
+
No em dashes (house style); this text can end up in a shared block card."""
|
|
106
110
|
challenge = decision.get("challenge_text") or "Revise the action to comply with security policy."
|
|
111
|
+
if challenge and challenge.rstrip()[-1:] not in ".!?":
|
|
112
|
+
challenge = challenge.rstrip() + "." # pulled-policy text may lack end punctuation; avoid a run-on
|
|
113
|
+
safe = decision.get("preferred_alternative")
|
|
114
|
+
on_tool = " on '%s'" % tool_name if tool_name else ""
|
|
115
|
+
safe_hint = " Safe alternative: %s" % safe if safe else ""
|
|
107
116
|
if tripped:
|
|
108
117
|
return (
|
|
109
|
-
"AgentX circuit breaker
|
|
110
|
-
"halted to stop a runaway loop. Do not retry the same action.
|
|
111
|
-
"
|
|
118
|
+
"AgentX circuit breaker: this call%s was blocked repeatedly and has been "
|
|
119
|
+
"halted to stop a runaway loop. Do not retry the same action. %s%s "
|
|
120
|
+
"If you cannot reach the goal safely, ask the human operator."
|
|
121
|
+
% (on_tool, challenge, safe_hint)
|
|
112
122
|
)
|
|
113
123
|
return (
|
|
114
|
-
"AgentX blocked this call before it ran. %s Revise
|
|
115
|
-
"
|
|
116
|
-
% challenge
|
|
124
|
+
"AgentX blocked this call%s before it ran. %s%s Revise to a safe form and try "
|
|
125
|
+
"again, or ask the human operator if you cannot reach the goal safely."
|
|
126
|
+
% (on_tool, challenge, safe_hint)
|
|
117
127
|
)
|
|
118
128
|
|
|
119
129
|
|
|
@@ -127,6 +137,81 @@ def _block_response(req_id, text):
|
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
|
|
140
|
+
def _harvest_enabled():
|
|
141
|
+
"""(B) recovery-pair harvest is OPT-IN: OFF unless AGENTX_MCP_HARVEST is truthy, so a
|
|
142
|
+
default / cold install captures NOTHING."""
|
|
143
|
+
return os.environ.get("AGENTX_MCP_HARVEST", "").strip().lower() in ("1", "true", "yes", "on")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Harvest:
|
|
147
|
+
"""Opt-in, tenant-private, LOCAL-ONLY capture of keyless recovery pairs
|
|
148
|
+
(designs/mcp-corpus-intake.md (B) — the SAFE-DEFAULT skeleton).
|
|
149
|
+
|
|
150
|
+
On a block it remembers ONLY {tool, policy_category}: policy_category is the closed pulse
|
|
151
|
+
vocab (validated), and tool is the SERVER-DEFINED tool identifier (NOT a vetted enum, so it
|
|
152
|
+
is kept LOCAL and never transmitted). It NEVER stores raw args / query / chain-of-thought.
|
|
153
|
+
On a later clean call to the SAME tool it forms a recovery-pair candidate, written at
|
|
154
|
+
session end to a LOCAL tenant-private file that NEVER crosses the network.
|
|
155
|
+
|
|
156
|
+
FOUNDER RATIFICATION (deliberately NOT decided here): (1) the abstraction — category+tool
|
|
157
|
+
only today; whether to capture a richer structural signature of the recovered call, AND how
|
|
158
|
+
to sanitize the server-defined tool name (raw / hashed / dropped) before any network sink,
|
|
159
|
+
is design-doc open decision #2; (2) there is NO network sink and no Discovery/adopt-queue
|
|
160
|
+
integration (moat-collection-day0, founder-gated)."""
|
|
161
|
+
|
|
162
|
+
def __init__(self):
|
|
163
|
+
self._pending = {} # tool -> coarse policy_category (abstract only)
|
|
164
|
+
self.pairs = [] # recovery-pair candidates: {tool, policy_category, recovered}
|
|
165
|
+
|
|
166
|
+
def note_block(self, tool, category):
|
|
167
|
+
# Record ONLY a coarse category in the closed pulse vocab. An off-vocab OR non-string
|
|
168
|
+
# category (a malformed pulled policy) is dropped AND clears any stale pending entry,
|
|
169
|
+
# so a later recovery is never mis-attributed to a prior category, and a non-string
|
|
170
|
+
# category can't raise on the `in` test.
|
|
171
|
+
if isinstance(category, str) and category in _BLOCK_CATEGORY_VOCAB:
|
|
172
|
+
self._pending[tool] = category
|
|
173
|
+
else:
|
|
174
|
+
self._pending.pop(tool, None)
|
|
175
|
+
|
|
176
|
+
def note_recovery(self, tool):
|
|
177
|
+
category = self._pending.pop(tool, None)
|
|
178
|
+
if category:
|
|
179
|
+
self.pairs.append({"tool": tool, "policy_category": category, "recovered": True})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _harvest_path():
|
|
183
|
+
"""Where the local recovery-pair file lives. An explicit AGENTX_MCP_HARVEST_PATH wins (the
|
|
184
|
+
opt-in user controls it — RECOMMENDED for MCP hosts, which launch the proxy from an unrelated
|
|
185
|
+
cwd, often $HOME or /); otherwise it sits in .agentx/ under the project root, beside the
|
|
186
|
+
overrides store. NB: launched outside a project (no .git/.agentx) the root falls back to cwd,
|
|
187
|
+
so the explicit path is preferred in the MCP execution context."""
|
|
188
|
+
explicit = os.environ.get("AGENTX_MCP_HARVEST_PATH")
|
|
189
|
+
if explicit:
|
|
190
|
+
return explicit
|
|
191
|
+
from agentx_sdk.overrides import _find_project_root
|
|
192
|
+
return os.path.join(_find_project_root(), ".agentx", "mcp_harvest.jsonl")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _flush_harvest(harvest, log):
|
|
196
|
+
"""Append the session's recovery-pair candidates to a LOCAL tenant-private JSONL (see
|
|
197
|
+
_harvest_path). LOCAL ONLY — it never crosses the network. The record is exactly the abstract
|
|
198
|
+
pair {tool, policy_category, recovered} (NO wall-clock timestamp, which would be deanonymizing
|
|
199
|
+
once a sink ships). Best-effort: any error is swallowed so harvest can never affect the proxy
|
|
200
|
+
or the run."""
|
|
201
|
+
if not harvest or not harvest.pairs:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
path = _harvest_path()
|
|
205
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
206
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
207
|
+
for pair in harvest.pairs:
|
|
208
|
+
f.write(json.dumps(pair) + "\n")
|
|
209
|
+
print("[agentx-mcp] harvested %d local recovery-pair candidate(s) -> %s"
|
|
210
|
+
% (len(harvest.pairs), path), file=log)
|
|
211
|
+
except Exception as err:
|
|
212
|
+
print("[agentx-mcp] harvest flush skipped: %s" % err, file=log)
|
|
213
|
+
|
|
214
|
+
|
|
130
215
|
class _ClientWriter:
|
|
131
216
|
"""Serializes every write to the client's stdout (the sacred JSON-RPC channel).
|
|
132
217
|
|
|
@@ -176,7 +261,7 @@ def _forward(line, child_in):
|
|
|
176
261
|
child_in.flush()
|
|
177
262
|
|
|
178
263
|
|
|
179
|
-
def _screen_message(msg, session_stats, streaks, max_turns, writer, log):
|
|
264
|
+
def _screen_message(msg, session_stats, streaks, max_turns, writer, log, harvest=None):
|
|
180
265
|
"""Screen ONE parsed JSON-RPC message. Returns ``"forward"`` (the caller relays it
|
|
181
266
|
to the server) or ``"block"`` (a blocked tools/call, already handled — do NOT
|
|
182
267
|
forward). Side effects on a block: emit the isError response (only if the call had
|
|
@@ -196,7 +281,19 @@ def _screen_message(msg, session_stats, streaks, max_turns, writer, log):
|
|
|
196
281
|
|
|
197
282
|
tool_key = str(name)
|
|
198
283
|
if not decision:
|
|
199
|
-
|
|
284
|
+
# A clean call zeroes this tool's block streak. If that streak was non-zero, this
|
|
285
|
+
# SAME tool was blocked earlier and now runs safe: a rough proxy for "the agent
|
|
286
|
+
# recovered on the coaching", so count it on the pulse. This is a HEURISTIC keyed
|
|
287
|
+
# by tool name (the keyless proxy has no per-call trace) so it can over-attribute
|
|
288
|
+
# (a later unrelated clean call on a tool that was once blocked) and under-count
|
|
289
|
+
# cross-tool recoveries; it is therefore NOT identical to the decorator's
|
|
290
|
+
# trace-keyed _credit_recovery (which bounds recovered <= challenged). Counts-only
|
|
291
|
+
# / advisory: harvest-IN (designs/mcp-corpus-intake.md (B))'s block->allow
|
|
292
|
+
# correlation hook used purely to COUNT (capturing the revised-safe call is later).
|
|
293
|
+
if streaks.pop(tool_key, None):
|
|
294
|
+
session_stats["self_corrections"] = session_stats.get("self_corrections", 0) + 1
|
|
295
|
+
if harvest is not None:
|
|
296
|
+
harvest.note_recovery(tool_key) # (B): form the abstract recovery-pair
|
|
200
297
|
return "forward"
|
|
201
298
|
|
|
202
299
|
# Blocked. Match the decorator's breaker edge: _trip_breaker_if_ceiling checks the
|
|
@@ -215,7 +312,22 @@ def _screen_message(msg, session_stats, streaks, max_turns, writer, log):
|
|
|
215
312
|
|
|
216
313
|
req_id = msg.get("id")
|
|
217
314
|
if req_id is not None:
|
|
218
|
-
|
|
315
|
+
if harvest is not None:
|
|
316
|
+
# (B): only a block that was actually COACHED (had an id to answer) is a harvest
|
|
317
|
+
# candidate — an id-less blocked call is dropped with no coaching, so a later clean
|
|
318
|
+
# call on it is not a coached recovery.
|
|
319
|
+
harvest.note_block(tool_key, decision.get("category"))
|
|
320
|
+
# A1b: enrich the keyless coaching with the org's adopted reframe (challenge +
|
|
321
|
+
# safe-path) via the SHARED _apply_org_override the decorator uses, so the org
|
|
322
|
+
# brain reaches the MCP wedge too and the two keyless paths can't drift. Keyless:
|
|
323
|
+
# it reads the local .agentx/overrides.json (no gateway). Total best-effort — it
|
|
324
|
+
# returns the inputs unchanged when nothing is adopted, so the cold install (the
|
|
325
|
+
# funnel target, no overrides) is completely unaffected.
|
|
326
|
+
ch, safe = _apply_org_override(
|
|
327
|
+
decision.get("policy_id"), decision.get("challenge_text"),
|
|
328
|
+
decision.get("preferred_alternative"), policy_name=decision.get("policy_name"))
|
|
329
|
+
coached = dict(decision, challenge_text=ch, preferred_alternative=safe)
|
|
330
|
+
writer.send(_block_response(req_id, _coaching_text(coached, tripped, name)))
|
|
219
331
|
else:
|
|
220
332
|
# A tools/call shaped as a notification (no id) cannot be answered, but it must
|
|
221
333
|
# NOT be forwarded either — drop it so the dangerous call never reaches the server.
|
|
@@ -226,7 +338,7 @@ def _screen_message(msg, session_stats, streaks, max_turns, writer, log):
|
|
|
226
338
|
return "block"
|
|
227
339
|
|
|
228
340
|
|
|
229
|
-
def _route_line(line, child_in, writer, session_stats, streaks, max_turns, log):
|
|
341
|
+
def _route_line(line, child_in, writer, session_stats, streaks, max_turns, log, harvest=None):
|
|
230
342
|
"""Handle ONE client->server line. Forwards it to the child UNLESS a tools/call in
|
|
231
343
|
it is blocked by the keyless shield. Scalars are forwarded byte-for-byte; a batch
|
|
232
344
|
(JSON array) is screened member by member and only the un-blocked members are
|
|
@@ -246,13 +358,13 @@ def _route_line(line, child_in, writer, session_stats, streaks, max_turns, log):
|
|
|
246
358
|
# dangerous tools/call buried in a batch can't slip through unscreened.
|
|
247
359
|
if isinstance(msg, list):
|
|
248
360
|
keep = [item for item in msg
|
|
249
|
-
if _screen_message(item, session_stats, streaks, max_turns, writer, log) == "forward"]
|
|
361
|
+
if _screen_message(item, session_stats, streaks, max_turns, writer, log, harvest) == "forward"]
|
|
250
362
|
if keep:
|
|
251
363
|
child_in.write(json.dumps(keep) + "\n")
|
|
252
364
|
child_in.flush()
|
|
253
365
|
return
|
|
254
366
|
|
|
255
|
-
if _screen_message(msg, session_stats, streaks, max_turns, writer, log) == "forward":
|
|
367
|
+
if _screen_message(msg, session_stats, streaks, max_turns, writer, log, harvest) == "forward":
|
|
256
368
|
_forward(line, child_in) # scalar: forward the ORIGINAL line verbatim
|
|
257
369
|
|
|
258
370
|
|
|
@@ -269,6 +381,7 @@ def run_proxy(child_cmd, *, client_in, client_out, session_stats, log=None,
|
|
|
269
381
|
log = log or sys.stderr
|
|
270
382
|
max_turns = _max_cognitive_turns()
|
|
271
383
|
streaks = {}
|
|
384
|
+
harvest = _Harvest() if _harvest_enabled() else None # (B): opt-in, default OFF -> inert
|
|
272
385
|
writer = _ClientWriter(client_out)
|
|
273
386
|
|
|
274
387
|
def _default_popen(cmd):
|
|
@@ -313,7 +426,7 @@ def run_proxy(child_cmd, *, client_in, client_out, session_stats, log=None,
|
|
|
313
426
|
|
|
314
427
|
try:
|
|
315
428
|
for line in client_in:
|
|
316
|
-
_route_line(line, child.stdin, writer, session_stats, streaks, max_turns, log)
|
|
429
|
+
_route_line(line, child.stdin, writer, session_stats, streaks, max_turns, log, harvest)
|
|
317
430
|
except Exception as err:
|
|
318
431
|
print("[agentx-mcp] client stream error: %s" % err, file=log)
|
|
319
432
|
finally:
|
|
@@ -321,6 +434,7 @@ def run_proxy(child_cmd, *, client_in, client_out, session_stats, log=None,
|
|
|
321
434
|
child.stdin.close()
|
|
322
435
|
except Exception:
|
|
323
436
|
pass
|
|
437
|
+
_flush_harvest(harvest, log) # (B): write the session's local, abstract recovery pairs
|
|
324
438
|
|
|
325
439
|
try:
|
|
326
440
|
rc = child.wait(timeout=10)
|
|
@@ -380,7 +494,7 @@ def main(argv=None):
|
|
|
380
494
|
decorators.suppress_atexit_summary()
|
|
381
495
|
|
|
382
496
|
session_stats = {"integration": "mcp", "total_calls": 0,
|
|
383
|
-
"intercepts": 0, "critical_blocks": 0}
|
|
497
|
+
"intercepts": 0, "critical_blocks": 0, "self_corrections": 0}
|
|
384
498
|
|
|
385
499
|
def _emit_pulse():
|
|
386
500
|
# Mirror the decorator path (decorators.py): never emit a pulse from CI /
|
|
@@ -354,9 +354,11 @@ def maybe_emit_nudge(session_stats):
|
|
|
354
354
|
now = time.time()
|
|
355
355
|
if _should_emit_nudge(session_stats, state, now):
|
|
356
356
|
print("─" * 60)
|
|
357
|
-
print(" ▶ That was the keyless Shield
|
|
358
|
-
print("
|
|
359
|
-
print("
|
|
357
|
+
print(" ▶ That was the keyless Shield: it blocked the call AND coached your")
|
|
358
|
+
print(" agent to self-correct. Recover (gateway + key)")
|
|
359
|
+
print(" adds the judge that catches what keywords miss, auto-runs the retry,")
|
|
360
|
+
print(" and escalates the biggest calls to a human.")
|
|
361
|
+
print(" Unlock Recover: agentx-core.com/#request-access")
|
|
360
362
|
print(" Community: discord.gg/PmWRTtaSx2")
|
|
361
363
|
state["last_nudge_ts"] = now
|
|
362
364
|
changed = True
|
|
@@ -388,6 +388,30 @@ def test_layer0_still_blocks_destructive_catalog_op_when_intent_armed(monkeypatc
|
|
|
388
388
|
assert "🚨 [AgentX Security Block]" in str(result)
|
|
389
389
|
|
|
390
390
|
|
|
391
|
+
def test_offline_keyword_shield_coaching_is_decrufted_and_names_safe_path(monkeypatch):
|
|
392
|
+
"""The offline keyword-shield block coaching mirrors the keyless MCP path (A1a): no
|
|
393
|
+
judge-era 'symbolic reasoning' / SAFE_WRITE taxonomy (this path has no judge), and it
|
|
394
|
+
surfaces the policy's concrete safe path, so the two keyless surfaces stay consistent."""
|
|
395
|
+
import agentx_sdk.decorators as dec
|
|
396
|
+
|
|
397
|
+
armed = [{
|
|
398
|
+
"id": "11111111-1111-1111-1111-111111111105",
|
|
399
|
+
"name": "Schema Boundary",
|
|
400
|
+
"blocked_intents": ["drop schema", "alter database"],
|
|
401
|
+
"socratic_prompt": "This drops or alters catalog schema, which is restricted.",
|
|
402
|
+
"preferred_alternative": "Target only your own tables, or use a read-only catalog query.",
|
|
403
|
+
}]
|
|
404
|
+
monkeypatch.setattr(dec, "LOCAL_POLICY_KEYWORDS", armed)
|
|
405
|
+
monkeypatch.delenv("AGENTX_BYPASS_LOCAL_SHIELD", raising=False)
|
|
406
|
+
|
|
407
|
+
result = str(mock_secure_database_executor(sql_query="DROP SCHEMA public"))
|
|
408
|
+
assert "🚨 [AgentX Security Block]" in result
|
|
409
|
+
assert "symbolic reasoning" not in result.lower() # judge-era cruft removed
|
|
410
|
+
assert "SAFE_WRITE" not in result # internal taxonomy removed
|
|
411
|
+
assert "Safe alternative:" in result # surfaces preferred_alternative
|
|
412
|
+
assert "read-only catalog query" in result # the concrete safe path
|
|
413
|
+
|
|
414
|
+
|
|
391
415
|
def test_is_fs_destructive_func_tokenizes_name():
|
|
392
416
|
"""Verb is matched as a discrete token (snake_case + camelCase), never as a
|
|
393
417
|
substring — so `undelete_safely` / `formatter` do not false-positive."""
|
|
@@ -32,13 +32,13 @@ def _line(method="tools/call", *, id=1, name="run_sql", arguments=None, **extra)
|
|
|
32
32
|
return json.dumps(msg) + "\n"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def _route(line, *, stats=None, streaks=None, max_turns=3):
|
|
35
|
+
def _route(line, *, stats=None, streaks=None, max_turns=3, harvest=None):
|
|
36
36
|
"""Drive one line through the core with in-memory streams. Returns
|
|
37
37
|
(forwarded_to_child, sent_to_client, stats)."""
|
|
38
38
|
child, client = io.StringIO(), io.StringIO()
|
|
39
39
|
stats = {} if stats is None else stats
|
|
40
40
|
mp._route_line(line, child, mp._ClientWriter(client), stats,
|
|
41
|
-
{} if streaks is None else streaks, max_turns, io.StringIO())
|
|
41
|
+
{} if streaks is None else streaks, max_turns, io.StringIO(), harvest)
|
|
42
42
|
return child.getvalue(), client.getvalue(), stats
|
|
43
43
|
|
|
44
44
|
|
|
@@ -125,6 +125,21 @@ def test_block_category_off_vocab_is_not_emitted(monkeypatch):
|
|
|
125
125
|
assert "block_category" not in stats # off-vocab dropped, never emitted
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def test_non_string_category_does_not_wedge_screening(monkeypatch):
|
|
129
|
+
"""A malformed pulled policy can carry a NON-string category; screening must drop it
|
|
130
|
+
without raising (a TypeError in `<list> in <frozenset>` would wedge the whole client
|
|
131
|
+
routing loop). Pre-existing base-path fragility surfaced by the #135 review."""
|
|
132
|
+
decision = {"policy_name": "X", "challenge_text": "no", "category": ["weird", "list"],
|
|
133
|
+
"policy_id": "P", "preferred_alternative": None}
|
|
134
|
+
monkeypatch.setattr(mp, "evaluate_call_keyless", lambda q: decision)
|
|
135
|
+
stats = {}
|
|
136
|
+
mp._screen_message({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
137
|
+
"params": {"name": "t", "arguments": {}}},
|
|
138
|
+
stats, {}, 3, mp._ClientWriter(io.StringIO()), io.StringIO())
|
|
139
|
+
assert "block_category" not in stats # non-string category dropped, never emitted
|
|
140
|
+
assert stats["critical_blocks"] == 1 # the block still stood; the loop did not wedge
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
def test_benign_catalog_read_is_exempt():
|
|
129
144
|
line = _line(id=3, name="run_sql",
|
|
130
145
|
arguments={"query": "SELECT * FROM information_schema.columns"})
|
|
@@ -155,6 +170,163 @@ def test_allow_resets_block_streak():
|
|
|
155
170
|
assert "run_sql" not in streaks # a clean call zeroes the streak
|
|
156
171
|
|
|
157
172
|
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# A1a — coaching is call-fitting and names a safe path (not generic)
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
def test_block_coaching_is_call_fitting_and_names_a_safe_path(monkeypatch):
|
|
177
|
+
"""The keyless coaching names the blocked TOOL and the policy's concrete safe
|
|
178
|
+
alternative, so the caller's model gets a task-fitting hint to recover on (not a
|
|
179
|
+
generic 'policy violation'), and the judge-era cruft is gone. Arm the shipped
|
|
180
|
+
builtins explicitly: a dev machine may carry a pulled .agentx/policies.json that
|
|
181
|
+
shadows them, but the cold/keyless install (the funnel target) runs the builtins."""
|
|
182
|
+
from agentx_sdk import decorators as dec
|
|
183
|
+
monkeypatch.setattr(dec, "LOCAL_POLICY_KEYWORDS", list(dec._BUILTIN_POLICY_KEYWORDS))
|
|
184
|
+
line = _line(name="run_sql", arguments={"query": "DROP TABLE users;"})
|
|
185
|
+
_, client_out, _ = _route(line)
|
|
186
|
+
text = json.loads(client_out)["result"]["content"][0]["text"]
|
|
187
|
+
assert "run_sql" in text # call-fitting: names the blocked tool
|
|
188
|
+
assert "Safe alternative:" in text # surfaces preferred_alternative
|
|
189
|
+
assert "WHERE clause" in text # the concrete safe path, not boilerplate
|
|
190
|
+
assert "symbolic reasoning" not in text.lower() # judge-era cruft removed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
# A2 — the proxy counts a keyless recover (same-tool block -> later allow)
|
|
195
|
+
# --------------------------------------------------------------------------- #
|
|
196
|
+
def test_same_tool_block_then_allow_counts_a_self_correction():
|
|
197
|
+
"""The proxy is the only place that sees BOTH halves of a recovery. A block on tool
|
|
198
|
+
T followed by a clean call on T (the model's safe revision) increments
|
|
199
|
+
self_corrections, making the keyless recover visible on the pulse. Conservative:
|
|
200
|
+
only a clean SAME-tool block->allow counts."""
|
|
201
|
+
danger = _line(name="run_sql", arguments={"query": "DROP TABLE users;"})
|
|
202
|
+
safe = _line(name="run_sql", arguments={"query": "SELECT 1"})
|
|
203
|
+
stats, streaks = {}, {}
|
|
204
|
+
_route(danger, stats=stats, streaks=streaks)
|
|
205
|
+
assert stats.get("self_corrections", 0) == 0 # a block alone is not a recovery
|
|
206
|
+
_route(safe, stats=stats, streaks=streaks)
|
|
207
|
+
assert stats["self_corrections"] == 1 # same tool now runs safe -> recovered
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_clean_call_without_prior_block_is_not_a_self_correction():
|
|
211
|
+
"""A clean call on a tool that was never blocked must NOT count as a recovery."""
|
|
212
|
+
_, _, stats = _route(_line(name="read_file", arguments={"path": "/data/ok.txt"}))
|
|
213
|
+
assert stats.get("self_corrections", 0) == 0 # no prior block -> no over-count
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
# (B) harvest-IN — opt-in, abstract-only, local recovery-pair capture
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
def test_harvest_is_opt_in_off_by_default(monkeypatch):
|
|
220
|
+
monkeypatch.delenv("AGENTX_MCP_HARVEST", raising=False)
|
|
221
|
+
assert mp._harvest_enabled() is False
|
|
222
|
+
monkeypatch.setenv("AGENTX_MCP_HARVEST", "1")
|
|
223
|
+
assert mp._harvest_enabled() is True
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_harvest_records_abstract_recovery_pair():
|
|
227
|
+
"""A same-tool block->allow yields ONE recovery-pair candidate carrying only the coarse
|
|
228
|
+
{tool, policy_category} — never a raw payload."""
|
|
229
|
+
h = mp._Harvest()
|
|
230
|
+
stats, streaks = {}, {}
|
|
231
|
+
_route(_line(name="run_sql", arguments={"query": "DROP TABLE users;"}),
|
|
232
|
+
stats=stats, streaks=streaks, harvest=h)
|
|
233
|
+
assert h.pairs == [] # a block alone is not a pair
|
|
234
|
+
_route(_line(name="run_sql", arguments={"query": "SELECT 1"}),
|
|
235
|
+
stats=stats, streaks=streaks, harvest=h)
|
|
236
|
+
assert h.pairs == [{"tool": "run_sql", "policy_category": "DESTRUCTIVE_ACTION", "recovered": True}]
|
|
237
|
+
assert "DROP TABLE" not in json.dumps(h.pairs) # privacy: no raw payload captured
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_harvest_drops_off_vocab_category():
|
|
241
|
+
"""A category outside the closed pulse vocab is never captured (privacy guard)."""
|
|
242
|
+
h = mp._Harvest()
|
|
243
|
+
h.note_block("t", "internal-billing-rule") # off-vocab -> dropped
|
|
244
|
+
h.note_recovery("t")
|
|
245
|
+
assert h.pairs == []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_flush_harvest_writes_exact_abstract_pair_to_local_jsonl(tmp_path, monkeypatch):
|
|
249
|
+
"""Flush appends the EXACT abstract pair (no deanonymizing timestamp) to a LOCAL jsonl
|
|
250
|
+
under the project root, best-effort."""
|
|
251
|
+
monkeypatch.delenv("AGENTX_MCP_HARVEST_PATH", raising=False)
|
|
252
|
+
monkeypatch.setattr("agentx_sdk.overrides._find_project_root", lambda *a, **k: str(tmp_path))
|
|
253
|
+
h = mp._Harvest()
|
|
254
|
+
h.pairs = [{"tool": "run_sql", "policy_category": "DESTRUCTIVE_ACTION", "recovered": True}]
|
|
255
|
+
mp._flush_harvest(h, io.StringIO())
|
|
256
|
+
out = tmp_path / ".agentx" / "mcp_harvest.jsonl"
|
|
257
|
+
assert out.exists()
|
|
258
|
+
rec = json.loads(out.read_text(encoding="utf-8").strip())
|
|
259
|
+
assert rec == {"tool": "run_sql", "policy_category": "DESTRUCTIVE_ACTION", "recovered": True}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_flush_harvest_honors_explicit_path(tmp_path, monkeypatch):
|
|
263
|
+
"""An explicit AGENTX_MCP_HARVEST_PATH wins (the MCP-context control)."""
|
|
264
|
+
target = tmp_path / "nested" / "harvest.jsonl"
|
|
265
|
+
monkeypatch.setenv("AGENTX_MCP_HARVEST_PATH", str(target))
|
|
266
|
+
h = mp._Harvest()
|
|
267
|
+
h.pairs = [{"tool": "t", "policy_category": "SECRETS_LEAK", "recovered": True}]
|
|
268
|
+
mp._flush_harvest(h, io.StringIO())
|
|
269
|
+
assert target.exists()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_harvest_off_vocab_block_clears_stale_pending():
|
|
273
|
+
"""An off-vocab re-block clears a stale pending category, so a later recovery is not
|
|
274
|
+
mis-attributed to the earlier category (or crashed by a non-string category)."""
|
|
275
|
+
h = mp._Harvest()
|
|
276
|
+
h.note_block("t", "DESTRUCTIVE_ACTION") # in-vocab -> pending
|
|
277
|
+
h.note_block("t", "internal-billing-rule") # off-vocab -> CLEARS pending
|
|
278
|
+
h.note_block("u", ["weird", "list"]) # non-string -> dropped, no crash
|
|
279
|
+
h.note_recovery("t")
|
|
280
|
+
assert h.pairs == [] # no mis-attributed pair
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_harvest_skips_idless_uncoached_block():
|
|
284
|
+
"""An id-less blocked call is dropped with no coaching, so a later clean call on that tool
|
|
285
|
+
is NOT recorded as a coached recovery (no phantom pair)."""
|
|
286
|
+
h = mp._Harvest()
|
|
287
|
+
stats, streaks = {}, {}
|
|
288
|
+
_route(_line(id=None, name="run_sql", arguments={"query": "DROP TABLE users;"}),
|
|
289
|
+
stats=stats, streaks=streaks, harvest=h) # id-less block: dropped, not harvested
|
|
290
|
+
_route(_line(name="run_sql", arguments={"query": "SELECT 1"}),
|
|
291
|
+
stats=stats, streaks=streaks, harvest=h)
|
|
292
|
+
assert h.pairs == [] # no phantom pair from an uncoached block
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------------- #
|
|
296
|
+
# A1b — the org's adopted reframe reaches the keyless MCP coaching
|
|
297
|
+
# --------------------------------------------------------------------------- #
|
|
298
|
+
def test_org_reframe_is_injected_into_keyless_coaching(monkeypatch):
|
|
299
|
+
"""When the org has adopted a reframe for the blocked policy, the keyless MCP coaching
|
|
300
|
+
carries the adopted challenge + safe-path (via the SHARED _apply_org_override the
|
|
301
|
+
decorator uses), so the org brain reaches the MCP wedge. Reads only the local
|
|
302
|
+
overrides store (keyless, no gateway)."""
|
|
303
|
+
from agentx_sdk import decorators as dec
|
|
304
|
+
reframe = {"challenge": "Use the audited soft_delete procedure for this table.",
|
|
305
|
+
"safe_path": "Call soft_delete(table, where=...) instead of DROP."}
|
|
306
|
+
monkeypatch.setattr(dec, "get_active_override",
|
|
307
|
+
lambda policy_id, policy_name=None, **k: reframe)
|
|
308
|
+
|
|
309
|
+
line = _line(name="run_sql", arguments={"query": "DROP TABLE users;"})
|
|
310
|
+
_, client_out, _ = _route(line)
|
|
311
|
+
text = json.loads(client_out)["result"]["content"][0]["text"]
|
|
312
|
+
assert "audited soft_delete procedure" in text # adopted challenge swapped in
|
|
313
|
+
assert "Safe alternative:" in text # adopted safe-path surfaced
|
|
314
|
+
assert "soft_delete(table" in text
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_no_org_override_leaves_base_coaching_unchanged(monkeypatch):
|
|
318
|
+
"""No adopted override -> the base builtin coaching is delivered unchanged, so the
|
|
319
|
+
cold install (the funnel target, no overrides) is unaffected by A1b."""
|
|
320
|
+
from agentx_sdk import decorators as dec
|
|
321
|
+
monkeypatch.setattr(dec, "get_active_override", lambda *a, **k: None)
|
|
322
|
+
|
|
323
|
+
line = _line(name="run_sql", arguments={"query": "DROP TABLE users;"})
|
|
324
|
+
_, client_out, _ = _route(line)
|
|
325
|
+
text = json.loads(client_out)["result"]["content"][0]["text"]
|
|
326
|
+
assert "AgentX blocked this call" in text # base wrapper, no reframe
|
|
327
|
+
assert "soft_delete" not in text
|
|
328
|
+
|
|
329
|
+
|
|
158
330
|
# --------------------------------------------------------------------------- #
|
|
159
331
|
# flatten
|
|
160
332
|
# --------------------------------------------------------------------------- #
|
{agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentx-security-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Runtime firewall for AI agents - blocks catastrophic tool calls and self-heals the run.
|
|
5
5
|
Home-page: https://agentx-core.com
|
|
6
6
|
Author: AgentX Core Team
|
|
@@ -147,6 +147,21 @@ Don't own the tool's Python? Running a non-Python agent? Wrap any **MCP server**
|
|
|
147
147
|
|
|
148
148
|
`agentx-mcp <real server command>` spawns the real server and relays the protocol untouched, intercepting only tool calls. A blocked call comes back to the agent as a coaching tool error it can self-correct on, so the run keeps going and the dangerous call never reaches the server.
|
|
149
149
|
|
|
150
|
+
**No Python in your stack?** You don't need a persistent install. Run it on demand with [`uvx`](https://docs.astral.sh/uv/) (or `pipx run`), which fetches the package into a throwaway environment, so your `mcp.json` stays one line:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"mcpServers": {
|
|
155
|
+
"filesystem": {
|
|
156
|
+
"command": "uvx",
|
|
157
|
+
"args": ["--from", "agentx-security-sdk", "agentx-mcp", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
(`pipx run --spec agentx-security-sdk agentx-mcp <real server command>` works the same way.)
|
|
164
|
+
|
|
150
165
|
---
|
|
151
166
|
|
|
152
167
|
### Step 3: See your first block: no key, no gateway, no signup
|
|
@@ -204,7 +219,7 @@ Injected Input Payload: 'Customer requested normal update. Retain account histor
|
|
|
204
219
|
|
|
205
220
|
### Step 4: (Upgrade) Recover: turn hard blocks into recoverable challenges
|
|
206
221
|
|
|
207
|
-
Everything above is keyless **Shield
|
|
222
|
+
Everything above is keyless **Shield**: it blocks the blatant calls and coaches your agent to self-correct. The **reasoning axis** (orthogonal to `AGENTX_MODE`) is **Recover**: the gateway judge catches what the keyword floor can't see, writes the **task-fitting challenge** when your policy carries none, and runs the coach-and-retry for you, plus Discovery for novel-intent classification. The tier no `403`-style firewall has. It takes two things, and they go together:
|
|
208
223
|
|
|
209
224
|
**1. A `GEMINI_API_KEY`**, the cheap part. Copy `.env.example` to `.env` and set it:
|
|
210
225
|
|
|
@@ -350,7 +365,7 @@ Inside the examples/ folder, you will find a few standalone scripts proving the
|
|
|
350
365
|
* **04_circuit_breaker_demo.py:** AgentX catches and prevents an infinite apology loop, saving time and tokens.
|
|
351
366
|
* **06_hitl_escalation.py:** See how an agent safely pauses execution and pings a SOC analyst for approval using a 202 Accepted queue.
|
|
352
367
|
* **09_budget_ceiling_demo.py:** Watch AgentX meter a runaway agent's cumulative spend and halt the session the moment it crosses your budget ceiling, a deterministic, gateway-side escalation (no LLM judge). Needs the gateway running + a key.
|
|
353
|
-
* **10_self_correction_coaching.py:** The Recover tier,
|
|
368
|
+
* **10_self_correction_coaching.py:** The Recover tier, where the gateway judge does the coaching for you. AgentX blocks a dangerous action and returns a task-fitting challenge that names a safe path, so your agent finishes the job instead of dead-ending. Puts the AgentX challenge side by side with a plain guardrail's, then watches the agent recover on it. Needs the gateway running + your Gemini key (the keyless Shield already coaches in-band; Recover's judge catches more and runs the retry for you).
|
|
354
369
|
* **And many more...**
|
|
355
370
|
|
|
356
371
|
---
|
|
@@ -9,7 +9,7 @@ if os.path.exists("README.md"):
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name="agentx-security-sdk",
|
|
12
|
-
version="0.4.
|
|
12
|
+
version="0.4.2",
|
|
13
13
|
author="AgentX Core Team",
|
|
14
14
|
author_email="founders@agentx-core.com",
|
|
15
15
|
description="Runtime firewall for AI agents - blocks catastrophic tool calls and self-heals the run.",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|