agent-loop-guard-runtime 0.6.0a2__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.
- agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
- app/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/admin_routes.py +179 -0
- app/api/anthropic_routes.py +21 -0
- app/api/common.py +457 -0
- app/api/mcp_routes.py +218 -0
- app/api/openai_routes.py +26 -0
- app/api/replay_routes.py +202 -0
- app/api/ui_routes.py +295 -0
- app/benchmark/__init__.py +2 -0
- app/benchmark/adapters.py +97 -0
- app/benchmark/data/starter-v1.json +38 -0
- app/benchmark/dataset.py +59 -0
- app/benchmark/models.py +46 -0
- app/benchmark/runner.py +63 -0
- app/benchmark/scorers.py +34 -0
- app/benchmark/statistics.py +48 -0
- app/benchmark/storage.py +78 -0
- app/cli.py +624 -0
- app/core/config.py +196 -0
- app/core/demo.py +109 -0
- app/core/loop_detector.py +120 -0
- app/core/policy_engine.py +167 -0
- app/core/redaction.py +84 -0
- app/core/security.py +54 -0
- app/core/token_meter.py +67 -0
- app/db/models.py +296 -0
- app/db/repository.py +1488 -0
- app/db/session.py +57 -0
- app/main.py +59 -0
- app/mcp/__init__.py +1 -0
- app/mcp/gateway.py +230 -0
- app/mcp/policy.py +281 -0
- app/mcp/presets/development.yml +25 -0
- app/mcp/presets/filesystem.yml +18 -0
- app/mcp/stdio.py +142 -0
- app/platform/__init__.py +1 -0
- app/platform/alembic/__init__.py +2 -0
- app/platform/alembic/env.py +38 -0
- app/platform/alembic/script.py.mako +24 -0
- app/platform/alembic/versions/0001_initial_schema.py +18 -0
- app/platform/alembic/versions/__init__.py +2 -0
- app/platform/events.py +44 -0
- app/platform/maintenance.py +138 -0
- app/platform/migrations.py +24 -0
- app/platform/setup.py +92 -0
- app/providers/__init__.py +5 -0
- app/providers/base.py +23 -0
- app/providers/mock.py +169 -0
- app/providers/upstream.py +101 -0
- app/replay/__init__.py +1 -0
- app/replay/costs.py +37 -0
- app/replay/formats.py +87 -0
- app/replay/sdk.py +102 -0
- app/sandbox/__init__.py +2 -0
- app/sandbox/policy.py +22 -0
- app/sandbox/workspace.py +281 -0
- app/static/styles.css +327 -0
- app/templates/agents.html +48 -0
- app/templates/base.html +27 -0
- app/templates/dashboard.html +55 -0
- app/templates/demo.html +36 -0
- app/templates/mcp.html +69 -0
- app/templates/policies.html +30 -0
- app/templates/replay.html +63 -0
- app/templates/replay_compare.html +74 -0
- app/templates/replay_detail.html +130 -0
- app/templates/session_detail.html +71 -0
- app/templates/sessions.html +24 -0
- app/templates/settings.html +20 -0
app/core/config.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _bool(value: str | bool | None, default: bool = False) -> bool:
|
|
12
|
+
if value is None:
|
|
13
|
+
return default
|
|
14
|
+
if isinstance(value, bool):
|
|
15
|
+
return value
|
|
16
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _int(value: str | int | None, default: int) -> int:
|
|
20
|
+
if value is None:
|
|
21
|
+
return default
|
|
22
|
+
try:
|
|
23
|
+
return int(value)
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get(data: dict[str, Any], path: str, default: Any = None) -> Any:
|
|
29
|
+
current: Any = data
|
|
30
|
+
for part in path.split("."):
|
|
31
|
+
if not isinstance(current, dict) or part not in current:
|
|
32
|
+
return default
|
|
33
|
+
current = current[part]
|
|
34
|
+
return current
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class AppConfig:
|
|
39
|
+
host: str = "127.0.0.1"
|
|
40
|
+
port: int = 8787
|
|
41
|
+
admin_ui: bool = True
|
|
42
|
+
storage_url: str = "sqlite:///./data/agent_loop_guard.db"
|
|
43
|
+
retention_days: int = 30
|
|
44
|
+
full_content_logging: bool = False
|
|
45
|
+
body_limit_bytes: int = 1_048_576
|
|
46
|
+
inactive_timeout_seconds: int = 1800
|
|
47
|
+
default_project_id: str = "default"
|
|
48
|
+
default_mode: str = "shadow"
|
|
49
|
+
default_provider: str = "mock"
|
|
50
|
+
allow_mode_header: bool = True
|
|
51
|
+
gateway_key: str = "alg_demo_key"
|
|
52
|
+
openai_base_url: str = "https://api.openai.com/v1"
|
|
53
|
+
openai_api_key: str | None = None
|
|
54
|
+
anthropic_base_url: str = "https://api.anthropic.com"
|
|
55
|
+
anthropic_api_key: str | None = None
|
|
56
|
+
mcp_policy_path: str = "mcp-policy.yml"
|
|
57
|
+
mcp_approval_timeout_seconds: int = 30
|
|
58
|
+
mcp_allowed_origins: list[str] = field(
|
|
59
|
+
default_factory=lambda: ["http://127.0.0.1", "http://localhost"]
|
|
60
|
+
)
|
|
61
|
+
mcp_servers: dict[str, dict[str, Any]] = field(
|
|
62
|
+
default_factory=lambda: {
|
|
63
|
+
"filesystem": {
|
|
64
|
+
"name": "Demo filesystem server",
|
|
65
|
+
"transport": "mock",
|
|
66
|
+
"target": "mock://filesystem",
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_env(cls) -> AppConfig:
|
|
73
|
+
defaults = cls()
|
|
74
|
+
data: dict[str, Any] = {}
|
|
75
|
+
config_path = os.getenv("ALG_CONFIG")
|
|
76
|
+
if config_path and Path(config_path).exists():
|
|
77
|
+
with open(config_path, encoding="utf-8") as fh:
|
|
78
|
+
data = yaml.safe_load(fh) or {}
|
|
79
|
+
|
|
80
|
+
return cls(
|
|
81
|
+
host=os.getenv("ALG_HOST", str(_get(data, "server.host", defaults.host))),
|
|
82
|
+
port=_int(os.getenv("ALG_PORT"), int(_get(data, "server.port", defaults.port))),
|
|
83
|
+
admin_ui=_bool(os.getenv("ALG_ADMIN_UI"), bool(_get(data, "server.admin_ui", True))),
|
|
84
|
+
storage_url=os.getenv(
|
|
85
|
+
"ALG_STORAGE_URL", str(_get(data, "storage.url", defaults.storage_url))
|
|
86
|
+
),
|
|
87
|
+
retention_days=_int(
|
|
88
|
+
os.getenv("ALG_RETENTION_DAYS"),
|
|
89
|
+
int(_get(data, "storage.retention_days", defaults.retention_days)),
|
|
90
|
+
),
|
|
91
|
+
full_content_logging=_bool(
|
|
92
|
+
os.getenv("ALG_FULL_CONTENT_LOGGING"),
|
|
93
|
+
bool(_get(data, "storage.full_content_logging", False)),
|
|
94
|
+
),
|
|
95
|
+
body_limit_bytes=_int(
|
|
96
|
+
os.getenv("ALG_BODY_LIMIT_BYTES"),
|
|
97
|
+
int(_get(data, "server.body_limit_bytes", defaults.body_limit_bytes)),
|
|
98
|
+
),
|
|
99
|
+
inactive_timeout_seconds=_int(
|
|
100
|
+
os.getenv("ALG_INACTIVE_TIMEOUT_SECONDS"),
|
|
101
|
+
int(
|
|
102
|
+
_get(
|
|
103
|
+
data,
|
|
104
|
+
"server.inactive_timeout_seconds",
|
|
105
|
+
defaults.inactive_timeout_seconds,
|
|
106
|
+
)
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
default_mode=os.getenv("ALG_MODE", str(_get(data, "projects.default.mode", "shadow"))),
|
|
110
|
+
default_provider=os.getenv(
|
|
111
|
+
"ALG_PROVIDER", str(_get(data, "projects.default.provider", "mock"))
|
|
112
|
+
),
|
|
113
|
+
allow_mode_header=_bool(
|
|
114
|
+
os.getenv("ALG_ALLOW_MODE_HEADER"),
|
|
115
|
+
bool(_get(data, "server.allow_mode_header", True)),
|
|
116
|
+
),
|
|
117
|
+
gateway_key=os.getenv(
|
|
118
|
+
"ALG_GATEWAY_KEY", str(_get(data, "gateway_key", defaults.gateway_key))
|
|
119
|
+
),
|
|
120
|
+
openai_base_url=os.getenv(
|
|
121
|
+
"ALG_OPENAI_BASE_URL",
|
|
122
|
+
str(_get(data, "providers.openai.base_url", "https://api.openai.com/v1")),
|
|
123
|
+
),
|
|
124
|
+
openai_api_key=os.getenv(
|
|
125
|
+
"OPENAI_API_KEY", _get(data, "providers.openai.api_key", None)
|
|
126
|
+
),
|
|
127
|
+
anthropic_base_url=os.getenv(
|
|
128
|
+
"ALG_ANTHROPIC_BASE_URL",
|
|
129
|
+
str(_get(data, "providers.anthropic.base_url", "https://api.anthropic.com")),
|
|
130
|
+
),
|
|
131
|
+
anthropic_api_key=os.getenv(
|
|
132
|
+
"ANTHROPIC_API_KEY", _get(data, "providers.anthropic.api_key", None)
|
|
133
|
+
),
|
|
134
|
+
mcp_policy_path=os.getenv(
|
|
135
|
+
"ALG_MCP_POLICY", str(_get(data, "mcp.policy", defaults.mcp_policy_path))
|
|
136
|
+
),
|
|
137
|
+
mcp_approval_timeout_seconds=_int(
|
|
138
|
+
os.getenv("ALG_MCP_APPROVAL_TIMEOUT_SECONDS"),
|
|
139
|
+
int(_get(data, "mcp.approval_timeout_seconds", 30)),
|
|
140
|
+
),
|
|
141
|
+
mcp_allowed_origins=list(
|
|
142
|
+
_get(data, "mcp.allowed_origins", defaults.mcp_allowed_origins)
|
|
143
|
+
),
|
|
144
|
+
mcp_servers=dict(_get(data, "mcp.servers", defaults.mcp_servers)),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def ensure_storage_parent(self) -> None:
|
|
148
|
+
if not self.storage_url.startswith("sqlite:///"):
|
|
149
|
+
return
|
|
150
|
+
raw_path = self.storage_url.removeprefix("sqlite:///")
|
|
151
|
+
if raw_path == ":memory:":
|
|
152
|
+
return
|
|
153
|
+
path = Path(raw_path)
|
|
154
|
+
if not path.is_absolute():
|
|
155
|
+
path = Path.cwd() / path
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
SAMPLE_CONFIG = """server:
|
|
160
|
+
host: 127.0.0.1
|
|
161
|
+
port: 8787
|
|
162
|
+
admin_ui: true
|
|
163
|
+
body_limit_bytes: 1048576
|
|
164
|
+
inactive_timeout_seconds: 1800
|
|
165
|
+
storage:
|
|
166
|
+
url: sqlite:///./data/agent_loop_guard.db
|
|
167
|
+
retention_days: 30
|
|
168
|
+
full_content_logging: false
|
|
169
|
+
gateway_key: alg_demo_key
|
|
170
|
+
projects:
|
|
171
|
+
default:
|
|
172
|
+
mode: shadow
|
|
173
|
+
provider: mock
|
|
174
|
+
providers:
|
|
175
|
+
mock:
|
|
176
|
+
type: mock
|
|
177
|
+
openai:
|
|
178
|
+
type: openai
|
|
179
|
+
base_url: https://api.openai.com/v1
|
|
180
|
+
api_key_env: OPENAI_API_KEY
|
|
181
|
+
anthropic:
|
|
182
|
+
type: anthropic
|
|
183
|
+
base_url: https://api.anthropic.com
|
|
184
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
185
|
+
mcp:
|
|
186
|
+
policy: mcp-policy.yml
|
|
187
|
+
approval_timeout_seconds: 30
|
|
188
|
+
allowed_origins:
|
|
189
|
+
- http://127.0.0.1
|
|
190
|
+
- http://localhost
|
|
191
|
+
servers:
|
|
192
|
+
filesystem:
|
|
193
|
+
name: Demo filesystem server
|
|
194
|
+
transport: mock
|
|
195
|
+
target: mock://filesystem
|
|
196
|
+
"""
|
app/core/demo.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from app.core.loop_detector import error_fingerprint, request_fingerprint, tool_call_fingerprints
|
|
6
|
+
from app.core.policy_engine import PolicyDecision, evaluate
|
|
7
|
+
from app.core.redaction import safe_preview
|
|
8
|
+
from app.core.token_meter import usage_or_estimate
|
|
9
|
+
from app.db.models import Agent, GuardSession
|
|
10
|
+
from app.db.repository import Repository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _body_for(scenario: str, index: int) -> dict[str, Any]:
|
|
14
|
+
if scenario == "normal":
|
|
15
|
+
return {"model": "demo-model", "input": f"Write a short unique demo line {index}."}
|
|
16
|
+
if scenario == "tool-loop":
|
|
17
|
+
return {
|
|
18
|
+
"model": "demo-model",
|
|
19
|
+
"messages": [{"role": "user", "content": "Inspect the same file again."}],
|
|
20
|
+
"tool_calls": [{"tool_name": "read_file", "arguments": {"path": "README.md"}}],
|
|
21
|
+
}
|
|
22
|
+
if scenario == "error-retry":
|
|
23
|
+
return {
|
|
24
|
+
"model": "demo-model",
|
|
25
|
+
"messages": [{"role": "user", "content": "Retry the failing upstream call."}],
|
|
26
|
+
"mock_status": 500,
|
|
27
|
+
"mock_error": "deterministic demo failure",
|
|
28
|
+
}
|
|
29
|
+
if scenario == "streaming":
|
|
30
|
+
return {"model": "demo-model", "input": f"Stream demo chunk {index}.", "stream": True}
|
|
31
|
+
return {"model": "demo-model", "input": "Repeat this exact request."}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _event(repo: Repository, session: GuardSession, request_id: str, decision: PolicyDecision) -> None:
|
|
35
|
+
if decision.reason_code:
|
|
36
|
+
repo.event(
|
|
37
|
+
session.id,
|
|
38
|
+
request_id,
|
|
39
|
+
"policy_triggered",
|
|
40
|
+
"error" if decision.blocked else "warning",
|
|
41
|
+
decision.reason_code,
|
|
42
|
+
decision.mode,
|
|
43
|
+
decision.details or {},
|
|
44
|
+
decision.rule_id,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_demo(repo: Repository, agent: Agent, scenario: str, mode: str) -> GuardSession:
|
|
49
|
+
scenario = scenario if scenario in {"normal", "exact-loop", "tool-loop", "error-retry", "streaming"} else "exact-loop"
|
|
50
|
+
count = {"normal": 12, "error-retry": 5, "streaming": 3}.get(scenario, 3)
|
|
51
|
+
session = repo.get_or_create_session(agent.project_id, agent.id, f"demo-{scenario}", 0)
|
|
52
|
+
if session.status == "paused":
|
|
53
|
+
session.status = "active"
|
|
54
|
+
policies = repo.policies(agent.project_id)
|
|
55
|
+
previous_error: tuple[str | None, int] = (None, 0)
|
|
56
|
+
|
|
57
|
+
for index in range(count):
|
|
58
|
+
body = _body_for(scenario, index)
|
|
59
|
+
req_fp = request_fingerprint(body)
|
|
60
|
+
tools = tool_call_fingerprints(body)
|
|
61
|
+
decision = evaluate(
|
|
62
|
+
mode=mode,
|
|
63
|
+
session=session,
|
|
64
|
+
agent=agent,
|
|
65
|
+
policies=policies,
|
|
66
|
+
request_fp=req_fp,
|
|
67
|
+
tool_fps=[f"{name}:{args_hash}" for name, args_hash in tools],
|
|
68
|
+
recent_request_fps=repo.recent_request_fingerprints(session.id, 30),
|
|
69
|
+
recent_tool_fps=repo.recent_tool_fingerprints(session.id, 30),
|
|
70
|
+
last_error_count=repo.consecutive_error_count(session.id)[1],
|
|
71
|
+
)
|
|
72
|
+
status = 500 if scenario == "error-retry" else decision.http_status if decision.blocked else 200
|
|
73
|
+
response: dict[str, Any] = {"ok": status < 400, "demo": scenario, "index": index}
|
|
74
|
+
err_fp = None
|
|
75
|
+
if scenario == "error-retry":
|
|
76
|
+
response = {"error": {"type": "mock_error", "message": "deterministic demo failure"}}
|
|
77
|
+
err_fp = error_fingerprint(status, response)
|
|
78
|
+
previous_fp, previous_count = previous_error
|
|
79
|
+
current_count = previous_count + 1 if previous_fp == err_fp else 1
|
|
80
|
+
previous_error = (err_fp, current_count)
|
|
81
|
+
if current_count >= 5 and not decision.reason_code:
|
|
82
|
+
decision = PolicyDecision(
|
|
83
|
+
decision="shadow_flag",
|
|
84
|
+
mode=mode,
|
|
85
|
+
reason_code="LOOP_ERROR_RETRY",
|
|
86
|
+
rule_id="rule_error_retry",
|
|
87
|
+
message="The same upstream error repeated consecutively.",
|
|
88
|
+
details={"count": current_count, "threshold": 5, "fingerprint": err_fp},
|
|
89
|
+
)
|
|
90
|
+
record = repo.record_request(
|
|
91
|
+
session=session,
|
|
92
|
+
protocol="openai",
|
|
93
|
+
endpoint="/api/demo/run",
|
|
94
|
+
model=str(body.get("model")),
|
|
95
|
+
fingerprint=req_fp,
|
|
96
|
+
status=status,
|
|
97
|
+
latency_ms=1,
|
|
98
|
+
tokens=usage_or_estimate("openai", body, response),
|
|
99
|
+
decision=decision.decision,
|
|
100
|
+
reason_code=decision.reason_code,
|
|
101
|
+
request_preview=safe_preview(body, False),
|
|
102
|
+
response_preview=safe_preview(response, False),
|
|
103
|
+
tool_calls=tools,
|
|
104
|
+
error_fingerprint=err_fp,
|
|
105
|
+
)
|
|
106
|
+
_event(repo, session, record.id, decision)
|
|
107
|
+
session = repo.get_session(session.id) or session
|
|
108
|
+
return session
|
|
109
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
DEFAULT_IGNORE_KEYS = {
|
|
9
|
+
"id",
|
|
10
|
+
"request_id",
|
|
11
|
+
"created",
|
|
12
|
+
"created_at",
|
|
13
|
+
"updated_at",
|
|
14
|
+
"timestamp",
|
|
15
|
+
"trace_id",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_string(value: str) -> str:
|
|
20
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _canonical(value: Any, ignore_keys: set[str]) -> Any:
|
|
24
|
+
if isinstance(value, dict):
|
|
25
|
+
return {
|
|
26
|
+
key: _canonical(value[key], ignore_keys)
|
|
27
|
+
for key in sorted(value)
|
|
28
|
+
if key not in ignore_keys
|
|
29
|
+
}
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return [_canonical(item, ignore_keys) for item in value]
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return _normalize_string(value)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonical_json(value: Any, ignore_keys: set[str] | None = None) -> str:
|
|
38
|
+
ignore = DEFAULT_IGNORE_KEYS if ignore_keys is None else DEFAULT_IGNORE_KEYS | ignore_keys
|
|
39
|
+
return json.dumps(_canonical(value, ignore), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_text(value: str) -> str:
|
|
43
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def relevant_request_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
selected: dict[str, Any] = {}
|
|
48
|
+
for key in ("model", "messages", "input", "system", "tools", "tool_choice"):
|
|
49
|
+
if key in body:
|
|
50
|
+
selected[key] = body[key]
|
|
51
|
+
return selected
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def request_fingerprint(body: dict[str, Any], ignore_keys: set[str] | None = None) -> str:
|
|
55
|
+
return sha256_text(canonical_json(relevant_request_body(body), ignore_keys))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical_arg_hash(value: Any) -> str:
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
try:
|
|
61
|
+
parsed = json.loads(value)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
parsed = value
|
|
64
|
+
else:
|
|
65
|
+
parsed = value
|
|
66
|
+
return sha256_text(canonical_json(parsed))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tool_call_fingerprints(body: Any) -> list[tuple[str, str]]:
|
|
70
|
+
calls: list[tuple[str, str]] = []
|
|
71
|
+
|
|
72
|
+
def walk(value: Any) -> None:
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
function = value.get("function")
|
|
75
|
+
if isinstance(function, dict) and "name" in function:
|
|
76
|
+
calls.append((str(function["name"]), _canonical_arg_hash(function.get("arguments", {}))))
|
|
77
|
+
elif value.get("type") == "tool_use" and "name" in value:
|
|
78
|
+
calls.append((str(value["name"]), _canonical_arg_hash(value.get("input", {}))))
|
|
79
|
+
elif "tool_name" in value and ("arguments" in value or "args" in value or "input" in value):
|
|
80
|
+
args = value.get("arguments", value.get("args", value.get("input", {})))
|
|
81
|
+
calls.append((str(value["tool_name"]), _canonical_arg_hash(args)))
|
|
82
|
+
for item in value.values():
|
|
83
|
+
walk(item)
|
|
84
|
+
elif isinstance(value, list):
|
|
85
|
+
for item in value:
|
|
86
|
+
walk(item)
|
|
87
|
+
|
|
88
|
+
walk(body)
|
|
89
|
+
return calls
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error_fingerprint(status_code: int, body: Any) -> str:
|
|
93
|
+
error_type = None
|
|
94
|
+
message = None
|
|
95
|
+
if isinstance(body, dict):
|
|
96
|
+
error = body.get("error", body)
|
|
97
|
+
if isinstance(error, dict):
|
|
98
|
+
error_type = error.get("type") or error.get("code")
|
|
99
|
+
message = error.get("message") or error.get("detail")
|
|
100
|
+
if message is None:
|
|
101
|
+
message = str(body)
|
|
102
|
+
normalized = {
|
|
103
|
+
"status": status_code,
|
|
104
|
+
"type": _normalize_string(str(error_type or "unknown")),
|
|
105
|
+
"message": _normalize_string(str(message))[:500],
|
|
106
|
+
}
|
|
107
|
+
return sha256_text(canonical_json(normalized))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def repeated_sequence(sequence: list[str], min_len: int = 2, max_len: int = 5, repeats: int = 3) -> bool:
|
|
111
|
+
if len(sequence) < min_len * repeats:
|
|
112
|
+
return False
|
|
113
|
+
upper = min(max_len, len(sequence) // repeats)
|
|
114
|
+
for size in range(min_len, upper + 1):
|
|
115
|
+
suffix = sequence[-size * repeats :]
|
|
116
|
+
pattern = suffix[:size]
|
|
117
|
+
if all(suffix[i : i + size] == pattern for i in range(0, len(suffix), size)):
|
|
118
|
+
return True
|
|
119
|
+
return False
|
|
120
|
+
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from app.core.loop_detector import repeated_sequence
|
|
6
|
+
from app.db.models import Agent, GuardSession, Policy
|
|
7
|
+
|
|
8
|
+
REASON_BY_RULE = {
|
|
9
|
+
"exact_repeat": "LOOP_EXACT",
|
|
10
|
+
"tool_repeat": "LOOP_TOOL_CALL",
|
|
11
|
+
"error_retry": "LOOP_ERROR_RETRY",
|
|
12
|
+
"sequence_repeat": "LOOP_SEQUENCE",
|
|
13
|
+
"max_requests": "LIMIT_REQUESTS",
|
|
14
|
+
"max_tokens": "LIMIT_TOKENS",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
MESSAGE_BY_REASON = {
|
|
18
|
+
"LOOP_EXACT": "The same normalized request repeated in the recent window.",
|
|
19
|
+
"LOOP_TOOL_CALL": "The same tool call and arguments repeated in the recent window.",
|
|
20
|
+
"LOOP_ERROR_RETRY": "The same upstream error repeated consecutively.",
|
|
21
|
+
"LOOP_SEQUENCE": "A short sequence of request fingerprints repeated.",
|
|
22
|
+
"LIMIT_REQUESTS": "The session request limit has been reached.",
|
|
23
|
+
"LIMIT_TOKENS": "The session token limit has been reached.",
|
|
24
|
+
"SESSION_PAUSED": "The session is paused.",
|
|
25
|
+
"AGENT_PAUSED": "The agent key is paused.",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class PolicyDecision:
|
|
31
|
+
decision: str
|
|
32
|
+
mode: str
|
|
33
|
+
reason_code: str | None = None
|
|
34
|
+
rule_id: str | None = None
|
|
35
|
+
message: str = "Allowed."
|
|
36
|
+
details: dict | None = None
|
|
37
|
+
http_status: int = 200
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def blocked(self) -> bool:
|
|
41
|
+
return self.decision == "block"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _policy_map(policies: list[Policy]) -> dict[str, Policy]:
|
|
45
|
+
return {policy.rule_type: policy for policy in policies if policy.enabled}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _finish(mode: str, policy: Policy, reason_code: str, details: dict) -> PolicyDecision:
|
|
49
|
+
decision = "shadow_flag"
|
|
50
|
+
status = 200
|
|
51
|
+
if mode == "enforce" and policy.action == "block":
|
|
52
|
+
decision = "block"
|
|
53
|
+
status = 429
|
|
54
|
+
return PolicyDecision(
|
|
55
|
+
decision=decision,
|
|
56
|
+
mode=mode,
|
|
57
|
+
reason_code=reason_code,
|
|
58
|
+
rule_id=policy.id,
|
|
59
|
+
message=MESSAGE_BY_REASON[reason_code],
|
|
60
|
+
details=details,
|
|
61
|
+
http_status=status,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def evaluate(
|
|
66
|
+
*,
|
|
67
|
+
mode: str,
|
|
68
|
+
session: GuardSession,
|
|
69
|
+
agent: Agent,
|
|
70
|
+
policies: list[Policy],
|
|
71
|
+
request_fp: str,
|
|
72
|
+
tool_fps: list[str],
|
|
73
|
+
recent_request_fps: list[str],
|
|
74
|
+
recent_tool_fps: list[str],
|
|
75
|
+
last_error_count: int,
|
|
76
|
+
) -> PolicyDecision:
|
|
77
|
+
mode = "enforce" if mode == "enforce" else "shadow"
|
|
78
|
+
|
|
79
|
+
if agent.paused:
|
|
80
|
+
return PolicyDecision(
|
|
81
|
+
decision="block",
|
|
82
|
+
mode=mode,
|
|
83
|
+
reason_code="AGENT_PAUSED",
|
|
84
|
+
rule_id="manual_agent_pause",
|
|
85
|
+
message=MESSAGE_BY_REASON["AGENT_PAUSED"],
|
|
86
|
+
details={},
|
|
87
|
+
http_status=409,
|
|
88
|
+
)
|
|
89
|
+
if session.status == "paused":
|
|
90
|
+
return PolicyDecision(
|
|
91
|
+
decision="block",
|
|
92
|
+
mode=mode,
|
|
93
|
+
reason_code="SESSION_PAUSED",
|
|
94
|
+
rule_id="manual_session_pause",
|
|
95
|
+
message=MESSAGE_BY_REASON["SESSION_PAUSED"],
|
|
96
|
+
details={},
|
|
97
|
+
http_status=409,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
rules = _policy_map(policies)
|
|
101
|
+
|
|
102
|
+
max_requests = rules.get("max_requests")
|
|
103
|
+
if max_requests and session.request_count >= max_requests.threshold:
|
|
104
|
+
return _finish(
|
|
105
|
+
mode,
|
|
106
|
+
max_requests,
|
|
107
|
+
"LIMIT_REQUESTS",
|
|
108
|
+
{"count": session.request_count, "threshold": max_requests.threshold},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
max_tokens = rules.get("max_tokens")
|
|
112
|
+
if max_tokens and session.total_tokens >= max_tokens.threshold:
|
|
113
|
+
return _finish(
|
|
114
|
+
mode,
|
|
115
|
+
max_tokens,
|
|
116
|
+
"LIMIT_TOKENS",
|
|
117
|
+
{"tokens": session.total_tokens, "threshold": max_tokens.threshold},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
exact = rules.get("exact_repeat")
|
|
121
|
+
if exact:
|
|
122
|
+
window = max(1, exact.window_size - 1)
|
|
123
|
+
recent = recent_request_fps[-window:]
|
|
124
|
+
count = recent.count(request_fp) + 1
|
|
125
|
+
if count >= exact.threshold:
|
|
126
|
+
return _finish(
|
|
127
|
+
mode,
|
|
128
|
+
exact,
|
|
129
|
+
"LOOP_EXACT",
|
|
130
|
+
{"count": count, "window": exact.window_size, "fingerprint": request_fp},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
tool = rules.get("tool_repeat")
|
|
134
|
+
if tool and tool_fps:
|
|
135
|
+
recent = recent_tool_fps[-tool.window_size :]
|
|
136
|
+
for fp in tool_fps:
|
|
137
|
+
count = recent.count(fp) + tool_fps.count(fp)
|
|
138
|
+
if count >= tool.threshold:
|
|
139
|
+
return _finish(
|
|
140
|
+
mode,
|
|
141
|
+
tool,
|
|
142
|
+
"LOOP_TOOL_CALL",
|
|
143
|
+
{"count": count, "window": tool.window_size, "fingerprint": fp},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
error_retry = rules.get("error_retry")
|
|
147
|
+
if error_retry and last_error_count >= error_retry.threshold:
|
|
148
|
+
return _finish(
|
|
149
|
+
mode,
|
|
150
|
+
error_retry,
|
|
151
|
+
"LOOP_ERROR_RETRY",
|
|
152
|
+
{"count": last_error_count, "threshold": error_retry.threshold},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
sequence = rules.get("sequence_repeat")
|
|
156
|
+
if sequence:
|
|
157
|
+
values = recent_request_fps[-sequence.window_size :] + [request_fp]
|
|
158
|
+
if repeated_sequence(values, repeats=sequence.threshold):
|
|
159
|
+
return _finish(
|
|
160
|
+
mode,
|
|
161
|
+
sequence,
|
|
162
|
+
"LOOP_SEQUENCE",
|
|
163
|
+
{"window": sequence.window_size, "threshold": sequence.threshold},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return PolicyDecision(decision="allow", mode=mode)
|
|
167
|
+
|
app/core/redaction.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
SECRET_PATTERNS = [
|
|
8
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
|
9
|
+
re.compile(r"sk-ant-[A-Za-z0-9_\-]{16,}"),
|
|
10
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9_]{16,}"),
|
|
11
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{16,}"),
|
|
12
|
+
re.compile(r"alg_[A-Za-z0-9_]{12,}"),
|
|
13
|
+
re.compile(r"(?i)(api[_-]?key|authorization|token|secret)\s*[:=]\s*['\"]?[^'\"\s,}]+"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
SENSITIVE_HEADERS = {
|
|
17
|
+
"authorization",
|
|
18
|
+
"x-api-key",
|
|
19
|
+
"api-key",
|
|
20
|
+
"anthropic-api-key",
|
|
21
|
+
"openai-api-key",
|
|
22
|
+
"proxy-authorization",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def redact_text(value: str | None) -> str | None:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
redacted = value
|
|
30
|
+
for pattern in SECRET_PATTERNS:
|
|
31
|
+
redacted = pattern.sub("[REDACTED]", redacted)
|
|
32
|
+
return redacted
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
36
|
+
safe: dict[str, str] = {}
|
|
37
|
+
for key, value in headers.items():
|
|
38
|
+
if key.lower() in SENSITIVE_HEADERS:
|
|
39
|
+
safe[key] = "[REDACTED]"
|
|
40
|
+
else:
|
|
41
|
+
safe[key] = redact_text(value) or ""
|
|
42
|
+
return safe
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def redact_value(value: Any) -> Any:
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {
|
|
48
|
+
str(key): "[REDACTED]"
|
|
49
|
+
if str(key).lower() in SENSITIVE_HEADERS
|
|
50
|
+
or any(part in str(key).lower() for part in ("token", "secret", "password", "api_key"))
|
|
51
|
+
else redact_value(item)
|
|
52
|
+
for key, item in value.items()
|
|
53
|
+
}
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return [redact_value(item) for item in value]
|
|
56
|
+
if isinstance(value, tuple):
|
|
57
|
+
return [redact_value(item) for item in value]
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
return redact_text(value)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def safe_preview(value: Any, full_content_logging: bool = False, max_chars: int = 500) -> str:
|
|
64
|
+
if not full_content_logging:
|
|
65
|
+
if isinstance(value, dict):
|
|
66
|
+
keys = ", ".join(sorted(str(k) for k in value.keys())[:12])
|
|
67
|
+
return f"metadata-only keys=[{keys}]"
|
|
68
|
+
return "metadata-only"
|
|
69
|
+
try:
|
|
70
|
+
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
71
|
+
except TypeError:
|
|
72
|
+
text = str(value)
|
|
73
|
+
text = redact_text(text) or ""
|
|
74
|
+
if len(text) > max_chars:
|
|
75
|
+
return text[:max_chars] + "...[truncated]"
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def safe_json_bytes(content: bytes, max_chars: int = 500) -> str:
|
|
80
|
+
text = content.decode("utf-8", errors="replace")
|
|
81
|
+
text = redact_text(text) or ""
|
|
82
|
+
if len(text) > max_chars:
|
|
83
|
+
return text[:max_chars] + "...[truncated]"
|
|
84
|
+
return text
|