stackdoctor 0.1.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.
@@ -0,0 +1,248 @@
1
+ """diagnose(): run checks concurrently, merge one timeline, derive findings and possible causes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import datetime as dt
7
+ import time
8
+
9
+ from .checks import celery, event, logs, postgres, redis, utcnow
10
+ from .config import get_config
11
+
12
+ # name -> (function, kind of timeout)
13
+ CHECKS = {
14
+ "postgres.active_queries": (postgres.active_queries, "default"),
15
+ "postgres.blocking_locks": (postgres.blocking_locks, "default"),
16
+ "postgres.slow_queries": (postgres.slow_queries, "default"),
17
+ "celery.workers": (celery.workers, "celery"),
18
+ "celery.queue_lengths": (celery.queue_lengths, "celery"),
19
+ "celery.failed_tasks": (celery.failed_tasks, "default"),
20
+ "redis.memory_and_clients": (redis.memory_and_clients, "default"),
21
+ "logs.recent_problems": (logs.recent_problems, "default"),
22
+ }
23
+
24
+ _JOBS = {"celery.workers", "celery.queue_lengths", "celery.failed_tasks", "postgres.blocking_locks",
25
+ "postgres.active_queries", "redis.memory_and_clients", "logs.recent_problems"}
26
+ _SLOW = {"postgres.active_queries", "postgres.blocking_locks", "postgres.slow_queries",
27
+ "redis.memory_and_clients", "celery.workers", "celery.queue_lengths", "logs.recent_problems"}
28
+ _REDIS = {"redis.memory_and_clients", "celery.queue_lengths", "logs.recent_problems"}
29
+ ROUTES = [
30
+ (("job", "task", "queue", "worker", "celery", "stuck", "backlog", "pending"), _JOBS),
31
+ (("slow", "latency", "api", "timeout", "query", "database", "db", "lock", "postgres", "hang"), _SLOW),
32
+ (("redis", "memory", "cache", "oom", "evict", "broker"), _REDIS),
33
+ ]
34
+
35
+ # Conditions observed "now" that are still in effect, so later effects can follow them.
36
+ ONGOING = {"lock_held", "idle_in_transaction", "query_blocked", "long_query", "no_workers",
37
+ "worker_missing", "workers_saturated", "task_long_running", "queue_backlog",
38
+ "queue_no_consumer", "memory_high"}
39
+
40
+ CHAIN_RULES = [
41
+ {
42
+ "hypothesis": "Celery worker went down → queue is not being consumed",
43
+ "causes": {"worker_shutdown", "worker_lost", "no_workers", "worker_missing"},
44
+ "effects": {"queue_backlog", "queue_no_consumer", "task_timeout", "timeout"},
45
+ "explanation": "With no (or fewer) workers consuming, messages accumulate in the broker "
46
+ "and callers waiting on results time out.",
47
+ },
48
+ {
49
+ "hypothesis": "Database lock → blocked queries → stuck or failing tasks",
50
+ "causes": {"lock_held", "idle_in_transaction"},
51
+ "effects": {"query_blocked", "db_lock_wait", "task_long_running", "workers_saturated", "queue_backlog",
52
+ "task_timeout", "db_lock_error", "db_timeout", "task_failed", "timeout"},
53
+ "explanation": "A transaction holding a lock makes other queries wait; tasks running those "
54
+ "queries hang, occupy worker slots, and eventually time out or fail.",
55
+ },
56
+ {
57
+ "hypothesis": "Long-running query → slow tasks/requests",
58
+ "causes": {"long_query"},
59
+ "effects": {"task_long_running", "workers_saturated", "db_timeout", "task_timeout", "timeout", "queue_backlog"},
60
+ "explanation": "A slow query (e.g. a sequential scan) occupies the database and the "
61
+ "code paths waiting on it.",
62
+ },
63
+ {
64
+ "hypothesis": "Redis memory pressure → broker/cache errors",
65
+ "causes": {"memory_high", "evictions"},
66
+ "effects": {"redis_error", "task_failed", "error_spike", "queue_backlog"},
67
+ "explanation": "Near maxmemory Redis evicts keys or rejects writes (OOM), which breaks "
68
+ "the broker, result backend or cache.",
69
+ },
70
+ {
71
+ "hypothesis": "Database connection problems → task failures",
72
+ "causes": {"db_connection_error"},
73
+ "effects": {"task_failed", "error_spike", "timeout"},
74
+ "explanation": "Workers or the API can't reach Postgres (down, or out of connections).",
75
+ },
76
+ {
77
+ "hypothesis": "All worker slots busy → queue backlog",
78
+ "causes": {"workers_saturated", "task_long_running"},
79
+ "effects": {"queue_backlog"},
80
+ "explanation": "Tasks are being consumed slower than they arrive.",
81
+ },
82
+ ]
83
+
84
+ _SEVERITY = {"critical": 0, "warning": 1, "info": 2}
85
+
86
+
87
+ def select_checks(symptom: str) -> list[str]:
88
+ text = (symptom or "").lower()
89
+ chosen: set[str] = set()
90
+ for words, checks in ROUTES:
91
+ if any(w in text for w in words):
92
+ chosen |= checks
93
+ return sorted(chosen or CHECKS) # vague symptom: run everything
94
+
95
+
96
+ async def _run(name: str) -> dict:
97
+ cfg = get_config()
98
+ fn, kind = CHECKS[name]
99
+ timeout = cfg.check_timeout_s
100
+ if kind == "celery": # ping + 4 inspect broadcasts, each waiting up to the inspect timeout
101
+ timeout = max(timeout, cfg.celery_inspect_timeout_s * 6 + 2)
102
+ t0 = time.perf_counter()
103
+ try:
104
+ result = await asyncio.wait_for(asyncio.to_thread(fn), timeout)
105
+ status = "skipped" if "skipped" in result else "ok"
106
+ out = {"status": status, "result": result}
107
+ except TimeoutError:
108
+ out = {"status": "timeout", "error": f"timed out after {timeout:.0f}s"}
109
+ except Exception as e:
110
+ out = {"status": "error", "error": f"{type(e).__name__}: {str(e)[:300]}"}
111
+ out["ms"] = round((time.perf_counter() - t0) * 1000)
112
+ return out
113
+
114
+
115
+ def _cross_check_queues(results: dict) -> list[dict]:
116
+ """Queues with messages that no live worker consumes (Redis brokers don't report consumers)."""
117
+ w = results.get("celery.workers", {}).get("result") or {}
118
+ q = results.get("celery.queue_lengths", {}).get("result") or {}
119
+ if "alive_count" not in w or "queues" not in q:
120
+ return []
121
+ consumed = {name for worker in w["workers"] for name in worker["queues"]}
122
+ events = []
123
+ for name, info in q["queues"].items():
124
+ n = info.get("messages") or 0
125
+ if n and name not in consumed and info.get("consumers") is None:
126
+ events.append(event(None, "celery", "queue_no_consumer",
127
+ f"Queue '{name}' has {n} messages and no live worker consumes it",
128
+ "critical", queue=name))
129
+ return events
130
+
131
+
132
+ def _fmt(e: dict) -> str:
133
+ return f"{e['ts'].isoformat(timespec='seconds')} {e['source']}.{e['kind']}: {e['detail']}"
134
+
135
+
136
+ def find_chains(events: list[dict], window_min: float) -> list[dict]:
137
+ window = dt.timedelta(minutes=window_min)
138
+ slack = dt.timedelta(seconds=60)
139
+ chains, used = [], []
140
+ for rule in CHAIN_RULES:
141
+ causes = sorted((e for e in events if e["kind"] in rule["causes"]), key=lambda e: e["ts"])
142
+ if not causes:
143
+ continue
144
+ cause = causes[0]
145
+ ongoing = cause["kind"] in ONGOING
146
+ effects: dict[str, dict] = {}
147
+ for e in sorted(events, key=lambda e: e["ts"]):
148
+ if e is cause or e["kind"] not in rule["effects"] or e["kind"] in effects:
149
+ continue
150
+ after = e["ts"] >= cause["ts"] - slack
151
+ close = ongoing or e["ts"] <= cause["ts"] + window
152
+ if after and close:
153
+ effects[e["kind"]] = e
154
+ if not effects:
155
+ continue
156
+ ids = {id(cause), *map(id, effects.values())}
157
+ if any(ids <= prev for prev in used):
158
+ continue # already explained by an earlier chain
159
+ used.append(ids)
160
+ sources = {e["source"] for e in effects.values()} | {cause["source"]}
161
+ confidence = "medium" if len(effects) >= 2 and len(sources) >= 2 else "low"
162
+ chains.append({
163
+ "label": "possible cause",
164
+ "hypothesis": rule["hypothesis"],
165
+ "confidence": confidence,
166
+ "cause": _fmt(cause),
167
+ "effects": [_fmt(e) for e in effects.values()],
168
+ "evidence_window": {"from": cause["ts"], "to": max(e["ts"] for e in effects.values())},
169
+ "explanation": rule["explanation"],
170
+ "caveat": "Inferred from timing only. Verify before acting; this is not a confirmed root cause.",
171
+ })
172
+ chains.sort(key=lambda c: c["confidence"] != "medium")
173
+ return chains
174
+
175
+
176
+ def _findings(events: list[dict], results: dict) -> tuple[list[dict], list[str]]:
177
+ grouped: dict[tuple, dict] = {}
178
+ for e in events:
179
+ if e["severity"] == "info":
180
+ continue
181
+ key = (e["source"], e["kind"])
182
+ f = grouped.setdefault(key, {"severity": e["severity"], "source": e["source"], "kind": e["kind"],
183
+ "count": 0, "first_seen": e["ts"], "example": e["detail"]})
184
+ f["count"] += e.get("count", 1)
185
+ if _SEVERITY[e["severity"]] < _SEVERITY[f["severity"]]:
186
+ f["severity"] = e["severity"]
187
+ findings = sorted(grouped.values(), key=lambda f: (_SEVERITY[f["severity"]], f["first_seen"]))
188
+
189
+ notes = []
190
+ ft = results.get("celery.failed_tasks", {}).get("result") or {}
191
+ if ft.get("visible") is False:
192
+ n = sum(1 for e in events if e["kind"] == "task_failed" and e["source"] == "logs")
193
+ notes.append(f"{ft['reason']} Falling back to logs: {n} task-failure group(s) found in logs.")
194
+ sq = results.get("postgres.slow_queries", {}).get("result") or {}
195
+ if sq.get("available") is False:
196
+ notes.append("pg_stat_statements is not installed; slow query stats unavailable (see postgres.slow_queries).")
197
+ lg = results.get("logs.recent_problems", {}).get("result") or {}
198
+ kinds = {e["kind"] for e in events}
199
+ if kinds & {"no_workers", "worker_missing"} and not kinds & {"worker_shutdown", "worker_lost"}:
200
+ seen = ", ".join(f"{k} last written {v.isoformat(timespec='seconds')}"
201
+ for k, v in (lg.get("last_activity") or {}).items() if v)
202
+ notes.append("Workers are missing but no shutdown/crash line was found in the logs. Celery prints "
203
+ "'worker: Warm shutdown' to stdout only (not to --logfile), so LOG_SOURCES must include "
204
+ "the worker's stdout (docker logs, or a file stdout is redirected to)."
205
+ + (f" Log activity: {seen}." if seen else ""))
206
+ if lg.get("errors"):
207
+ notes.append(f"Some log sources could not be read: {lg['errors']}")
208
+ return findings, notes
209
+
210
+
211
+ async def diagnose(symptom: str = "") -> dict:
212
+ cfg = get_config()
213
+ started = utcnow()
214
+ names = select_checks(symptom)
215
+ outcomes = await asyncio.gather(*(_run(n) for n in names))
216
+ results = dict(zip(names, outcomes))
217
+
218
+ events: list[dict] = []
219
+ for out in results.values():
220
+ result = out.get("result")
221
+ if isinstance(result, dict):
222
+ events.extend(result.pop("events", []))
223
+ events.extend(_cross_check_queues(results))
224
+ # A lock holder is also a "long query"; the lock is the more specific explanation.
225
+ lock_pids = {e.get("pid") for e in events if e["kind"] == "lock_held"}
226
+ events = [e for e in events if not (e["kind"] == "long_query" and e.get("pid") in lock_pids)]
227
+ for e in events:
228
+ if e["ts"] > started + dt.timedelta(seconds=30): # clock skew guard
229
+ e["ts"] = started
230
+
231
+ findings, notes = _findings(events, results)
232
+ chains = find_chains(events, cfg.chain_window_min)
233
+ timeline = [{"ts": e["ts"], "source": e["source"], "kind": e["kind"],
234
+ "severity": e["severity"], "detail": e["detail"]}
235
+ for e in sorted(events, key=lambda e: e["ts"])][-80:]
236
+
237
+ return {
238
+ "timestamp": started,
239
+ "symptom": symptom,
240
+ "checks_run": {n: r["status"] for n, r in results.items()},
241
+ "findings": findings,
242
+ "possible_causes": chains,
243
+ "notes": notes,
244
+ "timeline": timeline,
245
+ "checks": {n: r for n, r in results.items() if r["status"] != "skipped"},
246
+ "skipped": {n: r["result"]["skipped"] for n, r in results.items() if r["status"] == "skipped"},
247
+ "duration_ms": round((utcnow() - started).total_seconds() * 1000),
248
+ }
stackdoctor/safety.py ADDED
@@ -0,0 +1,331 @@
1
+ """Safety layer: SQL validation, Redis command allowlist, secret redaction, output caps.
2
+
3
+ Everything that leaves stackdoctor goes through `safe_output()`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import datetime as dt
9
+ import decimal
10
+ import json
11
+ import re
12
+ import uuid
13
+ from typing import Any
14
+
15
+ import sqlglot
16
+ from sqlglot import exp
17
+ from sqlglot.errors import ParseError, TokenError
18
+ from sqlglot.tokens import TokenType
19
+
20
+
21
+ class UnsafeError(ValueError):
22
+ """Raised when a request would break the read-only guarantee."""
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # SQL validation
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # Functions that have side effects or read server files, even inside a
30
+ # read-only transaction (or that execute arbitrary SQL from a string).
31
+ FORBIDDEN_FUNCTIONS = {
32
+ "pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
33
+ "pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
34
+ "set_config", "pg_rotate_logfile", "pg_switch_wal", "pg_promote",
35
+ "pg_create_restore_point", "pg_log_backend_memory_contexts",
36
+ "pg_notify", "pg_sleep", "pg_sleep_for", "pg_sleep_until",
37
+ "nextval", "setval",
38
+ "query_to_xml", "query_to_xml_and_xmlschema", "query_to_xmlschema",
39
+ "cursor_to_xml", "cursor_to_xmlschema",
40
+ "pg_file_write", "pg_file_rename", "pg_file_unlink", "pg_logdir_ls",
41
+ }
42
+ FORBIDDEN_PREFIXES = (
43
+ "lo_", "dblink", "pg_advisory_", "pg_try_advisory_", "pg_ls_",
44
+ "pg_read_", "pg_replication_", "pg_create_", "pg_drop_",
45
+ )
46
+
47
+ # Nodes that must never appear anywhere in the tree (catches writable CTEs).
48
+ FORBIDDEN_NODES = (
49
+ exp.Insert, exp.Update, exp.Delete, exp.Merge, exp.Create, exp.Drop,
50
+ exp.Alter, exp.Command, exp.Into, exp.Lock, exp.Copy, exp.Set,
51
+ exp.Transaction, exp.Commit, exp.Rollback, exp.TruncateTable,
52
+ )
53
+
54
+ _EXPLAIN_SAFE_OPTIONS = {"VERBOSE", "COSTS", "SETTINGS", "BUFFERS", "FORMAT",
55
+ "SUMMARY", "GENERIC_PLAN", "TEXT", "JSON", "YAML",
56
+ "XML", "TRUE", "FALSE", "ON", "OFF", "1", "0"}
57
+
58
+
59
+ def _function_name(node: exp.Func) -> str:
60
+ if isinstance(node, exp.Anonymous):
61
+ return str(node.name).lower()
62
+ return node.sql_name().lower()
63
+
64
+
65
+ def _check_select_tree(tree: exp.Expression) -> None:
66
+ if not isinstance(tree, (exp.Select, exp.Union, exp.Intersect, exp.Except)):
67
+ raise UnsafeError(f"Only SELECT / WITH / EXPLAIN are allowed (got {tree.key.upper()}).")
68
+ for node in tree.walk():
69
+ if isinstance(node, FORBIDDEN_NODES):
70
+ raise UnsafeError(f"Statement contains a forbidden clause: {node.key.upper()}.")
71
+ if isinstance(node, exp.Select) and node.args.get("into"):
72
+ raise UnsafeError("SELECT ... INTO is not allowed.")
73
+ if isinstance(node, exp.Func):
74
+ name = _function_name(node)
75
+ if name in FORBIDDEN_FUNCTIONS or name.startswith(FORBIDDEN_PREFIXES):
76
+ raise UnsafeError(f"Function {name}() is not allowed (side effects or file access).")
77
+
78
+
79
+ def _parse_single(sql: str) -> exp.Expression:
80
+ try:
81
+ statements = [s for s in sqlglot.parse(sql, dialect="postgres")
82
+ if s is not None and not isinstance(s, exp.Semicolon)]
83
+ except (ParseError, TokenError) as e:
84
+ raise UnsafeError(f"Could not parse SQL: {str(e).splitlines()[0]}") from None
85
+ if len(statements) != 1:
86
+ raise UnsafeError("Exactly one statement is allowed.")
87
+ return statements[0]
88
+
89
+
90
+ def _strip_trailing(sql: str) -> str:
91
+ """Return the SQL without trailing semicolons/comments, using the tokenizer."""
92
+ tokens = [t for t in sqlglot.tokenize(sql, dialect="postgres") if t.token_type != TokenType.SEMICOLON]
93
+ if not tokens:
94
+ raise UnsafeError("Empty SQL.")
95
+ return sql[: tokens[-1].end + 1]
96
+
97
+
98
+ def validate_select(sql: str, limit: int) -> str:
99
+ """Validate `sql` as a single read-only query and return the SQL to execute.
100
+
101
+ SELECT / WITH queries are wrapped so a row LIMIT always applies.
102
+ EXPLAIN (without ANALYZE) is returned unwrapped.
103
+ """
104
+ if not sql or not sql.strip():
105
+ raise UnsafeError("Empty SQL.")
106
+ try:
107
+ tokens = sqlglot.tokenize(sql, dialect="postgres")
108
+ except TokenError as e:
109
+ raise UnsafeError(f"Could not tokenize SQL: {e}") from None
110
+ if not tokens:
111
+ raise UnsafeError("Empty SQL.")
112
+
113
+ if tokens[0].text.upper() == "EXPLAIN":
114
+ # The postgres tokenizer swallows everything after EXPLAIN as one string,
115
+ # so tokenize the remainder on its own.
116
+ sql = sql[tokens[0].end + 1:]
117
+ tokens = sqlglot.tokenize(sql, dialect="postgres")
118
+ i = 0
119
+ if i < len(tokens) and tokens[i].token_type == TokenType.L_PAREN:
120
+ depth = 0
121
+ while i < len(tokens):
122
+ t = tokens[i]
123
+ if t.token_type == TokenType.L_PAREN:
124
+ depth += 1
125
+ elif t.token_type == TokenType.R_PAREN:
126
+ depth -= 1
127
+ if depth == 0:
128
+ i += 1
129
+ break
130
+ elif t.text.upper() not in _EXPLAIN_SAFE_OPTIONS and t.token_type != TokenType.COMMA:
131
+ raise UnsafeError(f"EXPLAIN option {t.text.upper()} is not allowed (no ANALYZE).")
132
+ i += 1
133
+ else:
134
+ while i < len(tokens) and tokens[i].text.upper() in {"VERBOSE", "ANALYZE", "ANALYSE"}:
135
+ if tokens[i].text.upper() in {"ANALYZE", "ANALYSE"}:
136
+ raise UnsafeError("EXPLAIN ANALYZE executes the query and is not allowed.")
137
+ i += 1
138
+ if i >= len(tokens):
139
+ raise UnsafeError("EXPLAIN needs a query.")
140
+ inner = sql[tokens[i].start:]
141
+ _check_select_tree(_parse_single(inner))
142
+ return "EXPLAIN " + _strip_trailing(sql)
143
+
144
+ _check_select_tree(_parse_single(sql))
145
+ body = _strip_trailing(sql)
146
+ # Newlines keep a trailing `-- comment` from swallowing the wrapper.
147
+ return f"SELECT * FROM (\n{body}\n) AS sd_sub LIMIT {int(limit)}"
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Redis allowlist (subcommand-aware)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ # None = no subcommand check; a set = only these subcommands are allowed.
155
+ REDIS_ALLOWED: dict[str, set[str] | None] = {
156
+ "PING": None, "INFO": None, "DBSIZE": None, "SCAN": None, "TYPE": None,
157
+ "TTL": None, "PTTL": None, "EXISTS": None, "GET": None, "MGET": None,
158
+ "GETRANGE": None, "STRLEN": None, "LLEN": None, "LRANGE": None,
159
+ "HLEN": None, "HSCAN": None, "SCARD": None, "SSCAN": None,
160
+ "ZCARD": None, "ZRANGE": None, "XLEN": None,
161
+ "CLIENT": {"LIST", "INFO"},
162
+ "OBJECT": {"ENCODING", "IDLETIME", "FREQ"},
163
+ "MEMORY": {"USAGE", "STATS"},
164
+ "CONFIG": {"GET"},
165
+ "SLOWLOG": {"GET", "LEN"},
166
+ }
167
+
168
+
169
+ def check_redis_command(*args: Any) -> None:
170
+ """Raise UnsafeError unless the command is on the read-only allowlist.
171
+
172
+ Accepts both ("CLIENT", "LIST") and ("CLIENT LIST",) forms, like redis-py.
173
+ """
174
+ if not args:
175
+ raise UnsafeError("Empty Redis command.")
176
+ parts = str(args[0]).split() + [str(a) for a in args[1:]]
177
+ if not parts:
178
+ raise UnsafeError("Empty Redis command.")
179
+ cmd = parts[0].upper()
180
+ if cmd not in REDIS_ALLOWED:
181
+ raise UnsafeError(f"Redis command {cmd} is not on the read-only allowlist.")
182
+ subs = REDIS_ALLOWED[cmd]
183
+ if subs is not None:
184
+ sub = parts[1].upper() if len(parts) > 1 else ""
185
+ if sub not in subs:
186
+ raise UnsafeError(f"Redis command {cmd} {sub} is not on the read-only allowlist.")
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Redaction
191
+ # ---------------------------------------------------------------------------
192
+
193
+ MASK = "***"
194
+ _SECRET_WORDS = r"(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|auth|credential|session[_-]?id|cookie|dsn)"
195
+ SENSITIVE_KEY_RE = re.compile(rf"(?i)^[\w.-]*{_SECRET_WORDS}[\w.-]*$")
196
+
197
+ _PATTERNS: list[tuple[re.Pattern, str]] = [
198
+ # scheme://user:password@host -> scheme://user:***@host
199
+ (re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^:/\s@]*:)([^@\s/]+)@"), rf"\1{MASK}@"),
200
+ # Authorization / Proxy-Authorization headers
201
+ (re.compile(r"(?i)\b((?:proxy-)?authorization[\"']?\s*[:=]\s*[\"']?)(?:(bearer|basic|token|digest)\s+)?[^\s\"',}]+"),
202
+ lambda m: f"{m.group(1)}{(m.group(2) + ' ') if m.group(2) else ''}{MASK}"),
203
+ # Bare bearer tokens
204
+ (re.compile(r"(?i)\b(bearer\s+)[a-z0-9._~+/=-]{8,}"), rf"\1{MASK}"),
205
+ # KEY=value, key: value, "key": "value" where key looks secret
206
+ (re.compile(rf"(?i)([\"']?\b[\w.-]*{_SECRET_WORDS}[\w.-]*[\"']?\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&}}]+)"),
207
+ lambda m: m.group(1) + (m.group(2)[0] + MASK + m.group(2)[0] if m.group(2)[:1] in "\"'" else MASK)),
208
+ # Well-known token formats
209
+ (re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), MASK), # AWS access key id
210
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), MASK), # GitHub
211
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), MASK), # Slack
212
+ (re.compile(r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b"), MASK), # Stripe
213
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), MASK), # OpenAI / Anthropic style
214
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b"), MASK), # Google API key
215
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), MASK), # JWT
216
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"), MASK),
217
+ ]
218
+
219
+
220
+ def redact_text(text: str) -> str:
221
+ for pattern, repl in _PATTERNS:
222
+ text = pattern.sub(repl, text)
223
+ return text
224
+
225
+
226
+ def redact(obj: Any) -> Any:
227
+ """Recursively redact secrets in strings and in values under secret-looking keys."""
228
+ if isinstance(obj, str):
229
+ return redact_text(obj)
230
+ if isinstance(obj, dict):
231
+ out = {}
232
+ for k, v in obj.items():
233
+ if isinstance(k, str) and SENSITIVE_KEY_RE.match(k) and isinstance(v, (str, int, float, bytes)):
234
+ out[k] = MASK
235
+ else:
236
+ out[k] = redact(v)
237
+ return out
238
+ if isinstance(obj, (list, tuple)):
239
+ return [redact(v) for v in obj]
240
+ return obj
241
+
242
+
243
+ def preview(value: Any, max_chars: int = 120) -> str:
244
+ """Short, redacted, single-line preview of any value (task args, Redis values...)."""
245
+ text = value if isinstance(value, str) else repr(value)
246
+ text = redact_text(" ".join(text.split()))
247
+ return text if len(text) <= max_chars else text[: max_chars - 1] + "…"
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # JSON + output caps
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def _json_default(o: Any) -> Any:
255
+ if isinstance(o, dt.datetime):
256
+ if o.tzinfo is None:
257
+ o = o.astimezone()
258
+ return o.astimezone(dt.timezone.utc).isoformat(timespec="seconds")
259
+ if isinstance(o, (dt.date, dt.time)):
260
+ return o.isoformat()
261
+ if isinstance(o, dt.timedelta):
262
+ return round(o.total_seconds(), 1)
263
+ if isinstance(o, decimal.Decimal):
264
+ return float(o)
265
+ if isinstance(o, (uuid.UUID, memoryview)):
266
+ return str(o)
267
+ if isinstance(o, bytes):
268
+ return o.decode("utf-8", errors="replace")
269
+ if isinstance(o, (set, frozenset)):
270
+ return sorted(o, key=str)
271
+ return str(o)
272
+
273
+
274
+ def to_jsonable(obj: Any) -> Any:
275
+ return json.loads(json.dumps(obj, default=_json_default))
276
+
277
+
278
+ def _largest_list(obj: Any, best: tuple[int, list | None] = (0, None)) -> tuple[int, list | None]:
279
+ if isinstance(obj, list):
280
+ if len(obj) > best[0]:
281
+ best = (len(obj), obj)
282
+ for v in obj:
283
+ best = _largest_list(v, best)
284
+ elif isinstance(obj, dict):
285
+ for v in obj.values():
286
+ best = _largest_list(v, best)
287
+ return best
288
+
289
+
290
+ def _shorten_strings(obj: Any, max_len: int) -> Any:
291
+ if isinstance(obj, str):
292
+ return obj if len(obj) <= max_len else obj[:max_len] + "…"
293
+ if isinstance(obj, dict):
294
+ return {k: _shorten_strings(v, max_len) for k, v in obj.items()}
295
+ if isinstance(obj, list):
296
+ return [_shorten_strings(v, max_len) for v in obj]
297
+ return obj
298
+
299
+
300
+ def cap_output(obj: Any, max_chars: int) -> Any:
301
+ """Shrink a JSON-able object until it serializes under `max_chars`."""
302
+ size = lambda o: len(json.dumps(o)) # noqa: E731
303
+ if size(obj) <= max_chars:
304
+ return obj
305
+ truncated = False
306
+ for _ in range(40):
307
+ n, lst = _largest_list(obj)
308
+ if lst is None or n <= 3:
309
+ break
310
+ dropped = n - n // 2
311
+ del lst[n // 2:]
312
+ lst.append({"_truncated": f"{dropped} more items omitted"})
313
+ truncated = True
314
+ if size(obj) <= max_chars:
315
+ break
316
+ for max_len in (1000, 300, 100):
317
+ if size(obj) <= max_chars:
318
+ break
319
+ obj = _shorten_strings(obj, max_len)
320
+ truncated = True
321
+ if size(obj) > max_chars:
322
+ text = json.dumps(obj)
323
+ return {"_truncated": True, "preview": text[: max_chars - 100]}
324
+ if truncated and isinstance(obj, dict):
325
+ obj["_output_truncated"] = True
326
+ return obj
327
+
328
+
329
+ def safe_output(obj: Any, max_chars: int) -> Any:
330
+ """Final gate for every tool response: JSON-safe, redacted, size-capped."""
331
+ return cap_output(redact(to_jsonable(obj)), max_chars)