arceo 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.
arceo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: arceo
3
+ Version: 0.1.0
4
+ Summary: Monitor any AI agent's tool calls and get a risk report.
5
+ Author: Arceo
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: langchain
10
+ Requires-Dist: langchain-core>=0.2; extra == "langchain"
11
+ Provides-Extra: crewai
12
+ Requires-Dist: crewai>=0.50; extra == "crewai"
13
+ Provides-Extra: openai
14
+ Requires-Dist: openai>=1.0; extra == "openai"
15
+ Provides-Extra: anthropic
16
+ Requires-Dist: anthropic>=0.30; extra == "anthropic"
17
+ Provides-Extra: backend
18
+ Requires-Dist: httpx>=0.27; extra == "backend"
19
+ Provides-Extra: cli
20
+ Requires-Dist: click>=8.0; extra == "cli"
21
+ Requires-Dist: pyyaml>=6.0; extra == "cli"
22
+ Requires-Dist: httpx>=0.27; extra == "cli"
23
+ Provides-Extra: all
24
+ Requires-Dist: langchain-core>=0.2; extra == "all"
25
+ Requires-Dist: crewai>=0.50; extra == "all"
26
+ Requires-Dist: openai>=1.0; extra == "all"
27
+ Requires-Dist: anthropic>=0.30; extra == "all"
28
+ Requires-Dist: httpx>=0.27; extra == "all"
29
+ Requires-Dist: click>=8.0; extra == "all"
30
+ Requires-Dist: pyyaml>=6.0; extra == "all"
31
+ Dynamic: author
32
+ Dynamic: requires-python
@@ -0,0 +1,28 @@
1
+ """Arceo SDK — monitor any AI agent's tool calls and get a risk report.
2
+
3
+ from arceo import monitor, tool
4
+
5
+ @tool(service="stripe", risk="moves_money")
6
+ def create_refund(customer_id, amount):
7
+ ...
8
+
9
+ @monitor(verbose=True)
10
+ def my_agent(prompt):
11
+ ...
12
+
13
+ # Or local analysis with no agent execution:
14
+ from arceo import analyze_local
15
+ analyze_local(tools=[{"name": "stripe", "actions": ["create_refund"]}])
16
+ """
17
+
18
+ from arceo.decorator import monitor, analyze_local, ArceoSecurityError
19
+ from arceo.client import ArceoClient
20
+ from arceo.models import ArceoTrace, ArceoToolCall, ArceoToolSchema, ArceoLLMCall
21
+ from arceo.frameworks.vanilla import tool
22
+
23
+ __all__ = [
24
+ "monitor", "analyze_local", "ArceoSecurityError",
25
+ "ArceoClient", "ArceoTrace", "ArceoToolCall", "ArceoToolSchema", "ArceoLLMCall",
26
+ "tool",
27
+ ]
28
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ from arceo.analysis.risk import infer_risk, infer_verb, detect_chains_local
@@ -0,0 +1,172 @@
1
+ """Local risk inference — runs in <10ms, no LLM, no network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ VERB_MAP = {
6
+ "get": "read", "list": "read", "read": "read", "describe": "read",
7
+ "fetch": "read", "search": "read", "query": "read", "check": "read",
8
+ "find": "read", "lookup": "read", "show": "read", "view": "read",
9
+ "create": "create", "add": "create", "insert": "create", "post": "create",
10
+ "new": "create", "register": "create", "provision": "create",
11
+ "update": "update", "modify": "update", "patch": "update", "set": "update",
12
+ "edit": "update", "change": "update", "configure": "update",
13
+ "delete": "delete", "remove": "delete", "destroy": "delete", "purge": "delete",
14
+ "drop": "delete", "wipe": "delete", "erase": "delete", "revoke": "delete",
15
+ "send": "send", "email": "send", "notify": "send", "publish": "send",
16
+ "broadcast": "send", "forward": "send", "export": "send", "post": "send",
17
+ "deploy": "execute", "migrate": "execute", "scale": "execute",
18
+ "restart": "execute", "terminate": "execute", "reboot": "execute",
19
+ "rollback": "execute", "trigger": "execute", "merge": "execute",
20
+ "pay": "transact", "charge": "transact", "refund": "transact",
21
+ "transfer": "transact", "payout": "transact", "invoice": "transact",
22
+ "void": "transact", "cancel": "transact",
23
+ }
24
+
25
+ SERVICE_RISK = {
26
+ "stripe": "moves_money", "square": "moves_money", "paypal": "moves_money",
27
+ "braintree": "moves_money", "quickbooks": "moves_money",
28
+ "gmail": "sends_external", "sendgrid": "sends_external", "ses": "sends_external",
29
+ "mailgun": "sends_external", "twilio": "sends_external",
30
+ "salesforce": "touches_pii", "hubspot": "touches_pii", "zendesk": "touches_pii",
31
+ "bamboohr": "touches_pii", "clearbit": "touches_pii", "intercom": "touches_pii",
32
+ }
33
+
34
+ INFRA_SERVICES = {"aws", "gcp", "azure", "aws_ec2", "aws_ecs", "aws_rds", "aws_iam", "aws_s3"}
35
+
36
+ ARG_HINTS = {
37
+ "touches_pii": {"email", "phone", "ssn", "address", "dob", "date_of_birth",
38
+ "first_name", "last_name", "name", "social_security", "customer_id"},
39
+ "moves_money": {"amount", "price", "cents", "currency", "total", "subtotal", "payment_id"},
40
+ "sends_external": {"to", "recipient", "destination", "webhook_url", "email_to"},
41
+ }
42
+
43
+ LABEL_TRANSITIONS = [
44
+ ("touches_pii", "sends_external", "potential_exfiltration", "critical"),
45
+ ("touches_pii", "moves_money", "pii_to_financial", "critical"),
46
+ ("touches_pii", "deletes_data", "pii_then_delete", "critical"),
47
+ ("moves_money", "deletes_data", "fraud_and_cover", "critical"),
48
+ ("moves_money", "sends_external", "money_then_external", "high"),
49
+ ("changes_production", "deletes_data", "infrastructure_destruction", "critical"),
50
+ ("changes_production", "changes_production", "cascading_changes", "high"),
51
+ ("deletes_data", "sends_external", "destroy_and_exfil", "high"),
52
+ ]
53
+
54
+ READ_PREFIXES = ("get_", "list_", "read_", "describe_", "fetch_", "search_", "query_", "check_", "find_", "show_")
55
+
56
+
57
+ def infer_verb(action_name: str) -> str:
58
+ """Infer the verb category from an action name."""
59
+ lower = action_name.lower()
60
+ for prefix, verb in VERB_MAP.items():
61
+ if lower.startswith(prefix + "_") or lower.startswith(prefix):
62
+ return verb
63
+ return "unknown"
64
+
65
+
66
+ def infer_risk(tool_name: str, action_name: str, arg_keys: list = None) -> tuple[list, bool]:
67
+ """Infer risk hints from tool name, action name, and argument keys.
68
+
69
+ Returns (risk_hints, is_read_only). Runs in <1ms.
70
+ """
71
+ hints = set()
72
+ lower_action = action_name.lower()
73
+ lower_tool = tool_name.lower()
74
+ is_read_only = lower_action.startswith(READ_PREFIXES)
75
+
76
+ # From verb
77
+ verb = infer_verb(action_name)
78
+ if verb == "delete":
79
+ hints.add("deletes_data")
80
+ elif verb == "send":
81
+ hints.add("sends_external")
82
+ elif verb == "transact":
83
+ hints.add("moves_money")
84
+ elif verb == "execute":
85
+ hints.add("changes_production")
86
+
87
+ # From service name
88
+ if lower_tool in SERVICE_RISK:
89
+ hints.add(SERVICE_RISK[lower_tool])
90
+ if lower_tool in INFRA_SERVICES and not is_read_only:
91
+ hints.add("changes_production")
92
+
93
+ # From action keywords
94
+ money_words = {"refund", "charge", "pay", "transfer", "payout", "invoice", "billing", "subscription", "price"}
95
+ pii_words = {"customer", "user", "contact", "account", "personal", "profile", "patient", "employee"}
96
+ send_words = {"send", "email", "notify", "message", "sms", "alert", "forward", "export"}
97
+ delete_words = {"delete", "remove", "destroy", "purge", "drop", "wipe", "terminate", "cancel", "revoke"}
98
+ prod_words = {"deploy", "merge", "release", "scale", "restart", "migrate", "provision", "reboot", "rollback"}
99
+
100
+ for w in money_words:
101
+ if w in lower_action:
102
+ hints.add("moves_money")
103
+ for w in pii_words:
104
+ if w in lower_action:
105
+ hints.add("touches_pii")
106
+ for w in send_words:
107
+ if w in lower_action:
108
+ hints.add("sends_external")
109
+ for w in delete_words:
110
+ if w in lower_action:
111
+ hints.add("deletes_data")
112
+ for w in prod_words:
113
+ if w in lower_action:
114
+ hints.add("changes_production")
115
+
116
+ # From argument keys
117
+ if arg_keys:
118
+ lower_keys = {k.lower() for k in arg_keys}
119
+ for label, hint_keys in ARG_HINTS.items():
120
+ if lower_keys & hint_keys:
121
+ hints.add(label)
122
+
123
+ return sorted(hints), is_read_only
124
+
125
+
126
+ def detect_chains_local(tool_calls) -> list:
127
+ """Detect dangerous chains from a sequence of tool calls. No network.
128
+
129
+ tool_calls: list of ArceoToolCall objects
130
+ Returns list of chain dicts.
131
+
132
+ A chain requires at least one non-read-only step — pure read sequences
133
+ (e.g. list_customers → get_customer) are not flagged as dangerous.
134
+ """
135
+ if len(tool_calls) < 2:
136
+ return []
137
+
138
+ # Build (index, risk_hints, is_read_only) triples
139
+ steps = [(i, set(tc.inferred_risk_hints), tc.is_read_only) for i, tc in enumerate(tool_calls)]
140
+
141
+ chains = []
142
+ seen = set()
143
+
144
+ for from_label, to_label, chain_name, severity in LABEL_TRANSITIONS:
145
+ if chain_name in seen:
146
+ continue
147
+ for i, (idx_a, hints_a, ro_a) in enumerate(steps):
148
+ if from_label not in hints_a:
149
+ continue
150
+ for idx_b, hints_b, ro_b in steps[i + 1:]:
151
+ if to_label not in hints_b:
152
+ continue
153
+ # Skip chains where both steps are read-only — not actually dangerous
154
+ if ro_a and ro_b:
155
+ continue
156
+ # Same label needs different operations
157
+ if from_label == to_label:
158
+ if tool_calls[idx_a].full_operation == tool_calls[idx_b].full_operation:
159
+ continue
160
+ chains.append({
161
+ "chain_name": chain_name,
162
+ "severity": severity,
163
+ "from_label": from_label,
164
+ "to_label": to_label,
165
+ "steps": [idx_a, idx_b],
166
+ "from_operation": tool_calls[idx_a].full_operation,
167
+ "to_operation": tool_calls[idx_b].full_operation,
168
+ })
169
+ seen.add(chain_name)
170
+ break
171
+
172
+ return chains
@@ -0,0 +1,106 @@
1
+ """CI output formatting and exit code handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arceo.scanner import ScanResult
6
+
7
+
8
+ def format_results(results: list[ScanResult], verbose: bool = False) -> str:
9
+ """Format scan results for terminal output."""
10
+ lines = ["", "arceo scan results:"]
11
+
12
+ for r in results:
13
+ lines.append(f" {r.agent_name}:")
14
+
15
+ if r.error:
16
+ lines.append(f" error: {r.error}")
17
+ continue
18
+
19
+ # Blast radius
20
+ icon = "pass" if r.blast_radius_pass else "FAIL"
21
+ threshold = f" (threshold: {r.blast_radius:.0f})" if not r.blast_radius_pass else ""
22
+ lines.append(f" blast_radius: {r.blast_radius:.0f}/100 {icon}{threshold}")
23
+
24
+ # Chains
25
+ icon = "pass" if r.chains_pass else "FAIL"
26
+ if r.chains_detected > 0:
27
+ chain_detail = f" ({', '.join(r.chain_names[:3])})" if r.chain_names else ""
28
+ lines.append(f" dangerous_chains: {r.chains_detected} {icon}{chain_detail}")
29
+ else:
30
+ lines.append(f" dangerous_chains: 0 {icon}")
31
+
32
+ # Policy violations
33
+ icon = "pass" if r.policy_pass else "FAIL"
34
+ if r.approval_violations:
35
+ detail = f" ({', '.join(r.approval_violations[:3])})"
36
+ lines.append(f" policy_violations: {len(r.approval_violations)} {icon}{detail}")
37
+ else:
38
+ lines.append(f" policy_violations: 0 {icon}")
39
+
40
+ # Extra detail in verbose mode
41
+ if verbose and r.report:
42
+ if r.violations_count > 0:
43
+ lines.append(f" violations: {r.violations_count}")
44
+ for v in r.violation_details[:5]:
45
+ lines.append(f" - {v}")
46
+ if r.data_flows_count > 0:
47
+ lines.append(f" data_flows: {r.data_flows_count}")
48
+ if r.risk_score > 0:
49
+ lines.append(f" simulation_risk: {r.risk_score:.1f}/100")
50
+
51
+ # Summary
52
+ failed = [r for r in results if not r.passed]
53
+ lines.append("")
54
+ if failed:
55
+ lines.append(f" FAILED: {len(failed)} agent(s) exceeded policy thresholds")
56
+ else:
57
+ lines.append(f" PASSED: all {len(results)} agent(s) within policy")
58
+ lines.append("")
59
+
60
+ return "\n".join(lines)
61
+
62
+
63
+ def format_github_comment(results: list[ScanResult]) -> str:
64
+ """Format results as a GitHub PR comment (markdown)."""
65
+ lines = ["## Arceo Scan Results", ""]
66
+
67
+ all_passed = all(r.passed for r in results)
68
+
69
+ for r in results:
70
+ status = "PASS" if r.passed else "FAIL"
71
+ lines.append(f"### {r.agent_name} — {status}")
72
+
73
+ if r.error:
74
+ lines.append(f"> Error: {r.error}")
75
+ lines.append("")
76
+ continue
77
+
78
+ lines.append("")
79
+ lines.append("| Check | Result | Detail |")
80
+ lines.append("|-------|--------|--------|")
81
+
82
+ br_icon = "PASS" if r.blast_radius_pass else "FAIL"
83
+ lines.append(f"| Blast Radius | {br_icon} | {r.blast_radius:.0f}/100 |")
84
+
85
+ ch_icon = "PASS" if r.chains_pass else "FAIL"
86
+ chains_detail = ", ".join(r.chain_names[:3]) if r.chain_names else "none"
87
+ lines.append(f"| Dangerous Chains | {ch_icon} | {r.chains_detected} ({chains_detail}) |")
88
+
89
+ pv_icon = "PASS" if r.policy_pass else "FAIL"
90
+ pv_detail = ", ".join(r.approval_violations[:3]) if r.approval_violations else "none"
91
+ lines.append(f"| Policy Violations | {pv_icon} | {len(r.approval_violations)} ({pv_detail}) |")
92
+
93
+ lines.append("")
94
+
95
+ if all_passed:
96
+ lines.append("**All agents passed policy checks.**")
97
+ else:
98
+ failed = [r.agent_name for r in results if not r.passed]
99
+ lines.append(f"**FAILED:** {', '.join(failed)} exceeded policy thresholds.")
100
+
101
+ return "\n".join(lines)
102
+
103
+
104
+ def get_exit_code(results: list[ScanResult]) -> int:
105
+ """Return 0 if all passed, 1 if any failed."""
106
+ return 0 if all(r.passed for r in results) else 1
@@ -0,0 +1,78 @@
1
+ """CLI: arceo scan — runs Arceo simulation on agent code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ try:
8
+ import click
9
+ except ImportError:
10
+ # Minimal fallback if click not installed
11
+ class click:
12
+ @staticmethod
13
+ def command(*a, **k):
14
+ def d(f): return f
15
+ return d
16
+ @staticmethod
17
+ def option(*a, **k):
18
+ def d(f): return f
19
+ return d
20
+ @staticmethod
21
+ def group(*a, **k):
22
+ def d(f): return f
23
+ return d
24
+
25
+
26
+ from arceo.config import load_config
27
+ from arceo.scanner import scan_all
28
+ from arceo.ci import format_results, format_github_comment, get_exit_code
29
+
30
+
31
+ @click.group()
32
+ def cli():
33
+ """Arceo — AI agent risk scanner."""
34
+ pass
35
+
36
+
37
+ @cli.command()
38
+ @click.option("--config", "-c", default="arceo.yaml", help="Path to arceo.yaml config file")
39
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
40
+ @click.option("--format", "output_format", type=click.Choice(["text", "github"]), default="text", help="Output format")
41
+ @click.option("--url", default=None, help="Override Arceo backend URL")
42
+ def scan(config, verbose, output_format, url):
43
+ """Scan all agents defined in arceo.yaml against policy thresholds."""
44
+ try:
45
+ cfg = load_config(config)
46
+ except FileNotFoundError:
47
+ click.echo(f"Error: Config file '{config}' not found.", err=True)
48
+ click.echo("Create an arceo.yaml file. See: https://github.com/Nikhilrangaa/ActionGate", err=True)
49
+ sys.exit(1)
50
+ except Exception as e:
51
+ click.echo(f"Error loading config: {e}", err=True)
52
+ sys.exit(1)
53
+
54
+ if url:
55
+ cfg.arceo_url = url
56
+
57
+ if not cfg.agents:
58
+ click.echo("No agents defined in config.", err=True)
59
+ sys.exit(1)
60
+
61
+ click.echo(f"Scanning {len(cfg.agents)} agent(s)...")
62
+
63
+ results = scan_all(cfg)
64
+
65
+ if output_format == "github":
66
+ click.echo(format_github_comment(results))
67
+ else:
68
+ click.echo(format_results(results, verbose=verbose))
69
+
70
+ sys.exit(get_exit_code(results))
71
+
72
+
73
+ def main():
74
+ cli()
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
@@ -0,0 +1,38 @@
1
+ """HTTP client for the Arceo backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from arceo.models import ArceoTrace
7
+
8
+
9
+ class ArceoClient:
10
+ def __init__(self, api_url="http://localhost:8000", api_key="", timeout=30.0):
11
+ self.api_url = api_url.rstrip("/")
12
+ self.api_key = api_key
13
+ self.timeout = timeout
14
+
15
+ def analyze(self, trace: ArceoTrace) -> dict:
16
+ """Send trace to backend. Returns raw response dict or None."""
17
+ try:
18
+ import httpx
19
+ except ImportError:
20
+ print("Arceo: httpx not installed, skipping backend. pip install httpx", file=sys.stderr)
21
+ return None
22
+
23
+ headers = {"Content-Type": "application/json"}
24
+ if self.api_key:
25
+ headers["X-API-Key"] = self.api_key
26
+
27
+ try:
28
+ resp = httpx.post(
29
+ "%s/api/sdk/analyze-trace" % self.api_url,
30
+ json=trace.to_api_payload(),
31
+ headers=headers,
32
+ timeout=self.timeout,
33
+ )
34
+ resp.raise_for_status()
35
+ return resp.json()
36
+ except Exception as e:
37
+ print("Arceo: Backend unreachable (%s), local analysis only." % e, file=sys.stderr)
38
+ return None
@@ -0,0 +1,65 @@
1
+ """Parses arceo.yaml config file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class AgentConfig:
14
+ name: str
15
+ entry: str
16
+ decorator: str = "arceo.monitor"
17
+
18
+
19
+ @dataclass
20
+ class PolicyConfig:
21
+ max_blast_radius: float = 100.0
22
+ block_chains: bool = False
23
+ require_approval_for: list[str] = field(default_factory=list)
24
+
25
+
26
+ @dataclass
27
+ class ArceoConfig:
28
+ agents: list[AgentConfig] = field(default_factory=list)
29
+ policy: PolicyConfig = field(default_factory=PolicyConfig)
30
+ arceo_url: str = "http://localhost:8000"
31
+ api_key: str = ""
32
+
33
+
34
+ def load_config(path: str | Path = "arceo.yaml") -> ArceoConfig:
35
+ """Load and parse arceo.yaml from the given path."""
36
+ config_path = Path(path)
37
+
38
+ if not config_path.exists():
39
+ raise FileNotFoundError(f"Config file not found: {config_path}")
40
+
41
+ with open(config_path) as f:
42
+ raw = yaml.safe_load(f)
43
+
44
+ if not raw:
45
+ raise ValueError("Empty config file")
46
+
47
+ agents = []
48
+ for a in raw.get("agents", []):
49
+ agents.append(AgentConfig(
50
+ name=a["name"],
51
+ entry=a.get("entry", ""),
52
+ decorator=a.get("decorator", "arceo.monitor"),
53
+ ))
54
+
55
+ policy_raw = raw.get("policy", {})
56
+ policy = PolicyConfig(
57
+ max_blast_radius=float(policy_raw.get("max_blast_radius", 100)),
58
+ block_chains=bool(policy_raw.get("block_chains", False)),
59
+ require_approval_for=policy_raw.get("require_approval_for", []),
60
+ )
61
+
62
+ arceo_url = raw.get("arceo_url", os.getenv("ARCEO_URL", "http://localhost:8000"))
63
+ api_key = raw.get("api_key", os.getenv("ARCEO_API_KEY", ""))
64
+
65
+ return ArceoConfig(agents=agents, policy=policy, arceo_url=arceo_url, api_key=api_key)