sre-agent-cli 0.2.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.
sre_agent/policy.py ADDED
@@ -0,0 +1,208 @@
1
+ """The policy engine: the only path between a proposal and an action.
2
+
3
+ Pure functions over explicit inputs. No model output is ever trusted here;
4
+ a proposal arrives as structured data and leaves as a decision with a reason.
5
+ See docs/safety-model.md for the contract this code implements.
6
+
7
+ What this module decides, in plain words:
8
+
9
+ - **Classification.** Every action names a runbook, and every runbook the
10
+ agent may ever propose is listed in one fixed catalogue in this file.
11
+ The catalogue says whether the action only reads (it changes nothing)
12
+ or mutates reversibly (it changes something that can be undone, like a
13
+ restart, a scale, or a rollback). A runbook that is not in the
14
+ catalogue is unknown, and unknown actions are denied — a made-up
15
+ runbook name can never slip through as "needs approval".
16
+ - **Blast radius.** A mutating action is checked against the limits of
17
+ its environment: how many replicas it touches, which namespaces are
18
+ allowed at all, which namespaces are on the no-touch list, and whether
19
+ its target is still the target pinned on the incident it belongs to.
20
+ - **Tier gate.** What remains is decided by the environment's tier
21
+ (Section 6 of the plan): read-only actions are allowed, mutations
22
+ need a human's approval at Tier 2, and only pre-approved runbook
23
+ classes run without one at Tier 3.
24
+
25
+ Recording the decision is the audit log's job (``audit.py``); this
26
+ module only decides. ``audit.evaluate_and_audit`` is the normal way to
27
+ call both together, so no decision goes unrecorded.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from enum import Enum
33
+
34
+ from pydantic import BaseModel, Field
35
+
36
+ from .models import Target
37
+
38
+
39
+ class Tier(int, Enum):
40
+ OBSERVE = 0
41
+ ADVISE = 1
42
+ ACT_WITH_APPROVAL = 2
43
+ ACT_WITHIN_BOUNDS = 3
44
+
45
+
46
+ class Decision(str, Enum):
47
+ ALLOW = "allow"
48
+ REQUIRE_APPROVAL = "require_approval"
49
+ DENY = "deny"
50
+
51
+
52
+ class ActionClass(str, Enum):
53
+ """What kind of action a runbook is. Fixed by the catalogue below."""
54
+
55
+ READ_ONLY = "read_only"
56
+ REVERSIBLE_MUTATION = "reversible_mutation"
57
+
58
+
59
+ # The allow-list. Every runbook id the agent may propose must be named
60
+ # here, with its class. Anything not named here is unknown and denied.
61
+ # The three read-only entries are the tool layer's tools; the three
62
+ # mutating entries are the runbook library's first runbooks (Phase 2).
63
+ RUNBOOK_CATALOG: dict[str, ActionClass] = {
64
+ "query-metrics": ActionClass.READ_ONLY,
65
+ "search-logs": ActionClass.READ_ONLY,
66
+ "get-service-status": ActionClass.READ_ONLY,
67
+ "restart-workload": ActionClass.REVERSIBLE_MUTATION,
68
+ "scale-replicas": ActionClass.REVERSIBLE_MUTATION,
69
+ "rollback-deployment": ActionClass.REVERSIBLE_MUTATION,
70
+ }
71
+
72
+
73
+ def classify_action(runbook_id: str) -> ActionClass | None:
74
+ """Return the class of a runbook, or None if it is not allow-listed."""
75
+ return RUNBOOK_CATALOG.get(runbook_id)
76
+
77
+
78
+ class ProposedAction(BaseModel):
79
+ """A structured proposal. Free-form commands are not representable."""
80
+
81
+ runbook_id: str
82
+ target: Target
83
+ parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
84
+ replicas_affected: int = Field(default=0, ge=0)
85
+ rationale: str
86
+
87
+
88
+ class EnvironmentPolicy(BaseModel):
89
+ """What the agent may do in one environment. Defaults are restrictive."""
90
+
91
+ environment: str
92
+ granted_tier: Tier = Tier.ADVISE
93
+ # runbook classes allowed to run at Tier 3 in this environment
94
+ tier3_runbooks: set[str] = Field(default_factory=set)
95
+ max_replicas_affected: int = 1
96
+ # if non-empty, only these namespaces may be touched at all
97
+ allowed_namespaces: set[str] = Field(default_factory=set)
98
+ denied_namespaces: set[str] = Field(default_factory=set)
99
+ halted: bool = False
100
+
101
+
102
+ class PolicyResult(BaseModel):
103
+ decision: Decision
104
+ reason: str
105
+ action_class: ActionClass | None = None
106
+
107
+
108
+ def evaluate(
109
+ action: ProposedAction,
110
+ policy: EnvironmentPolicy,
111
+ *,
112
+ incident_target: Target | None = None,
113
+ ) -> PolicyResult:
114
+ """Decide whether a proposed action may run, needs approval, or is denied.
115
+
116
+ Order matters: hard stops are checked before any allowance, so a halted
117
+ or out-of-scope environment can never be overridden by a grant.
118
+
119
+ ``incident_target`` is the target pinned on the incident the action
120
+ belongs to, when the caller has one. An action aimed anywhere else is
121
+ denied: actions cannot retarget mid-flight.
122
+ """
123
+ action_class = classify_action(action.runbook_id)
124
+
125
+ if action.target.environment != policy.environment:
126
+ return PolicyResult(
127
+ decision=Decision.DENY,
128
+ reason="action target environment does not match this policy",
129
+ action_class=action_class,
130
+ )
131
+
132
+ if action.target.namespace in policy.denied_namespaces:
133
+ return PolicyResult(
134
+ decision=Decision.DENY,
135
+ reason=f"namespace '{action.target.namespace}' is on the no-touch list",
136
+ action_class=action_class,
137
+ )
138
+
139
+ if incident_target is not None and action.target != incident_target:
140
+ return PolicyResult(
141
+ decision=Decision.DENY,
142
+ reason="action target does not match the target pinned on the incident",
143
+ action_class=action_class,
144
+ )
145
+
146
+ if action_class is None:
147
+ return PolicyResult(
148
+ decision=Decision.DENY,
149
+ reason=f"runbook '{action.runbook_id}' is not on the allow-list",
150
+ action_class=None,
151
+ )
152
+
153
+ if action_class is ActionClass.READ_ONLY:
154
+ # Reads change nothing, so the tier gate and the halt switch do
155
+ # not block them (docs/safety-model.md: "Reads may continue").
156
+ return PolicyResult(
157
+ decision=Decision.ALLOW,
158
+ reason="read-only action; it changes nothing",
159
+ action_class=action_class,
160
+ )
161
+
162
+ if policy.halted:
163
+ return PolicyResult(
164
+ decision=Decision.DENY,
165
+ reason="agent is halted in this environment",
166
+ action_class=action_class,
167
+ )
168
+
169
+ if policy.allowed_namespaces and action.target.namespace not in policy.allowed_namespaces:
170
+ return PolicyResult(
171
+ decision=Decision.DENY,
172
+ reason=f"namespace '{action.target.namespace}' is not in this environment's "
173
+ "allowed namespaces",
174
+ action_class=action_class,
175
+ )
176
+
177
+ if action.replicas_affected > policy.max_replicas_affected:
178
+ return PolicyResult(
179
+ decision=Decision.DENY,
180
+ reason=(
181
+ f"blast radius too large: {action.replicas_affected} replicas affected, "
182
+ f"limit is {policy.max_replicas_affected}"
183
+ ),
184
+ action_class=action_class,
185
+ )
186
+
187
+ if policy.granted_tier < Tier.ACT_WITH_APPROVAL:
188
+ return PolicyResult(
189
+ decision=Decision.DENY,
190
+ reason=f"environment is at Tier {policy.granted_tier}; mutations are not granted",
191
+ action_class=action_class,
192
+ )
193
+
194
+ if (
195
+ policy.granted_tier >= Tier.ACT_WITHIN_BOUNDS
196
+ and action.runbook_id in policy.tier3_runbooks
197
+ ):
198
+ return PolicyResult(
199
+ decision=Decision.ALLOW,
200
+ reason="pre-approved runbook class within blast-radius limits (Tier 3)",
201
+ action_class=action_class,
202
+ )
203
+
204
+ return PolicyResult(
205
+ decision=Decision.REQUIRE_APPROVAL,
206
+ reason="mutation requires a human to approve this specific action (Tier 2)",
207
+ action_class=action_class,
208
+ )
@@ -0,0 +1,296 @@
1
+ """Postmortem draft generation: the incident report, from the record.
2
+
3
+ In plain words, this is the last step of an incident: after the
4
+ problem is fixed, someone has to write down what happened. This
5
+ module writes the *first draft* of that report, using only what
6
+ the incident record itself holds — its evidence, its diagnosis,
7
+ the actions recorded on it, and the audit records the caller
8
+ hands in for it. A human edits and publishes the draft; nothing
9
+ here publishes anything.
10
+
11
+ How honesty is kept, in plain words:
12
+
13
+ - **The draft invents nothing.** It is a pure reader of the
14
+ record: it changes no incident, runs no tool and proposes no
15
+ action. If the record holds no diagnosis, the draft says the
16
+ cause was not recorded instead of guessing one. If no action
17
+ was recorded, it says so. What the record does not know is
18
+ listed, in its own section, as not known.
19
+ - **The timeline uses only timestamps the record really has.**
20
+ Incidents do not stamp each status change — a transition only
21
+ moves ``updated_at`` — so the timeline is built from the
22
+ incident's creation time, each evidence record's observation
23
+ time, and each audit record's decision time, and it ends with
24
+ the record's last update. No time is made up for a change the
25
+ record did not date.
26
+ - **An unfinished incident gets a preliminary draft, labelled
27
+ as one.** A draft can be generated at any point — it is useful
28
+ mid-incident too — but until the incident is resolved the
29
+ draft says plainly that it is not a final postmortem.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from datetime import UTC, datetime
35
+
36
+ from pydantic import BaseModel, Field
37
+
38
+ from .approvals import approve_pending, create_pending, execute_approved
39
+ from .audit import AuditLog, evaluate_and_audit
40
+ from .logging_config import get_logger
41
+ from .models import AuditRecord, Evidence, Hypothesis, Incident, IncidentStatus, Target
42
+ from .policy import EnvironmentPolicy, Tier
43
+ from .reasoning import ReasoningLoop
44
+ from .runbooks import build_action, demo_runbooks
45
+ from .tools import demo_tool_layer
46
+
47
+ log = get_logger(__name__)
48
+
49
+
50
+ def _utcnow() -> datetime:
51
+ return datetime.now(UTC)
52
+
53
+
54
+ class TimelineEntry(BaseModel):
55
+ """One dated line in the draft's timeline, from the record."""
56
+
57
+ at: datetime
58
+ text: str
59
+
60
+
61
+ class PostmortemDraft(BaseModel):
62
+ """A first draft of an incident's postmortem, ready to edit."""
63
+
64
+ incident_id: str
65
+ title: str
66
+ target: Target
67
+ status: IncidentStatus
68
+ resolved: bool
69
+ generated_at: datetime = Field(default_factory=_utcnow)
70
+ diagnosis: str | None = None
71
+ timeline: list[TimelineEntry] = Field(default_factory=list)
72
+ hypotheses: list[Hypothesis] = Field(default_factory=list)
73
+ evidence: list[Evidence] = Field(default_factory=list)
74
+ actions: list[Evidence] = Field(default_factory=list)
75
+ audit_records: list[AuditRecord] = Field(default_factory=list)
76
+ unknowns: list[str] = Field(default_factory=list)
77
+
78
+ def render_markdown(self) -> str:
79
+ """Write the draft as Markdown a human can edit and publish.
80
+
81
+ Every section is present even when the record behind it is
82
+ empty; an empty section says so in plain words rather than
83
+ being quietly dropped, so a reader can tell the difference
84
+ between "none" and "not written yet".
85
+ """
86
+ target = self.target
87
+ lines = [
88
+ f"# Postmortem draft: {self.title}",
89
+ "",
90
+ f"Incident: {self.incident_id}",
91
+ (
92
+ f"Target: {target.service} in {target.namespace} "
93
+ f"({target.environment})"
94
+ ),
95
+ f"Status: {self.status.value}",
96
+ ]
97
+ if not self.resolved:
98
+ lines.append(
99
+ "**Preliminary draft — this incident is not resolved yet, "
100
+ "so this is not a final postmortem.**"
101
+ )
102
+ lines += ["", "## Timeline", ""]
103
+ if self.timeline:
104
+ for entry in self.timeline:
105
+ lines.append(f"- {entry.at.isoformat()} — {entry.text}")
106
+ else:
107
+ lines.append("- No dated events were recorded.")
108
+ lines += ["", "## Diagnosis", ""]
109
+ lines.append(
110
+ self.diagnosis
111
+ if self.diagnosis
112
+ else "Not recorded: the record holds no diagnosis, so no cause is stated."
113
+ )
114
+ lines += ["", "## Possible causes, as recorded", ""]
115
+ if self.hypotheses:
116
+ for hyp in self.hypotheses:
117
+ lines.append(
118
+ f"- {hyp.statement} (confidence {hyp.confidence:.2f}, "
119
+ f"supported by {len(hyp.supporting_evidence)} evidence record(s))"
120
+ )
121
+ else:
122
+ lines.append("- None were recorded.")
123
+ lines += ["", "## Evidence", ""]
124
+ if self.evidence:
125
+ for ev in self.evidence:
126
+ lines.append(
127
+ f"- [{ev.kind}] {ev.summary} (query: {ev.query})"
128
+ )
129
+ else:
130
+ lines.append("- No evidence was recorded.")
131
+ lines += ["", "## Actions taken", ""]
132
+ if self.actions:
133
+ for ev in self.actions:
134
+ lines.append(f"- {ev.summary} (query: {ev.query})")
135
+ else:
136
+ lines.append("- No action was recorded on this incident.")
137
+ lines += ["", "## Decisions and approvals (audit log)", ""]
138
+ if self.audit_records:
139
+ for record in self.audit_records:
140
+ approver = (
141
+ f", approved by {record.approved_by}" if record.approved_by else ""
142
+ )
143
+ lines.append(
144
+ f"- {record.action_summary} — decision: {record.decision} "
145
+ f"({record.reason}){approver}"
146
+ )
147
+ else:
148
+ lines.append("- No audit records were supplied for this incident.")
149
+ lines += ["", "## What is not known", ""]
150
+ if self.unknowns:
151
+ for unknown in self.unknowns:
152
+ lines.append(f"- {unknown}")
153
+ else:
154
+ lines.append("- Nothing: the record covers every section above.")
155
+ lines.append("")
156
+ return "\n".join(lines)
157
+
158
+
159
+ def generate_postmortem(
160
+ incident: Incident,
161
+ audit_records: list[AuditRecord] | None = None,
162
+ *,
163
+ now: datetime | None = None,
164
+ ) -> PostmortemDraft:
165
+ """Build a postmortem draft from *incident*'s own record.
166
+
167
+ Pure: the incident is read, never changed, and nothing is run.
168
+ Audit records are taken from the caller's list but filtered to
169
+ this incident, so one incident's draft can never carry another
170
+ incident's decisions.
171
+ """
172
+ records = [r for r in audit_records or [] if r.incident_id == incident.id]
173
+ records.sort(key=lambda r: r.decided_at)
174
+
175
+ timeline = [
176
+ TimelineEntry(
177
+ at=incident.created_at,
178
+ text=f"Incident opened: {incident.title}",
179
+ )
180
+ ]
181
+ for ev in sorted(incident.evidence, key=lambda e: e.observed_at):
182
+ timeline.append(
183
+ TimelineEntry(
184
+ at=ev.observed_at,
185
+ text=f"Evidence ({ev.kind}): {ev.summary}",
186
+ )
187
+ )
188
+ for record in records:
189
+ timeline.append(
190
+ TimelineEntry(
191
+ at=record.decided_at,
192
+ text=(
193
+ f"Decision recorded: {record.action_summary} — "
194
+ f"{record.decision}"
195
+ ),
196
+ )
197
+ )
198
+ timeline.append(
199
+ TimelineEntry(
200
+ at=incident.updated_at,
201
+ text=f"Record last updated; status is {incident.status.value}.",
202
+ )
203
+ )
204
+ timeline.sort(key=lambda e: e.at)
205
+
206
+ unknowns: list[str] = []
207
+ if incident.status is not IncidentStatus.RESOLVED:
208
+ unknowns.append(
209
+ f"The incident is not resolved yet (status: {incident.status.value}); "
210
+ "this draft is preliminary and the ending is not known."
211
+ )
212
+ if not incident.diagnosis:
213
+ unknowns.append(
214
+ "No diagnosis was recorded, so the cause is not stated in this draft."
215
+ )
216
+ if not incident.hypotheses:
217
+ unknowns.append("No possible causes were recorded.")
218
+ if not incident.evidence:
219
+ unknowns.append("No evidence was recorded.")
220
+ actions = [ev for ev in incident.evidence if ev.kind == "action"]
221
+ if not actions:
222
+ unknowns.append("No runbook action was recorded on this incident.")
223
+ if not records:
224
+ unknowns.append("No audit records were supplied for this incident.")
225
+
226
+ log.info(
227
+ "postmortem draft generated for incident %s (status %s, %d unknown(s))",
228
+ incident.id,
229
+ incident.status.value,
230
+ len(unknowns),
231
+ )
232
+ return PostmortemDraft(
233
+ incident_id=incident.id,
234
+ title=incident.title,
235
+ target=incident.target,
236
+ status=incident.status,
237
+ resolved=incident.status is IncidentStatus.RESOLVED,
238
+ generated_at=now if now is not None else _utcnow(),
239
+ diagnosis=incident.diagnosis,
240
+ timeline=timeline,
241
+ hypotheses=list(incident.hypotheses),
242
+ evidence=list(incident.evidence),
243
+ actions=actions,
244
+ audit_records=records,
245
+ unknowns=unknowns,
246
+ )
247
+
248
+
249
+ def demo_postmortem() -> PostmortemDraft:
250
+ """A full demo postmortem, built end to end with no setup.
251
+
252
+ One seeded demo incident is diagnosed by the real reasoning
253
+ loop, fixed by the real Tier 2 path (propose, approve by name,
254
+ run the restart once), and resolved — then drafted here. The
255
+ draft therefore shows a real diagnosis, a real action record
256
+ and real audit records, all about the demo checkout workload
257
+ and nothing real.
258
+ """
259
+ target = Target(environment="demo", namespace="shop", service="checkout")
260
+ incident = Incident(
261
+ id="demo-incident-1",
262
+ title="HighErrorRate on checkout",
263
+ target=target,
264
+ )
265
+ ReasoningLoop(demo_tool_layer()).diagnose(incident)
266
+
267
+ executor, backend = demo_runbooks()
268
+ action = build_action(
269
+ "restart-workload",
270
+ target,
271
+ {},
272
+ backend,
273
+ rationale=incident.diagnosis or "demo diagnosis",
274
+ )
275
+ policy = EnvironmentPolicy(
276
+ environment="demo",
277
+ granted_tier=Tier.ACT_WITH_APPROVAL,
278
+ max_replicas_affected=5,
279
+ )
280
+ audit = AuditLog()
281
+ result = evaluate_and_audit(
282
+ action, policy, audit, incident_id=incident.id, incident_target=target
283
+ )
284
+ pending = create_pending(action, result, incident_id=incident.id)
285
+ grant, allowed = approve_pending(
286
+ pending,
287
+ approved_by="Demo Approver",
288
+ policy=policy,
289
+ audit_log=audit,
290
+ incident_target=target,
291
+ )
292
+ incident.transition(IncidentStatus.MITIGATING)
293
+ run_result = execute_approved(executor, pending, grant, allowed)
294
+ incident.add_evidence(run_result.to_evidence())
295
+ incident.transition(IncidentStatus.RESOLVED)
296
+ return generate_postmortem(incident, audit.for_incident(incident.id))