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 +32 -0
- arceo-0.1.0/arceo/__init__.py +28 -0
- arceo-0.1.0/arceo/analysis/__init__.py +1 -0
- arceo-0.1.0/arceo/analysis/risk.py +172 -0
- arceo-0.1.0/arceo/ci.py +106 -0
- arceo-0.1.0/arceo/cli.py +78 -0
- arceo-0.1.0/arceo/client.py +38 -0
- arceo-0.1.0/arceo/config.py +65 -0
- arceo-0.1.0/arceo/decorator.py +201 -0
- arceo-0.1.0/arceo/frameworks/__init__.py +1 -0
- arceo-0.1.0/arceo/frameworks/anthropic_sdk.py +89 -0
- arceo-0.1.0/arceo/frameworks/crewai_sdk.py +52 -0
- arceo-0.1.0/arceo/frameworks/langchain.py +110 -0
- arceo-0.1.0/arceo/frameworks/openai_sdk.py +100 -0
- arceo-0.1.0/arceo/frameworks/vanilla.py +105 -0
- arceo-0.1.0/arceo/models.py +130 -0
- arceo-0.1.0/arceo/parser.py +107 -0
- arceo-0.1.0/arceo/report.py +117 -0
- arceo-0.1.0/arceo/scanner.py +221 -0
- arceo-0.1.0/arceo/tracing/__init__.py +1 -0
- arceo-0.1.0/arceo/tracing/context.py +19 -0
- arceo-0.1.0/arceo.egg-info/PKG-INFO +32 -0
- arceo-0.1.0/arceo.egg-info/SOURCES.txt +31 -0
- arceo-0.1.0/arceo.egg-info/dependency_links.txt +1 -0
- arceo-0.1.0/arceo.egg-info/entry_points.txt +2 -0
- arceo-0.1.0/arceo.egg-info/requires.txt +29 -0
- arceo-0.1.0/arceo.egg-info/top_level.txt +1 -0
- arceo-0.1.0/pyproject.toml +35 -0
- arceo-0.1.0/setup.cfg +4 -0
- arceo-0.1.0/setup.py +20 -0
- arceo-0.1.0/tests/test_parser.py +44 -0
- arceo-0.1.0/tests/test_risk.py +80 -0
- arceo-0.1.0/tests/test_vanilla.py +79 -0
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
|
arceo-0.1.0/arceo/ci.py
ADDED
|
@@ -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
|
arceo-0.1.0/arceo/cli.py
ADDED
|
@@ -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)
|