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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|