consequence-gate 0.1.0__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.
- consequence_gate-0.1.0/PKG-INFO +254 -0
- consequence_gate-0.1.0/README.md +218 -0
- consequence_gate-0.1.0/consequence_gate/__init__.py +3 -0
- consequence_gate-0.1.0/consequence_gate/backtest/__init__.py +0 -0
- consequence_gate-0.1.0/consequence_gate/backtest/harness.py +43 -0
- consequence_gate-0.1.0/consequence_gate/backtest/reporter.py +20 -0
- consequence_gate-0.1.0/consequence_gate/cli.py +75 -0
- consequence_gate-0.1.0/consequence_gate/core/__init__.py +0 -0
- consequence_gate-0.1.0/consequence_gate/core/circuit_breaker.py +56 -0
- consequence_gate-0.1.0/consequence_gate/core/evaluator.py +27 -0
- consequence_gate-0.1.0/consequence_gate/core/models.py +35 -0
- consequence_gate-0.1.0/consequence_gate/integrations/__init__.py +0 -0
- consequence_gate-0.1.0/consequence_gate/integrations/examples/run_langgraph.py +60 -0
- consequence_gate-0.1.0/consequence_gate/integrations/examples/run_mcp_proxy.py +100 -0
- consequence_gate-0.1.0/consequence_gate/integrations/langgraph_hook.py +197 -0
- consequence_gate-0.1.0/consequence_gate/integrations/mcp_proxy.py +316 -0
- consequence_gate-0.1.0/consequence_gate/integrations/strands_hook.py +224 -0
- consequence_gate-0.1.0/consequence_gate/simulators/__init__.py +0 -0
- consequence_gate-0.1.0/consequence_gate/simulators/communications.py +290 -0
- consequence_gate-0.1.0/consequence_gate/simulators/database.py +165 -0
- consequence_gate-0.1.0/consequence_gate/simulators/financial.py +102 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/PKG-INFO +254 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/SOURCES.txt +35 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/dependency_links.txt +1 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/entry_points.txt +2 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/requires.txt +16 -0
- consequence_gate-0.1.0/consequence_gate.egg-info/top_level.txt +1 -0
- consequence_gate-0.1.0/pyproject.toml +86 -0
- consequence_gate-0.1.0/setup.cfg +4 -0
- consequence_gate-0.1.0/tests/test_circuit_breaker.py +23 -0
- consequence_gate-0.1.0/tests/test_cli.py +34 -0
- consequence_gate-0.1.0/tests/test_communications_sim.py +212 -0
- consequence_gate-0.1.0/tests/test_database_sim.py +21 -0
- consequence_gate-0.1.0/tests/test_financial_sim.py +35 -0
- consequence_gate-0.1.0/tests/test_langgraph_hook.py +167 -0
- consequence_gate-0.1.0/tests/test_mcp_proxy.py +198 -0
- consequence_gate-0.1.0/tests/test_strands_hook.py +260 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: consequence-gate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Speculative outcome-simulation layer for AI agent tool calls — predicts consequence (blast radius, irreversibility, velocity) before execution and steers agents toward safer alternatives.
|
|
5
|
+
Author-email: Anandkrishnan Shnn <anandkrshnn@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/anandkrshnn-ai/consequence-gate
|
|
8
|
+
Project-URL: Documentation, https://github.com/anandkrshnn-ai/consequence-gate#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/anandkrshnn-ai/consequence-gate
|
|
10
|
+
Project-URL: Issues, https://github.com/anandkrshnn-ai/consequence-gate/issues
|
|
11
|
+
Keywords: ai-agents,ai-safety,ai-governance,langchain,langgraph,mcp,agent-security,runtime-governance
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: langchain>=0.3.0
|
|
25
|
+
Requires-Dist: langchain-agents>=0.3.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
+
Provides-Extra: strands
|
|
32
|
+
Requires-Dist: strands-agents>=0.1.0; extra == "strands"
|
|
33
|
+
Provides-Extra: mcp
|
|
34
|
+
Provides-Extra: langgraph
|
|
35
|
+
Requires-Dist: langgraph>=0.2.0; extra == "langgraph"
|
|
36
|
+
|
|
37
|
+
# consequence-gate
|
|
38
|
+
|
|
39
|
+
A speculative outcome-simulation layer for AI agent tool calls. It sits
|
|
40
|
+
**upstream** of static runtime access gates (AgentWall, AWS Strands
|
|
41
|
+
`BeforeToolCallEvent`, MCP proxies, Prisma AIRS) and asks a different
|
|
42
|
+
question than they do.
|
|
43
|
+
|
|
44
|
+
Static gates ask: *does this call match an allowed pattern?*
|
|
45
|
+
`consequence-gate` asks: *what will this call actually do, and is that
|
|
46
|
+
outcome safe?*
|
|
47
|
+
|
|
48
|
+
## Why this exists
|
|
49
|
+
|
|
50
|
+
Static runtime gates are fast (sub-millisecond) and effective at schema
|
|
51
|
+
validation, RBAC, and pattern matching -- but a schema-valid,
|
|
52
|
+
policy-compliant call can still be consequence-catastrophic. A
|
|
53
|
+
`process_claim(amount=50000)` call can pass every static check while
|
|
54
|
+
pushing an account over its daily velocity limit via an irreversible
|
|
55
|
+
instant transfer. `consequence-gate` projects the *outcome* of a call
|
|
56
|
+
(balance deltas, row-count blast radius, FK cascade depth,
|
|
57
|
+
irreversibility) before the call reaches your existing static gate, and
|
|
58
|
+
either passes it through, asks a human, denies it outright, or steers
|
|
59
|
+
the agent toward a pre-vetted safer alternative.
|
|
60
|
+
|
|
61
|
+
This is explicitly **not** a replacement for AgentWall / Strands / MCP
|
|
62
|
+
proxies -- it's a prediction layer that runs before them, in the same
|
|
63
|
+
pipeline.
|
|
64
|
+
|
|
65
|
+
## Core contracts
|
|
66
|
+
|
|
67
|
+
- **No silent argument mutation.** Steering returns structured guidance
|
|
68
|
+
and a suggested alternative call; the agent (or a human) still has to
|
|
69
|
+
commit to it. This preserves the audit property that every executed
|
|
70
|
+
call was one the agent explicitly chose.
|
|
71
|
+
- **Idempotency keys are derived from the transaction's own natural key**
|
|
72
|
+
(e.g. `claim_id`, or `table + filter hash`), never a fresh random token
|
|
73
|
+
per retry -- otherwise a lost-response retry looks like a brand-new
|
|
74
|
+
transaction instead of a duplicate.
|
|
75
|
+
- **Hard retry cap on STEER.** Regardless of guidance quality, retries
|
|
76
|
+
are capped (default: 2) before forcing escalation to a human, as a
|
|
77
|
+
backstop against loop-thrashing.
|
|
78
|
+
- **Confidence-gated escalation.** Low-confidence projections route to
|
|
79
|
+
`ASK`, never to a confident-looking `ALLOW` or `DENY` -- an
|
|
80
|
+
unfounded heuristic is worse than admitting uncertainty.
|
|
81
|
+
|
|
82
|
+
## Modules
|
|
83
|
+
|
|
84
|
+
- `consequence_gate.simulators.financial` -- **functional**: disbursement / claim / refund
|
|
85
|
+
velocity and irreversibility modeling.
|
|
86
|
+
- `consequence_gate.simulators.database` -- **functional**: row-count blast radius via the
|
|
87
|
+
DB's own query planner (`EXPLAIN`, not hardcoded selectivity constants)
|
|
88
|
+
and recursive `ON DELETE CASCADE` graph walking.
|
|
89
|
+
- `consequence_gate.simulators.communications` -- **functional**: outbound email/SMS/notification
|
|
90
|
+
blast radius, unsubscribe suppression compliance, canary cohort analysis, sender reputation impact.
|
|
91
|
+
- `consequence_gate.core` -- shared models, the confidence/threshold
|
|
92
|
+
evaluator, and the idempotency-locked circuit breaker.
|
|
93
|
+
- `consequence_gate.integrations.strands_hook` -- **functional**: AWS Strands
|
|
94
|
+
`BeforeToolCallEvent` adapter with full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle.
|
|
95
|
+
- `consequence_gate.integrations.mcp_proxy` -- **functional**: MCP stdio proxy
|
|
96
|
+
intercepting `tools/call` requests, returning JSON-RPC errors or `isError=true` tool results.
|
|
97
|
+
- `consequence_gate.integrations.langgraph_hook` -- **functional**: LangGraph middleware
|
|
98
|
+
(`@wrap_tool_call`) intercepting tool execution with full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle.
|
|
99
|
+
- `consequence_gate.backtest` -- offline JSONL trace replay harness and
|
|
100
|
+
four-quadrant FP/FN/TN report generator, for evaluating this layer
|
|
101
|
+
against historical execution logs with zero production integration.
|
|
102
|
+
|
|
103
|
+
## Quickstart
|
|
104
|
+
|
|
105
|
+
### Installation
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e ".[dev]"
|
|
109
|
+
pytest
|
|
110
|
+
python examples/run_backtest_demo.py
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### AWS Strands Integration
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from consequence_gate.integrations.strands_hook import create_financial_gate_hook
|
|
117
|
+
from strands.agents import Agent
|
|
118
|
+
|
|
119
|
+
hook = create_financial_gate_hook(
|
|
120
|
+
daily_tier_limit_inr=25000.0,
|
|
121
|
+
instant_wire_threshold=10000.0,
|
|
122
|
+
max_retries=2,
|
|
123
|
+
context_provider=lambda event: {
|
|
124
|
+
"account_rolling_24h_spend": get_current_spend(event),
|
|
125
|
+
"kyc_verified": is_kyc_verified(event),
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
agent = Agent(hooks=[hook])
|
|
130
|
+
response = agent("Process this claim for 50,000 INR")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### MCP Proxy Integration
|
|
134
|
+
|
|
135
|
+
Run as a standalone proxy in front of any MCP server:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Financial disbursement gate
|
|
139
|
+
python -m consequence_gate.integrations.examples.run_mcp_proxy financial \\
|
|
140
|
+
--downstream-command "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb" \\
|
|
141
|
+
--daily-tier-limit 25000 \\
|
|
142
|
+
--instant-wire-threshold 10000
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Configure in Claude Desktop / Cursor / Windsurf:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"mcpServers": {
|
|
150
|
+
"my-consequence-gate": {
|
|
151
|
+
"command": "python",
|
|
152
|
+
"args": ["-m", "consequence_gate.integrations.examples.run_mcp_proxy", "financial", "--downstream-command", "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb"]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### LangGraph Integration
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from langchain.agents import create_agent
|
|
162
|
+
from consequence_gate.integrations.langgraph_hook import create_financial_gate_middleware
|
|
163
|
+
|
|
164
|
+
middleware = create_financial_gate_middleware(
|
|
165
|
+
daily_tier_limit_inr=25000.0,
|
|
166
|
+
instant_wire_threshold=10000.0,
|
|
167
|
+
max_retries=2,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
agent = create_agent(
|
|
171
|
+
model="claude-sonnet-4",
|
|
172
|
+
tools=[my_tool],
|
|
173
|
+
middleware=[middleware],
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Run the example:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python -m consequence_gate.integrations.examples.run_langgraph
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Database Deletion Gate (Strands)
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from consequence_gate.integrations.strands_hook import create_database_gate_hook
|
|
187
|
+
|
|
188
|
+
hook = create_database_gate_hook(
|
|
189
|
+
max_autonomous_delete_rows=100,
|
|
190
|
+
db_conn=get_db_connection(),
|
|
191
|
+
max_retries=2,
|
|
192
|
+
context_provider=lambda event: {
|
|
193
|
+
"table_metadata": get_table_metadata(event),
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
agent = Agent(hooks=[hook])
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Communications Blast Gate (Strands)
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from consequence_gate.integrations.strands_hook import create_communication_gate_hook
|
|
204
|
+
|
|
205
|
+
hook = create_communication_gate_hook(
|
|
206
|
+
max_autonomous_recipients=10000,
|
|
207
|
+
canary_min_size=100,
|
|
208
|
+
canary_max_bounce_rate=0.05,
|
|
209
|
+
canary_max_complaint_rate=0.01,
|
|
210
|
+
context_provider=lambda event: {
|
|
211
|
+
"segment_counts": get_segment_counts(event),
|
|
212
|
+
"recent_unsubscribes": get_recent_unsubscribes(event),
|
|
213
|
+
"historical_bounce_rate": 0.02,
|
|
214
|
+
"historical_complaint_rate": 0.005,
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
agent = Agent(hooks=[hook])
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Decision Matrix
|
|
222
|
+
|
|
223
|
+
| Decision | Strands | MCP | LangGraph |
|
|
224
|
+
|----------|---------|-----|-----------|
|
|
225
|
+
| `ALLOW` | Executes normally | Forwarded to downstream MCP server | Tool executes via handler(request) |
|
|
226
|
+
| `DENY` | `BLOCKED: <reason>` | JSON-RPC error (code=-32603) | Raises `ValueError("BLOCKED: ...")` |
|
|
227
|
+
| `ASK` | `ESCALATION_REQUIRED: <reason>` | `isError=true` tool result | Raises `ValueError("ESCALATION_REQUIRED: ...")` |
|
|
228
|
+
| `STEER` | `STEER_GUIDANCE: <guidance>\nSuggested alternative...` | `isError=true` + guidance | `ToolMessage(content="STEER_GUIDANCE: ...", status="error")` |
|
|
229
|
+
|
|
230
|
+
## Backtest Workflow
|
|
231
|
+
|
|
232
|
+
Before deploying to production, run an offline backtest against historical
|
|
233
|
+
execution traces:
|
|
234
|
+
|
|
235
|
+
1. Export 1,000-5,000 tool-call traces as JSONL (see `examples/backtest_sample_traces.jsonl`)
|
|
236
|
+
2. Run `python examples/run_backtest_demo.py` against your traces
|
|
237
|
+
3. Review the four-quadrant breakdown:
|
|
238
|
+
- True Negative: correctly allowed benign operations
|
|
239
|
+
- False Negative Caught: schema-valid calls that would have breached limits
|
|
240
|
+
- False Positive Relieved: over-blocking that the simulator would have avoided
|
|
241
|
+
- Steer Recovery Rate: percentage of blocked turns that could have completed via guidance
|
|
242
|
+
|
|
243
|
+
## Status
|
|
244
|
+
|
|
245
|
+
- **Financial simulator**: functional with unit tests
|
|
246
|
+
- **Database simulator**: functional with unit tests (EXPLAIN-based row estimation, recursive FK cascade walk)
|
|
247
|
+
- **Communications simulator**: functional with unit tests (blast radius, unsubscribe compliance, canary cohorts, reputation impact)
|
|
248
|
+
- **Strands integration**: functional with unit tests (full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle)
|
|
249
|
+
- **MCP integration**: functional with unit tests (stdio transport, JSON-RPC error handling)
|
|
250
|
+
- **LangGraph integration**: functional with unit tests (`@wrap_tool_call` middleware pattern)
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
Apache-2.0
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# consequence-gate
|
|
2
|
+
|
|
3
|
+
A speculative outcome-simulation layer for AI agent tool calls. It sits
|
|
4
|
+
**upstream** of static runtime access gates (AgentWall, AWS Strands
|
|
5
|
+
`BeforeToolCallEvent`, MCP proxies, Prisma AIRS) and asks a different
|
|
6
|
+
question than they do.
|
|
7
|
+
|
|
8
|
+
Static gates ask: *does this call match an allowed pattern?*
|
|
9
|
+
`consequence-gate` asks: *what will this call actually do, and is that
|
|
10
|
+
outcome safe?*
|
|
11
|
+
|
|
12
|
+
## Why this exists
|
|
13
|
+
|
|
14
|
+
Static runtime gates are fast (sub-millisecond) and effective at schema
|
|
15
|
+
validation, RBAC, and pattern matching -- but a schema-valid,
|
|
16
|
+
policy-compliant call can still be consequence-catastrophic. A
|
|
17
|
+
`process_claim(amount=50000)` call can pass every static check while
|
|
18
|
+
pushing an account over its daily velocity limit via an irreversible
|
|
19
|
+
instant transfer. `consequence-gate` projects the *outcome* of a call
|
|
20
|
+
(balance deltas, row-count blast radius, FK cascade depth,
|
|
21
|
+
irreversibility) before the call reaches your existing static gate, and
|
|
22
|
+
either passes it through, asks a human, denies it outright, or steers
|
|
23
|
+
the agent toward a pre-vetted safer alternative.
|
|
24
|
+
|
|
25
|
+
This is explicitly **not** a replacement for AgentWall / Strands / MCP
|
|
26
|
+
proxies -- it's a prediction layer that runs before them, in the same
|
|
27
|
+
pipeline.
|
|
28
|
+
|
|
29
|
+
## Core contracts
|
|
30
|
+
|
|
31
|
+
- **No silent argument mutation.** Steering returns structured guidance
|
|
32
|
+
and a suggested alternative call; the agent (or a human) still has to
|
|
33
|
+
commit to it. This preserves the audit property that every executed
|
|
34
|
+
call was one the agent explicitly chose.
|
|
35
|
+
- **Idempotency keys are derived from the transaction's own natural key**
|
|
36
|
+
(e.g. `claim_id`, or `table + filter hash`), never a fresh random token
|
|
37
|
+
per retry -- otherwise a lost-response retry looks like a brand-new
|
|
38
|
+
transaction instead of a duplicate.
|
|
39
|
+
- **Hard retry cap on STEER.** Regardless of guidance quality, retries
|
|
40
|
+
are capped (default: 2) before forcing escalation to a human, as a
|
|
41
|
+
backstop against loop-thrashing.
|
|
42
|
+
- **Confidence-gated escalation.** Low-confidence projections route to
|
|
43
|
+
`ASK`, never to a confident-looking `ALLOW` or `DENY` -- an
|
|
44
|
+
unfounded heuristic is worse than admitting uncertainty.
|
|
45
|
+
|
|
46
|
+
## Modules
|
|
47
|
+
|
|
48
|
+
- `consequence_gate.simulators.financial` -- **functional**: disbursement / claim / refund
|
|
49
|
+
velocity and irreversibility modeling.
|
|
50
|
+
- `consequence_gate.simulators.database` -- **functional**: row-count blast radius via the
|
|
51
|
+
DB's own query planner (`EXPLAIN`, not hardcoded selectivity constants)
|
|
52
|
+
and recursive `ON DELETE CASCADE` graph walking.
|
|
53
|
+
- `consequence_gate.simulators.communications` -- **functional**: outbound email/SMS/notification
|
|
54
|
+
blast radius, unsubscribe suppression compliance, canary cohort analysis, sender reputation impact.
|
|
55
|
+
- `consequence_gate.core` -- shared models, the confidence/threshold
|
|
56
|
+
evaluator, and the idempotency-locked circuit breaker.
|
|
57
|
+
- `consequence_gate.integrations.strands_hook` -- **functional**: AWS Strands
|
|
58
|
+
`BeforeToolCallEvent` adapter with full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle.
|
|
59
|
+
- `consequence_gate.integrations.mcp_proxy` -- **functional**: MCP stdio proxy
|
|
60
|
+
intercepting `tools/call` requests, returning JSON-RPC errors or `isError=true` tool results.
|
|
61
|
+
- `consequence_gate.integrations.langgraph_hook` -- **functional**: LangGraph middleware
|
|
62
|
+
(`@wrap_tool_call`) intercepting tool execution with full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle.
|
|
63
|
+
- `consequence_gate.backtest` -- offline JSONL trace replay harness and
|
|
64
|
+
four-quadrant FP/FN/TN report generator, for evaluating this layer
|
|
65
|
+
against historical execution logs with zero production integration.
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
### Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e ".[dev]"
|
|
73
|
+
pytest
|
|
74
|
+
python examples/run_backtest_demo.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### AWS Strands Integration
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from consequence_gate.integrations.strands_hook import create_financial_gate_hook
|
|
81
|
+
from strands.agents import Agent
|
|
82
|
+
|
|
83
|
+
hook = create_financial_gate_hook(
|
|
84
|
+
daily_tier_limit_inr=25000.0,
|
|
85
|
+
instant_wire_threshold=10000.0,
|
|
86
|
+
max_retries=2,
|
|
87
|
+
context_provider=lambda event: {
|
|
88
|
+
"account_rolling_24h_spend": get_current_spend(event),
|
|
89
|
+
"kyc_verified": is_kyc_verified(event),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
agent = Agent(hooks=[hook])
|
|
94
|
+
response = agent("Process this claim for 50,000 INR")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### MCP Proxy Integration
|
|
98
|
+
|
|
99
|
+
Run as a standalone proxy in front of any MCP server:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Financial disbursement gate
|
|
103
|
+
python -m consequence_gate.integrations.examples.run_mcp_proxy financial \\
|
|
104
|
+
--downstream-command "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb" \\
|
|
105
|
+
--daily-tier-limit 25000 \\
|
|
106
|
+
--instant-wire-threshold 10000
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Configure in Claude Desktop / Cursor / Windsurf:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"mcpServers": {
|
|
114
|
+
"my-consequence-gate": {
|
|
115
|
+
"command": "python",
|
|
116
|
+
"args": ["-m", "consequence_gate.integrations.examples.run_mcp_proxy", "financial", "--downstream-command", "npx -y @modelcontextprotocol/server-postgres postgresql://localhost/mydb"]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### LangGraph Integration
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from langchain.agents import create_agent
|
|
126
|
+
from consequence_gate.integrations.langgraph_hook import create_financial_gate_middleware
|
|
127
|
+
|
|
128
|
+
middleware = create_financial_gate_middleware(
|
|
129
|
+
daily_tier_limit_inr=25000.0,
|
|
130
|
+
instant_wire_threshold=10000.0,
|
|
131
|
+
max_retries=2,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
agent = create_agent(
|
|
135
|
+
model="claude-sonnet-4",
|
|
136
|
+
tools=[my_tool],
|
|
137
|
+
middleware=[middleware],
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Run the example:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
python -m consequence_gate.integrations.examples.run_langgraph
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Database Deletion Gate (Strands)
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from consequence_gate.integrations.strands_hook import create_database_gate_hook
|
|
151
|
+
|
|
152
|
+
hook = create_database_gate_hook(
|
|
153
|
+
max_autonomous_delete_rows=100,
|
|
154
|
+
db_conn=get_db_connection(),
|
|
155
|
+
max_retries=2,
|
|
156
|
+
context_provider=lambda event: {
|
|
157
|
+
"table_metadata": get_table_metadata(event),
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
agent = Agent(hooks=[hook])
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Communications Blast Gate (Strands)
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from consequence_gate.integrations.strands_hook import create_communication_gate_hook
|
|
168
|
+
|
|
169
|
+
hook = create_communication_gate_hook(
|
|
170
|
+
max_autonomous_recipients=10000,
|
|
171
|
+
canary_min_size=100,
|
|
172
|
+
canary_max_bounce_rate=0.05,
|
|
173
|
+
canary_max_complaint_rate=0.01,
|
|
174
|
+
context_provider=lambda event: {
|
|
175
|
+
"segment_counts": get_segment_counts(event),
|
|
176
|
+
"recent_unsubscribes": get_recent_unsubscribes(event),
|
|
177
|
+
"historical_bounce_rate": 0.02,
|
|
178
|
+
"historical_complaint_rate": 0.005,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
agent = Agent(hooks=[hook])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Decision Matrix
|
|
186
|
+
|
|
187
|
+
| Decision | Strands | MCP | LangGraph |
|
|
188
|
+
|----------|---------|-----|-----------|
|
|
189
|
+
| `ALLOW` | Executes normally | Forwarded to downstream MCP server | Tool executes via handler(request) |
|
|
190
|
+
| `DENY` | `BLOCKED: <reason>` | JSON-RPC error (code=-32603) | Raises `ValueError("BLOCKED: ...")` |
|
|
191
|
+
| `ASK` | `ESCALATION_REQUIRED: <reason>` | `isError=true` tool result | Raises `ValueError("ESCALATION_REQUIRED: ...")` |
|
|
192
|
+
| `STEER` | `STEER_GUIDANCE: <guidance>\nSuggested alternative...` | `isError=true` + guidance | `ToolMessage(content="STEER_GUIDANCE: ...", status="error")` |
|
|
193
|
+
|
|
194
|
+
## Backtest Workflow
|
|
195
|
+
|
|
196
|
+
Before deploying to production, run an offline backtest against historical
|
|
197
|
+
execution traces:
|
|
198
|
+
|
|
199
|
+
1. Export 1,000-5,000 tool-call traces as JSONL (see `examples/backtest_sample_traces.jsonl`)
|
|
200
|
+
2. Run `python examples/run_backtest_demo.py` against your traces
|
|
201
|
+
3. Review the four-quadrant breakdown:
|
|
202
|
+
- True Negative: correctly allowed benign operations
|
|
203
|
+
- False Negative Caught: schema-valid calls that would have breached limits
|
|
204
|
+
- False Positive Relieved: over-blocking that the simulator would have avoided
|
|
205
|
+
- Steer Recovery Rate: percentage of blocked turns that could have completed via guidance
|
|
206
|
+
|
|
207
|
+
## Status
|
|
208
|
+
|
|
209
|
+
- **Financial simulator**: functional with unit tests
|
|
210
|
+
- **Database simulator**: functional with unit tests (EXPLAIN-based row estimation, recursive FK cascade walk)
|
|
211
|
+
- **Communications simulator**: functional with unit tests (blast radius, unsubscribe compliance, canary cohorts, reputation impact)
|
|
212
|
+
- **Strands integration**: functional with unit tests (full `ALLOW`/`DENY`/`ASK`/`STEER` lifecycle)
|
|
213
|
+
- **MCP integration**: functional with unit tests (stdio transport, JSON-RPC error handling)
|
|
214
|
+
- **LangGraph integration**: functional with unit tests (`@wrap_tool_call` middleware pattern)
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
Apache-2.0
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline backtest harness: replays historical JSONL tool-call traces
|
|
3
|
+
through a simulator + evaluator, WITHOUT re-executing anything, to
|
|
4
|
+
measure the four-quadrant FP/FN/TN/steer-recovery breakdown against
|
|
5
|
+
the trace's recorded existing_gate_decision and actual_execution_status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Callable, Dict, Iterable, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_traces(path: str) -> List[Dict]:
|
|
13
|
+
traces = []
|
|
14
|
+
with open(path) as f:
|
|
15
|
+
for line in f:
|
|
16
|
+
line = line.strip()
|
|
17
|
+
if line:
|
|
18
|
+
traces.append(json.loads(line))
|
|
19
|
+
return traces
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_backtest(traces: Iterable[Dict], simulate_and_evaluate: Callable[[Dict], str]) -> List[Dict]:
|
|
23
|
+
"""
|
|
24
|
+
simulate_and_evaluate: function(trace) -> decision string ("ALLOW"/"DENY"/"ASK"/"STEER")
|
|
25
|
+
Returns per-trace records annotated with quadrant classification.
|
|
26
|
+
"""
|
|
27
|
+
results = []
|
|
28
|
+
for trace in traces:
|
|
29
|
+
new_decision = simulate_and_evaluate(trace)
|
|
30
|
+
old_decision = trace.get("existing_gate_decision", "ALLOW")
|
|
31
|
+
outcome = trace.get("actual_execution_status", "UNKNOWN")
|
|
32
|
+
|
|
33
|
+
if old_decision == "ALLOW" and new_decision in ("DENY", "STEER", "ASK") and outcome != "SUCCESS":
|
|
34
|
+
quadrant = "FALSE_NEGATIVE_CAUGHT"
|
|
35
|
+
elif old_decision in ("DENY", "ASK") and new_decision == "ALLOW":
|
|
36
|
+
quadrant = "FALSE_POSITIVE_RELIEVED"
|
|
37
|
+
elif old_decision == "ALLOW" and new_decision == "ALLOW":
|
|
38
|
+
quadrant = "TRUE_NEGATIVE"
|
|
39
|
+
else:
|
|
40
|
+
quadrant = "OTHER"
|
|
41
|
+
|
|
42
|
+
results.append({**trace, "simulated_decision": new_decision, "quadrant": quadrant})
|
|
43
|
+
return results
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates the four-quadrant FP/FN breakdown report from backtest results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_report(results: List[Dict]) -> Dict:
|
|
10
|
+
counts = Counter(r["quadrant"] for r in results)
|
|
11
|
+
total = len(results)
|
|
12
|
+
return {
|
|
13
|
+
"total_traces": total,
|
|
14
|
+
"true_negative": counts.get("TRUE_NEGATIVE", 0),
|
|
15
|
+
"false_negative_caught": counts.get("FALSE_NEGATIVE_CAUGHT", 0),
|
|
16
|
+
"false_positive_relieved": counts.get("FALSE_POSITIVE_RELIEVED", 0),
|
|
17
|
+
"other": counts.get("OTHER", 0),
|
|
18
|
+
"false_negative_rate": counts.get("FALSE_NEGATIVE_CAUGHT", 0) / total if total else 0.0,
|
|
19
|
+
"false_positive_relief_rate": counts.get("FALSE_POSITIVE_RELIEVED", 0) / total if total else 0.0,
|
|
20
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for consequence-gate.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from consequence_gate import __version__
|
|
9
|
+
from consequence_gate.backtest.harness import load_traces, run_backtest
|
|
10
|
+
from consequence_gate.backtest.reporter import generate_report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_evaluator(trace: dict) -> str:
|
|
14
|
+
"""Heuristic evaluator for CLI demo and trace analysis."""
|
|
15
|
+
tool = trace.get("tool_name", "")
|
|
16
|
+
args = trace.get("tool_args", {})
|
|
17
|
+
|
|
18
|
+
tool_lower = tool.lower()
|
|
19
|
+
args_str = str(args).lower()
|
|
20
|
+
|
|
21
|
+
if any(k in tool_lower for k in ("delete", "drop", "purge", "truncate")) or any(
|
|
22
|
+
k in args_str for k in ("drop ", "delete from", "truncate ", "purge")
|
|
23
|
+
):
|
|
24
|
+
return "DENY"
|
|
25
|
+
if "transfer" in tool.lower() or "pay" in tool.lower():
|
|
26
|
+
amount = args.get("amount", 0)
|
|
27
|
+
if isinstance(amount, (int, float)) and amount > 5000:
|
|
28
|
+
return "ASK"
|
|
29
|
+
return "ALLOW"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="consequence-gate",
|
|
35
|
+
description="Speculative outcome-simulation gate & trace backtesting for AI agent tool calls.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
38
|
+
|
|
39
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
40
|
+
|
|
41
|
+
# Backtest subcommand
|
|
42
|
+
bt_parser = subparsers.add_parser("backtest", help="Run offline backtesting on a JSONL trace file")
|
|
43
|
+
bt_parser.add_argument("file", help="Path to JSONL file containing recorded agent traces")
|
|
44
|
+
bt_parser.add_argument("--json", action="store_true", help="Output results in JSON format")
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
|
|
48
|
+
if args.command == "backtest":
|
|
49
|
+
try:
|
|
50
|
+
traces = load_traces(args.file)
|
|
51
|
+
results = run_backtest(traces, default_evaluator)
|
|
52
|
+
report = generate_report(results)
|
|
53
|
+
|
|
54
|
+
if args.json:
|
|
55
|
+
print(json.dumps(report, indent=2))
|
|
56
|
+
else:
|
|
57
|
+
print("\n================ CONSEQUENCE GATE BACKTEST REPORT ================")
|
|
58
|
+
print(f"Total Traces Evaluated: {report['total_traces']}")
|
|
59
|
+
print(f"True Negatives: {report['true_negative']}")
|
|
60
|
+
print(f"False Negatives Caught: {report['false_negative_caught']}")
|
|
61
|
+
print(f"False Positives Relieved: {report['false_positive_relieved']}")
|
|
62
|
+
print(f"Other / Unclassified: {report['other']}")
|
|
63
|
+
print("-----------------------------------------------------------------")
|
|
64
|
+
print(f"False Negative Catch Rate: {report['false_negative_rate']:.2%}")
|
|
65
|
+
print(f"False Positive Relief Rate: {report['false_positive_relief_rate']:.2%}")
|
|
66
|
+
print("=================================================================\n")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"Error executing backtest: {e}", file=sys.stderr)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
else:
|
|
71
|
+
parser.print_help()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerCircuitBreaker: idempotency-locked retry cap for STEER decisions.
|
|
3
|
+
|
|
4
|
+
Design contract (see project history / design notes):
|
|
5
|
+
- The idempotency token is derived ONCE from the transaction's own natural
|
|
6
|
+
key (e.g. claim_id, table+filter hash) -- never regenerated per retry.
|
|
7
|
+
A fresh UUID per attempt defeats duplicate-execution protection.
|
|
8
|
+
- Responses are cached per token, so a retry with the same natural key
|
|
9
|
+
returns the cached result instead of re-executing (Stripe-style contract).
|
|
10
|
+
- Retry count is tracked server-side per token, with a hard cap. Once
|
|
11
|
+
exceeded, the breaker forces ASK (human escalation) regardless of how
|
|
12
|
+
good the steering guidance is -- this is a backstop against
|
|
13
|
+
loop-thrashing, independent of guidance quality.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
from .models import GateDecision, EvaluationResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SteerCircuitBreaker:
|
|
21
|
+
def __init__(self, max_retries: int = 2):
|
|
22
|
+
self.max_retries = max_retries
|
|
23
|
+
self._attempts: Dict[str, int] = {}
|
|
24
|
+
self._responses: Dict[str, EvaluationResult] = {}
|
|
25
|
+
|
|
26
|
+
def token_for(self, natural_key: str) -> str:
|
|
27
|
+
return f"steer_{natural_key}"
|
|
28
|
+
|
|
29
|
+
def resolve(self, natural_key: str, confidence: float,
|
|
30
|
+
base_steer: Dict[str, Any]) -> EvaluationResult:
|
|
31
|
+
token = self.token_for(natural_key)
|
|
32
|
+
|
|
33
|
+
if token in self._responses:
|
|
34
|
+
return self._responses[token]
|
|
35
|
+
|
|
36
|
+
attempt = self._attempts.get(token, 0)
|
|
37
|
+
|
|
38
|
+
if attempt >= self.max_retries:
|
|
39
|
+
result = EvaluationResult(
|
|
40
|
+
decision=GateDecision.ASK,
|
|
41
|
+
confidence=confidence,
|
|
42
|
+
reason=f"Steer circuit breaker tripped ({attempt}/{self.max_retries}). Escalating to human.",
|
|
43
|
+
)
|
|
44
|
+
self._responses[token] = result
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
self._attempts[token] = attempt + 1
|
|
48
|
+
base_steer.setdefault("suggested_args", {})["idempotency_key"] = token
|
|
49
|
+
|
|
50
|
+
result = EvaluationResult(
|
|
51
|
+
decision=GateDecision.STEER,
|
|
52
|
+
confidence=confidence,
|
|
53
|
+
reason=f"Steered to safer path (attempt {attempt + 1}/{self.max_retries}).",
|
|
54
|
+
steer_payload=base_steer,
|
|
55
|
+
)
|
|
56
|
+
return result
|