@obsunified/collector 2.2.0 → 2.2.1

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.
@@ -11,11 +11,18 @@ import type {
11
11
  RemediationProposalStatus,
12
12
  RemediationStateSpec,
13
13
  } from "@obsunified/types";
14
+ import {
15
+ ChangeEventKindSchema,
16
+ EvidencePredicateSchema,
17
+ RemediationActionKindSchema,
18
+ } from "@obsunified/types";
14
19
  import { AgentAutonomyLevel } from "@obsunified/types/constants";
15
20
  import type { CollectorPlugin } from "../framework/collector";
21
+ import type { CollectorEnv } from "../framework/env";
16
22
  import {
17
23
  computeChangeBlastRadius,
18
24
  getChangeEvent,
25
+ InvalidTimestampError,
19
26
  listChangeEvents,
20
27
  recordChangeEvent,
21
28
  } from "../lib/autosre-change-correlation";
@@ -47,28 +54,16 @@ import {
47
54
  RemediationGateError,
48
55
  rejectRemediationProposal,
49
56
  } from "../lib/autosre-remediation-gate";
57
+ import { sha256Hex } from "../lib/hash";
50
58
  import { sqlDbFor } from "../lib/sql-db";
51
59
  import { getProjectId } from "./_context";
52
60
  import { readJsonObject } from "./_json";
53
61
 
54
- const CHANGE_KINDS = new Set<ChangeEventKind>([
55
- "deploy",
56
- "rollback",
57
- "feature_flag",
58
- "config_change",
59
- "prompt_rollout",
60
- "agent_version",
61
- "schema_migration",
62
- "infra_scale",
63
- ]);
64
-
65
- const REMEDIATION_ACTION_KINDS = new Set<RemediationActionKind>([
66
- "open_code_pr",
67
- "toggle_feature_flag",
68
- "rollback_deploy",
69
- "apply_config",
70
- "promote_agent_prompt",
71
- ]);
62
+ const CHANGE_KINDS = new Set<string>(ChangeEventKindSchema.options);
63
+
64
+ const REMEDIATION_ACTION_KINDS = new Set<string>(
65
+ RemediationActionKindSchema.options,
66
+ );
72
67
 
