cursorfleet 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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """Command and path sanitizers for the hook hot path. Stdlib only (no typing/dataclasses).
2
+
3
+ Policy: ADR 0003 ("Commands" and "Paths") and docs/privacy.md. A raw command line is
4
+ reduced to ``argv0``, ``subcommand``, a redacted and truncated single-line ``display``
5
+ and a keyed ``command_hash``; the raw string never leaves this module. Redaction is
6
+ BEST EFFORT: a novel secret format can slip through (threat model, residual risks).
7
+ Users can switch the display off (``[privacy] store_command_display = false``).
8
+
9
+ Paths become workspace-relative POSIX strings after symlink resolution; anything outside
10
+ every workspace root, or not representable, becomes ``<external>``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import os
17
+ import re
18
+
19
+ from cursorfleet.state.runtime import safe_realpath
20
+
21
+ EXTERNAL = "<external>"
22
+ REDACTED = "<redacted>"
23
+ EMAIL = "<email>"
24
+ DEFAULT_DISPLAY_MAX = 200
25
+ _MAX_RAW_COMMAND = 64 * 1024 # bound parsing work on pathological inputs
26
+ # Redaction regexes are quadratic on adversarial input (a 64 KiB command took 5-10 s: security
27
+ # review SR-01), so they only ever see this many characters. Only the first ``display_max``
28
+ # (at most 200) characters can be stored, and the window leaves ample context after them,
29
+ # so a secret that starts inside the stored part is still redacted before truncation.
30
+ _REDACT_WINDOW = 1024
31
+ _MAX_TOKENS = 256
32
+
33
+ # ---------------------------------------------------------------- regexes (lazy)
34
+
35
+ _SENSITIVE_NAMES = (
36
+ "token|secret|passw(?:or)?d|passwd|apikey|api[_-]key|credential|private[_-]?key|"
37
+ "access[_-]?key|auth|bearer|session[_-]?id|cookie|signature|sig"
38
+ )
39
+
40
+ _cache: dict[str, re.Pattern[str]] = {}
41
+
42
+
43
+ def _rx(name: str) -> re.Pattern[str]:
44
+ pattern = _cache.get(name)
45
+ if pattern is None:
46
+ pattern = re.compile(_PATTERNS[name][0], _PATTERNS[name][1])
47
+ _cache[name] = pattern
48
+ return pattern
49
+
50
+
51
+ _PATTERNS: dict[str, tuple[str, int]] = {
52
+ "pem": (
53
+ r"-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----.*?(?:-----END [A-Z0-9 ]*"
54
+ r"(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----|\Z)",
55
+ re.DOTALL,
56
+ ),
57
+ "header": (
58
+ r"(?i)\b(authorization|proxy-authorization|x-api-key|x-auth-token|x-access-token|"
59
+ r"x-amz-security-token|cookie|set-cookie)(\s*[:=]\s*)[^\"'\r\n]*",
60
+ 0,
61
+ ),
62
+ "json_secret": (
63
+ r"(?i)(\"[A-Za-z0-9_.\-]*(?:" + _SENSITIVE_NAMES + r")[A-Za-z0-9_.\-]*\"\s*:\s*)"
64
+ r"(\"(?:[^\"\\]|\\.)*\"|[^\s,}\]]+)",
65
+ 0,
66
+ ),
67
+ "url_userinfo": (r"([A-Za-z][A-Za-z0-9+.\-]*://)[^/\s@\"']+@", 0),
68
+ "url_query": (
69
+ r"(?i)([?&;])([A-Za-z0-9_.\-]*(?:" + _SENSITIVE_NAMES + r")[A-Za-z0-9_.\-]*)=([^&\s\"']*)",
70
+ 0,
71
+ ),
72
+ "literal": (
73
+ r"(?:sk-[A-Za-z0-9_\-]{16,}|(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}"
74
+ r"|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}"
75
+ r"|glpat-[A-Za-z0-9_\-]{12,}|xox[abprs]-[A-Za-z0-9\-]{10,}"
76
+ r"|(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[A-Z0-9]{16}|AIza[0-9A-Za-z_\-]{30,}"
77
+ r"|eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}"
78
+ r"|npm_[A-Za-z0-9]{30,}|pypi-[A-Za-z0-9_\-]{30,}"
79
+ r"|SG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}|hf_[A-Za-z0-9]{30,}"
80
+ r"|ya29\.[A-Za-z0-9_\-]{20,}|dop_v1_[a-f0-9]{40,}|shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}"
81
+ r"|key-[a-f0-9]{32})",
82
+ 0,
83
+ ),
84
+ "secret_flag": (
85
+ r"(?i)^-{1,2}[a-z0-9_\-]*(?:token|passw(?:or)?d|passwd|secret|api-?key|credential|"
86
+ r"auth|bearer|cookie|private-?key|access-?key|key)$",
87
+ 0,
88
+ ),
89
+ "email": (r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,}", 0),
90
+ "assign": (r"^(-{0,2}[A-Za-z_][A-Za-z0-9_.\-]*)=(.*)$", re.DOTALL),
91
+ "ident": (r"^[A-Za-z_][A-Za-z0-9_]*=", 0),
92
+ "argv0_ok": (r"^[A-Za-z0-9_][A-Za-z0-9._+\-]{0,63}$", 0),
93
+ "sub_ok": (r"^[a-z][a-z0-9:_\-]{0,63}$", 0),
94
+ }
95
+
96
+ _SCHEME_WORDS = frozenset({"bearer"})
97
+ _USER_FLAG_TOOLS = frozenset({"curl", "wget", "http", "https", "xh"})
98
+ _ATTACHED_PASSWORD_TOOLS = frozenset({"mysql", "mysqldump", "mysqladmin", "mariadb"})
99
+
100
+ # ---------------------------------------------------------------- tokens and redaction
101
+
102
+
103
+ def _strip_quotes(token: str) -> tuple[str, str, str]:
104
+ """Split ``token`` into (leading quotes/parens, core, trailing quotes/punct)."""
105
+ start = 0
106
+ while start < len(token) and token[start] in "\"'`(":
107
+ start += 1
108
+ end = len(token)
109
+ while end > start and token[end - 1] in "\"'`),;":
110
+ end -= 1
111
+ return token[:start], token[start:end], token[end:]
112
+
113
+
114
+ def _entropy(value: str) -> float:
115
+ counts: dict[str, int] = {}
116
+ for ch in value:
117
+ counts[ch] = counts.get(ch, 0) + 1
118
+ total = len(value)
119
+ return -sum(c / total * math.log2(c / total) for c in counts.values())
120
+
121
+
122
+ def _is_blob(core: str) -> bool:
123
+ """Heuristic for opaque secrets: long base64/url-safe/hex strings."""
124
+ for segment in core.split("/"):
125
+ n = len(segment)
126
+ if n < 20:
127
+ continue
128
+ body = segment.rstrip("=")
129
+ if not body or any(not (c.isascii() and (c.isalnum() or c in "+_-")) for c in body):
130
+ continue
131
+ if all(c in "0123456789abcdefABCDEF" for c in body):
132
+ if n >= 32 and n not in (40, 64):
133
+ return True
134
+ continue
135
+ has_digit = any(c.isdigit() for c in body)
136
+ has_alpha = any(c.isalpha() for c in body)
137
+ if has_digit and has_alpha and _entropy(body) >= 3.6:
138
+ return True
139
+ return False
140
+
141
+
142
+ def _redact_blobs(core: str) -> str:
143
+ return "/".join(REDACTED if _is_blob(part) else part for part in core.split("/"))
144
+
145
+
146
+ def redact_text(text: str) -> str:
147
+ """Whole-string redaction passes (multi-token and literal patterns), bounded in size."""
148
+ text = _rx("pem").sub(REDACTED, text[:_REDACT_WINDOW])
149
+ text = _rx("header").sub(lambda m: m.group(1) + m.group(2) + REDACTED, text)
150
+ text = _rx("json_secret").sub(lambda m: m.group(1) + '"' + REDACTED + '"', text)
151
+ text = _rx("url_userinfo").sub(lambda m: m.group(1) + REDACTED + "@", text)
152
+ text = _rx("url_query").sub(lambda m: m.group(1) + m.group(2) + "=" + REDACTED, text)
153
+ text = _rx("literal").sub(REDACTED, text)
154
+ return _rx("email").sub(
155
+ lambda m: m.group(0) if m.group(0).startswith("git@") else EMAIL,
156
+ text,
157
+ )
158
+
159
+
160
+ def _to_printable_line(text: str) -> str:
161
+ cleaned = "".join(ch if ch.isprintable() else " " for ch in text)
162
+ return " ".join(cleaned.split())
163
+
164
+
165
+ def redact_command(raw: str, argv0: str | None = None) -> str:
166
+ """Return a single-line, redacted rendering of ``raw`` (untruncated)."""
167
+ text = _to_printable_line(raw[:_REDACT_WINDOW])
168
+ text = redact_text(text)
169
+ out: list[str] = []
170
+ redact_next = 0
171
+ prev_core = ""
172
+ tokens = text.split(" ")[:_MAX_TOKENS]
173
+ words = {_basename(t) for t in tokens}
174
+ attached_pw = bool(words & _ATTACHED_PASSWORD_TOOLS) # mysql -pSECRET
175
+ sshpass = "sshpass" in words # sshpass -p SECRET
176
+ for token in tokens:
177
+ lead, core, trail = _strip_quotes(token)
178
+ lowered = core.lower()
179
+ if redact_next > 0:
180
+ redact_next -= 1
181
+ out.append(lead + REDACTED + trail)
182
+ prev_core = lowered
183
+ continue
184
+ rendered = token
185
+ assign = _rx("assign").match(core)
186
+ if assign is not None:
187
+ name = assign.group(1)
188
+ if not name.startswith("-") or _rx("secret_flag").match(name):
189
+ rendered = lead + name + "=" + REDACTED + trail
190
+ elif attached_pw and len(core) > 2 and core.startswith("-p") and core[2] != "-":
191
+ rendered = lead + "-p" + REDACTED + trail
192
+ elif _rx("secret_flag").match(core) or (
193
+ (core in {"-u", "--user"} and argv0 in _USER_FLAG_TOOLS) or (sshpass and core == "-p")
194
+ ):
195
+ redact_next = 1
196
+ elif prev_core in _SCHEME_WORDS:
197
+ rendered = lead + REDACTED + trail
198
+ else:
199
+ clean = _redact_blobs(core)
200
+ if clean != core:
201
+ rendered = lead + clean + trail
202
+ out.append(rendered)
203
+ prev_core = lowered
204
+ return " ".join(part for part in out if part)
205
+
206
+
207
+ def truncate_display(text: str, max_chars: int) -> tuple[str, bool]:
208
+ """Truncate to ``max_chars`` at a token boundary where possible. Returns (text, cut)."""
209
+ if len(text) <= max_chars:
210
+ return text, False
211
+ budget = max(max_chars - 3, 1)
212
+ cut = text[:budget]
213
+ boundary = cut.rfind(" ")
214
+ if boundary >= budget // 2:
215
+ cut = cut[:boundary]
216
+ return cut.rstrip() + "...", True
217
+
218
+
219
+ # ---------------------------------------------------------------- command facts
220
+
221
+ _MULTI_COMMAND_TOOLS = frozenset(
222
+ {
223
+ "git", "npm", "pnpm", "yarn", "uv", "pip", "pip3", "cargo", "go", "docker", "kubectl",
224
+ "gh", "make", "poetry", "pipx", "dotnet", "mvn", "gradle", "brew", "apt", "apt-get",
225
+ "systemctl", "terraform", "helm", "bun", "deno", "rustup", "conda", "hatch", "just",
226
+ }
227
+ ) # fmt: skip
228
+ _VALUE_FLAGS = {"git": frozenset({"-C", "-c", "--git-dir", "--work-tree"})}
229
+ _SEGMENT_BREAKS = frozenset({"&&", "||", ";", "|", "&", "|&"})
230
+ _WRAPPERS = frozenset({"sudo", "env", "time", "nice", "nohup", "command", "exec", "xargs"})
231
+ _VERIFY_ARGV0 = frozenset(
232
+ {
233
+ "pytest", "py.test", "tox", "nox", "ruff", "mypy", "pyright", "flake8", "pylint",
234
+ "eslint", "jest", "vitest", "mocha", "tsc", "rspec", "phpunit", "golangci-lint",
235
+ "shellcheck", "hadolint", "bandit", "unittest", "ctest", "clippy-driver",
236
+ }
237
+ ) # fmt: skip
238
+ _VERIFY_SUBCOMMANDS = {
239
+ "npm": {"test", "t", "lint", "check", "typecheck", "verify"},
240
+ "pnpm": {"test", "lint", "check", "typecheck"},
241
+ "yarn": {"test", "lint", "check", "typecheck"},
242
+ "bun": {"test"},
243
+ "cargo": {"test", "clippy", "check", "nextest"},
244
+ "go": {"test", "vet"},
245
+ "make": {"test", "lint", "check", "verify"},
246
+ "just": {"test", "lint", "check"},
247
+ "dotnet": {"test"},
248
+ "mvn": {"test", "verify"},
249
+ "gradle": {"test", "check"},
250
+ "deno": {"test", "lint", "check"},
251
+ }
252
+ _SCRIPT_RUNNERS = frozenset({"npm", "pnpm", "yarn", "bun"})
253
+
254
+
255
+ def _split_segments(tokens: list[str]) -> list[list[str]]:
256
+ segments: list[list[str]] = [[]]
257
+ for token in tokens:
258
+ if token in _SEGMENT_BREAKS:
259
+ segments.append([])
260
+ else:
261
+ segments[-1].append(token)
262
+ return [s for s in segments if s]
263
+
264
+
265
+ def _basename(word: str) -> str:
266
+ core = word.strip("\"'`")
267
+ return core.replace("\\", "/").rsplit("/", 1)[-1]
268
+
269
+
270
+ def _drop_prefixes(words: list[str]) -> list[str]:
271
+ """Drop leading ``NAME=value`` assignments and simple wrappers (sudo, env, time...)."""
272
+ index = 0
273
+ while index < len(words):
274
+ word = words[index]
275
+ if _rx("ident").match(word):
276
+ index += 1
277
+ continue
278
+ if _basename(word) in _WRAPPERS:
279
+ index += 1
280
+ while index < len(words) and words[index].startswith("-"):
281
+ index += 1 # skip the wrapper's own flags (sudo -n, env -i, ...)
282
+ continue
283
+ break
284
+ return words[index:]
285
+
286
+
287
+ def parse_command(raw: str) -> tuple[str | None, str | None, list[list[str]]]:
288
+ """Return ``(argv0, subcommand, segments)`` from a raw command line. Never raises.
289
+
290
+ A tolerant whitespace tokenizer (quotes are stripped from words, not interpreted);
291
+ good enough for classification and display, not a shell parser.
292
+ """
293
+ tokens = raw[:_MAX_RAW_COMMAND].split()[:_MAX_TOKENS]
294
+ segments = [_drop_prefixes(seg) for seg in _split_segments(tokens)]
295
+ segments = [s for s in segments if s]
296
+ if not segments:
297
+ return None, None, []
298
+ first = segments[0]
299
+ name = _basename(first[0])
300
+ argv0 = name if _rx("argv0_ok").match(name) else None
301
+ sub = _subcommand(argv0, first[1:]) if argv0 is not None else None
302
+ return argv0, sub, segments
303
+
304
+
305
+ def _subcommand(argv0: str, args: list[str]) -> str | None:
306
+ if argv0 not in _MULTI_COMMAND_TOOLS:
307
+ return None
308
+ skip = _VALUE_FLAGS.get(argv0, frozenset())
309
+ index = 0
310
+ while index < len(args):
311
+ word = args[index].strip("\"'`")
312
+ if word in skip:
313
+ index += 2
314
+ continue
315
+ if word.startswith("-"):
316
+ index += 1
317
+ continue
318
+ return word if _rx("sub_ok").match(word) else None
319
+ return None
320
+
321
+
322
+ def is_verify_command(argv0: str | None, subcommand: str | None, display: str | None) -> bool:
323
+ """True if a command looks like a test, lint or type-check run (heuristic)."""
324
+ if argv0 is not None and argv0 in _VERIFY_ARGV0:
325
+ return True
326
+ if argv0 is not None and subcommand in _VERIFY_SUBCOMMANDS.get(argv0, ()):
327
+ return True
328
+ if display:
329
+ _a, _s, segments = parse_command(display)
330
+ return any(_segment_verifies(words) for words in segments)
331
+ return False
332
+
333
+
334
+ def _segment_verifies(words: list[str]) -> bool:
335
+ cleaned = [_basename(w) for w in words]
336
+ if not cleaned:
337
+ return False
338
+ head, rest = cleaned[0], cleaned[1:]
339
+ if head in _VERIFY_ARGV0:
340
+ return True
341
+ if head in {"uv", "poetry", "pipx", "hatch", "pdm", "npx", "pnpx", "bunx"}:
342
+ inner = [w for w in rest if w not in {"run", "exec", "--"} and not w.startswith("-")]
343
+ return bool(inner) and inner[0] in _VERIFY_ARGV0
344
+ if head in {"python", "python3", "py"} and len(rest) >= 2 and rest[0] == "-m":
345
+ return rest[1] in _VERIFY_ARGV0 or rest[1] == "pytest"
346
+ sub = next((w for w in rest if not w.startswith("-")), None)
347
+ if sub is not None and sub in _VERIFY_SUBCOMMANDS.get(head, ()):
348
+ return True
349
+ if head in _SCRIPT_RUNNERS and len(rest) >= 2 and rest[0] == "run":
350
+ script = rest[1].lower()
351
+ return any(word in script for word in ("test", "lint", "check", "typecheck", "verify"))
352
+ return False
353
+
354
+
355
+ def sanitize_command(
356
+ raw: object,
357
+ *,
358
+ key: bytes | None,
359
+ store_display: bool = True,
360
+ display_max: int = DEFAULT_DISPLAY_MAX,
361
+ hash_commands: bool = True,
362
+ exit_code: int | None = None,
363
+ duration_ms: int | None = None,
364
+ ) -> dict[str, object] | None:
365
+ """Reduce a raw command string to a ``SanitizedCommand`` dict, or ``None`` if unusable."""
366
+ if not isinstance(raw, str) or not raw.strip():
367
+ return None
368
+ argv0, subcommand, _segments = parse_command(raw)
369
+ result: dict[str, object] = {"argv0": argv0 or "unknown"}
370
+ if subcommand is not None:
371
+ result["subcommand"] = subcommand
372
+ if (
373
+ isinstance(exit_code, int)
374
+ and not isinstance(exit_code, bool)
375
+ and -1024 <= exit_code <= 1024
376
+ ):
377
+ result["exit_code"] = exit_code
378
+ if (
379
+ isinstance(duration_ms, int)
380
+ and not isinstance(duration_ms, bool)
381
+ and 0 <= duration_ms <= 10**12
382
+ ):
383
+ result["duration_ms"] = duration_ms
384
+ if store_display:
385
+ limit = max(20, min(display_max, DEFAULT_DISPLAY_MAX))
386
+ display, cut = truncate_display(redact_command(raw, argv0), limit)
387
+ if display:
388
+ result["display"] = display
389
+ result["display_truncated"] = cut
390
+ if hash_commands and key:
391
+ digest = _keyed_hash(key, raw)
392
+ if digest:
393
+ result["command_hash"] = digest
394
+ return result
395
+
396
+
397
+ def _keyed_hash(key: bytes, raw: str) -> str | None:
398
+ try:
399
+ import hmac
400
+
401
+ normalized = "\x00".join(raw[:_MAX_RAW_COMMAND].split()).encode("utf-8", "replace")
402
+ return hmac.digest(key, normalized, "sha256").hex()[:32]
403
+ except (ImportError, ValueError, TypeError): # pragma: no cover - defensive
404
+ return None
405
+
406
+
407
+ # ---------------------------------------------------------------- paths
408
+
409
+
410
+ class PathResolver:
411
+ """Relativize paths against workspace roots (symlink-resolved, cross-platform)."""
412
+
413
+ __slots__ = ("_roots",)
414
+
415
+ def __init__(self, roots: list[str]) -> None:
416
+ resolved: list[str] = []
417
+ for root in roots:
418
+ if isinstance(root, str) and root and os.path.isabs(root):
419
+ try:
420
+ real = safe_realpath(root)
421
+ except (OSError, ValueError):
422
+ continue
423
+ if real not in resolved:
424
+ resolved.append(real)
425
+ self._roots = resolved
426
+
427
+ @property
428
+ def roots(self) -> list[str]:
429
+ return list(self._roots)
430
+
431
+ def relativize(self, raw: object) -> str:
432
+ """Return a workspace-relative POSIX path, or ``<external>`` if outside/unusable."""
433
+ if not isinstance(raw, str) or not raw or len(raw) > 4096 or "\x00" in raw:
434
+ return EXTERNAL
435
+ try:
436
+ if os.path.isabs(raw):
437
+ candidate = safe_realpath(raw)
438
+ elif self._roots:
439
+ candidate = safe_realpath(os.path.join(self._roots[0], raw))
440
+ else:
441
+ return EXTERNAL
442
+ except (OSError, ValueError):
443
+ return EXTERNAL
444
+ norm_candidate = os.path.normcase(os.path.normpath(candidate))
445
+ for root in self._roots:
446
+ try:
447
+ norm_root = os.path.normcase(os.path.normpath(root))
448
+ common = os.path.commonpath([norm_candidate, norm_root])
449
+ except (OSError, ValueError):
450
+ continue
451
+ if os.path.normcase(os.path.normpath(common)) != norm_root:
452
+ continue
453
+ if norm_candidate == norm_root:
454
+ return EXTERNAL # the root itself is not a file path worth recording
455
+ try:
456
+ relative = os.path.relpath(candidate, root).replace(os.sep, "/")
457
+ except ValueError:
458
+ return EXTERNAL
459
+ return _validated_relative(relative)
460
+ return EXTERNAL
461
+
462
+
463
+ def _validated_relative(relative: str) -> str:
464
+ if (
465
+ not relative
466
+ or len(relative) > 1024
467
+ or "\\" in relative
468
+ or not relative.isprintable()
469
+ or relative.startswith("/")
470
+ or any(part in {"", ".", ".."} for part in relative.split("/"))
471
+ or re.match(r"^[A-Za-z]:", relative)
472
+ ):
473
+ return EXTERNAL
474
+ return relative