stacktrace-cli 0.13.0__py3-none-any.whl → 0.14.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.
- stacktrace/__init__.py +1 -1
- stacktrace/cost/measures.py +2 -2
- stacktrace/cost/rules.py +1 -1
- stacktrace/daemon/costs.py +4 -4
- stacktrace/daemon/fleet.py +25 -0
- stacktrace/daemon/policy_collect.py +45 -5
- stacktrace/daemon/policy_runtime.py +33 -5
- stacktrace/daemon/presentation.py +8 -0
- stacktrace/daemon/runtime.py +20 -1
- stacktrace/daemon/store.py +46 -1
- stacktrace/daemon/webhook.py +231 -27
- stacktrace/detector/deterministic.py +550 -525
- stacktrace/detector/rules.py +14 -10
- stacktrace/finding_contract.py +355 -38
- stacktrace/finding_titles.py +9 -20
- stacktrace/hooks/claude.py +4 -1
- stacktrace/hooks/codex.py +84 -0
- stacktrace/monitor/render.py +1 -3
- stacktrace/monitor/site/app.js +29 -2
- stacktrace/monitor/site/styles.css +1 -0
- stacktrace/monitor/verdicts.py +26 -9
- stacktrace/remote/detect_payload.py +20 -2
- stacktrace/remote/hook_policy.py +52 -7
- stacktrace/remote/policy_service.py +0 -21
- stacktrace/remote/policy_show.py +11 -1
- stacktrace/remote/upload_contract.py +9 -0
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.14.0.dist-info}/METADATA +4 -4
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.14.0.dist-info}/RECORD +30 -29
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.14.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.14.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
stacktrace/cost/measures.py
CHANGED
|
@@ -127,8 +127,8 @@ def repeats(
|
|
|
127
127
|
most repeated first.
|
|
128
128
|
|
|
129
129
|
A call that recorded a failure takes no part in any cost measure
|
|
130
|
-
(cost-phase-1 §1): a denied one reached nothing, and a failing one
|
|
131
|
-
|
|
130
|
+
(cost-phase-1 §1): a denied one reached nothing, and a failing one returned
|
|
131
|
+
no result to repeat. A call in `skip` was already cited by another rule.
|
|
132
132
|
"""
|
|
133
133
|
seen: dict[tuple[str | None, str, str, str], list[ToolCallLike]] = {}
|
|
134
134
|
for call in calls:
|
stacktrace/cost/rules.py
CHANGED
|
@@ -244,7 +244,7 @@ class Context:
|
|
|
244
244
|
zone: tzinfo
|
|
245
245
|
snapshot: Snapshot | None = None
|
|
246
246
|
#: Spans another rule already cited, which the repeats reason leaves out
|
|
247
|
-
#: (`
|
|
247
|
+
#: (`blocked-*`): one event is never reported twice.
|
|
248
248
|
cited: frozenset[str] = frozenset()
|
|
249
249
|
#: What correlation placed each call on, by span. A call it does not name
|
|
250
250
|
#: gets a label of ours (`cost/components.py`).
|
stacktrace/daemon/costs.py
CHANGED
|
@@ -460,9 +460,9 @@ def summaries_from_rows(rows: Iterable[Mapping[str, Any]]) -> list[Summary]:
|
|
|
460
460
|
]
|
|
461
461
|
|
|
462
462
|
|
|
463
|
-
#: Rules whose citations the repeats reason leaves out: `
|
|
464
|
-
#:
|
|
465
|
-
#:
|
|
463
|
+
#: Rules whose citations the repeats reason leaves out: every `blocked-*` rule,
|
|
464
|
+
#: which together are the reliability family. One event, one report
|
|
465
|
+
#: (cost-phase-1 §1).
|
|
466
466
|
_LOOP_CITERS = RELIABILITY_RULES
|
|
467
467
|
|
|
468
468
|
|
|
@@ -651,7 +651,7 @@ class CostPass:
|
|
|
651
651
|
self._log(misses)
|
|
652
652
|
|
|
653
653
|
def _cited(self, runs: Sequence[Run[Any]]) -> frozenset[str]:
|
|
654
|
-
"""Spans
|
|
654
|
+
"""Spans the `blocked-*` rules cite, which the
|
|
655
655
|
repeats reason leaves out: worked out from each changed session this
|
|
656
656
|
tick, and whatever the store already holds for each run's main
|
|
657
657
|
session."""
|
stacktrace/daemon/fleet.py
CHANGED
|
@@ -24,6 +24,7 @@ from contextlib import suppress
|
|
|
24
24
|
from dataclasses import dataclass
|
|
25
25
|
from datetime import UTC, datetime
|
|
26
26
|
from typing import Any, Protocol
|
|
27
|
+
from urllib.parse import urlsplit
|
|
27
28
|
|
|
28
29
|
import httpx
|
|
29
30
|
|
|
@@ -156,6 +157,17 @@ class DrainReport:
|
|
|
156
157
|
stopped: str | None = None
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
def _console_origin(url: object) -> str | None:
|
|
161
|
+
"""The scheme and host of Fleet's `dashboard_url`, or None for anything
|
|
162
|
+
that is not an http(s) URL with a host."""
|
|
163
|
+
if not isinstance(url, str):
|
|
164
|
+
return None
|
|
165
|
+
parts = urlsplit(url)
|
|
166
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
167
|
+
return None
|
|
168
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
169
|
+
|
|
170
|
+
|
|
159
171
|
class FleetSubscriber:
|
|
160
172
|
"""Drain `records` by cursor, one request per agent kind."""
|
|
161
173
|
|
|
@@ -223,6 +235,7 @@ class FleetSubscriber:
|
|
|
223
235
|
config = load_remote_config(config_path)
|
|
224
236
|
if config.token is None:
|
|
225
237
|
self.store.clear_cursors()
|
|
238
|
+
self.store.clear_fleet_console()
|
|
226
239
|
return DrainReport(stopped="not configured")
|
|
227
240
|
if not self.store.has_queued_rows():
|
|
228
241
|
# `partitions()` stays non-empty long after its backlog has fully
|
|
@@ -247,6 +260,7 @@ class FleetSubscriber:
|
|
|
247
260
|
config = load_remote_config(config_path)
|
|
248
261
|
if config.token is None:
|
|
249
262
|
self.store.clear_cursors()
|
|
263
|
+
self.store.clear_fleet_console()
|
|
250
264
|
return DrainReport(stopped="not configured")
|
|
251
265
|
token = config.token
|
|
252
266
|
config = _recover_interrupted_asset_transition(config=config, config_path=config_path)
|
|
@@ -272,6 +286,11 @@ class FleetSubscriber:
|
|
|
272
286
|
if asset_id != config.asset_id:
|
|
273
287
|
# No separate lock needed — this whole span already holds it.
|
|
274
288
|
_advance_asset_id(config=config, config_path=config_path, asset_id=asset_id)
|
|
289
|
+
# A new asset id means a new registration — first-ever, or a
|
|
290
|
+
# tenant switch — and either way any cached console address
|
|
291
|
+
# named a prior tenant's Fleet. The next successful upload
|
|
292
|
+
# replaces it; until then, no webhook alert should link to one.
|
|
293
|
+
self.store.clear_fleet_console()
|
|
275
294
|
|
|
276
295
|
uploaded = 0
|
|
277
296
|
parked = 0
|
|
@@ -602,6 +621,12 @@ class FleetSubscriber:
|
|
|
602
621
|
except RemoteClientError as error:
|
|
603
622
|
raise DrainStopped(f"upload rejected: {error}") from error
|
|
604
623
|
else:
|
|
624
|
+
# Where Fleet's console is, for the link a webhook alert carries
|
|
625
|
+
# (ADR-0147). Fleet names it on every upload; the origin is all
|
|
626
|
+
# that is kept.
|
|
627
|
+
origin = _console_origin(getattr(result, "dashboard_url", None))
|
|
628
|
+
if origin is not None:
|
|
629
|
+
self.store.set_fleet_console(origin)
|
|
605
630
|
sent = len(payload["detections"])
|
|
606
631
|
kept = int(getattr(result, "detections_ingested", sent))
|
|
607
632
|
if kept < sent:
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import contextlib
|
|
10
10
|
import hashlib
|
|
11
|
+
import inspect
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
import signal
|
|
@@ -29,8 +30,11 @@ MAX_BUNDLE_BYTES = 2 * 1024 * 1024
|
|
|
29
30
|
COLLECT_TIMEOUT = 45
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def collector_available() -> bool:
|
|
33
|
-
|
|
33
|
+
def collector_available(agent_kind: str = "claude-code") -> bool:
|
|
34
|
+
collector = getattr(openaca.core, "collect_policy_evidence", None)
|
|
35
|
+
if not callable(collector):
|
|
36
|
+
return False
|
|
37
|
+
return agent_kind != "codex" or "agent_kind" in inspect.signature(collector).parameters
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def _source(ref: ComponentRef, scope: Scope) -> str | None:
|
|
@@ -90,11 +94,24 @@ def evaluate_bundle(policy: Policy, evidence: Any, scope: Scope) -> tuple[Entry,
|
|
|
90
94
|
name = path[-1].get("name")
|
|
91
95
|
if not isinstance(name, str) or not name or len(name) > 4096:
|
|
92
96
|
continue
|
|
97
|
+
if scope.agent_kind == "codex":
|
|
98
|
+
from stacktrace.hooks.codex import namespace_for_server
|
|
99
|
+
|
|
100
|
+
if component_type != "mcp_server" or ref.extra.get("enabled") is False:
|
|
101
|
+
continue
|
|
102
|
+
# Codex exposes the server namespace for both direct and plugin MCPs.
|
|
103
|
+
# Keep normalized collisions: DecisionCache applies category defaults.
|
|
104
|
+
name = namespace_for_server(name)
|
|
105
|
+
if name is None:
|
|
106
|
+
continue
|
|
93
107
|
blocked = decision.blocked
|
|
94
108
|
rule = next(iter(getattr(decision, "rule_paths", ())), None) if blocked else None
|
|
95
109
|
detail = descriptions.get(rule) if rule is not None else None
|
|
96
110
|
if detail is not None and len(detail) > MAX_RULE_DETAIL:
|
|
97
111
|
detail = detail[: MAX_RULE_DETAIL - 3] + "..."
|
|
112
|
+
if scope.agent_kind == "codex":
|
|
113
|
+
entries.append((Entry("mcp_tool", name, "codex", blocked, rule, detail), 0))
|
|
114
|
+
continue
|
|
98
115
|
owner = decision.subject.ref.name if decision.controlled_by_plugin else None
|
|
99
116
|
source = "plugin" if owner else _source(ref, scope)
|
|
100
117
|
rank = 1 if source in {"project", "local"} and not owner and ":" not in name else 0
|
|
@@ -154,6 +171,7 @@ def collect_bounded(document: dict[str, object], scope: Scope) -> Collected:
|
|
|
154
171
|
request = json.dumps(
|
|
155
172
|
{
|
|
156
173
|
"document": document,
|
|
174
|
+
"agent_kind": scope.agent_kind,
|
|
157
175
|
"config_root": scope.config_root,
|
|
158
176
|
"project_root": scope.project_root,
|
|
159
177
|
"global_config": scope.global_config,
|
|
@@ -188,9 +206,14 @@ def collect_bounded(document: dict[str, object], scope: Scope) -> Collected:
|
|
|
188
206
|
"inventory_digest",
|
|
189
207
|
"vulnerability_run_id",
|
|
190
208
|
"posture_run_id",
|
|
209
|
+
"inventory_gap_count",
|
|
191
210
|
}:
|
|
192
211
|
raise ValueError("policy_evidence_invalid")
|
|
193
212
|
for name, value in identifiers.items():
|
|
213
|
+
if name == "inventory_gap_count":
|
|
214
|
+
if type(value) is not int or not 0 <= value < 2**31:
|
|
215
|
+
raise ValueError("policy_evidence_invalid")
|
|
216
|
+
continue
|
|
194
217
|
if value is None and name != "inventory_digest":
|
|
195
218
|
continue
|
|
196
219
|
if (
|
|
@@ -240,12 +263,21 @@ def main() -> int:
|
|
|
240
263
|
if len(raw) > MAX_BUNDLE_BYTES:
|
|
241
264
|
return 1
|
|
242
265
|
request = json.loads(raw)
|
|
243
|
-
scope = Scope(
|
|
266
|
+
scope = Scope(
|
|
267
|
+
request["config_root"],
|
|
268
|
+
request["project_root"],
|
|
269
|
+
request["global_config"],
|
|
270
|
+
request.get("agent_kind", "claude-code"),
|
|
271
|
+
)
|
|
244
272
|
# This child owns its environment. Select the same native global file
|
|
245
273
|
# as the launching host, without mutating the resident daemon's env.
|
|
246
274
|
root = Path(scope.config_root)
|
|
247
275
|
target: Path | None = root
|
|
248
|
-
if
|
|
276
|
+
if scope.agent_kind == "codex":
|
|
277
|
+
if Path(scope.global_config) != (root / "config.toml").resolve():
|
|
278
|
+
raise ValueError("invalid_global_config")
|
|
279
|
+
os.environ["CODEX_HOME"] = str(root)
|
|
280
|
+
elif Path(scope.global_config) == (root / ".claude.json").resolve():
|
|
249
281
|
os.environ["CLAUDE_CONFIG_DIR"] = str(root)
|
|
250
282
|
elif (
|
|
251
283
|
root == (Path.home() / ".claude").resolve()
|
|
@@ -265,7 +297,14 @@ def main() -> int:
|
|
|
265
297
|
os.dup2(quiet.fileno(), 1)
|
|
266
298
|
os.dup2(quiet.fileno(), 2)
|
|
267
299
|
policy = parse_policy(request["document"])
|
|
268
|
-
|
|
300
|
+
options: dict[str, Any] = (
|
|
301
|
+
{"agent_kind": scope.agent_kind} if scope.agent_kind == "codex" else {}
|
|
302
|
+
)
|
|
303
|
+
if "allow_partial_inventory" in inspect.signature(collector).parameters:
|
|
304
|
+
options["allow_partial_inventory"] = True
|
|
305
|
+
evidence: Any = collector(
|
|
306
|
+
policy, target=target, project=Path(scope.project_root), **options
|
|
307
|
+
)
|
|
269
308
|
digest = inventory_digest(evidence)
|
|
270
309
|
# Both gates use this exact owned bundle. The facade exposes no
|
|
271
310
|
# advisory database revision; never invent one from a timestamp.
|
|
@@ -274,6 +313,7 @@ def main() -> int:
|
|
|
274
313
|
digest,
|
|
275
314
|
run_id if policy.risk_gates.vulnerabilities is not None else None,
|
|
276
315
|
run_id if policy.risk_gates.posture_rule_ids else None,
|
|
316
|
+
len(getattr(evidence, "inventory_gaps", ())),
|
|
277
317
|
)
|
|
278
318
|
paths = sorted(
|
|
279
319
|
{
|
|
@@ -35,10 +35,20 @@ class Scope:
|
|
|
35
35
|
config_root: str
|
|
36
36
|
project_root: str
|
|
37
37
|
global_config: str = ""
|
|
38
|
+
agent_kind: str = "claude-code"
|
|
38
39
|
|
|
39
40
|
def __post_init__(self) -> None:
|
|
41
|
+
if self.agent_kind not in {"claude-code", "codex"}:
|
|
42
|
+
raise ValueError("unsupported_agent")
|
|
40
43
|
if not self.global_config:
|
|
41
|
-
object.__setattr__(
|
|
44
|
+
object.__setattr__(
|
|
45
|
+
self,
|
|
46
|
+
"global_config",
|
|
47
|
+
str(
|
|
48
|
+
Path(self.config_root)
|
|
49
|
+
/ ("config.toml" if self.agent_kind == "codex" else ".claude.json")
|
|
50
|
+
),
|
|
51
|
+
)
|
|
42
52
|
for value in (self.config_root, self.project_root, self.global_config):
|
|
43
53
|
if not value or len(value) > 4096 or "\x00" in value or not Path(value).is_absolute():
|
|
44
54
|
raise ValueError("invalid_scope")
|
|
@@ -86,6 +96,7 @@ class EvidenceIds:
|
|
|
86
96
|
inventory_digest: str
|
|
87
97
|
vulnerability_run_id: str | None
|
|
88
98
|
posture_run_id: str | None
|
|
99
|
+
inventory_gap_count: int = 0
|
|
89
100
|
|
|
90
101
|
|
|
91
102
|
@dataclass(frozen=True)
|
|
@@ -227,7 +238,7 @@ class DecisionCache:
|
|
|
227
238
|
"""Use defaults from the same policy as the active decision table."""
|
|
228
239
|
if policy is None:
|
|
229
240
|
return Result("defer", reason, revision)
|
|
230
|
-
if kind == "mcp_tool" and source in {"user", "project", "local"}:
|
|
241
|
+
if kind == "mcp_tool" and source in {"user", "project", "local", "codex"}:
|
|
231
242
|
default = policy.mcps.default
|
|
232
243
|
category = "mcps"
|
|
233
244
|
elif (
|
|
@@ -263,6 +274,15 @@ def configuration_digest(scope: Scope, watch_paths: tuple[str, ...]) -> str:
|
|
|
263
274
|
str(root / "plugins" / "installed_plugins.json"),
|
|
264
275
|
*watch_paths,
|
|
265
276
|
}
|
|
277
|
+
if scope.agent_kind == "codex":
|
|
278
|
+
paths = {
|
|
279
|
+
str(root),
|
|
280
|
+
str(root / "config.toml"),
|
|
281
|
+
str(root / "hooks.json"),
|
|
282
|
+
str(project / ".codex"),
|
|
283
|
+
str(project / ".codex/config.toml"),
|
|
284
|
+
*watch_paths,
|
|
285
|
+
}
|
|
266
286
|
records = []
|
|
267
287
|
for name in sorted(paths):
|
|
268
288
|
try:
|
|
@@ -408,12 +428,15 @@ class PolicyRuntime:
|
|
|
408
428
|
try:
|
|
409
429
|
if set(value) != {"agent_kind", "config_root", "global_config", "project_root"}:
|
|
410
430
|
return False
|
|
411
|
-
if value["agent_kind"]
|
|
431
|
+
if value["agent_kind"] not in {"claude-code", "codex"} or not all(
|
|
412
432
|
isinstance(item, str) for item in value.values()
|
|
413
433
|
):
|
|
414
434
|
return False
|
|
415
435
|
scope = Scope(
|
|
416
|
-
str(value["config_root"]),
|
|
436
|
+
str(value["config_root"]),
|
|
437
|
+
str(value["project_root"]),
|
|
438
|
+
str(value["global_config"]),
|
|
439
|
+
str(value["agent_kind"]),
|
|
417
440
|
)
|
|
418
441
|
with _asset_transition_lock(self.config_path, timeout=0.05):
|
|
419
442
|
config = load_remote_config(self.config_path)
|
|
@@ -445,7 +468,7 @@ class PolicyRuntime:
|
|
|
445
468
|
for key, value in action.items()
|
|
446
469
|
):
|
|
447
470
|
return Result("defer", "invalid_action")
|
|
448
|
-
if action["agent_kind"]
|
|
471
|
+
if action["agent_kind"] not in {"claude-code", "codex"}:
|
|
449
472
|
return Result("defer", "unsupported_agent")
|
|
450
473
|
kind, name, source = action["action_kind"], action["name"], action["source"]
|
|
451
474
|
if kind not in {"mcp_tool", "skill", "command", "plugin_agent"}:
|
|
@@ -454,6 +477,7 @@ class PolicyRuntime:
|
|
|
454
477
|
str(action["config_root"]),
|
|
455
478
|
str(action["project_root"]),
|
|
456
479
|
str(action["global_config"]),
|
|
480
|
+
str(action["agent_kind"]),
|
|
457
481
|
)
|
|
458
482
|
with _asset_transition_lock(self.config_path, timeout=0.05):
|
|
459
483
|
config = load_remote_config(self.config_path)
|
|
@@ -527,6 +551,7 @@ class PolicyRuntime:
|
|
|
527
551
|
{
|
|
528
552
|
"scope_id": hashlib.sha256(repr(scope).encode()).hexdigest()[:16],
|
|
529
553
|
"project_root": scope.project_root,
|
|
554
|
+
"agent_kind": scope.agent_kind,
|
|
530
555
|
"evaluated_revision": None if snapshot is None else snapshot.revision,
|
|
531
556
|
"update_pending": snapshot is None
|
|
532
557
|
or snapshot.token != self.cache.capture(scope)
|
|
@@ -541,6 +566,9 @@ class PolicyRuntime:
|
|
|
541
566
|
if snapshot is None or snapshot.evidence is None
|
|
542
567
|
else snapshot.evidence.posture_run_id,
|
|
543
568
|
"evidence_age_seconds": age,
|
|
569
|
+
"inventory_gap_count": None
|
|
570
|
+
if snapshot is None or snapshot.evidence is None
|
|
571
|
+
else snapshot.evidence.inventory_gap_count,
|
|
544
572
|
"valid": inputs_current and snapshot is not None,
|
|
545
573
|
}
|
|
546
574
|
)
|
|
@@ -56,6 +56,14 @@ _GUIDANCE = {
|
|
|
56
56
|
"Code or artifacts were published without explicit user request",
|
|
57
57
|
"Verify the publication was intended and inspect what was made public.",
|
|
58
58
|
),
|
|
59
|
+
"stacktrace-credential-switched": (
|
|
60
|
+
"The agent got past a refusal with a credential nobody gave it",
|
|
61
|
+
(
|
|
62
|
+
"Revoke or rotate that credential. Then remove it from where the agent can "
|
|
63
|
+
"read it. If the agent needs this access, grant it to the agent's own credential."
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
# Retired by ADR-0149. Findings stored before then still name them.
|
|
59
67
|
"stacktrace-mcp-transient-failure": (
|
|
60
68
|
"MCP auth failure followed by recovery on a server",
|
|
61
69
|
"Investigate whether the recovery involved a credential change or a transient issue.",
|
stacktrace/daemon/runtime.py
CHANGED
|
@@ -23,6 +23,7 @@ from stacktrace.cost.clock import environment_zone
|
|
|
23
23
|
from stacktrace.detector.run import NO_ANALYZER
|
|
24
24
|
from stacktrace.private_state import ensure_private_directory
|
|
25
25
|
from stacktrace.remote.config import get_config_path as remote_config_path
|
|
26
|
+
from stacktrace.remote.identity import resolve_asset_identity
|
|
26
27
|
from stacktrace.sessions.access import IncrementalSessions, collect_sessions
|
|
27
28
|
from stacktrace.telemetry import emit_error
|
|
28
29
|
from stacktrace.telemetry.posthog import flush as telemetry_flush
|
|
@@ -79,6 +80,17 @@ INITIAL_COMPOSITION_TIMEOUT = 5.0
|
|
|
79
80
|
logger = logging.getLogger(__name__)
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def _environment_name() -> str | None:
|
|
84
|
+
"""This machine as Fleet names it: the `display_name` registration sends
|
|
85
|
+
(ADR-0105), so a Slack footer reads the same as Fleet's alert. A machine
|
|
86
|
+
that registered reads its stored id and writes nothing. None if it cannot
|
|
87
|
+
be read: the footer then leaves the Environment out."""
|
|
88
|
+
try:
|
|
89
|
+
return resolve_asset_identity().display_name or None
|
|
90
|
+
except Exception: # noqa: BLE001 - a footer label never stops the daemon
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
82
94
|
def daemon_is_running(path: Path) -> bool:
|
|
83
95
|
"""Whether a daemon this user owns is listening at `path`.
|
|
84
96
|
|
|
@@ -267,7 +279,14 @@ def run_daemon(
|
|
|
267
279
|
# every reader below: Devin's answer is a call to its server
|
|
268
280
|
# (ADR-0112).
|
|
269
281
|
signed_in = SignedInAccounts(store, org_id=FleetOrg())
|
|
270
|
-
|
|
282
|
+
# The same reader the Fleet upload asks, so a Slack message names
|
|
283
|
+
# the person exactly when a Fleet detection does (ADR-0147).
|
|
284
|
+
webhook_subscriber = WebhookSubscriber(
|
|
285
|
+
store=store,
|
|
286
|
+
config_path=get_config_path(),
|
|
287
|
+
person=signed_in.person,
|
|
288
|
+
environment=_environment_name(),
|
|
289
|
+
)
|
|
271
290
|
server = NotificationServer(paths.socket, store)
|
|
272
291
|
started_at = datetime.now(UTC).isoformat()
|
|
273
292
|
server.on_status = lambda: {
|
stacktrace/daemon/store.py
CHANGED
|
@@ -97,6 +97,10 @@ DEFAULT_PRUNE_INTERVAL = timedelta(hours=6)
|
|
|
97
97
|
#: that read every transcript.
|
|
98
98
|
_BASELINE_COMPLETE = "cost_baseline_complete"
|
|
99
99
|
|
|
100
|
+
#: The `schema_meta` key holding the console address Fleet named in its last
|
|
101
|
+
#: upload response, so a webhook alert can link to the finding (ADR-0147).
|
|
102
|
+
_FLEET_CONSOLE = "fleet_console"
|
|
103
|
+
|
|
100
104
|
#: Usage rows Fleet accepted are kept this long at minute grain, and rows
|
|
101
105
|
#: never sent are rolled into their hour after it (usage-upload.md, Retention).
|
|
102
106
|
USAGE_MINUTE_RETENTION = timedelta(days=7)
|
|
@@ -733,6 +737,35 @@ class FindingStore:
|
|
|
733
737
|
(_BASELINE_COMPLETE, "true" if complete else "false"),
|
|
734
738
|
)
|
|
735
739
|
|
|
740
|
+
def set_fleet_console(self, origin: str) -> None:
|
|
741
|
+
"""Keep the console address Fleet named in an upload response. Each
|
|
742
|
+
response replaces it, so switching Fleets follows the next upload."""
|
|
743
|
+
with self._lock:
|
|
744
|
+
self._connection.execute(
|
|
745
|
+
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES (?, ?)",
|
|
746
|
+
(_FLEET_CONSOLE, origin),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def fleet_console(self) -> str | None:
|
|
750
|
+
"""The console address the last upload response named, or None
|
|
751
|
+
before any upload."""
|
|
752
|
+
with self._lock:
|
|
753
|
+
row = self._connection.execute(
|
|
754
|
+
"SELECT value FROM schema_meta WHERE key = ?", (_FLEET_CONSOLE,)
|
|
755
|
+
).fetchone()
|
|
756
|
+
return row[0] if row else None
|
|
757
|
+
|
|
758
|
+
def clear_fleet_console(self) -> None:
|
|
759
|
+
"""Forget the cached console address.
|
|
760
|
+
|
|
761
|
+
Called alongside `clear_cursors()` when Fleet is unconfigured, and
|
|
762
|
+
when the drain registers under a new asset id (a tenant switch): the
|
|
763
|
+
cached origin names the console of the tenant it was uploaded to,
|
|
764
|
+
and a webhook alert must never link a finding to a console that can
|
|
765
|
+
no longer resolve it."""
|
|
766
|
+
with self._lock:
|
|
767
|
+
self._connection.execute("DELETE FROM schema_meta WHERE key = ?", (_FLEET_CONSOLE,))
|
|
768
|
+
|
|
736
769
|
def baseline_complete(self) -> bool:
|
|
737
770
|
"""Whether the stored baseline rows came from a complete backfill.
|
|
738
771
|
False until one has: a store whose backfill never completed has no
|
|
@@ -2467,9 +2500,21 @@ class FindingStore:
|
|
|
2467
2500
|
#: the gate (cost-notifications §2.2).
|
|
2468
2501
|
_ANY_SEVERITY = frozenset({"stacktrace-cost-runaway-session"})
|
|
2469
2502
|
|
|
2503
|
+
#: Rules that notify at `medium` confidence when their severity is `high`.
|
|
2504
|
+
#: Credential egress: a credential that left is as serious however its
|
|
2505
|
+
#: egress was seen, and `curl`, `gh` or `ssh` is read from the command
|
|
2506
|
+
#: string, so it is graded `medium` (ADR-0148).
|
|
2507
|
+
_MEDIUM_CONFIDENCE_WHEN_HIGH = frozenset({"stacktrace-credential-egress"})
|
|
2508
|
+
|
|
2470
2509
|
@staticmethod
|
|
2471
2510
|
def _eligible(finding: dict[str, object]) -> bool:
|
|
2472
|
-
|
|
2511
|
+
confidence = finding.get("confidence")
|
|
2512
|
+
sure = confidence == "high" or (
|
|
2513
|
+
confidence == "medium"
|
|
2514
|
+
and finding.get("severity") == "high"
|
|
2515
|
+
and finding.get("rule_id") in FindingStore._MEDIUM_CONFIDENCE_WHEN_HIGH
|
|
2516
|
+
)
|
|
2517
|
+
return sure and (
|
|
2473
2518
|
finding.get("severity") in FindingStore._NOTIFIABLE_SEVERITIES
|
|
2474
2519
|
or finding.get("rule_id") in FindingStore._ANY_SEVERITY
|
|
2475
2520
|
)
|