actionrail 0.1.0b7__py3-none-any.whl

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 (42) hide show
  1. actionrail/__init__.py +29 -0
  2. actionrail/cli.py +82 -0
  3. actionrail/mcp_quickstart.py +193 -0
  4. actionrail/quickstart.py +207 -0
  5. actionrail/sdk/__init__.py +47 -0
  6. actionrail/sdk/action_tests.py +276 -0
  7. actionrail/sdk/actions.py +86 -0
  8. actionrail/sdk/config.py +123 -0
  9. actionrail/sdk/config_cache.py +92 -0
  10. actionrail/sdk/context.py +66 -0
  11. actionrail/sdk/enforce.py +310 -0
  12. actionrail/sdk/grounding/__init__.py +18 -0
  13. actionrail/sdk/grounding/base.py +23 -0
  14. actionrail/sdk/grounding/http.py +88 -0
  15. actionrail/sdk/grounding/match.py +164 -0
  16. actionrail/sdk/grounding/mcp.py +222 -0
  17. actionrail/sdk/grounding/mysql.py +139 -0
  18. actionrail/sdk/grounding/postgres.py +123 -0
  19. actionrail/sdk/grounding/sqlite.py +39 -0
  20. actionrail/sdk/instrument.py +59 -0
  21. actionrail/sdk/lifecycle.py +83 -0
  22. actionrail/sdk/numeric.py +24 -0
  23. actionrail/sdk/outbox.py +81 -0
  24. actionrail/sdk/pipeline.py +230 -0
  25. actionrail/sdk/policy.py +52 -0
  26. actionrail/sdk/privacy.py +30 -0
  27. actionrail/sdk/report.py +332 -0
  28. actionrail/sdk/review.py +107 -0
  29. actionrail/sdk/rule_validation.py +406 -0
  30. actionrail/sdk/runtime.py +428 -0
  31. actionrail/sdk/runtime_config.py +172 -0
  32. actionrail/sdk/safewrite.py +36 -0
  33. actionrail/sdk/scanner.py +212 -0
  34. actionrail/sdk/state.py +28 -0
  35. actionrail/sdk/telemetry.py +131 -0
  36. actionrail-0.1.0b7.dist-info/METADATA +483 -0
  37. actionrail-0.1.0b7.dist-info/RECORD +42 -0
  38. actionrail-0.1.0b7.dist-info/WHEEL +5 -0
  39. actionrail-0.1.0b7.dist-info/entry_points.txt +4 -0
  40. actionrail-0.1.0b7.dist-info/licenses/LICENSE +201 -0
  41. actionrail-0.1.0b7.dist-info/licenses/NOTICE +6 -0
  42. actionrail-0.1.0b7.dist-info/top_level.txt +1 -0
