primarypath-server 1.0.0__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 (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/netguard.py ADDED
@@ -0,0 +1,73 @@
1
+ """SSRF guard for outbound callback_url (webhook) targets.
2
+
3
+ The service POSTs the decision to a caller-supplied callback_url. Without a
4
+ check, a caller could point it at an internal address (cloud metadata,
5
+ loopback, private ranges) and have the service fetch it — SSRF. This
6
+ validates the URL at create time.
7
+
8
+ Self-hosted buyers legitimately POST to internal webhooks (an internal
9
+ PagerDuty, a private queue), so private/loopback targets are allowed only
10
+ when webhook_allow_private is set — but the cloud metadata range
11
+ (169.254.0.0/16, including 169.254.169.254) is refused always, because
12
+ nothing legitimate lives there.
13
+
14
+ Note the residual risk: this validates the literal host at create time; a
15
+ hostname that resolves to a public IP now but a private one later (DNS
16
+ rebinding) is not caught here. Bind egress with a NetworkPolicy / firewall
17
+ for defense in depth (documented in OPERATIONS).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import ipaddress
22
+ from urllib.parse import urlparse
23
+
24
+
25
+ class UnsafeCallbackURL(ValueError):
26
+ pass
27
+
28
+
29
+ def _addresses(host: str) -> list[ipaddress._BaseAddress]:
30
+ # literal IP?
31
+ try:
32
+ return [ipaddress.ip_address(host)]
33
+ except ValueError:
34
+ pass
35
+ # hostname: resolve best-effort (may be empty if resolution fails)
36
+ import socket
37
+
38
+ out: list[ipaddress._BaseAddress] = []
39
+ try:
40
+ for info in socket.getaddrinfo(host, None):
41
+ try:
42
+ out.append(ipaddress.ip_address(info[4][0]))
43
+ except ValueError:
44
+ continue
45
+ except OSError:
46
+ pass
47
+ return out
48
+
49
+
50
+ def validate_callback_url(url: str, allow_private: bool) -> None:
51
+ """Raise UnsafeCallbackURL if the target is unsafe for the posture."""
52
+ parsed = urlparse(url)
53
+ if parsed.scheme not in ("http", "https"):
54
+ raise UnsafeCallbackURL("callback_url must be http(s)")
55
+ host = parsed.hostname
56
+ if not host:
57
+ raise UnsafeCallbackURL("callback_url has no host")
58
+
59
+ if host.lower() == "localhost" and not allow_private:
60
+ raise UnsafeCallbackURL("callback_url may not target localhost")
61
+
62
+ addrs = _addresses(host)
63
+ for ip in addrs:
64
+ assert isinstance(ip, (ipaddress.IPv4Address, ipaddress.IPv6Address))
65
+ # the cloud metadata / link-local range is refused ALWAYS
66
+ if ip.is_link_local:
67
+ raise UnsafeCallbackURL(
68
+ "callback_url may not target the link-local/metadata range")
69
+ if not allow_private and (ip.is_private or ip.is_loopback
70
+ or ip.is_reserved or ip.is_multicast):
71
+ raise UnsafeCallbackURL(
72
+ "callback_url may not target a private/loopback address "
73
+ "(set webhook_allow_private to allow internal webhooks)")
app/obslog.py ADDED
@@ -0,0 +1,105 @@
1
+ """Structured JSON logging with correlation ids and secret redaction.
2
+
3
+ One JSON object per line on stdout. A redaction filter scrubs every
4
+ configured secret (tokens, session secret, Slack/Teams credentials, PINs)
5
+ from every record on every logger it is attached to — a leaked secret in
6
+ a log line is an incident; this makes the safe path the default path.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ import datetime as dt
12
+ import json
13
+ import logging
14
+ import re
15
+ import uuid
16
+
17
+ request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
18
+ "primarypath_request_id", default=None)
19
+
20
+
21
+ def new_request_id() -> str:
22
+ return uuid.uuid4().hex[:12]
23
+
24
+
25
+ def gather_secrets(settings) -> list[str]:
26
+ values = [
27
+ settings.service_token, settings.admin_token, settings.session_secret,
28
+ settings.oidc_client_secret, settings.slack_bot_token,
29
+ settings.slack_signing_secret, settings.teams_app_password,
30
+ settings.pkcs11_pin, settings.siem_token,
31
+ ]
32
+ return [v for v in values if v and len(v) >= 4]
33
+
34
+
35
+ # Issued tokens (F22) are dynamic, so they can't be pre-registered like env
36
+ # secrets; scrub them by their minted shape instead. Storage is hash-only —
37
+ # this covers the plaintext-in-flight window (e.g. a response body logged in
38
+ # an exception).
39
+ _ISSUED_TOKEN_RE = re.compile(r"pp_(?:service|admin)_[A-Za-z0-9_-]{16,}")
40
+
41
+
42
+ class RedactingFilter(logging.Filter):
43
+ def __init__(self, secrets: list[str]):
44
+ super().__init__()
45
+ self.secrets = secrets
46
+
47
+ def _scrub(self, text: str) -> str:
48
+ for s in self.secrets:
49
+ if s in text:
50
+ text = text.replace(s, "[REDACTED]")
51
+ return _ISSUED_TOKEN_RE.sub("[REDACTED]", text)
52
+
53
+ def filter(self, record: logging.LogRecord) -> bool:
54
+ try:
55
+ message = record.getMessage()
56
+ except Exception:
57
+ return True
58
+ scrubbed = self._scrub(message)
59
+ if scrubbed != message:
60
+ record.msg = scrubbed
61
+ record.args = ()
62
+ return True
63
+
64
+
65
+ class JsonFormatter(logging.Formatter):
66
+ def format(self, record: logging.LogRecord) -> str:
67
+ out = {
68
+ "ts": dt.datetime.now(dt.UTC).isoformat(timespec="milliseconds"),
69
+ "level": record.levelname.lower(),
70
+ "logger": record.name,
71
+ "msg": record.getMessage(),
72
+ }
73
+ rid = request_id_var.get()
74
+ if rid:
75
+ out["request_id"] = rid
76
+ if record.exc_info and record.exc_info[0] is not None:
77
+ out["exc"] = self.formatException(record.exc_info)
78
+ return json.dumps(out, ensure_ascii=False)
79
+
80
+
81
+ _configured = False
82
+
83
+
84
+ def setup_logging(settings) -> None:
85
+ """Idempotent: JSON handler + redaction on the root logger."""
86
+ global _configured
87
+ root = logging.getLogger()
88
+ redactor = RedactingFilter(gather_secrets(settings))
89
+ if _configured:
90
+ # refresh secrets on the existing handlers (new app, new settings)
91
+ for h in root.handlers:
92
+ h.filters = [f for f in h.filters
93
+ if not isinstance(f, RedactingFilter)]
94
+ h.addFilter(redactor)
95
+ return
96
+ if settings.log_json:
97
+ handler = logging.StreamHandler()
98
+ handler.setFormatter(JsonFormatter())
99
+ handler.addFilter(redactor)
100
+ root.addHandler(handler)
101
+ root.setLevel(settings.log_level.upper())
102
+ else:
103
+ for h in root.handlers:
104
+ h.addFilter(redactor)
105
+ _configured = True
app/policy.py ADDED
@@ -0,0 +1,390 @@
1
+ """Event-sourced approval policy + the shadow -> assisted -> autonomous
2
+ rollout, per tenant.
3
+
4
+ The current policy is a fold of `policy_set` events (last-write-wins per
5
+ action), materialized into the `policy_view` projection — same discipline
6
+ as approvals_view: the log is truth, the table is a rebuildable cache.
7
+ Changing policy appends a `policy_set` carrying `by`/`reason`, so weakening
8
+ a control leaves a chained, signed, per-tenant record. The governance of
9
+ the governance is audited.
10
+
11
+ Modes:
12
+ - assisted -> requires a human decision (the default, fail-safe).
13
+ - autonomous -> runs without approval; the service auto-grants by
14
+ `system:policy` so the loop resolves immediately.
15
+ - sampled -> earned autonomy with teeth: most proposals auto-grant,
16
+ but a DETERMINISTIC 1-in-N (HMAC-SHA256 of the approval_id,
17
+ keyed by a per-tenant server-side salt) is routed to a
18
+ human before execution. The middle rung between assisted
19
+ and autonomous: agents keep their speed, humans keep a
20
+ real, unpredictable-to-the-agent check. The salt is
21
+ disclosed in the admin-gated export, so an auditor can
22
+ recompute every pick; the agent plane cannot read it, so
23
+ an agent cannot grind call ids that are never sampled.
24
+ - shadow -> observe without gating: auto-grants, tagged shadow, while a
25
+ track record accrues. A rollout stage, not a trust level.
26
+
27
+ IMPORTANT — shadow EXECUTES in this approval-gateway model. Unlike drangue's
28
+ in-process Autonomy (where shadow means "propose, do not run"), the service
29
+ only speaks approved/denied over HTTP; it cannot stop a framework-agnostic
30
+ agent from running a granted action. Shadow here is the F13 "record-only"
31
+ stage: the tool runs and the proposal is logged for the track record. Do NOT
32
+ use shadow to observe a dangerous action without side effects — use assisted
33
+ (a human gates it) or leave the action ungoverned but ingest run_events. The
34
+ auto-grant reason always names the mode so the decision is never silent.
35
+
36
+ `requires_approval` is the master gate; `mode` drives the track record and
37
+ graduation. Setting a rule derives `requires_approval` from the mode when
38
+ the caller does not pin it explicitly.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import hmac
44
+ import secrets
45
+ import time
46
+
47
+ from .ledger.store import DEFAULT_TENANT, Ledger
48
+
49
+ MODES = ("shadow", "assisted", "sampled", "autonomous")
50
+ POLICY_REVIEWER = "system:policy"
51
+ DEFAULT_SAMPLE_N = 10
52
+
53
+ VIEW_COLUMNS = (
54
+ "action, requires_approval, reversible, mode, allow, deny, "
55
+ "approver_groups, require_second_approval, sample_n, updated_by, "
56
+ "reason, updated_ts_ms, risk, blast_radius, version"
57
+ )
58
+ RISKS = ("low", "medium", "high", "critical")
59
+
60
+
61
+ def default_risk(reversible: bool, require_second: bool) -> str:
62
+ """Server-authored risk when the admin did not state one: an
63
+ irreversible action is high, irreversible under dual control critical."""
64
+ if not reversible:
65
+ return "critical" if require_second else "high"
66
+ return "medium"
67
+
68
+
69
+ def default_requires_approval(mode: str) -> bool:
70
+ """assisted gates; shadow/autonomous do not; sampled gates per
71
+ approval (the 1-in-N pick, decided at create time)."""
72
+ return mode == "assisted"
73
+
74
+
75
+ def sampled_for_review(approval_id: str, sample_n: int, salt: str) -> bool:
76
+ """The deterministic 1-in-N pick: HMAC-SHA256 over the approval_id,
77
+ keyed by a per-tenant server-side salt. Deterministic, so retrying the
78
+ same id gets the same answer — and KEYED, because the agent chooses
79
+ run_id:call_id: an unkeyed hash would let it grind call ids offline
80
+ until none of its proposals are ever sampled. The salt lives on the
81
+ operator plane (the agent token cannot read it) and is disclosed in
82
+ the admin-gated audit export, so an auditor can still recompute from
83
+ the export why any proposal was (or was not) routed to a human."""
84
+ if sample_n <= 1:
85
+ return True
86
+ digest = hmac.new(salt.encode("utf-8"), approval_id.encode("utf-8"),
87
+ hashlib.sha256).digest()
88
+ return int.from_bytes(digest[:8], "big") % sample_n == 0
89
+
90
+
91
+ class NotAnApprover(Exception):
92
+ """The reviewer is not in the action's approver_groups (F21)."""
93
+
94
+
95
+ def check_approver_groups(rule: dict | None, identity: dict | None) -> None:
96
+ """Enforce 'who may approve': if the action pins approver_groups, a human
97
+ approver's IdP groups must intersect them. Admins bypass (the operator
98
+ can always decide). In dev/token mode (identity is None) there are no
99
+ groups to check — the deployment is single-trust."""
100
+ if not rule:
101
+ return
102
+ required = set(rule.get("approver_groups") or [])
103
+ if not required or identity is None:
104
+ return
105
+ if "admin" in (identity.get("roles") or []):
106
+ return
107
+ if not (set(identity.get("groups") or []) & required):
108
+ raise NotAnApprover(
109
+ "your groups do not include an approver group for this action")
110
+
111
+
112
+ def case_controls(stored: dict | None, live: dict | None,
113
+ identity: dict | None) -> bool:
114
+ """The controls a decision on an existing case must satisfy: the
115
+ stricter of the policy snapshotted when the case was created and the
116
+ rule as it stands now (B6). A relaxation applies to new proposals only;
117
+ a tightening applies to pending cases too. The reviewer must pass the
118
+ approver_groups of both, and dual control binds if either demands it.
119
+ Raises NotAnApprover; returns whether a second approver is required."""
120
+ for rule in (stored, live):
121
+ check_approver_groups(rule, identity)
122
+ return any(bool(r and r.get("require_second_approval"))
123
+ for r in (stored, live))
124
+
125
+
126
+ def _row_to_rule(row: tuple) -> dict:
127
+ keys = [k.strip() for k in VIEW_COLUMNS.split(",")]
128
+ return dict(zip(keys, row))
129
+
130
+
131
+ class PolicyStore:
132
+ def __init__(self, pool, ledger: Ledger, settings):
133
+ self.pool = pool
134
+ self.ledger = ledger
135
+ self.settings = settings
136
+ self._salts: dict[str, str] = {}
137
+
138
+ async def sampling_salt(self, tenant: str = DEFAULT_TENANT) -> str:
139
+ """The per-tenant key of the 1-in-N pick. Operational, not chained
140
+ (it is a secret against the agent, like a session key, not a fact
141
+ about the work); created on first use, immutable thereafter so
142
+ every historical pick stays recomputable."""
143
+ cached = self._salts.get(tenant)
144
+ if cached is not None:
145
+ return cached
146
+ async with self.pool.connection() as conn:
147
+ await conn.execute(
148
+ "INSERT INTO sampling_salt (tenant_id, salt) "
149
+ "VALUES (%s, %s) ON CONFLICT (tenant_id) DO NOTHING",
150
+ (tenant, secrets.token_hex(32)))
151
+ cur = await conn.execute(
152
+ "SELECT salt FROM sampling_salt WHERE tenant_id = %s",
153
+ (tenant,))
154
+ salt = (await cur.fetchone())[0]
155
+ self._salts[tenant] = salt
156
+ return salt
157
+
158
+ # -- read (fold via the projection) --------------------------------------
159
+
160
+ async def current_policy(self, tenant: str = DEFAULT_TENANT) -> list[dict]:
161
+ async with self.pool.connection() as conn:
162
+ cur = await conn.execute(
163
+ f"SELECT {VIEW_COLUMNS} FROM policy_view WHERE tenant_id = %s "
164
+ f"ORDER BY action", (tenant,))
165
+ rows = await cur.fetchall()
166
+ return [_row_to_rule(r) for r in rows]
167
+
168
+ async def policy_for(self, action: str,
169
+ tenant: str = DEFAULT_TENANT) -> dict | None:
170
+ async with self.pool.connection() as conn:
171
+ cur = await conn.execute(
172
+ f"SELECT {VIEW_COLUMNS} FROM policy_view "
173
+ f"WHERE tenant_id = %s AND action = %s", (tenant, action))
174
+ row = await cur.fetchone()
175
+ return _row_to_rule(row) if row else None
176
+
177
+ async def effective(self, action: str,
178
+ tenant: str = DEFAULT_TENANT) -> dict:
179
+ """The gating decision for an action: the rule if one exists, else
180
+ the configured fail-safe default (require approval)."""
181
+ rule = await self.policy_for(action, tenant)
182
+ if rule is not None:
183
+ # dual control implies a human gate: an action that demands two
184
+ # approvers can never be auto-granted, whatever the mode says
185
+ require_second = bool(rule.get("require_second_approval"))
186
+ out = {"requires_approval": (rule["requires_approval"]
187
+ or require_second),
188
+ "mode": rule["mode"],
189
+ "require_second_approval": require_second,
190
+ "approver_groups": list(rule.get("approver_groups") or []),
191
+ "reversible": bool(rule.get("reversible")),
192
+ "risk": rule.get("risk") or default_risk(
193
+ bool(rule.get("reversible")), require_second),
194
+ "blast_radius": rule.get("blast_radius"),
195
+ "version": rule.get("version"),
196
+ "source": "policy"}
197
+ if rule["mode"] == "sampled":
198
+ out["sample_n"] = rule.get("sample_n") or DEFAULT_SAMPLE_N
199
+ return out
200
+ return {
201
+ "requires_approval": self.settings.policy_default_requires_approval,
202
+ "mode": "assisted" if self.settings.policy_default_requires_approval
203
+ else "autonomous",
204
+ "require_second_approval": False,
205
+ "approver_groups": [],
206
+ # no rule: nothing vouches for reversibility, so it is treated
207
+ # as irreversible and high risk until an admin says otherwise
208
+ "reversible": False, "risk": "high", "blast_radius": None,
209
+ "version": None,
210
+ "source": "default",
211
+ }
212
+
213
+ @staticmethod
214
+ def snapshot(effective: dict) -> dict:
215
+ """The request-time policy snapshot stamped into approval_requested:
216
+ exactly what the console shows and the server enforces bulk
217
+ constraints on, versioned so a later rule change is visible."""
218
+ return {k: effective.get(k) for k in (
219
+ "version", "mode", "requires_approval", "reversible", "risk",
220
+ "blast_radius", "require_second_approval", "approver_groups",
221
+ "source")}
222
+
223
+ # -- write (append policy_set) -------------------------------------------
224
+
225
+ async def set_policy(self, action: str, *, mode: str, by: str,
226
+ reason: str = "", reversible: bool = False,
227
+ requires_approval: bool | None = None,
228
+ allow: list | None = None, deny: list | None = None,
229
+ approver_groups: list | None = None,
230
+ require_second_approval: bool = False,
231
+ sample_n: int | None = None,
232
+ risk: str | None = None,
233
+ blast_radius: str | None = None,
234
+ tenant: str = DEFAULT_TENANT) -> dict:
235
+ if mode not in MODES:
236
+ raise ValueError(f"mode must be one of {MODES}")
237
+ if risk is not None and risk not in RISKS:
238
+ raise ValueError(f"risk must be one of {RISKS}")
239
+ risk = risk or default_risk(bool(reversible),
240
+ bool(require_second_approval))
241
+ if requires_approval is None:
242
+ requires_approval = default_requires_approval(mode)
243
+ if mode == "sampled":
244
+ sample_n = sample_n or DEFAULT_SAMPLE_N
245
+ if sample_n < 2:
246
+ raise ValueError("sample_n must be >= 2 (1 would gate all)")
247
+ else:
248
+ # inert outside sampled mode, but PRESERVED so a demote/promote
249
+ # round trip does not silently reset the rate
250
+ sample_n = sample_n or 0
251
+ payload = {
252
+ "action": action, "mode": mode,
253
+ "requires_approval": bool(requires_approval),
254
+ "reversible": bool(reversible),
255
+ "allow": allow or [], "deny": deny or [],
256
+ "approver_groups": approver_groups or [],
257
+ "require_second_approval": bool(require_second_approval),
258
+ "sample_n": sample_n,
259
+ "risk": risk, "blast_radius": blast_radius or None,
260
+ "by": by, "reason": reason,
261
+ }
262
+ await self.ledger.append("policy_set", payload, tenant=tenant)
263
+ rule = await self.policy_for(action, tenant)
264
+ assert rule is not None # just appended
265
+ return rule
266
+
267
+ # -- rollout track record + graduation -----------------------------------
268
+
269
+ async def rollout(self, tenant: str = DEFAULT_TENANT,
270
+ now: float | None = None) -> list[dict]:
271
+ """Per-action track record plus a graduation recommendation.
272
+
273
+ The record is a SQL aggregate over approvals_view (indexed on
274
+ tenant, tool, requested time) within an EXPLICIT lookback window —
275
+ policy_rollout_lookback_days, reported on every row as
276
+ `window_days` — never a silently truncated slice of history.
277
+ Incidents are counted from structured `incident_reported` events
278
+ (POST /api/approvals/{id}/incident), not from words in notes.
279
+ Actions come from both the policy and the approvals seen, so an
280
+ ungoverned-but-active action still surfaces."""
281
+ days = self.settings.policy_rollout_lookback_days
282
+ now_ms = int((now if now is not None else time.time()) * 1000)
283
+ since = now_ms - days * 86_400_000 if days > 0 else 0
284
+ rules = {r["action"]: r for r in await self.current_policy(tenant)}
285
+ auto = POLICY_REVIEWER + "%"
286
+
287
+ rec: dict[str, dict] = {}
288
+ async with self.pool.connection() as conn:
289
+ cur = await conn.execute(
290
+ """
291
+ SELECT tool,
292
+ count(*),
293
+ count(*) FILTER (WHERE status = 'pending'),
294
+ count(*) FILTER (WHERE status = 'granted'
295
+ AND reviewer LIKE %s),
296
+ count(*) FILTER (WHERE status = 'granted'
297
+ AND reviewer NOT LIKE %s),
298
+ count(*) FILTER (WHERE status = 'denied')
299
+ FROM approvals_view
300
+ WHERE tenant_id = %s AND requested_ts_ms >= %s
301
+ GROUP BY tool
302
+ """, (auto, auto, tenant, since))
303
+ for tool, n, pending, auto_n, human_ok, human_no in \
304
+ await cur.fetchall():
305
+ rec[tool] = {
306
+ "proposals": n, "human_approvals": human_ok,
307
+ "human_rejections": human_no, "auto_executed": auto_n,
308
+ "pending": pending, "incidents": 0}
309
+ cur = await conn.execute(
310
+ """
311
+ SELECT payload_json->>'tool', count(*)
312
+ FROM events
313
+ WHERE tenant_id = %s AND type = 'incident_reported'
314
+ AND ts_ms >= %s
315
+ GROUP BY 1
316
+ """, (tenant, since))
317
+ for tool, n in await cur.fetchall():
318
+ rec.setdefault(tool, {
319
+ "proposals": 0, "human_approvals": 0,
320
+ "human_rejections": 0, "auto_executed": 0,
321
+ "pending": 0, "incidents": 0})["incidents"] = n
322
+
323
+ actions = sorted(set(rec) | set(rules))
324
+ out = []
325
+ for action in actions:
326
+ record = dict(rec.get(action, {
327
+ "proposals": 0, "human_approvals": 0, "human_rejections": 0,
328
+ "auto_executed": 0, "pending": 0, "incidents": 0}))
329
+ rule = rules.get(action)
330
+ mode = rule["mode"] if rule else "assisted"
331
+ reversible = bool(rule["reversible"]) if rule else False
332
+ decisions = record["human_approvals"] + record["human_rejections"]
333
+ record["decisions"] = decisions
334
+ record["approval_rate"] = (
335
+ record["human_approvals"] / decisions if decisions else None)
336
+ record["window_days"] = days
337
+ out.append({
338
+ "action": action, "mode": mode, "reversible": reversible,
339
+ "requires_approval": (rule["requires_approval"] if rule
340
+ else self.settings
341
+ .policy_default_requires_approval),
342
+ "record": record,
343
+ "recommendation": self._recommend(mode, reversible, record),
344
+ })
345
+ return out
346
+
347
+ def _recommend(self, mode: str, reversible: bool, record: dict) -> dict:
348
+ s = self.settings
349
+ decisions = record["decisions"]
350
+ rate = record["approval_rate"]
351
+ rejections = record["human_rejections"]
352
+ reject_rate = (rejections / decisions) if decisions else 0.0
353
+
354
+ # demote first: an incident or a rejection spike tightens the control
355
+ if record["incidents"] > 0 or (
356
+ decisions >= s.policy_graduation_min_decisions
357
+ and reject_rate >= s.policy_demotion_rejection_rate):
358
+ if mode in ("autonomous", "sampled"):
359
+ return {"action": "demote", "to_mode": "assisted",
360
+ "why": (f"{record['incidents']} incident(s) reported "
361
+ f"in the last {record.get('window_days')} days"
362
+ if record["incidents"]
363
+ else f"rejection rate {reject_rate:.0%} "
364
+ f"over {decisions} decisions")}
365
+ if mode == "shadow":
366
+ return {"action": "demote", "to_mode": "assisted",
367
+ "why": "tighten to human approval while risk is present"}
368
+ return {"action": "hold", "to_mode": mode,
369
+ "why": "already gated by a human (assisted)"}
370
+
371
+ # promote: strong track record on a reversible action. assisted
372
+ # graduates to SAMPLED first (autonomy with a live 1-in-N check),
373
+ # sampled to autonomous once the sampled decisions also hold up.
374
+ if (mode in ("assisted", "shadow", "sampled") and reversible
375
+ and decisions >= s.policy_graduation_min_decisions
376
+ and rate is not None
377
+ and rate >= s.policy_graduation_approval_rate):
378
+ to_mode = "autonomous" if mode == "sampled" else "sampled"
379
+ return {"action": "promote", "to_mode": to_mode,
380
+ "why": f"{rate:.0%} approval over {decisions} decisions, "
381
+ f"reversible action"}
382
+
383
+ if mode in ("assisted", "shadow") and not reversible and rate == 1.0 \
384
+ and decisions >= s.policy_graduation_min_decisions:
385
+ return {"action": "hold", "to_mode": mode,
386
+ "why": "strong record but action is irreversible — "
387
+ "keep a human in the loop"}
388
+ return {"action": "hold", "to_mode": mode,
389
+ "why": f"{decisions} decisions "
390
+ f"(need {s.policy_graduation_min_decisions} to graduate)"}
app/ratelimit.py ADDED
@@ -0,0 +1,78 @@
1
+ """A minimal in-memory rate limiter for abuse-sensitive endpoints.
2
+
3
+ Fixed 1-minute window per (client IP, path-class). Guards auth, decisions,
4
+ and chat interactions — brute-force / spam surfaces — and deliberately does
5
+ NOT touch reads or agent polling (wait_for_decision polls GET faster than a
6
+ limiter should allow). In-memory and per-replica; production should also
7
+ rate-limit at the ingress. Dependency-free.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import math
13
+
14
+ # path prefixes that are guarded; everything else is unlimited
15
+ GUARDED_PREFIXES = (
16
+ "/auth/login", "/auth/callback",
17
+ "/slack/interactions", "/teams/activities",
18
+ )
19
+
20
+
21
+ def is_guarded(method: str, path: str) -> bool:
22
+ if path.startswith(GUARDED_PREFIXES):
23
+ return True
24
+ # approval decisions: POST /api/approvals/{id}/decision
25
+ return (method == "POST" and path.startswith("/api/approvals/")
26
+ and path.endswith("/decision"))
27
+
28
+
29
+ def _class(path: str) -> str:
30
+ for p in GUARDED_PREFIXES:
31
+ if path.startswith(p):
32
+ return p
33
+ return "decision"
34
+
35
+
36
+ # authenticated agent WRITES: bounded per credential so a buggy or
37
+ # compromised agent cannot turn its token into unbounded storage/CPU.
38
+ # Reads and polling (wait_for_decision) are never limited.
39
+ def is_agent_write(method: str, path: str) -> bool:
40
+ if method != "POST":
41
+ return False
42
+ if path in ("/api/approvals", "/api/evals", "/api/gate"):
43
+ return True
44
+ return path.startswith("/api/runs/") and path.endswith("/events")
45
+
46
+
47
+ def agent_key(authorization: str | None, ip: str) -> str:
48
+ """The credential, not the address: replicas of one agent share a
49
+ token, and one NAT can front many agents."""
50
+ if authorization:
51
+ return "tok:" + hashlib.sha256(authorization.encode()).hexdigest()[:16]
52
+ return "ip:" + ip
53
+
54
+
55
+ class RateLimiter:
56
+ def __init__(self, per_minute: int, agent_per_minute: int = 0):
57
+ self.per_minute = per_minute
58
+ self.agent_per_minute = agent_per_minute
59
+ self._buckets: dict[tuple[str, str, int], int] = {}
60
+
61
+ def _hit(self, subject: str, cls: str, now: float, limit: int) -> bool:
62
+ if limit <= 0:
63
+ return True
64
+ window = math.floor(now / 60)
65
+ key = (subject, cls, window)
66
+ count = self._buckets.get(key, 0) + 1
67
+ self._buckets[key] = count
68
+ # opportunistic cleanup of stale windows
69
+ if len(self._buckets) > 4096:
70
+ self._buckets = {k: v for k, v in self._buckets.items()
71
+ if k[2] >= window}
72
+ return count <= limit
73
+
74
+ def allow(self, ip: str, path: str, now: float) -> bool:
75
+ return self._hit(ip, _class(path), now, self.per_minute)
76
+
77
+ def allow_agent_write(self, subject: str, now: float) -> bool:
78
+ return self._hit(subject, "agent_write", now, self.agent_per_minute)
app/routes/__init__.py ADDED
File without changes