py-harness-cli 0.3.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.
Files changed (92) hide show
  1. finetune/__init__.py +1 -0
  2. finetune/agent_system.py +41 -0
  3. finetune/agent_traces.py +157 -0
  4. finetune/everyday.py +30 -0
  5. finetune/hf_ollama.py +158 -0
  6. finetune/huggingface_store.py +144 -0
  7. finetune/models.py +74 -0
  8. finetune/paths.py +11 -0
  9. finetune/python_vibe.py +788 -0
  10. finetune/splits.py +54 -0
  11. finetune/systems.py +9 -0
  12. harness/__init__.py +42 -0
  13. harness/__main__.py +8 -0
  14. harness/act/__init__.py +6 -0
  15. harness/act/autofix/__init__.py +110 -0
  16. harness/act/autofix/additions.py +217 -0
  17. harness/act/autofix/conflicts.py +124 -0
  18. harness/act/autofix/cover.py +419 -0
  19. harness/act/autofix/mechanical.py +151 -0
  20. harness/act/autofix/missing_imports.py +50 -0
  21. harness/act/autofix/moves.py +439 -0
  22. harness/act/autofix/names.py +339 -0
  23. harness/act/autofix/scaffold.py +224 -0
  24. harness/act/code.py +157 -0
  25. harness/act/gate.py +229 -0
  26. harness/act/parse.py +247 -0
  27. harness/act/patch_fix.py +138 -0
  28. harness/act/tools.py +244 -0
  29. harness/agent/__init__.py +11 -0
  30. harness/agent/dispatch.py +235 -0
  31. harness/agent/loop.py +699 -0
  32. harness/agent/options.py +144 -0
  33. harness/agent/policy.py +856 -0
  34. harness/agent/prompt.py +170 -0
  35. harness/cli.py +393 -0
  36. harness/editor_kit.py +265 -0
  37. harness/guard/__init__.py +6 -0
  38. harness/guard/fallbacks.py +6 -0
  39. harness/guard/loop_guard.py +57 -0
  40. harness/guard/python_vibe.py +68 -0
  41. harness/guard/run.py +41 -0
  42. harness/guard/types.py +19 -0
  43. harness/locate.py +767 -0
  44. harness/mcp_stdio.py +306 -0
  45. harness/memory/__init__.py +5 -0
  46. harness/memory/conversation.py +104 -0
  47. harness/model/__init__.py +6 -0
  48. harness/model/chat_backend.py +100 -0
  49. harness/model/engine.py +165 -0
  50. harness/model/ollama_generate.py +60 -0
  51. harness/model/openai_generate.py +156 -0
  52. harness/model/outbound.py +83 -0
  53. harness/model/route.py +90 -0
  54. harness/observe/__init__.py +6 -0
  55. harness/observe/eval_gate.py +80 -0
  56. harness/observe/eval_loop.py +185 -0
  57. harness/observe/eval_tasks.py +399 -0
  58. harness/observe/report_md.py +102 -0
  59. harness/observe/trace_record.py +79 -0
  60. harness/openai_api.py +81 -0
  61. harness/paths.py +88 -0
  62. harness/py.typed +0 -0
  63. harness/scan/__init__.py +6 -0
  64. harness/scan/app_spec.py +338 -0
  65. harness/scan/design.py +112 -0
  66. harness/scan/existing.py +131 -0
  67. harness/scan/layout.py +254 -0
  68. harness/scan/names.py +308 -0
  69. harness/scan/project_brief.py +287 -0
  70. harness/scan/project_docs.py +42 -0
  71. harness/scan/project_scan.py +49 -0
  72. harness/scan/repo_map.py +101 -0
  73. harness/secrets.py +39 -0
  74. harness/server.py +199 -0
  75. harness/ship/__init__.py +1 -0
  76. harness/ship/bot_pr.py +221 -0
  77. harness/ship/git_ship.py +262 -0
  78. harness/ship/identity.py +62 -0
  79. harness/ship/ticket.py +251 -0
  80. harness/skillkit/__init__.py +6 -0
  81. harness/skillkit/catalog.py +241 -0
  82. harness/skillkit/refuse_change.py +640 -0
  83. harness/skillkit/refuse_finish.py +295 -0
  84. harness/skillkit/target.py +238 -0
  85. harness/task.py +717 -0
  86. py_harness_cli-0.3.0.dist-info/METADATA +177 -0
  87. py_harness_cli-0.3.0.dist-info/RECORD +92 -0
  88. py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
  89. py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
  90. py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
  91. py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
  92. py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,287 @@
