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.
Files changed (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. 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