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.
Files changed (37) hide show
  1. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/PKG-INFO +18 -3
  2. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/README.md +17 -2
  3. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/__init__.py +1 -1
  4. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/cli.py +3 -2
  5. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/decorators.py +28 -10
  6. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/mcp_proxy.py +133 -19
  7. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/pulse.py +5 -3
  8. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_agentx_sdk.py +24 -0
  9. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_mcp_proxy.py +174 -2
  10. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/PKG-INFO +18 -3
  11. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/setup.py +1 -1
  12. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/LICENSE +0 -0
  13. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/client.py +0 -0
  14. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/conftest.py +0 -0
  15. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/db.py +0 -0
  16. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/envfile.py +0 -0
  17. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/overrides.py +0 -0
  18. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/rules.py +0 -0
  19. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_async_protect.py +0 -0
  20. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_autocontribute.py +0 -0
  21. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_block_category.py +0 -0
  22. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_block_result.py +0 -0
  23. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_build2_overrides.py +0 -0
  24. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_cli.py +0 -0
  25. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_client.py +0 -0
  26. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_console_encoding.py +0 -0
  27. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_db.py +0 -0
  28. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_fail_modes.py +0 -0
  29. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_nudge.py +0 -0
  30. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_pulse.py +0 -0
  31. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_sdk/test_rules.py +0 -0
  32. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/SOURCES.txt +0 -0
  33. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/dependency_links.txt +0 -0
  34. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/entry_points.txt +0 -0
  35. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/requires.txt +0 -0
  36. {agentx_security_sdk-0.4.0 → agentx_security_sdk-0.4.2}/agentx_security_sdk.egg-info/top_level.txt +0 -0
  37. {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.0
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**. The **reasoning axis** (orthogonal to `AGENTX_MODE`) turns a block from a dead end into a **recoverable, task-fitting challenge** the agent self-corrects from, plus Discovery for novel-intent classification. This is **Recover**, the tier no `403`-style firewall has. It takes two things, and they go together:
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, a block that *coaches*. 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 upgrade from the keyless Shield).
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**. The **reasoning axis** (orthogonal to `AGENTX_MODE`) turns a block from a dead end into a **recoverable, task-fitting challenge** the agent self-corrects from, plus Discovery for novel-intent classification. This is **Recover**, the tier no `403`-style firewall has. It takes two things, and they go together:
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, a block that *coaches*. 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 upgrade from the keyless Shield).
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
  ---
@@ -1,4 +1,4 @@
1
- __version__ = "0.4.0"
1
+ __version__ = "0.4.2"
2
2
 
3
3
  from .decorators import (
4
4
  agentx_protect,
@@ -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). The goal is RECOVER: the block becomes a coached")
1161
- print(" self-heal so your agent finishes the task. Two steps get you there:")
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": "Policy Violation: Mass Destructive Intent. Your intent to perform a DESTRUCTIVE_WRITE is prohibited. Explain your symbolic reasoning, or revise your strategy to use a SAFE_WRITE instead.",
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": "Policy Violation: Network Sandbox (SSRF). Direct traversal to loopback interfaces or cloud metadata endpoints is banned. Revise your target destination.",
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": "Policy Violation: Secrets and PII Exfiltration. This request has been blocked. Change your execution path to avoid extracting secrets or PII.",
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": "Policy Violation: Customer Privacy Shield. Accessing raw customer PII is restricted. Adjust your query to avoid retrieving un-hashed contact coordinates.",
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
- if category in _BLOCK_CATEGORY_VOCAB:
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} Explain your symbolic reasoning for this action, or revise your strategy to use a SAFE_WRITE instead. "
1366
- f"System Instruction: Your request has been blocked. Analyze this security barrier, "
1367
- f"adjust your parameters payload to be safe, change your execution query path, "
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. The policy's own challenge already names the
103
- violation, so we do NOT repeat the policy name here (it stays in the stderr log
104
- via _screen_message). No em dashes (house style); this text can end up in a
105
- shared block card."""
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. This call was blocked repeatedly and has been "
110
- "halted to stop a runaway loop. Do not retry the same action. Choose a "
111
- "safe alternative or ask the human operator. %s" % challenge
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 the action to a safe form "
115
- "and try again, or ask the human operator if you cannot reach the goal safely."
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
- streaks.pop(tool_key, None) # a clean call zeroes this tool's block streak
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
- writer.send(_block_response(req_id, _coaching_text(decision, tripped)))
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 (block only). Recover makes your agent")
358
- print(" heal and finish the task instead of dead-ending on the block.")
359
- print(" Unlock it: agentx-core.com/#request-access")
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
  # --------------------------------------------------------------------------- #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-security-sdk
3
- Version: 0.4.0
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**. The **reasoning axis** (orthogonal to `AGENTX_MODE`) turns a block from a dead end into a **recoverable, task-fitting challenge** the agent self-corrects from, plus Discovery for novel-intent classification. This is **Recover**, the tier no `403`-style firewall has. It takes two things, and they go together:
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, a block that *coaches*. 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 upgrade from the keyless Shield).
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.0",
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.",