73
68
  const parseLimit = (raw: string | undefined, fallback = 50): number => {
74
69
  const parsed = Number.parseInt(raw ?? String(fallback), 10);
@@ -85,9 +80,11 @@ const isAgentAutonomyLevel = (value: unknown): value is AgentAutonomyLevel =>
85
80
  * require an authenticated human dashboard session (`obs_session` cookie) and
86
81
  * reject programmatic agent/MCP bearer tokens (`403 Forbidden`).
87
82
  */
88
- function requireHumanDashboardSession(c: {
83
+ async function requireHumanDashboardSession(c: {
89
84
  req: { header: (name: string) => string | undefined };
90
- }): { ok: true; sessionActor: string } | { ok: false; message: string } {
85
+ }): Promise<
86
+ { ok: true; sessionActor: string } | { ok: false; message: string }
87
+ > {
91
88
  const authHeader = c.req.header("Authorization");
92
89
  if (authHeader) {
93
90
  return {
@@ -98,23 +95,69 @@ function requireHumanDashboardSession(c: {
98
95
  }
99
96
  const cookieHeader = c.req.header("Cookie") ?? "";
100
97
  const match = cookieHeader.match(/(?:^|;\s*)obs_session=([^;]*)/);
101
- if (!match || !match[1]) {
98
+ if (!match?.[1]) {
102
99
  return {
103
100
  ok: false,
104
101
  message:
105
102
  "Human approval requires an authenticated human dashboard session (obs_session cookie).",
106
103
  };
107
104
  }
108
- const explicitActor = c.req.header("X-Obs-User-Actor")?.trim();
109
- const rawSession = decodeURIComponent(match[1]);
110
- const sessionActor =
111
- explicitActor || `dashboard_session:${rawSession.slice(0, 24)}`;
105
+ // The recorded approver is derived from the session itself. Client-supplied
106
+ // identities (X-Obs-User-Actor, body.approvedByUserId) are never trusted:
107
+ // they would let any session holder attribute an approval to someone else.
108
+ // Only a hash prefix is stored so the cookie value never reaches the DB.
109
+ let rawSession = match[1];
110
+ try {
111
+ rawSession = decodeURIComponent(rawSession);
112
+ } catch {
113
+ // keep the raw value; it is only hashed
114
+ }
115
+ const sessionActor = `dashboard_session:${(await sha256Hex(rawSession)).slice(0, 16)}`;
112
116
  return { ok: true, sessionActor };
113
117
  }
114
118
 
115
119
  export const autosreRoutesPlugin: CollectorPlugin = {
116
120
  name: "autosre-routes",
117
121
  register(app, runtime) {
122
+ /**
123
+ * Grant handoff: the plaintext grant token exists only in the response of
124
+ * the request that mints it (propose with an autonomous grant, or human
125
+ * approve). Incidents do not store a webhook, so the caller may pass
126
+ * `webhookUrl` (+ optional `secret`) in that same request and the token is
127
+ * pushed to the runner over the SSRF-guarded, HMAC-signed incident webhook.
128
+ * Without a `webhookUrl` the token is simply returned in the response.
129
+ */
130
+ const deliverGrant = async (
131
+ env: CollectorEnv | undefined,
132
+ db: ReturnType<typeof sqlDbFor>,
133
+ projectId: string,
134
+ body: Record<string, unknown>,
135
+ proposal: {
136
+ id: string;
137
+ incidentId: string;
138
+ environment: string;
139
+ status: string;
140
+ grantToken?: string;
141
+ },
142
+ ) => {
143
+ const webhookUrl =
144
+ typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : "";
145
+ if (!webhookUrl || !proposal.grantToken) return undefined;
146
+ if (proposal.status !== "grant_issued") return undefined;
147
+ return dispatchIncidentWebhook(db, projectId, proposal.incidentId, {
148
+ url: webhookUrl,
149
+ secret: typeof body.secret === "string" ? body.secret : undefined,
150
+ environment: proposal.environment,
151
+ allowPrivateNetworks: env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
152
+ logger: runtime.logger,
153
+ grant: {
154
+ proposalId: proposal.id,
155
+ status: proposal.status,
156
+ grantToken: proposal.grantToken,
157
+ },
158
+ });
159
+ };
160
+
118
161
  // 1. Change Ingestion (Ingest Auth /v1/changes)
119
162
  app.post("/v1/changes", async (c) => {
120
163
  const projectId = getProjectId(c);
@@ -122,7 +165,8 @@ export const autosreRoutesPlugin: CollectorPlugin = {
122
165
  if (!body) {
123
166
  return c.json({ error: "Invalid JSON body" }, 400);
124
167
  }
125
- const rawKind = typeof body.kind === "string" ? body.kind.trim() : "deploy";
168
+ const rawKind =
169
+ typeof body.kind === "string" ? body.kind.trim() : "deploy";
126
170
  const normalizedKind = (
127
171
  rawKind === "agent_prompt_release" ? "prompt_rollout" : rawKind
128
172
  ) as ChangeEventKind;
@@ -181,47 +225,58 @@ export const autosreRoutesPlugin: CollectorPlugin = {
181
225
  ? body.actor
182
226
  : null;
183
227
 
184
- const changeEvent = await recordChangeEvent(db, projectId, {
185
- id: typeof body.id === "string" ? body.id : undefined,
186
- environment:
187
- typeof body.environment === "string" ? body.environment : undefined,
188
- kind: normalizedKind,
189
- source:
190
- body.source === "inferred_telemetry" ||
191
- body.source === "autosre_remediation"
192
- ? body.source
193
- : "explicit_api",
194
- targetNodeId:
195
- typeof body.targetNodeId === "string" ? body.targetNodeId : undefined,
196
- serviceName,
197
- namespace,
198
- version: typeof body.version === "string" ? body.version : null,
199
- previousVersion:
200
- typeof body.previousVersion === "string"
201
- ? body.previousVersion
202
- : null,
203
- commitSha: typeof body.commitSha === "string" ? body.commitSha : null,
204
- previousCommitSha:
205
- typeof body.previousCommitSha === "string"
206
- ? body.previousCommitSha
207
- : null,
208
- configDigest:
209
- typeof body.configDigest === "string" ? body.configDigest : null,
210
- summary,
211
- actorId,
212
- repositoryUrl,
213
- pullRequestUrl,
214
- causedByActionId:
215
- typeof body.causedByActionId === "string"
216
- ? body.causedByActionId
217
- : null,
218
- agentRunId:
219
- typeof body.agentRunId === "string" ? body.agentRunId : null,
220
- touchedPaths,
221
- attributes,
222
- occurredAt:
223
- typeof body.occurredAt === "string" ? body.occurredAt : undefined,
224
- });
228
+ let changeEvent: Awaited<ReturnType<typeof recordChangeEvent>>;
229
+ try {
230
+ changeEvent = await recordChangeEvent(db, projectId, {
231
+ id: typeof body.id === "string" ? body.id : undefined,
232
+ environment:
233
+ typeof body.environment === "string" ? body.environment : undefined,
234
+ kind: normalizedKind,
235
+ source:
236
+ body.source === "inferred_telemetry" ||
237
+ body.source === "autosre_remediation" ||
238
+ body.source === "ci_webhook"
239
+ ? body.source
240
+ : "explicit_api",
241
+ targetNodeId:
242
+ typeof body.targetNodeId === "string"
243
+ ? body.targetNodeId
244
+ : undefined,
245
+ serviceName,
246
+ namespace,
247
+ version: typeof body.version === "string" ? body.version : null,
248
+ previousVersion:
249
+ typeof body.previousVersion === "string"
250
+ ? body.previousVersion
251
+ : null,
252
+ commitSha: typeof body.commitSha === "string" ? body.commitSha : null,
253
+ previousCommitSha:
254
+ typeof body.previousCommitSha === "string"
255
+ ? body.previousCommitSha
256
+ : null,
257
+ configDigest:
258
+ typeof body.configDigest === "string" ? body.configDigest : null,
259
+ summary,
260
+ actorId,
261
+ repositoryUrl,
262
+ pullRequestUrl,
263
+ causedByActionId:
264
+ typeof body.causedByActionId === "string"
265
+ ? body.causedByActionId
266
+ : null,
267
+ agentRunId:
268
+ typeof body.agentRunId === "string" ? body.agentRunId : null,
269
+ touchedPaths,
270
+ attributes,
271
+ occurredAt:
272
+ typeof body.occurredAt === "string" ? body.occurredAt : undefined,
273
+ });
274
+ } catch (err) {
275
+ if (err instanceof InvalidTimestampError) {
276
+ return c.json({ error: err.message }, 400);
277
+ }
278
+ throw err;
279
+ }
225
280
 
226
281
  return c.json({ changeEvent, change: changeEvent }, 201);
227
282
  });
@@ -254,23 +309,27 @@ export const autosreRoutesPlugin: CollectorPlugin = {
254
309
  const rawKind = c.req.query("kind");
255
310
  const normalizedKind =
256
311
  rawKind === "agent_prompt_release" ? "prompt_rollout" : rawKind;
257
- if (
258
- normalizedKind &&
259
- !CHANGE_KINDS.has(normalizedKind as ChangeEventKind)
260
- ) {
312
+ if (normalizedKind && !CHANGE_KINDS.has(normalizedKind)) {
261
313
  return c.json({ error: `Invalid kind: ${rawKind}` }, 400);
262
314
  }
263
- const changes = await listChangeEvents(db, {
264
- projectId,
265
- environment: c.req.query("environment") || undefined,
266
- serviceName:
267
- c.req.query("serviceName") || c.req.query("service") || undefined,
268
- targetNodeId: c.req.query("targetNodeId") || undefined,
269
- kind: normalizedKind || undefined,
270
- since: c.req.query("since") || undefined,
271
- limit: parseLimit(c.req.query("limit"), 50),
272
- });
273
- return c.json({ changes });
315
+ try {
316
+ const changes = await listChangeEvents(db, {
317
+ projectId,
318
+ environment: c.req.query("environment") || undefined,
319
+ serviceName:
320
+ c.req.query("serviceName") || c.req.query("service") || undefined,
321
+ targetNodeId: c.req.query("targetNodeId") || undefined,
322
+ kind: normalizedKind || undefined,
323
+ since: c.req.query("since") || undefined,
324
+ limit: parseLimit(c.req.query("limit"), 50),
325
+ });
326
+ return c.json({ changes });
327
+ } catch (err) {
328
+ if (err instanceof InvalidTimestampError) {
329
+ return c.json({ error: err.message }, 400);
330
+ }
331
+ throw err;
332
+ }
274
333
  });
275
334
 
276
335
  app.get("/internal/changes/:id/blast-radius", async (c) => {
@@ -394,10 +453,13 @@ export const autosreRoutesPlugin: CollectorPlugin = {
394
453
  typeof body.leaseDurationSeconds === "number" &&
395
454
  Number.isFinite(body.leaseDurationSeconds)
396
455
  ? body.leaseDurationSeconds
397
- : typeof body.leaseDurationMs === "number" &&
398
- Number.isFinite(body.leaseDurationMs)
399
- ? Math.ceil(body.leaseDurationMs / 1000)
400
- : undefined;
456
+ : typeof body.ttlSeconds === "number" &&
457
+ Number.isFinite(body.ttlSeconds)
458
+ ? body.ttlSeconds
459
+ : typeof body.leaseDurationMs === "number" &&
460
+ Number.isFinite(body.leaseDurationMs)
461
+ ? Math.ceil(body.leaseDurationMs / 1000)
462
+ : undefined;
401
463
  const result = await acquireIncidentLease(db, projectId, incidentId, {
402
464
  environment:
403
465
  typeof body.environment === "string" ? body.environment : undefined,
@@ -424,9 +486,12 @@ export const autosreRoutesPlugin: CollectorPlugin = {
424
486
  const result = await dispatchIncidentWebhook(db, projectId, incidentId, {
425
487
  url: webhookUrl,
426
488
  secret: typeof body?.secret === "string" ? body.secret : undefined,
427
- allowPrivateNetworks: Boolean(
428
- body?.allowPrivateNetworks ?? body?.allowPrivateWebhookTargets,
429
- ),
489
+ environment:
490
+ typeof body?.environment === "string" ? body.environment : undefined,
491
+ // Operator-level setting only: a request body must never be able to
492
+ // turn off the SSRF guard.
493
+ allowPrivateNetworks: c.env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
494
+ logger: runtime.logger,
430
495
  });
431
496
  return c.json(result, result.delivered ? 200 : 502);
432
497
  });
@@ -497,6 +562,26 @@ export const autosreRoutesPlugin: CollectorPlugin = {
497
562
  );
498
563
  }
499
564
 
565
+ if (body.causalChain !== undefined && !Array.isArray(body.causalChain)) {
566
+ return c.json({ error: "causalChain must be an array" }, 400);
567
+ }
568
+ for (const [i, triple] of (
569
+ (body.causalChain as unknown[] | undefined) ?? []
570
+ ).entries()) {
571
+ const predicate =
572
+ triple && typeof triple === "object"
573
+ ? (triple as Record<string, unknown>).predicate
574
+ : undefined;
575
+ if (!EvidencePredicateSchema.safeParse(predicate).success) {
576
+ return c.json(
577
+ {
578
+ error: `causalChain[${i}].predicate is not a known evidence predicate: ${String(predicate)}`,
579
+ },
580
+ 400,
581
+ );
582
+ }
583
+ }
584
+
500
585
  const db = sqlDbFor(c.env);
501
586
  const input: InvestigationFindingInput = {
502
587
  id: typeof body.id === "string" ? body.id : undefined,
@@ -552,7 +637,9 @@ export const autosreRoutesPlugin: CollectorPlugin = {
552
637
  typeof body.agentRunId === "string" ? body.agentRunId : null,
553
638
  };
554
639
 
555
- const finding = await recordInvestigationFinding(db, projectId, input);
640
+ const finding = await recordInvestigationFinding(db, projectId, input, {
641
+ logger: runtime.logger,
642
+ });
556
643
  return c.json({ finding, id: finding.id }, 201);
557
644
  });
558
645
 
@@ -701,12 +788,22 @@ export const autosreRoutesPlugin: CollectorPlugin = {
701
788
  };
702
789
 
703
790
  try {
704
- const proposal = await proposeRemediationAction(db, projectId, input);
791
+ const proposal = await proposeRemediationAction(db, projectId, input, {
792
+ logger: runtime.logger,
793
+ });
794
+ const webhookDelivery = await deliverGrant(
795
+ c.env,
796
+ db,
797
+ projectId,
798
+ body,
799
+ proposal,
800
+ );
705
801
  return c.json(
706
802
  {
707
803
  proposal,
708
804
  ...proposal,
709
805
  grantToken: proposal.grantToken ?? null,
806
+ ...(webhookDelivery ? { webhookDelivery } : {}),
710
807
  },
711
808
  201,
712
809
  );
@@ -721,7 +818,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
721
818
  });
722
819
 
723
820
  app.post("/internal/remediations/:id/approve", async (c) => {
724
- const sessionCheck = requireHumanDashboardSession(c);
821
+ const sessionCheck = await requireHumanDashboardSession(c);
725
822
  if (!sessionCheck.ok) {
726
823
  return c.json(
727
824
  { error: "Forbidden", message: sessionCheck.message },
@@ -735,14 +832,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
735
832
  {};
736
833
  const environment =
737
834
  typeof body.environment === "string" ? body.environment : undefined;
738
- const approvedByUserId =
739
- typeof body.approvedByUserId === "string" &&
740
- body.approvedByUserId.trim()
741
- ? body.approvedByUserId.trim()
742
- : typeof body.approvedBySession === "string" &&
743
- body.approvedBySession.trim()
744
- ? body.approvedBySession.trim()
745
- : sessionCheck.sessionActor;
835
+ const approvedByUserId = sessionCheck.sessionActor;
746
836
 
747
837
  const db = sqlDbFor(c.env);
748
838
  try {
@@ -755,10 +845,18 @@ export const autosreRoutesPlugin: CollectorPlugin = {
755
845
  approvedByUserId,
756
846
  },
757
847
  );
848
+ const webhookDelivery = await deliverGrant(
849
+ c.env,
850
+ db,
851
+ projectId,
852
+ body,
853
+ proposal,
854
+ );
758
855
  return c.json({
759
856
  proposal,
760
857
  ...proposal,
761
858
  grantToken: proposal.grantToken ?? null,
859
+ ...(webhookDelivery ? { webhookDelivery } : {}),
762
860
  });
763
861
  } catch (err) {
764
862
  const message = err instanceof Error ? err.message : String(err);
@@ -771,7 +869,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
771
869
  });
772
870
 
773
871
  app.post("/internal/remediations/:id/reject", async (c) => {
774
- const sessionCheck = requireHumanDashboardSession(c);
872
+ const sessionCheck = await requireHumanDashboardSession(c);
775
873
  if (!sessionCheck.ok) {
776
874
  return c.json(
777
875
  { error: "Forbidden", message: sessionCheck.message },
@@ -785,8 +883,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
785
883
  {};
786
884
  const environment =
787
885
  typeof body.environment === "string" ? body.environment : undefined;
788
- const reason =
789
- typeof body.reason === "string" ? body.reason : undefined;
886
+ const reason = typeof body.reason === "string" ? body.reason : undefined;
790
887
 
791
888
  const db = sqlDbFor(c.env);
792
889
  try {
@@ -837,6 +934,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
837
934
  grantToken,
838
935
  runnerId:
839
936
  typeof body.runnerId === "string" ? body.runnerId : undefined,
937
+ logger: runtime.logger,
840
938
  },
841
939
  );
842
940
  return c.json({ claimed: true, proposal, ...proposal });
@@ -846,7 +944,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
846
944
  err instanceof RemediationGateError
847
945
  ? (err.statusCode as 400 | 403 | 404 | 409 | 410)
848
946
  : 409;
849
- return c.json({ claimed: false, error: message, reason: message }, status);
947
+ return c.json(
948
+ { claimed: false, error: message, reason: message },
949
+ status,
950
+ );
850
951
  }
851
952
  });
852
953
 
@@ -856,6 +957,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
856
957
  const body =
857
958
  (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
858
959
  {};
960
+ const runnerId =
961
+ typeof body.runnerId === "string" && body.runnerId.trim()
962
+ ? body.runnerId.trim()
963
+ : "autosre-runner";
859
964
  const db = sqlDbFor(c.env);
860
965
  try {
861
966
  const proposal = await completeRemediationProposal(
@@ -867,6 +972,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
867
972
  typeof body.environment === "string"
868
973
  ? body.environment
869
974
  : undefined,
975
+ runnerId,
870
976
  resultingChangeId:
871
977
  typeof body.resultingChangeId === "string"
872
978
  ? body.resultingChangeId
@@ -894,6 +1000,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
894
1000
  const body =
895
1001
  (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
896
1002
  {};
1003
+ const runnerId =
1004
+ typeof body.runnerId === "string" && body.runnerId.trim()
1005
+ ? body.runnerId.trim()
1006
+ : "autosre-runner";
897
1007
  const db = sqlDbFor(c.env);
898
1008
  try {
899
1009
  const proposal = await failRemediationProposal(
@@ -905,6 +1015,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
905
1015
  typeof body.environment === "string"
906
1016
  ? body.environment
907
1017
  : undefined,
1018
+ runnerId,
908
1019
  failureReason:
909
1020
  typeof body.failureReason === "string"
910
1021
  ? body.failureReason
@@ -916,7 +1027,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
916
1027
  const message = err instanceof Error ? err.message : String(err);
917
1028
  const status =
918
1029
  err instanceof RemediationGateError
919
- ? (err.statusCode as 400 | 404)
1030
+ ? (err.statusCode as 400 | 404 | 409)
920
1031
  : 400;
921
1032
  return c.json({ error: message }, status);
922
1033
  }
@@ -927,12 +1038,16 @@ export const autosreRoutesPlugin: CollectorPlugin = {
927
1038
  const body =
928
1039
  (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
929
1040
  {};
1041
+ // Scoped to the caller's project; the clock is never client-controlled
1042
+ // and the traffic floor can only be raised, never lowered, by a caller.
930
1043
  const summary = await evaluateRemediationWatchdogs(db, {
931
- nowIso: typeof body.nowIso === "string" ? body.nowIso : undefined,
1044
+ projectId: getProjectId(c),
932
1045
  minComparableRequests:
933
- typeof body.minComparableRequests === "number"
934
- ? body.minComparableRequests
1046
+ typeof body.minComparableRequests === "number" &&
1047
+ Number.isFinite(body.minComparableRequests)
1048
+ ? Math.max(50, body.minComparableRequests)
935
1049
  : undefined,
1050
+ logger: runtime.logger,
936
1051
  });
937
1052
  return c.json({ summary, ...summary });
938
1053
  });
@@ -1,7 +1,4 @@
1
- import type {
2
- CausalProofTier,
3
- RelationshipCertainty,
4
- } from "@obsunified/types";
1
+ import type { CausalProofTier, RelationshipCertainty } from "@obsunified/types";
5
2
  import type {
6
3
  ActionRef,
7
4
  AICallRef,