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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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
|