1
+ """Small vs large project brief. Deterministic. No model.
2
+
3
+ Small repos get a file list (comfortable daily explore / edit / run).
4
+ Large repos get a harness: map, --scope, do not read everything.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from harness.paths import TEXT_SUFFIXES, is_secret_name, rel_posix, suffix_globs
14
+ from harness.scan.project_scan import SKIP_DIR
15
+ from harness.task import (
16
+ everyday_example_path,
17
+ everyday_skill_name,
18
+ looks_like_add_feature,
19
+ looks_like_app_overflow,
20
+ looks_like_everyday_code,
21
+ looks_like_fix_smell,
22
+ looks_like_merge,
23
+ looks_like_new_package,
24
+ looks_like_question,
25
+ looks_like_ship,
26
+ looks_like_ticket,
27
+ looks_like_ticket_work,
28
+ question_symbol,
29
+ )
30
+
31
+ SMALL_MAX_FILES = 40
32
+ SMALL_MAX_BYTES = 200_000
33
+ MAP_MAX_ENTRIES = 80
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ProjectBrief:
38
+ kind: str
39
+ files: int
40
+ bytes: int
41
+ listed: tuple[tuple[str, int], ...]
42
+ tops: tuple[tuple[str, int], ...]
43
+
44
+
45
+ def resolve_scope(project: Path, scope: str) -> Path:
46
+ root = project.resolve()
47
+ if not scope or scope in {".", "./"}:
48
+ return root
49
+ path = (root / scope).resolve() if not Path(scope).is_absolute() else Path(scope).resolve()
50
+ try:
51
+ path.relative_to(root)
52
+ except ValueError as exc:
53
+ raise ValueError(f"{path} is outside {root}") from exc
54
+ if path.is_file():
55
+ rel = path.relative_to(root)
56
+ parent = rel.parent.as_posix() if rel.parent != Path(".") else "."
57
+ raise ValueError(
58
+ f"scope is a file ({rel}). Use Path: {rel} or Scope: {parent}"
59
+ )
60
+ if not path.is_dir():
61
+ raise ValueError(f"scope is not a directory: {scope}")
62
+ return path
63
+
64
+
65
+ def iter_text_files(project: Path, scope: str = "") -> list[tuple[Path, int]]:
66
+ root = project.resolve()
67
+ base = resolve_scope(project, scope) if scope else root
68
+ found: list[tuple[Path, int]] = []
69
+ for pattern in suffix_globs():
70
+ for path in base.rglob(pattern):
71
+ if not path.is_file():
72
+ continue
73
+ if any(part in SKIP_DIR for part in path.parts):
74
+ continue
75
+ if path.suffix.lower() not in TEXT_SUFFIXES:
76
+ continue
77
+ if is_secret_name(path.name):
78
+ continue
79
+ try:
80
+ size = path.stat().st_size
81
+ except OSError:
82
+ continue
83
+ found.append((path, size))
84
+ found.sort(key=lambda item: rel_posix(item[0], root))
85
+ return found
86
+
87
+
88
+ def classify_project(project: Path, scope: str = "") -> ProjectBrief:
89
+ root = project.resolve()
90
+ found = iter_text_files(project, scope)
91
+ total = sum(size for _path, size in found)
92
+ listed = tuple(
93
+ (rel_posix(path, root), size) for path, size in found[:SMALL_MAX_FILES]
94
+ )
95
+ counts: dict[str, int] = {}
96
+ for path, _size in found:
97
+ rel = path.relative_to(root)
98
+ top = rel.parts[0] if len(rel.parts) > 1 else str(rel)
99
+ counts[top] = counts.get(top, 0) + 1
100
+ tops = tuple(sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:16])
101
+ kind = "small" if len(found) <= SMALL_MAX_FILES and total <= SMALL_MAX_BYTES else "large"
102
+ return ProjectBrief(
103
+ kind=kind,
104
+ files=len(found),
105
+ bytes=total,
106
+ listed=listed,
107
+ tops=tops,
108
+ )
109
+
110
+
111
+ def _kb(n: int) -> str:
112
+ if n < 1024:
113
+ return f"{n} B"
114
+ return f"{n / 1024:.1f} KB"
115
+
116
+
117
+ def render_brief(brief: ProjectBrief, *, scope: str = "") -> str:
118
+ header = (
119
+ f"Mode: {brief.kind} files={brief.files} size={_kb(brief.bytes)}"
120
+ + (f" scope={scope}" if scope else "")
121
+ )
122
+ if brief.kind == "small":
123
+ lines = [
124
+ header,
125
+ "Small project — explore, edit, and run on this laptop.",
126
+ "You can read every listed file. Prefer Action: patch for one-line fixes.",
127
+ "Questions: if you see # auto-read, Action: done. Else read one file, then done. Do not edit.",
128
+ "Files:",
129
+ ]
130
+ for rel, size in brief.listed:
131
+ lines.append(f" {rel} {_kb(size)}")
132
+ return "\n".join(lines)
133
+ lines = [
134
+ header,
135
+ "Large project — use the harness. Do not read the whole repo.",
136
+ "Start with Action: map. Then Action: grep with a tight Query.",
137
+ "Pass --scope <subdir> (or Action: map + Scope:) to stay inside one tree.",
138
+ "Do not Action: done after one tiny __init__.py.",
139
+ "Top-level (file counts):",
140
+ ]
141
+ for name, count in brief.tops:
142
+ lines.append(f" {name}/ {count}" if not name.endswith((".py", ".md")) else f" {name} {count}")
143
+ return "\n".join(lines)
144
+
145
+
146
+ def render_brief_for_person(brief: ProjectBrief, *, scope: str = "") -> str:
147
+ """The same facts, addressed to the person who typed the command.
148
+
149
+ `render_brief` is written for the model: it ends in instructions like
150
+ "Action: done". That is the first thing a new user sees, and to them it
151
+ reads as noise, so the command line uses this instead.
152
+ """
153
+ size = _kb(brief.bytes)
154
+ lines = [
155
+ f"{brief.files} Python and Markdown files, {size} in total."
156
+ + (f" Looking only at {scope}." if scope else ""),
157
+ ]
158
+ if brief.kind == "small":
159
+ lines.append(
160
+ "Small enough that py-harness can read all of it, so you can "
161
+ "ask about any part."
162
+ )
163
+ else:
164
+ lines.append(
165
+ "Large, so py-harness will search rather than read everything. "
166
+ "Add --scope <folder> to keep it in one place."
167
+ )
168
+ lines.append("")
169
+ if brief.kind == "small":
170
+ lines.append("Files:")
171
+ for rel, item_size in brief.listed:
172
+ lines.append(f" {rel} {_kb(item_size)}")
173
+ else:
174
+ lines.append("Top-level folders, by number of files:")
175
+ for name, count in brief.tops:
176
+ lines.append(f" {name} {count}")
177
+ return "\n".join(lines)
178
+
179
+
180
+ def render_map(project: Path, scope: str = "", *, max_entries: int = MAP_MAX_ENTRIES) -> str:
181
+ root = project.resolve()
182
+ found = iter_text_files(project, scope)
183
+ if not found:
184
+ return "(no project text files in scope)"
185
+ lines = [f"map {scope or '.'} {len(found)} files {_kb(sum(s for _p, s in found))}"]
186
+ for path, size in found[:max_entries]:
187
+ lines.append(f" {rel_posix(path, root)} {_kb(size)}")
188
+ if len(found) > max_entries:
189
+ lines.append(
190
+ f"# … {len(found) - max_entries} more. Narrow Scope: or pass --scope"
191
+ )
192
+ return "\n".join(lines)
193
+
194
+
195
+ def start_hint(brief: ProjectBrief, task: str, *, located: bool = False) -> str:
196
+ if looks_like_question(task) and located:
197
+ return (
198
+ "The harness already located the symbol. "
199
+ "Action: done Summary: quote the -> type from the def line. "
200
+ "Do not read, locate, grep, or edit."
201
+ )
202
+ if brief.kind == "large":
203
+ return "Start with Action: map, then grep. Do not Action: done yet."
204
+ if looks_like_question(task):
205
+ symbol = question_symbol(task)
206
+ if symbol:
207
+ return (
208
+ f"This is a question. First Action: grep Query: {symbol}. "
209
+ "Then Action: read the file that defines it. "
210
+ "Then Action: done with the answer. Do not edit unless asked."
211
+ )
212
+ return (
213
+ "This is a question. Read what you need, then Action: done with the answer. "
214
+ "Do not edit unless asked."
215
+ )
216
+ if looks_like_ticket(task) and (
217
+ looks_like_ticket_work(task) or not looks_like_ship(task)
218
+ ):
219
+ return (
220
+ "This is a ticket. The brief names Where and Job. "
221
+ "Action: read that Path (or locate the symbol). Then do the Job. "
222
+ "Commit / push / pr only if the task asked."
223
+ )
224
+ if looks_like_ship(task):
225
+ if looks_like_merge(task):
226
+ return (
227
+ "This is a merge task. Action: merge Number: <pr>. No force."
228
+ )
229
+ return (
230
+ "This is a ship task. Order: Action: issue Number: N → "
231
+ "Action: branch Name: proceed/short-slug → patch → "
232
+ "Action: commit Summary: why → Action: push → "
233
+ "Action: pr Title: + Body: Closes #N. No force. Not main/master."
234
+ )
235
+ if looks_like_new_package(task):
236
+ from harness.task import mentions_cli, mentions_http, package_noun
237
+
238
+ noun = package_noun(task)
239
+ shape = "argparse and one snake_case function" if mentions_cli(task) else "one snake_case function"
240
+ http = (
241
+ " urllib only — no curl. Token from the environment."
242
+ if mentions_http(task)
243
+ else ""
244
+ )
245
+ return (
246
+ "This is a new-package task. First Action: edit Path: "
247
+ f"pkg/{noun}.py with {shape}.{http} "
248
+ f"Then tests/test_{noun}.py. Do not put logic in scripts/. "
249
+ "pkg/__init__.py is already exports-only. Do not locate. Do not ask."
250
+ )
251
+ if looks_like_app_overflow(task):
252
+ from harness.scan.app_spec import overflow_edit_line
253
+
254
+ return (
255
+ f"{overflow_edit_line(task)} Do not locate. Do not grep. Do not ask."
256
+ )
257
+ if looks_like_fix_smell(task):
258
+ return (
259
+ "This is a smell/rename task. Patch one opaque name to readable "
260
+ "snake_case. Do not add features."
261
+ )
262
+ if looks_like_everyday_code(task):
263
+ skill = everyday_skill_name(task) or "write-script"
264
+ example = everyday_example_path(task)
265
+ return (
266
+ f"This is a {skill} task. First Action: edit Path: {example} "
267
+ "with one function. urllib only — no curl. Then a test, then run."
268
+ )
269
+ return _start_hint_write(task)
270
+
271
+
272
+ def _start_hint_write(task: str) -> str:
273
+ """The leftover write-shaped jobs after the named kinds above."""
274
+ if looks_like_app_overflow(task):
275
+ return (
276
+ "This is an overflow CLI task. First Action: edit Path: "
277
+ "pkg/pr_review.py with argparse subcommand comment and "
278
+ "def comment_on(...). urllib. Token from the environment. "
279
+ "Do not grep. Do not ask."
280
+ )
281
+ if looks_like_add_feature(task):
282
+ return (
283
+ "This is an add-feature task. Grep first. If it is missing, add the "
284
+ "smallest change plus a test, then run. Do not invent extras. "
285
+ "Put new logic in pkg/<noun>.py, not __init__.py."
286
+ )
287
+ return "Start with Action: grep or Action: read. Prefer patch for one-line bugs."
@@ -0,0 +1,42 @@
1
+ """Read the instructions a project publishes for contributors.
2
+
3
+ A project states its own conventions in `AGENTS.md`, `CLAUDE.md` or
4
+ `CONTRIBUTING.md`: where new code belongs, which test runner is used, what
5
+ must not be changed. Those instructions are more accurate for that project
6
+ than the general skills shipped with this harness, so they are placed
7
+ before them in the prompt.
8
+
9
+ The text is truncated to a fixed number of characters, because it is meant
10
+ as a short preface and not as the main content of the prompt.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ DOC_NAMES = ("AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md")
18
+ MAX_DOC_CHARS = 1200
19
+
20
+
21
+ def find_doc(project: Path) -> Path | None:
22
+ root = project.resolve()
23
+ for name in DOC_NAMES:
24
+ candidate = root / name
25
+ if candidate.is_file():
26
+ return candidate
27
+ return None
28
+
29
+
30
+ def render_house_rules(project: Path, *, limit: int = MAX_DOC_CHARS) -> str:
31
+ doc = find_doc(project)
32
+ if doc is None:
33
+ return ""
34
+ try:
35
+ text = doc.read_text(encoding="utf-8").strip()
36
+ except OSError:
37
+ return ""
38
+ if not text:
39
+ return ""
40
+ if len(text) > limit:
41
+ text = text[:limit].rstrip() + "\n# … truncated"
42
+ return f"House rules from {doc.name} (follow them over the kit skill):\n{text}"
@@ -0,0 +1,49 @@
1
+ """First-party .py files small enough for the 0.5B context window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ SKIP_DIR = {
8
+ ".git",
9
+ # Editor settings, including the ones `py-harness editors` writes.
10
+ # A project summary that counts the tool's own configuration as part
11
+ # of your project is describing itself, not the code.
12
+ ".cursor",
13
+ ".vscode",
14
+ ".idea",
15
+ ".zed",
16
+ ".venv",
17
+ "venv",
18
+ "node_modules",
19
+ "__pycache__",
20
+ "site-packages",
21
+ ".mlx_cache",
22
+ "adapters",
23
+ "fused",
24
+ "scratch",
25
+ }
26
+ MAX_REVIEW_BYTES = 2500
27
+
28
+
29
+ def list_small_py_files(
30
+ project: Path,
31
+ *,
32
+ limit: int = 100,
33
+ max_bytes: int = MAX_REVIEW_BYTES,
34
+ min_bytes: int = 200,
35
+ ) -> list[Path]:
36
+ root = project.resolve()
37
+ out: list[Path] = []
38
+ for path in root.rglob("*.py"):
39
+ if any(part in SKIP_DIR for part in path.parts):
40
+ continue
41
+ try:
42
+ size = path.stat().st_size
43
+ except OSError:
44
+ continue
45
+ if size < min_bytes or size > max_bytes:
46
+ continue
47
+ out.append(path)
48
+ out.sort(key=lambda p: (p.stat().st_size, str(p)))
49
+ return out[:limit]
@@ -0,0 +1,101 @@
1
+ """List the functions and classes each Python file defines.
2
+
3
+ `map` reports which files exist and how large they are. That is not enough
4
+ to choose a file to open. This module reports the signature of every
5
+ top-level function and class instead, which names the file's purpose.
6
+
7
+ Output is capped by file count and line count so it stays small enough to
8
+ send to a model with a limited context window.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ from pathlib import Path
15
+
16
+ from harness.paths import rel_posix
17
+ from harness.scan.project_brief import iter_text_files
18
+
19
+ MAX_OUTLINE_FILES = 24
20
+ MAX_PER_FILE = 10
21
+ MAX_OUTLINE_LINES = 120
22
+
23
+
24
+ def _signature(node: ast.AST) -> str:
25
+ if isinstance(node, ast.ClassDef):
26
+ return f"class {node.name}"
27
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
28
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
29
+ head = f"{prefix} {node.name}({ast.unparse(node.args)})"
30
+ if node.returns is not None:
31
+ head += f" -> {ast.unparse(node.returns)}"
32
+ return head
33
+ return ""
34
+
35
+
36
+ def file_signatures(source: str, *, limit: int = MAX_PER_FILE) -> list[str]:
37
+ """Top-level defs/classes plus one level of methods. [] if unparsable."""
38
+ try:
39
+ tree = ast.parse(source)
40
+ except (SyntaxError, ValueError):
41
+ return []
42
+ out: list[str] = []
43
+ for node in tree.body:
44
+ sig = _signature(node)
45
+ if not sig:
46
+ continue
47
+ out.append(sig)
48
+ if isinstance(node, ast.ClassDef):
49
+ for child in node.body:
50
+ child_sig = _signature(child)
51
+ if child_sig and not child.name.startswith("_"):
52
+ out.append(" " + child_sig)
53
+ if len(out) >= limit:
54
+ break
55
+ if len(out) >= limit:
56
+ break
57
+ return out[:limit]
58
+
59
+
60
+ def render_outline(
61
+ project: Path,
62
+ scope: str = "",
63
+ *,
64
+ max_files: int = MAX_OUTLINE_FILES,
65
+ max_lines: int = MAX_OUTLINE_LINES,
66
+ ) -> str:
67
+ root = project.resolve()
68
+ found = [
69
+ (path, size)
70
+ for path, size in iter_text_files(project, scope)
71
+ if path.suffix in {".py", ".pyi"}
72
+ ]
73
+ if not found:
74
+ return "(no .py files in scope)"
75
+ lines = ["outline (signatures only — read a file before you patch it):"]
76
+ body = 0
77
+ shown = 0
78
+ for path, _size in found:
79
+ if shown >= max_files or body >= max_lines:
80
+ break
81
+ try:
82
+ source = path.read_text(encoding="utf-8")
83
+ except OSError:
84
+ continue
85
+ sigs = file_signatures(source)
86
+ if not sigs:
87
+ continue
88
+ shown += 1
89
+ lines.append(rel_posix(path, root))
90
+ for sig in sigs:
91
+ lines.append(f" {sig}")
92
+ body += 1
93
+ if body >= max_lines:
94
+ lines.append("# … outline truncated. Narrow Scope: or pass --scope")
95
+ break
96
+ if len(lines) == 1:
97
+ return "(no top-level defs or classes in scope)"
98
+ remaining = len(found) - shown
99
+ if remaining > 0 and body < max_lines:
100
+ lines.append(f"# … {remaining} more files. Narrow Scope: or pass --scope")
101
+ return "\n".join(lines)
harness/secrets.py ADDED
@@ -0,0 +1,39 @@
1
+ """Shapes that are a secret whoever is looking at them.
2
+
3
+ These patterns had one home, in the guard that reads drafts coming back
4
+ from the model. Nothing read what was going the other way, so a run
5
+ pointed at a remote host posted whatever the harness had gathered — for
6
+ a named-file task, the whole file — with no check at all.
7
+
8
+ The same four shapes answer both questions, so they live below both:
9
+ the guard reviews what arrives, `model.outbound` reviews what leaves.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ # Named rather than lumped together, so a refusal can say which one it
17
+ # saw without quoting the thing it saw.
18
+ SECRET_SHAPES: tuple[tuple[str, re.Pattern[str]], ...] = (
19
+ ("an AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
20
+ ("an Anthropic key", re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}")),
21
+ ("a GitHub token", re.compile(r"ghp_[A-Za-z0-9]{20,}")),
22
+ (
23
+ "a private key",
24
+ re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"),
25
+ ),
26
+ )
27
+
28
+
29
+ def secret_in(text: str) -> str:
30
+ """What secret the text contains, in words. "" when none.
31
+
32
+ The answer never includes the match. Saying "an AWS access key" is
33
+ enough to act on, and printing the key to explain that it leaked
34
+ would be the leak.
35
+ """
36
+ for name, pattern in SECRET_SHAPES:
37
+ if pattern.search(text):
38
+ return name
39
+ return ""