actionrail/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """ActionRail — grounded actions for AI agents."""
2
+
3
+ from .sdk.action_tests import ActionTestError, ActionTestReport, run_action_tests
4
+ from .sdk.actions import ActionDefinition
5
+ from .sdk.context import current_trusted_context, trusted_context
6
+ from .sdk.enforce import enforce
7
+ from .sdk.instrument import instrument
8
+ from .sdk.lifecycle import close_reporting, flush_reporting, get_runtime_health
9
+ from .sdk.pipeline import Decision
10
+ from .sdk.runtime import ActionBlocked, ActionHeld, ActionNotAllowed, ActionRuntime
11
+
12
+ __all__ = [
13
+ "ActionTestError",
14
+ "ActionDefinition",
15
+ "ActionTestReport",
16
+ "run_action_tests",
17
+ "instrument",
18
+ "enforce",
19
+ "trusted_context",
20
+ "current_trusted_context",
21
+ "flush_reporting",
22
+ "close_reporting",
23
+ "get_runtime_health",
24
+ "Decision",
25
+ "ActionRuntime",
26
+ "ActionNotAllowed",
27
+ "ActionBlocked",
28
+ "ActionHeld",
29
+ ]
actionrail/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """Command-line interface for ActionRail framework tooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ import json
8
+ import sys
9
+
10
+ from .sdk.action_tests import ActionTestError, run_action_tests
11
+
12
+
13
+ def _package_version() -> str:
14
+ try:
15
+ return version("actionrail")
16
+ except PackageNotFoundError:
17
+ return "development"
18
+
19
+
20
+ def _parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="actionrail",
23
+ description="Test and operate ActionRail action contracts.",
24
+ )
25
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_package_version()}")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+ test = commands.add_parser(
28
+ "test",
29
+ help="run deterministic allow, hold, and block regression cases",
30
+ )
31
+ test.add_argument("config", nargs="?", default="actionrail.yaml", help="rules file")
32
+ test.add_argument(
33
+ "--cases",
34
+ default="actionrail.cases.yaml",
35
+ help="Action Tests document (default: actionrail.cases.yaml)",
36
+ )
37
+ test.add_argument(
38
+ "--live-sources",
39
+ action="store_true",
40
+ help="allow cases without fixtures to use Sources from the rules file",
41
+ )
42
+ test.add_argument("--format", choices=("text", "json"), default="text")
43
+ return parser
44
+
45
+
46
+ def _print_text(report) -> None:
47
+ print("ActionRail Action Tests")
48
+ print(f"Rules: {report.config_path}")
49
+ print(f"Cases: {report.cases_path}\n")
50
+ for result in report.results:
51
+ mark = "PASS" if result.passed else "FAIL"
52
+ print(f"{mark} {result.name}")
53
+ print(f" {result.tool}: expected {result.expected}, got {result.actual}")
54
+ for reason in result.reasons:
55
+ print(f" {reason}")
56
+ if result.error:
57
+ print(f" {result.error}")
58
+ print(f"\n{report.passed} passed · {report.failed} failed")
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = _parser().parse_args(argv)
63
+ if args.command != "test":
64
+ return 2
65
+ try:
66
+ report = run_action_tests(
67
+ args.config,
68
+ args.cases,
69
+ live_sources=args.live_sources,
70
+ )
71
+ except ActionTestError as exc:
72
+ print(f"Action Tests configuration error: {exc}", file=sys.stderr)
73
+ return 2
74
+ if args.format == "json":
75
+ print(json.dumps(report.to_dict(), indent=2))
76
+ else:
77
+ _print_text(report)
78
+ return 0 if report.successful else 1
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
@@ -0,0 +1,193 @@
1
+ """Run a local MCP Streamable HTTP grounding example.
2
+
3
+ The example starts a temporary read-only MCP server, calls it through the real
4
+ ActionRail MCP Source adapter, and proves that a cross-customer payment is
5
+ blocked before the simulated refund function executes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import socket
11
+ import threading
12
+ import time
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from typing import Iterator
16
+
17
+ from mcp.types import CallToolResult, ToolAnnotations
18
+
19
+ from actionrail.sdk.config import config_from_dict
20
+ from actionrail.sdk.grounding import MCPSource
21
+ from actionrail.sdk.pipeline import decide
22
+
23
+
24
+ CALLER_CUSTOMER_ID = "CUSTOMER-100"
25
+ OWN_PAYMENT_ID = "PAYMENT-OWN"
26
+ OTHER_PAYMENT_ID = "PAYMENT-OTHER"
27
+
28
+ _PAYMENTS = {
29
+ OWN_PAYMENT_ID: {
30
+ "payment_id": OWN_PAYMENT_ID,
31
+ "customer_id": CALLER_CUSTOMER_ID,
32
+ "status": "captured",
33
+ },
34
+ OTHER_PAYMENT_ID: {
35
+ "payment_id": OTHER_PAYMENT_ID,
36
+ "customer_id": "CUSTOMER-OTHER",
37
+ "status": "captured",
38
+ },
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class _Gateway:
44
+ endpoint: str
45
+ server: object
46
+ thread: threading.Thread
47
+ socket: socket.socket
48
+
49
+
50
+ @contextmanager
51
+ def _local_gateway() -> Iterator[_Gateway]:
52
+ import uvicorn
53
+ from mcp.server.fastmcp import FastMCP
54
+
55
+ mcp = FastMCP(
56
+ "ActionRail MCP quickstart",
57
+ stateless_http=True,
58
+ json_response=True,
59
+ log_level="ERROR",
60
+ )
61
+
62
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
63
+ def lookup_payment(payment_id: str) -> CallToolResult:
64
+ """Return one payment record without modifying the billing system."""
65
+
66
+ return CallToolResult(
67
+ content=[],
68
+ structuredContent={"payment": _PAYMENTS.get(payment_id)},
69
+ )
70
+
71
+ listener = socket.socket()
72
+ listener.bind(("127.0.0.1", 0))
73
+ host, port = listener.getsockname()
74
+ server = uvicorn.Server(
75
+ uvicorn.Config(
76
+ mcp.streamable_http_app(),
77
+ log_level="error",
78
+ lifespan="on",
79
+ )
80
+ )
81
+ thread = threading.Thread(
82
+ target=server.run,
83
+ kwargs={"sockets": [listener]},
84
+ daemon=True,
85
+ )
86
+ thread.start()
87
+ deadline = time.monotonic() + 5
88
+ while not server.started and thread.is_alive() and time.monotonic() < deadline:
89
+ time.sleep(0.01)
90
+ if not server.started:
91
+ server.should_exit = True
92
+ thread.join(timeout=2)
93
+ listener.close()
94
+ raise RuntimeError("the local MCP server did not start")
95
+
96
+ gateway = _Gateway(f"http://{host}:{port}/mcp", server, thread, listener)
97
+ try:
98
+ yield gateway
99
+ finally:
100
+ server.should_exit = True
101
+ thread.join(timeout=5)
102
+ listener.close()
103
+
104
+
105
+ def run_quickstart() -> dict:
106
+ config = config_from_dict({
107
+ "schema_version": 1,
108
+ "tools": {
109
+ "refund_payment": {
110
+ "kind": "consequential",
111
+ "args": {
112
+ "payment_id": {
113
+ "ground": {
114
+ "checks": [{
115
+ "source": "billing-mcp",
116
+ "match": [
117
+ {"column": "customer_id", "ctx": "customer_id"},
118
+ {"column": "status", "value": "captured"},
119
+ ],
120
+ }],
121
+ },
122
+ },
123
+ },
124
+ },
125
+ },
126
+ })
127
+
128
+ executed: list[str] = []
129
+
130
+ def refund_payment(payment_id: str) -> None:
131
+ executed.append(payment_id)
132
+
133
+ with _local_gateway() as gateway:
134
+ source = MCPSource(
135
+ endpoint=gateway.endpoint,
136
+ tool="lookup_payment",
137
+ arguments={"payment_id": "{value}"},
138
+ select="payment",
139
+ )
140
+ sources = {"billing-mcp": source}
141
+ context = {"customer_id": CALLER_CUSTOMER_ID}
142
+
143
+ allowed = decide(
144
+ "refund_payment",
145
+ {"payment_id": OWN_PAYMENT_ID},
146
+ config,
147
+ sources,
148
+ context,
149
+ )
150
+ if allowed.outcome == "allow":
151
+ refund_payment(OWN_PAYMENT_ID)
152
+
153
+ blocked = decide(
154
+ "refund_payment",
155
+ {"payment_id": OTHER_PAYMENT_ID},
156
+ config,
157
+ sources,
158
+ context,
159
+ )
160
+ if blocked.outcome == "allow":
161
+ refund_payment(OTHER_PAYMENT_ID)
162
+
163
+ if allowed.outcome != "allow":
164
+ raise RuntimeError(f"expected owned payment to be allowed, got {allowed.outcome}")
165
+ if blocked.outcome != "block":
166
+ raise RuntimeError(f"expected cross-customer payment to be blocked, got {blocked.outcome}")
167
+ if executed != [OWN_PAYMENT_ID]:
168
+ raise RuntimeError(f"unexpected refund executions: {executed}")
169
+
170
+ return {
171
+ "allowed": allowed.outcome,
172
+ "blocked": blocked.outcome,
173
+ "executed": executed,
174
+ }
175
+
176
+
177
+ def main() -> int:
178
+ try:
179
+ result = run_quickstart()
180
+ except (OSError, RuntimeError) as exc:
181
+ print(f"ActionRail MCP quickstart failed: {exc}")
182
+ return 1
183
+
184
+ print("✓ Started a local MCP Streamable HTTP server")
185
+ print("✓ Discovered a verification tool marked read-only")
186
+ print(f"✓ Owned payment decision: {result['allowed']}")
187
+ print(f"✓ Cross-customer payment decision: {result['blocked']}")
188
+ print("✓ The blocked refund function did not execute")
189
+ return 0
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())
@@ -0,0 +1,207 @@
1
+ """Run the deterministic ActionRail + LangGraph quickstart.
2
+
3
+ The command creates a local LangGraph agent, registers it and a SQLite Source
4
+ with the ActionRail Console, installs a grounding rule, and proves that a
5
+ cross-customer refund is blocked before the refund tool executes.
6
+
7
+ Start ``actionrail-console`` first, then run ``actionrail-quickstart``. No
8
+ model API key or repository checkout is required.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sqlite3
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+ from langchain_core.messages import AIMessage, HumanMessage
20
+ from langchain_core.tools import tool
21
+
22
+ from actionrail import close_reporting, enforce
23
+
24
+ ORDER_ID = "ORDER-OTHER-CUSTOMER"
25
+ CALLER_CUSTOMER_ID = "CUSTOMER-100"
26
+
27
+
28
+ def _langgraph_types():
29
+ try:
30
+ from langgraph.graph import END, START, MessagesState, StateGraph
31
+ from langgraph.prebuilt import ToolNode
32
+ except ImportError as exc: # pragma: no cover - exercised by installed-package smoke tests
33
+ raise RuntimeError(
34
+ 'LangGraph is not installed. Run: python -m pip install "actionrail[agents]"'
35
+ ) from exc
36
+ return END, START, MessagesState, StateGraph, ToolNode
37
+
38
+
39
+ def _build_database(path: Path) -> None:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ connection = sqlite3.connect(path)
42
+ try:
43
+ connection.execute(
44
+ "CREATE TABLE IF NOT EXISTS orders "
45
+ "(order_id TEXT PRIMARY KEY, customer_id TEXT, status TEXT)"
46
+ )
47
+ connection.execute("DELETE FROM orders")
48
+ connection.executemany(
49
+ "INSERT INTO orders VALUES (?, ?, ?)",
50
+ [
51
+ ("ORDER-MINE", CALLER_CUSTOMER_ID, "delivered"),
52
+ (ORDER_ID, "CUSTOMER-OTHER", "delivered"),
53
+ ],
54
+ )
55
+ connection.commit()
56
+ finally:
57
+ connection.close()
58
+
59
+
60
+ def _build_agent(executed: list[dict]):
61
+ END, START, MessagesState, StateGraph, ToolNode = _langgraph_types()
62
+
63
+ @tool
64
+ def issue_refund(order_id: str, amount: float) -> str:
65
+ """Issue a real customer refund for a delivered order."""
66
+ executed.append({"order_id": order_id, "amount": amount})
67
+ return f"Refunded {amount} for {order_id}"
68
+
69
+ def request_refund(_state: MessagesState):
70
+ return {"messages": [AIMessage(
71
+ content="",
72
+ tool_calls=[{
73
+ "name": "issue_refund",
74
+ "args": {"order_id": ORDER_ID, "amount": 250},
75
+ "id": "quickstart-refund",
76
+ "type": "tool_call",
77
+ }],
78
+ )]}
79
+
80
+ graph = StateGraph(MessagesState)
81
+ graph.add_node("agent", request_refund)
82
+ graph.add_node("tools", ToolNode([issue_refund]))
83
+ graph.add_edge(START, "agent")
84
+ graph.add_edge("agent", "tools")
85
+ graph.add_edge("tools", END)
86
+ return graph.compile()
87
+
88
+
89
+ def _register(client: httpx.Client, database: Path, agent_name: str) -> tuple[str, str, str]:
90
+ created = client.post("/api/agents", json={"name": agent_name})
91
+ created.raise_for_status()
92
+ identity = created.json()
93
+ agent_id = identity["agent_id"]
94
+ api_key = identity["api_key"]
95
+ source_name = f"quickstart-billing-{agent_id[:8]}"
96
+
97
+ source = client.post("/api/sources", json={
98
+ "name": source_name,
99
+ "adapter": "sqlite",
100
+ "config": {"path": str(database)},
101
+ })
102
+ source.raise_for_status()
103
+
104
+ rules = {"schema_version": 1, "tools": {"issue_refund": {
105
+ "kind": "consequential",
106
+ "args": {
107
+ "order_id": {"ground": {"checks": [{
108
+ "source": source_name,
109
+ "query": (
110
+ "SELECT customer_id, status FROM orders "
111
+ "WHERE order_id = :value"
112
+ ),
113
+ "match": [
114
+ {"column": "customer_id", "ctx": "customer_id"},
115
+ {"column": "status", "value": "delivered"},
116
+ ],
117
+ }]}},
118
+ "amount": {"policy": "amount <= 1000"},
119
+ },
120
+ # Runtime values stay local. Activity shows a useful redacted preview;
121
+ # add a field to report_args only after deciding it is safe to export.
122
+ "write": {
123
+ "preview": "Refund order {order_id} for {amount}",
124
+ "report_args": [],
125
+ },
126
+ }}}
127
+ saved = client.put(f"/api/agents/{agent_id}/rules", json={"rules": rules})
128
+ saved.raise_for_status()
129
+ return agent_id, api_key, source_name
130
+
131
+
132
+ def run_quickstart(
133
+ *,
134
+ endpoint: str = "http://127.0.0.1:8020",
135
+ database: Path | None = None,
136
+ agent_name: str = "LangGraph refund quickstart",
137
+ state_dir: Path | None = None,
138
+ ) -> dict:
139
+ endpoint = endpoint.rstrip("/")
140
+ database = (database or Path.home() / ".actionrail" / "quickstart-billing.sqlite3").resolve()
141
+ _build_database(database)
142
+
143
+ with httpx.Client(base_url=endpoint, timeout=5.0) as client:
144
+ agent_id, api_key, source_name = _register(client, database, agent_name)
145
+ executed = []
146
+ agent = _build_agent(executed)
147
+ agent = enforce(
148
+ agent,
149
+ agent_id=agent_id,
150
+ api_key=api_key,
151
+ endpoint=endpoint,
152
+ ctx={"customer_id": CALLER_CUSTOMER_ID},
153
+ state_dir=state_dir,
154
+ )
155
+ result = agent.invoke({"messages": [HumanMessage(content="Refund the order")]})
156
+ blocked_message = str(result["messages"][-1].content)
157
+
158
+ if executed:
159
+ raise RuntimeError("quickstart safety check failed: the refund tool executed")
160
+ if "BLOCKED" not in blocked_message:
161
+ raise RuntimeError(f"expected a blocked tool result, received: {blocked_message}")
162
+ if not close_reporting(agent, timeout=5):
163
+ raise RuntimeError("the ActionRail reporting queue did not flush cleanly")
164
+
165
+ feed = client.get("/api/decisions", params={"agent": agent_id, "page_size": 5})
166
+ feed.raise_for_status()
167
+ activity = feed.json().get("decisions", [])
168
+ if not activity:
169
+ raise RuntimeError("the blocked decision did not appear in Activity")
170
+ decision = activity[0]
171
+ serialized = json.dumps(decision)
172
+ if ORDER_ID in serialized or "CUSTOMER-OTHER" in serialized:
173
+ raise RuntimeError("Activity unexpectedly contains private runtime values")
174
+
175
+ return {
176
+ "agent_id": agent_id,
177
+ "source_name": source_name,
178
+ "blocked_message": blocked_message,
179
+ "activity": decision,
180
+ "executed": executed,
181
+ "database": str(database),
182
+ "endpoint": endpoint,
183
+ }
184
+
185
+
186
+ def main(argv: list[str] | None = None) -> int:
187
+ parser = argparse.ArgumentParser(description=__doc__)
188
+ parser.add_argument("--endpoint", default="http://127.0.0.1:8020")
189
+ parser.add_argument("--database", type=Path)
190
+ args = parser.parse_args(argv)
191
+ try:
192
+ result = run_quickstart(endpoint=args.endpoint, database=args.database)
193
+ except (httpx.HTTPError, OSError, RuntimeError) as exc:
194
+ print(f"ActionRail quickstart failed: {exc}")
195
+ print(f"Is actionrail-console running at {args.endpoint.rstrip('/')}?")
196
+ return 1
197
+
198
+ print(f"✓ Registered agent {result['agent_id']}")
199
+ print(f"✓ Connected local source {result['source_name']}")
200
+ print("✓ ActionRail blocked the cross-customer refund before the tool executed")
201
+ print("✓ The value-free decision is visible in Activity")
202
+ print(f"Open {result['endpoint']}/activity")
203
+ return 0
204
+
205
+
206
+ if __name__ == "__main__":
207
+ raise SystemExit(main())
@@ -0,0 +1,47 @@
1
+ """ActionRail runtime SDK."""
2
+
3
+ from .action_tests import ActionTestError, ActionTestReport, run_action_tests
4
+ from .actions import ActionDefinition
5
+ from .context import current_trusted_context, trusted_context
6
+ from .enforce import enforce
7
+ from .instrument import instrument
8
+ from .lifecycle import close_reporting, flush_reporting, get_runtime_health
9
+ from .report import Reporter
10
+ from .pipeline import Decision
11
+ from .runtime import ActionBlocked, ActionHeld, ActionNotAllowed, ActionRuntime
12
+ from .scanner import (
13
+ ToolInfo,
14
+ SurfaceMap,
15
+ calls_from_messages,
16
+ enumerate_tools,
17
+ observe_run,
18
+ build_surface_map,
19
+ print_surface_map,
20
+ )
21
+
22
+ __all__ = [
23
+ "ActionTestError",
24
+ "ActionDefinition",
25
+ "ActionTestReport",
26
+ "run_action_tests",
27
+ "instrument",
28
+ "enforce",
29
+ "trusted_context",
30
+ "current_trusted_context",
31
+ "Reporter",
32
+ "Decision",
33
+ "ActionRuntime",
34
+ "ActionNotAllowed",
35
+ "ActionBlocked",
36
+ "ActionHeld",
37
+ "flush_reporting",
38
+ "close_reporting",
39
+ "get_runtime_health",
40
+ "ToolInfo",
41
+ "SurfaceMap",
42
+ "calls_from_messages",
43
+ "enumerate_tools",
44
+ "observe_run",
45
+ "build_surface_map",
46
+ "print_surface_map",
47
+ ]