ghostrules 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.
- ghostrules/__init__.py +6 -0
- ghostrules/_compat.py +12 -0
- ghostrules/clean.py +41 -0
- ghostrules/cli.py +319 -0
- ghostrules/config.py +93 -0
- ghostrules/jsonloc.py +194 -0
- ghostrules/markdown_utils.py +59 -0
- ghostrules/mcp_config.py +243 -0
- ghostrules/models.py +188 -0
- ghostrules/py.typed +0 -0
- ghostrules/reporters/__init__.py +1 -0
- ghostrules/reporters/common.py +86 -0
- ghostrules/reporters/json_.py +45 -0
- ghostrules/reporters/markdown.py +55 -0
- ghostrules/reporters/sarif.py +137 -0
- ghostrules/reporters/table.py +235 -0
- ghostrules/reveal.py +90 -0
- ghostrules/rules/__init__.py +16 -0
- ghostrules/rules/agent_settings.py +159 -0
- ghostrules/rules/helpers.py +118 -0
- ghostrules/rules/injection.py +317 -0
- ghostrules/rules/mcp.py +445 -0
- ghostrules/rules/patterns.py +187 -0
- ghostrules/rules/registry.py +78 -0
- ghostrules/rules/unicode.py +503 -0
- ghostrules/scanner.py +244 -0
- ghostrules/secrets.py +219 -0
- ghostrules/targets.py +220 -0
- ghostrules/unicode_utils.py +138 -0
- ghostrules-0.1.0.dist-info/METADATA +333 -0
- ghostrules-0.1.0.dist-info/RECORD +34 -0
- ghostrules-0.1.0.dist-info/WHEEL +4 -0
- ghostrules-0.1.0.dist-info/entry_points.txt +2 -0
- ghostrules-0.1.0.dist-info/licenses/LICENSE +21 -0
ghostrules/__init__.py
ADDED
ghostrules/_compat.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Python-version compatibility shims."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else: # pragma: no cover - exercised only on Python 3.10
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
__all__ = ["tomllib"]
|
ghostrules/clean.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``ghostrules clean``: remove the invisible characters the unicode rules flag."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ghostrules.rules.unicode import removable
|
|
9
|
+
from ghostrules.secrets import mask_secrets
|
|
10
|
+
from ghostrules.unicode_utils import render_invisible
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CleanResult:
|
|
15
|
+
"""Outcome of cleaning a text."""
|
|
16
|
+
|
|
17
|
+
original: str
|
|
18
|
+
cleaned: str
|
|
19
|
+
removed: int
|
|
20
|
+
kept_bidi: int
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def changed(self) -> bool:
|
|
24
|
+
return self.original != self.cleaned
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clean_text(text: str, *, keep_bom: bool = False) -> CleanResult:
|
|
28
|
+
"""Remove exactly the characters GR001-GR004 flag (see ``rules.unicode.removable``)."""
|
|
29
|
+
removal = removable(text, keep_bom=keep_bom)
|
|
30
|
+
drop = set(removal.offsets)
|
|
31
|
+
cleaned = "".join(ch for i, ch in enumerate(text) if i not in drop)
|
|
32
|
+
return CleanResult(text, cleaned, len(drop), removal.kept_bidi)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unified_diff(result: CleanResult, name: str) -> str:
|
|
36
|
+
"""Unified diff of the change. Invisible characters are shown as tokens so the removed
|
|
37
|
+
ones are actually visible, and secrets are masked."""
|
|
38
|
+
old = result.original.splitlines(keepends=True)
|
|
39
|
+
new = result.cleaned.splitlines(keepends=True)
|
|
40
|
+
diff = difflib.unified_diff(old, new, fromfile=name, tofile=f"{name} (cleaned)")
|
|
41
|
+
return mask_secrets(render_invisible("".join(diff), with_name=False))
|
ghostrules/cli.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Command line interface: ``scan`` (default), ``reveal``, ``clean``, ``rules``, ``version``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any, NoReturn, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from typer.core import TyperGroup
|
|
16
|
+
|
|
17
|
+
from ghostrules import __version__
|
|
18
|
+
from ghostrules.clean import clean_text, unified_diff
|
|
19
|
+
from ghostrules.config import Config, ConfigError, load_config
|
|
20
|
+
from ghostrules.models import ScanResult, Severity
|
|
21
|
+
from ghostrules.reporters import json_, markdown, sarif, table
|
|
22
|
+
from ghostrules.reporters.common import safe
|
|
23
|
+
from ghostrules.reveal import build as build_reveal
|
|
24
|
+
from ghostrules.reveal import has_hidden_content
|
|
25
|
+
from ghostrules.rules import all_rules
|
|
26
|
+
from ghostrules.scanner import scan_paths
|
|
27
|
+
|
|
28
|
+
EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DefaultScanGroup(TyperGroup):
|
|
32
|
+
"""Make ``scan`` the default command: ``ghostrules .`` and ``ghostrules --format json``."""
|
|
33
|
+
|
|
34
|
+
def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
|
|
35
|
+
first = args[0] if args else None
|
|
36
|
+
if first is None or (first not in self.commands and first not in ctx.help_option_names):
|
|
37
|
+
args = ["scan", *args]
|
|
38
|
+
return super().parse_args(ctx, args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
name="ghostrules",
|
|
43
|
+
cls=DefaultScanGroup,
|
|
44
|
+
help="Find the instructions your AI agent sees but you don't.",
|
|
45
|
+
add_completion=False,
|
|
46
|
+
rich_markup_mode=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutputFormat(str, enum.Enum):
|
|
51
|
+
"""Report formats for ``scan --format``."""
|
|
52
|
+
|
|
53
|
+
table = "table"
|
|
54
|
+
json = "json"
|
|
55
|
+
sarif = "sarif"
|
|
56
|
+
markdown = "markdown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_RENDERERS = {
|
|
60
|
+
OutputFormat.json: json_.render,
|
|
61
|
+
OutputFormat.sarif: sarif.render,
|
|
62
|
+
OutputFormat.markdown: markdown.render,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fail(message: str) -> NoReturn:
|
|
67
|
+
typer.echo(f"error: {safe(message)}", err=True)
|
|
68
|
+
raise typer.Exit(EXIT_ERROR)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _utf8_streams() -> None:
|
|
72
|
+
"""Reports contain box-drawing and ⟦U+XXXX⟧ tokens: never crash on a legacy code page."""
|
|
73
|
+
for stream in (sys.stdout, sys.stderr):
|
|
74
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
|
|
75
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
76
|
+
if reconfigure is not None and encoding != "utf8":
|
|
77
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_text(path: Path) -> tuple[bytes, str]:
|
|
81
|
+
try:
|
|
82
|
+
data = path.read_bytes()
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
_fail(f"cannot read {path}: {exc.strerror or exc}")
|
|
85
|
+
return data, data.decode("utf-8", errors="surrogateescape")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_severity(value: str | None, *, allow_none: bool) -> Severity | None:
|
|
89
|
+
if value is None:
|
|
90
|
+
return None
|
|
91
|
+
if allow_none and value.strip().lower() == "none":
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return Severity.parse(value)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise typer.BadParameter(str(exc)) from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _console(no_color: bool) -> Console:
|
|
100
|
+
return table.make_console(color=False if no_color else None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- scan ----------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def scan(
|
|
108
|
+
paths: Annotated[
|
|
109
|
+
Optional[list[Path]], # noqa: UP045 - Typer needs Optional on 3.10
|
|
110
|
+
typer.Argument(help="Files or directories to scan (default: current directory)."),
|
|
111
|
+
] = None,
|
|
112
|
+
format_: Annotated[
|
|
113
|
+
OutputFormat, typer.Option("--format", help="Report format.", case_sensitive=False)
|
|
114
|
+
] = OutputFormat.table,
|
|
115
|
+
output: Annotated[
|
|
116
|
+
Optional[Path], # noqa: UP045
|
|
117
|
+
typer.Option("--output", "-o", help="Write the report to this file instead of stdout."),
|
|
118
|
+
] = None,
|
|
119
|
+
fail_on: Annotated[
|
|
120
|
+
Optional[str], # noqa: UP045
|
|
121
|
+
typer.Option(help="Exit 1 if a finding has at least this severity, or 'none'. [high]"),
|
|
122
|
+
] = None,
|
|
123
|
+
min_severity: Annotated[
|
|
124
|
+
Optional[str], # noqa: UP045
|
|
125
|
+
typer.Option(help="Hide findings below this severity. [low]"),
|
|
126
|
+
] = None,
|
|
127
|
+
all_files: Annotated[
|
|
128
|
+
bool, typer.Option("--all-files", help="Also check every text file for hidden Unicode.")
|
|
129
|
+
] = False,
|
|
130
|
+
home: Annotated[
|
|
131
|
+
bool, typer.Option("--home", help="Also scan your user-level agent configs (read-only).")
|
|
132
|
+
] = False,
|
|
133
|
+
disable: Annotated[
|
|
134
|
+
Optional[str], # noqa: UP045
|
|
135
|
+
typer.Option(help="Comma-separated rule IDs to disable, e.g. GR010,GR012."),
|
|
136
|
+
] = None,
|
|
137
|
+
config: Annotated[
|
|
138
|
+
Optional[Path], # noqa: UP045
|
|
139
|
+
typer.Option(help="Path to a config file (default: .ghostrules.toml in the scan root)."),
|
|
140
|
+
] = None,
|
|
141
|
+
no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Scan agent instruction and configuration files for hidden or dangerous content.
|
|
144
|
+
|
|
145
|
+
Exit codes: 0 clean (or below --fail-on), 1 findings at or above --fail-on, 2 error.
|
|
146
|
+
"""
|
|
147
|
+
_utf8_streams()
|
|
148
|
+
targets = paths or [Path(".")]
|
|
149
|
+
base = targets[0] if targets[0].is_dir() else targets[0].parent
|
|
150
|
+
try:
|
|
151
|
+
cfg = load_config(base, config)
|
|
152
|
+
except ConfigError as exc:
|
|
153
|
+
_fail(str(exc))
|
|
154
|
+
cfg = _apply_overrides(cfg, fail_on, min_severity, disable)
|
|
155
|
+
|
|
156
|
+
result = scan_paths(targets, cfg, all_files=all_files, include_home=home)
|
|
157
|
+
label = ", ".join(str(p) for p in targets)
|
|
158
|
+
_emit(result, format_, output, no_color, label)
|
|
159
|
+
|
|
160
|
+
if result.errors:
|
|
161
|
+
raise typer.Exit(EXIT_ERROR)
|
|
162
|
+
if cfg.fail_on is not None and any(f.severity >= cfg.fail_on for f in result.findings):
|
|
163
|
+
raise typer.Exit(EXIT_FINDINGS)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _apply_overrides(
|
|
167
|
+
cfg: Config, fail_on: str | None, min_severity: str | None, disable: str | None
|
|
168
|
+
) -> Config:
|
|
169
|
+
changes: dict[str, object] = {}
|
|
170
|
+
if fail_on is not None:
|
|
171
|
+
changes["fail_on"] = _parse_severity(fail_on, allow_none=True)
|
|
172
|
+
if min_severity is not None:
|
|
173
|
+
changes["min_severity"] = _parse_severity(min_severity, allow_none=False)
|
|
174
|
+
if disable:
|
|
175
|
+
ids = {part.strip().upper() for part in disable.split(",") if part.strip()}
|
|
176
|
+
changes["disable"] = cfg.disable | ids
|
|
177
|
+
return replace(cfg, **changes) # type: ignore[arg-type]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _emit(
|
|
181
|
+
result: ScanResult, fmt: OutputFormat, output: Path | None, no_color: bool, label: str
|
|
182
|
+
) -> None:
|
|
183
|
+
if fmt is OutputFormat.table:
|
|
184
|
+
if output is None:
|
|
185
|
+
_console(no_color).print(table.build(result, target=label))
|
|
186
|
+
return
|
|
187
|
+
text = table.render(result, color=False, width=100, target=label)
|
|
188
|
+
else:
|
|
189
|
+
text = _RENDERERS[fmt](result)
|
|
190
|
+
for warning in result.warnings:
|
|
191
|
+
typer.echo(f"warning: {safe(warning)}", err=True)
|
|
192
|
+
for error in result.errors:
|
|
193
|
+
typer.echo(f"error: {safe(error)}", err=True)
|
|
194
|
+
if output is None:
|
|
195
|
+
sys.stdout.write(text)
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
output.write_text(text, encoding="utf-8")
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
_fail(f"cannot write {output}: {exc.strerror or exc}")
|
|
201
|
+
typer.echo(f"report written to {output}", err=True)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- reveal / clean ------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def reveal(
|
|
209
|
+
file: Annotated[Path, typer.Argument(help="File to show.")],
|
|
210
|
+
no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Print a file with invisible characters made visible, plus the text they hide."""
|
|
213
|
+
_utf8_streams()
|
|
214
|
+
_data, text = _read_text(file)
|
|
215
|
+
console = _console(no_color)
|
|
216
|
+
header = Text.assemble(("◆ ghostrules reveal", "bold"), (f" {safe(str(file))}", "dim"))
|
|
217
|
+
verdict = (
|
|
218
|
+
Text("hidden content found: see highlighted tokens and decoded text", style="bold red")
|
|
219
|
+
if has_hidden_content(text)
|
|
220
|
+
else Text("✔ nothing hidden: no invisible characters or HTML comments", style="green")
|
|
221
|
+
)
|
|
222
|
+
console.print(Group(header, Text(""), build_reveal(text), Text(""), verdict))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def clean(
|
|
227
|
+
file: Annotated[Path, typer.Argument(help="File to clean.")],
|
|
228
|
+
write: Annotated[
|
|
229
|
+
bool, typer.Option("--write", help="Modify the file (a .ghostrules.bak copy is kept).")
|
|
230
|
+
] = False,
|
|
231
|
+
keep_bom: Annotated[
|
|
232
|
+
bool, typer.Option("--keep-bom", help="Keep a leading byte-order mark.")
|
|
233
|
+
] = False,
|
|
234
|
+
no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Remove invisible characters (GR001-GR004). Without --write only show the diff."""
|
|
237
|
+
_utf8_streams()
|
|
238
|
+
data, text = _read_text(file)
|
|
239
|
+
console = _console(no_color)
|
|
240
|
+
result = clean_text(text, keep_bom=keep_bom)
|
|
241
|
+
if result.kept_bidi:
|
|
242
|
+
console.print(
|
|
243
|
+
Text(
|
|
244
|
+
f"note: kept {result.kept_bidi} bidirectional control(s): the file is mostly "
|
|
245
|
+
"right-to-left text, where they are legitimate.",
|
|
246
|
+
style="yellow",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
if not result.changed:
|
|
250
|
+
console.print(Text("✔ Nothing to clean.", style="green"))
|
|
251
|
+
return
|
|
252
|
+
if not write:
|
|
253
|
+
console.print(_styled_diff(unified_diff(result, safe(str(file)))))
|
|
254
|
+
console.print(
|
|
255
|
+
Text(
|
|
256
|
+
f"{result.removed} character(s) would be removed. Run again with --write.",
|
|
257
|
+
style="bold",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
backup = file.with_name(file.name + ".ghostrules.bak")
|
|
262
|
+
if backup.exists():
|
|
263
|
+
_fail(f"{backup} already exists; move it away first so no earlier backup is lost")
|
|
264
|
+
try:
|
|
265
|
+
backup.write_bytes(data)
|
|
266
|
+
file.write_bytes(result.cleaned.encode("utf-8", errors="surrogateescape"))
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
_fail(f"cannot write {file}: {exc.strerror or exc}")
|
|
269
|
+
console.print(
|
|
270
|
+
Text(f"✔ Removed {result.removed} character(s) from {safe(str(file))}", style="bold green")
|
|
271
|
+
)
|
|
272
|
+
console.print(Text(f" original saved as {safe(str(backup))}", style="dim"))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _styled_diff(diff: str) -> Text:
|
|
276
|
+
out = Text()
|
|
277
|
+
for line in diff.splitlines():
|
|
278
|
+
if line.startswith(("+++", "---")):
|
|
279
|
+
style = "bold"
|
|
280
|
+
elif line.startswith("@@"):
|
|
281
|
+
style = "cyan"
|
|
282
|
+
elif line.startswith("+"):
|
|
283
|
+
style = "green"
|
|
284
|
+
elif line.startswith("-"):
|
|
285
|
+
style = "red"
|
|
286
|
+
else:
|
|
287
|
+
style = ""
|
|
288
|
+
out.append(safe(line) + "\n", style=style)
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- rules / version -----------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command()
|
|
296
|
+
def rules(
|
|
297
|
+
no_color: Annotated[bool, typer.Option("--no-color", help="Plain output, no colours.")] = False,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""List every detection rule."""
|
|
300
|
+
_utf8_streams()
|
|
301
|
+
grid = Table(box=None, pad_edge=False, header_style="bold")
|
|
302
|
+
grid.add_column("ID", no_wrap=True)
|
|
303
|
+
grid.add_column("Severity", no_wrap=True)
|
|
304
|
+
grid.add_column("Applies to", no_wrap=True, style="dim")
|
|
305
|
+
grid.add_column("Title")
|
|
306
|
+
for rule in all_rules():
|
|
307
|
+
grid.add_row(
|
|
308
|
+
Text(rule.id, style="bold"),
|
|
309
|
+
Text(rule.severity.label, style=table.TEXT_STYLE[rule.severity]),
|
|
310
|
+
Text(", ".join(sorted(rule.applies_to))),
|
|
311
|
+
Text(rule.title),
|
|
312
|
+
)
|
|
313
|
+
_console(no_color).print(grid)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command()
|
|
317
|
+
def version() -> None:
|
|
318
|
+
"""Print the ghostrules version."""
|
|
319
|
+
typer.echo(f"ghostrules {__version__}")
|
ghostrules/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Configuration loading: ``--config``, ``.ghostrules.toml`` or ``[tool.ghostrules]``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ghostrules._compat import tomllib
|
|
10
|
+
from ghostrules.models import Severity
|
|
11
|
+
|
|
12
|
+
CONFIG_FILENAME = ".ghostrules.toml"
|
|
13
|
+
_LIST_KEYS = ("disable", "exclude", "extra_targets", "allow_domains")
|
|
14
|
+
_KNOWN_KEYS = {*_LIST_KEYS, "fail_on", "min_severity"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(Exception):
|
|
18
|
+
"""Raised for unreadable or invalid configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Config:
|
|
23
|
+
"""Effective configuration. ``fail_on=None`` means never fail."""
|
|
24
|
+
|
|
25
|
+
disable: frozenset[str] = frozenset()
|
|
26
|
+
fail_on: Severity | None = Severity.HIGH
|
|
27
|
+
min_severity: Severity = Severity.LOW
|
|
28
|
+
exclude: tuple[str, ...] = ()
|
|
29
|
+
extra_targets: tuple[str, ...] = ()
|
|
30
|
+
allow_domains: tuple[str, ...] = ()
|
|
31
|
+
source: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _string_list(table: dict[str, Any], key: str) -> list[str]:
|
|
35
|
+
value = table.get(key, [])
|
|
36
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
37
|
+
raise ConfigError(f"'{key}' must be a list of strings")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_config(table: dict[str, Any], source: str | None = None) -> Config:
|
|
42
|
+
"""Build a ``Config`` from an already-parsed TOML table, validating keys and values."""
|
|
43
|
+
unknown = sorted(set(table) - _KNOWN_KEYS)
|
|
44
|
+
if unknown:
|
|
45
|
+
raise ConfigError(f"unknown config key(s): {', '.join(unknown)}")
|
|
46
|
+
try:
|
|
47
|
+
fail_on_raw = table.get("fail_on", "high")
|
|
48
|
+
if not isinstance(fail_on_raw, str):
|
|
49
|
+
raise ConfigError("'fail_on' must be a string")
|
|
50
|
+
fail_on = None if fail_on_raw.strip().lower() == "none" else Severity.parse(fail_on_raw)
|
|
51
|
+
min_raw = table.get("min_severity", "low")
|
|
52
|
+
if not isinstance(min_raw, str):
|
|
53
|
+
raise ConfigError("'min_severity' must be a string")
|
|
54
|
+
min_severity = Severity.parse(min_raw)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise ConfigError(str(exc)) from None
|
|
57
|
+
return Config(
|
|
58
|
+
disable=frozenset(rule.strip().upper() for rule in _string_list(table, "disable")),
|
|
59
|
+
fail_on=fail_on,
|
|
60
|
+
min_severity=min_severity,
|
|
61
|
+
exclude=tuple(_string_list(table, "exclude")),
|
|
62
|
+
extra_targets=tuple(_string_list(table, "extra_targets")),
|
|
63
|
+
allow_domains=tuple(d.lower() for d in _string_list(table, "allow_domains")),
|
|
64
|
+
source=source,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
69
|
+
try:
|
|
70
|
+
with path.open("rb") as handle:
|
|
71
|
+
return tomllib.load(handle)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise ConfigError(f"cannot read {path}: {exc.strerror or exc}") from None
|
|
74
|
+
except tomllib.TOMLDecodeError as exc:
|
|
75
|
+
raise ConfigError(f"invalid TOML in {path}: {exc}") from None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(base_dir: Path, explicit: Path | None = None) -> Config:
|
|
79
|
+
"""Load configuration, trying in order: ``explicit``, ``.ghostrules.toml``, pyproject.
|
|
80
|
+
|
|
81
|
+
Returns defaults when none is found. A pyproject without ``[tool.ghostrules]`` is skipped.
|
|
82
|
+
"""
|
|
83
|
+
if explicit is not None:
|
|
84
|
+
return parse_config(_read_toml(explicit), source=str(explicit))
|
|
85
|
+
dotfile = base_dir / CONFIG_FILENAME
|
|
86
|
+
if dotfile.is_file():
|
|
87
|
+
return parse_config(_read_toml(dotfile), source=CONFIG_FILENAME)
|
|
88
|
+
pyproject = base_dir / "pyproject.toml"
|
|
89
|
+
if pyproject.is_file():
|
|
90
|
+
table = _read_toml(pyproject).get("tool", {}).get("ghostrules")
|
|
91
|
+
if isinstance(table, dict):
|
|
92
|
+
return parse_config(table, source="pyproject.toml [tool.ghostrules]")
|
|
93
|
+
return Config()
|
ghostrules/jsonloc.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""JSON with comments: tolerant stripping and a parser that records source offsets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def strip_jsonc(text: str) -> str:
|
|
12
|
+
"""Blank out ``//`` and ``/* */`` comments and trailing commas, outside of strings.
|
|
13
|
+
|
|
14
|
+
Characters are replaced by spaces (newlines kept) so offsets, lines and columns still match
|
|
15
|
+
the original text.
|
|
16
|
+
"""
|
|
17
|
+
out: list[str] = []
|
|
18
|
+
pending_comma: int | None = None
|
|
19
|
+
in_string = False
|
|
20
|
+
i, n = 0, len(text)
|
|
21
|
+
while i < n:
|
|
22
|
+
ch = text[i]
|
|
23
|
+
if in_string:
|
|
24
|
+
out.append(ch)
|
|
25
|
+
if ch == "\\" and i + 1 < n:
|
|
26
|
+
out.append(text[i + 1])
|
|
27
|
+
i += 1
|
|
28
|
+
elif ch == '"':
|
|
29
|
+
in_string = False
|
|
30
|
+
i += 1
|
|
31
|
+
elif text.startswith("//", i):
|
|
32
|
+
end = text.find("\n", i)
|
|
33
|
+
end = n if end == -1 else end
|
|
34
|
+
out.append(" " * (end - i))
|
|
35
|
+
i = end
|
|
36
|
+
elif text.startswith("/*", i):
|
|
37
|
+
close = text.find("*/", i + 2)
|
|
38
|
+
end = n if close == -1 else close + 2
|
|
39
|
+
out.append("".join(c if c == "\n" else " " for c in text[i:end]))
|
|
40
|
+
i = end
|
|
41
|
+
else:
|
|
42
|
+
if ch in "}]" and pending_comma is not None:
|
|
43
|
+
out[pending_comma] = " "
|
|
44
|
+
if ch == ",":
|
|
45
|
+
pending_comma = len(out)
|
|
46
|
+
elif not ch.isspace():
|
|
47
|
+
pending_comma = None
|
|
48
|
+
in_string = ch == '"'
|
|
49
|
+
out.append(ch)
|
|
50
|
+
i += 1
|
|
51
|
+
return "".join(out)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class JNode:
|
|
56
|
+
"""A parsed JSON value with its source span. ``end`` is exclusive.
|
|
57
|
+
|
|
58
|
+
``members`` (objects) maps each key to ``(key_start, value_node)``; ``elements`` holds array
|
|
59
|
+
items. Both are ``None`` for scalars.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
value: Any
|
|
63
|
+
start: int
|
|
64
|
+
end: int
|
|
65
|
+
members: dict[str, tuple[int, JNode]] | None = None
|
|
66
|
+
elements: list[JNode] | None = None
|
|
67
|
+
|
|
68
|
+
def get(self, key: str) -> JNode | None:
|
|
69
|
+
"""Child node of an object by key."""
|
|
70
|
+
if self.members is None or key not in self.members:
|
|
71
|
+
return None
|
|
72
|
+
return self.members[key][1]
|
|
73
|
+
|
|
74
|
+
def string_span(self) -> tuple[int, int]:
|
|
75
|
+
"""Span of a string value without its quotes (or the whole node for other scalars)."""
|
|
76
|
+
if isinstance(self.value, str):
|
|
77
|
+
return self.start + 1, max(self.start + 1, self.end - 1)
|
|
78
|
+
return self.start, self.end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_SCALAR_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null")
|
|
82
|
+
_MAX_DEPTH = 100
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Parser:
|
|
86
|
+
def __init__(self, text: str) -> None:
|
|
87
|
+
self.text = text
|
|
88
|
+
self.pos = 1 if text.startswith("\ufeff") else 0
|
|
89
|
+
|
|
90
|
+
def _skip_ws(self) -> None:
|
|
91
|
+
while self.pos < len(self.text) and self.text[self.pos] in " \t\r\n":
|
|
92
|
+
self.pos += 1
|
|
93
|
+
|
|
94
|
+
def parse(self) -> JNode:
|
|
95
|
+
node = self._value(0)
|
|
96
|
+
self._skip_ws()
|
|
97
|
+
if self.pos != len(self.text):
|
|
98
|
+
raise ValueError("trailing data")
|
|
99
|
+
return node
|
|
100
|
+
|
|
101
|
+
def _value(self, depth: int) -> JNode:
|
|
102
|
+
if depth > _MAX_DEPTH:
|
|
103
|
+
raise ValueError("too deep")
|
|
104
|
+
self._skip_ws()
|
|
105
|
+
if self.pos >= len(self.text):
|
|
106
|
+
raise ValueError("unexpected end")
|
|
107
|
+
ch = self.text[self.pos]
|
|
108
|
+
if ch == "{":
|
|
109
|
+
return self._object(depth)
|
|
110
|
+
if ch == "[":
|
|
111
|
+
return self._array(depth)
|
|
112
|
+
if ch == '"':
|
|
113
|
+
start, raw = self.pos, self._raw_string()
|
|
114
|
+
return JNode(json.loads(raw), start, self.pos)
|
|
115
|
+
match = _SCALAR_RE.match(self.text, self.pos)
|
|
116
|
+
if not match:
|
|
117
|
+
raise ValueError("bad value")
|
|
118
|
+
self.pos = match.end()
|
|
119
|
+
return JNode(json.loads(match.group()), match.start(), match.end())
|
|
120
|
+
|
|
121
|
+
def _raw_string(self) -> str:
|
|
122
|
+
start = self.pos
|
|
123
|
+
self.pos += 1
|
|
124
|
+
while self.pos < len(self.text):
|
|
125
|
+
ch = self.text[self.pos]
|
|
126
|
+
if ch == "\\":
|
|
127
|
+
self.pos += 2
|
|
128
|
+
continue
|
|
129
|
+
self.pos += 1
|
|
130
|
+
if ch == '"':
|
|
131
|
+
return self.text[start : self.pos]
|
|
132
|
+
raise ValueError("unterminated string")
|
|
133
|
+
|
|
134
|
+
def _object(self, depth: int) -> JNode:
|
|
135
|
+
start = self.pos
|
|
136
|
+
self.pos += 1
|
|
137
|
+
members: dict[str, tuple[int, JNode]] = {}
|
|
138
|
+
value: dict[str, Any] = {}
|
|
139
|
+
self._skip_ws()
|
|
140
|
+
if self.text.startswith("}", self.pos):
|
|
141
|
+
self.pos += 1
|
|
142
|
+
return JNode(value, start, self.pos, members=members)
|
|
143
|
+
while True:
|
|
144
|
+
self._skip_ws()
|
|
145
|
+
if not self.text.startswith('"', self.pos):
|
|
146
|
+
raise ValueError("expected key")
|
|
147
|
+
key_start = self.pos
|
|
148
|
+
key = json.loads(self._raw_string())
|
|
149
|
+
self._skip_ws()
|
|
150
|
+
if not self.text.startswith(":", self.pos):
|
|
151
|
+
raise ValueError("expected colon")
|
|
152
|
+
self.pos += 1
|
|
153
|
+
node = self._value(depth + 1)
|
|
154
|
+
members[key] = (key_start, node)
|
|
155
|
+
value[key] = node.value
|
|
156
|
+
self._skip_ws()
|
|
157
|
+
if self.text.startswith(",", self.pos):
|
|
158
|
+
self.pos += 1
|
|
159
|
+
elif self.text.startswith("}", self.pos):
|
|
160
|
+
self.pos += 1
|
|
161
|
+
return JNode(value, start, self.pos, members=members)
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError("expected comma or brace")
|
|
164
|
+
|
|
165
|
+
def _array(self, depth: int) -> JNode:
|
|
166
|
+
start = self.pos
|
|
167
|
+
self.pos += 1
|
|
168
|
+
elements: list[JNode] = []
|
|
169
|
+
self._skip_ws()
|
|
170
|
+
if self.text.startswith("]", self.pos):
|
|
171
|
+
self.pos += 1
|
|
172
|
+
return JNode([], start, self.pos, elements=elements)
|
|
173
|
+
while True:
|
|
174
|
+
elements.append(self._value(depth + 1))
|
|
175
|
+
self._skip_ws()
|
|
176
|
+
if self.text.startswith(",", self.pos):
|
|
177
|
+
self.pos += 1
|
|
178
|
+
elif self.text.startswith("]", self.pos):
|
|
179
|
+
self.pos += 1
|
|
180
|
+
return JNode([e.value for e in elements], start, self.pos, elements=elements)
|
|
181
|
+
else:
|
|
182
|
+
raise ValueError("expected comma or bracket")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_located(text: str) -> JNode | None:
|
|
186
|
+
"""Parse JSON (comments and trailing commas tolerated) keeping source offsets.
|
|
187
|
+
|
|
188
|
+
Offsets refer to ``text`` itself, because comment stripping preserves length. Returns ``None``
|
|
189
|
+
if the text is not valid JSON.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
return _Parser(strip_jsonc(text)).parse()
|
|
193
|
+
except (ValueError, RecursionError):
|
|
194
|
+
return None
|