cloak-cli 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.
- cloak/__init__.py +3 -0
- cloak/__main__.py +4 -0
- cloak/cli.py +380 -0
- cloak/context/__init__.py +1 -0
- cloak/context/generator.py +255 -0
- cloak/filesystem.py +58 -0
- cloak/obfuscate/__init__.py +1 -0
- cloak/obfuscate/manifest.py +26 -0
- cloak/obfuscate/runner.py +199 -0
- cloak/obfuscate/transformer.py +163 -0
- cloak/policy.py +92 -0
- cloak/scan/__init__.py +1 -0
- cloak/scan/scanner.py +141 -0
- cloak_cli-0.1.0.dist-info/METADATA +391 -0
- cloak_cli-0.1.0.dist-info/RECORD +18 -0
- cloak_cli-0.1.0.dist-info/WHEEL +4 -0
- cloak_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cloak_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
cloak/__init__.py
ADDED
cloak/__main__.py
ADDED
cloak/cli.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""CLOAK CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from cloak import __version__
|
|
13
|
+
from cloak.context.generator import generate as run_context
|
|
14
|
+
from cloak.filesystem import walk_repo
|
|
15
|
+
from cloak.obfuscate.runner import ObfuscateError, ObfuscateResult
|
|
16
|
+
from cloak.obfuscate.runner import run_obfuscate as do_obfuscate
|
|
17
|
+
from cloak.policy import Policy, find_policy, load_policy
|
|
18
|
+
from cloak.scan.scanner import Finding
|
|
19
|
+
from cloak.scan.scanner import scan as run_scan
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="cloak",
|
|
23
|
+
help="Local CLI for safer LLM workflows. Run `cloak --help` for commands.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
26
|
+
)
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
console.print(f"cloak {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def root(
|
|
38
|
+
version: Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
typer.Option(
|
|
41
|
+
"--version",
|
|
42
|
+
callback=_version_callback,
|
|
43
|
+
is_eager=True,
|
|
44
|
+
help="Show version and exit.",
|
|
45
|
+
),
|
|
46
|
+
] = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""CLOAK — local CLI for safer LLM workflows."""
|
|
49
|
+
del version # handled by callback
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def scan(
|
|
54
|
+
path: Annotated[Path, typer.Argument(help="Path to scan (file or directory).")],
|
|
55
|
+
policy_path: Annotated[
|
|
56
|
+
Path | None,
|
|
57
|
+
typer.Option("--policy", help="Path to .cloakpolicy file. Default: walks up from `path`."),
|
|
58
|
+
] = None,
|
|
59
|
+
json_out: Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option("--json", help="Emit JSON instead of human-readable output."),
|
|
62
|
+
] = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Find secrets and proprietary markers in code.
|
|
65
|
+
|
|
66
|
+
Wraps detect-secrets and layers in policy.secret_rules. Raw secrets are never printed —
|
|
67
|
+
only redacted previews. Exits 1 if findings exist, 0 if clean.
|
|
68
|
+
"""
|
|
69
|
+
policy = load_policy(policy_path or find_policy(path))
|
|
70
|
+
repo_root = path.resolve() if path.is_dir() else path.resolve().parent
|
|
71
|
+
files = list(walk_repo(path, policy))
|
|
72
|
+
findings = run_scan(files, policy, repo_root=repo_root)
|
|
73
|
+
|
|
74
|
+
if json_out:
|
|
75
|
+
_emit_scan_json(policy, files, findings)
|
|
76
|
+
else:
|
|
77
|
+
_emit_scan_terminal(policy, files, findings)
|
|
78
|
+
|
|
79
|
+
raise typer.Exit(code=1 if findings else 0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def context(
|
|
84
|
+
path: Annotated[Path, typer.Argument(help="Path to generate context from.")],
|
|
85
|
+
out: Annotated[
|
|
86
|
+
Path | None,
|
|
87
|
+
typer.Option("--out", help="Output file. Default: stdout."),
|
|
88
|
+
] = None,
|
|
89
|
+
copy: Annotated[
|
|
90
|
+
bool,
|
|
91
|
+
typer.Option("--copy", help="Copy result to system clipboard."),
|
|
92
|
+
] = False,
|
|
93
|
+
strict: Annotated[
|
|
94
|
+
bool,
|
|
95
|
+
typer.Option(
|
|
96
|
+
"--strict",
|
|
97
|
+
help="Use strict redaction (alias enums, paraphrase docstrings).",
|
|
98
|
+
),
|
|
99
|
+
] = False,
|
|
100
|
+
policy_path: Annotated[
|
|
101
|
+
Path | None,
|
|
102
|
+
typer.Option("--policy", help="Path to .cloakpolicy file."),
|
|
103
|
+
] = None,
|
|
104
|
+
json_out: Annotated[
|
|
105
|
+
bool,
|
|
106
|
+
typer.Option("--json", help="Emit JSON status instead of generating context."),
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Generate redacted markdown safe to paste into an LLM.
|
|
110
|
+
|
|
111
|
+
Function bodies are replaced with `...`; module-level UPPER_SNAKE constants holding
|
|
112
|
+
dict/list/set/tuple literals are redacted. `--strict` aliases enum values and strips
|
|
113
|
+
docstrings. Output goes to stdout, `--out`, and/or `--copy` (clipboard) — flags compose.
|
|
114
|
+
"""
|
|
115
|
+
policy = load_policy(policy_path or find_policy(path))
|
|
116
|
+
repo_root = path.resolve() if path.is_dir() else path.resolve().parent
|
|
117
|
+
files = list(walk_repo(path, policy))
|
|
118
|
+
|
|
119
|
+
if json_out:
|
|
120
|
+
_emit_context_status_json(policy, files, strict=strict)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
markdown = run_context(files, policy, strict=strict, repo_root=repo_root)
|
|
124
|
+
|
|
125
|
+
wrote_anywhere = False
|
|
126
|
+
if out is not None:
|
|
127
|
+
out.write_text(markdown, encoding="utf-8")
|
|
128
|
+
console.print(f"[green]✓[/green] wrote {len(markdown):,} chars to {out}")
|
|
129
|
+
wrote_anywhere = True
|
|
130
|
+
|
|
131
|
+
if copy:
|
|
132
|
+
if _copy_to_clipboard(markdown):
|
|
133
|
+
console.print(f"[green]✓[/green] copied {len(markdown):,} chars to clipboard")
|
|
134
|
+
wrote_anywhere = True
|
|
135
|
+
else:
|
|
136
|
+
console.print(
|
|
137
|
+
"[yellow]![/yellow] no clipboard tool found "
|
|
138
|
+
"(install pbcopy / xclip / wl-copy / clip.exe)"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not wrote_anywhere:
|
|
142
|
+
# Default: print to stdout (use plain print to avoid rich wrapping markdown).
|
|
143
|
+
print(markdown)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def obfuscate(
|
|
148
|
+
path: Annotated[Path, typer.Argument(help="Path to obfuscate.")],
|
|
149
|
+
out: Annotated[Path, typer.Option("--out", help="Output directory.")],
|
|
150
|
+
verify: Annotated[
|
|
151
|
+
str | None,
|
|
152
|
+
typer.Option(
|
|
153
|
+
"--verify",
|
|
154
|
+
help="Test command to run against the output (must pass for the operation to succeed).",
|
|
155
|
+
),
|
|
156
|
+
] = None,
|
|
157
|
+
profile: Annotated[
|
|
158
|
+
str,
|
|
159
|
+
typer.Option("--profile", help="Obfuscation profile: standard or aggressive."),
|
|
160
|
+
] = "standard",
|
|
161
|
+
policy_path: Annotated[
|
|
162
|
+
Path | None,
|
|
163
|
+
typer.Option("--policy", help="Path to .cloakpolicy file."),
|
|
164
|
+
] = None,
|
|
165
|
+
json_out: Annotated[
|
|
166
|
+
bool,
|
|
167
|
+
typer.Option("--json", help="Emit JSON status instead of running obfuscation."),
|
|
168
|
+
] = False,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Produce a transformed copy of a repo, verified against a test command.
|
|
171
|
+
|
|
172
|
+
v1 (Python only): renames module-private `_names`, optionally strips docstrings per
|
|
173
|
+
policy, copies non-Python files unchanged, writes a `cloak-manifest.json` audit trail
|
|
174
|
+
with source/output sha256 hashes and the rename map. If `--verify` is given, runs the
|
|
175
|
+
test command in the output dir; on non-zero exit, the operation fails.
|
|
176
|
+
"""
|
|
177
|
+
policy = load_policy(policy_path or find_policy(path))
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
result = do_obfuscate(
|
|
181
|
+
path,
|
|
182
|
+
out,
|
|
183
|
+
policy,
|
|
184
|
+
verify_command=verify,
|
|
185
|
+
profile=profile,
|
|
186
|
+
)
|
|
187
|
+
except ObfuscateError as e:
|
|
188
|
+
if json_out:
|
|
189
|
+
print(json.dumps({"command": "obfuscate", "status": "error", "error": str(e)}))
|
|
190
|
+
else:
|
|
191
|
+
console.print(f"[red]✗ obfuscate failed:[/red] {e}")
|
|
192
|
+
raise typer.Exit(code=2) from e
|
|
193
|
+
|
|
194
|
+
exit_code = 0
|
|
195
|
+
if verify and result.verify_passed is False:
|
|
196
|
+
exit_code = 1
|
|
197
|
+
|
|
198
|
+
if json_out:
|
|
199
|
+
_emit_obfuscate_json(policy, result)
|
|
200
|
+
else:
|
|
201
|
+
_emit_obfuscate_terminal(policy, result)
|
|
202
|
+
|
|
203
|
+
raise typer.Exit(code=exit_code)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _emit_obfuscate_json(policy: Policy, result: ObfuscateResult) -> None:
|
|
207
|
+
payload = {
|
|
208
|
+
"command": "obfuscate",
|
|
209
|
+
"status": ("ok" if result.verify_passed in (True, None) else "verify_failed"),
|
|
210
|
+
"output_dir": str(result.output_dir),
|
|
211
|
+
"manifest_path": str(result.manifest_path),
|
|
212
|
+
"files_copied": result.files_copied,
|
|
213
|
+
"files_transformed": result.files_transformed,
|
|
214
|
+
"rename_count": len(result.rename_map),
|
|
215
|
+
"policy_loaded_from": str(policy.source) if policy.source else None,
|
|
216
|
+
"verify_command": result.verify_command,
|
|
217
|
+
"verify_passed": result.verify_passed,
|
|
218
|
+
}
|
|
219
|
+
print(json.dumps(payload, indent=2))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _emit_obfuscate_terminal(policy: Policy, result: ObfuscateResult) -> None:
|
|
223
|
+
if result.verify_command and result.verify_passed is False:
|
|
224
|
+
console.print(
|
|
225
|
+
Panel.fit(
|
|
226
|
+
f"[bold red]✗ Verify failed[/bold red]\n"
|
|
227
|
+
f"command: {result.verify_command}\n\n"
|
|
228
|
+
f"{(result.verify_output or '').rstrip()[:2000]}",
|
|
229
|
+
border_style="red",
|
|
230
|
+
title="cloak obfuscate",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
console.print(
|
|
234
|
+
" [yellow]Output written, but verification failed — do not ship this bundle.[/yellow]"
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
title = "cloak obfuscate"
|
|
239
|
+
lines = [
|
|
240
|
+
"[bold green]✓ Obfuscated[/bold green]",
|
|
241
|
+
f"output: {result.output_dir}",
|
|
242
|
+
f"manifest: {result.manifest_path}",
|
|
243
|
+
f"transformed: {result.files_transformed} python files",
|
|
244
|
+
f"copied: {result.files_copied} other files",
|
|
245
|
+
f"renames: {len(result.rename_map)} module-private identifiers",
|
|
246
|
+
]
|
|
247
|
+
if result.verify_command:
|
|
248
|
+
lines.append(f"verify: [green]passed[/green] ({result.verify_command})")
|
|
249
|
+
elif result.verify_command is None:
|
|
250
|
+
lines.append(
|
|
251
|
+
'verify: [yellow]not run[/yellow] — pass --verify "pytest" to gate the output'
|
|
252
|
+
)
|
|
253
|
+
console.print(Panel.fit("\n".join(lines), border_style="green", title=title))
|
|
254
|
+
if not policy.source:
|
|
255
|
+
console.print(" [dim]no .cloakpolicy found; default obfuscate rules applied[/dim]")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _copy_to_clipboard(text: str) -> bool:
|
|
259
|
+
"""Best-effort copy to system clipboard. Returns True on success."""
|
|
260
|
+
import shutil
|
|
261
|
+
import subprocess
|
|
262
|
+
|
|
263
|
+
candidates: list[list[str]] = []
|
|
264
|
+
if shutil.which("pbcopy"): # macOS
|
|
265
|
+
candidates.append(["pbcopy"])
|
|
266
|
+
if shutil.which("wl-copy"): # Wayland
|
|
267
|
+
candidates.append(["wl-copy"])
|
|
268
|
+
if shutil.which("xclip"): # X11
|
|
269
|
+
candidates.append(["xclip", "-selection", "clipboard"])
|
|
270
|
+
if shutil.which("clip.exe"): # Windows
|
|
271
|
+
candidates.append(["clip.exe"])
|
|
272
|
+
|
|
273
|
+
for cmd in candidates:
|
|
274
|
+
try:
|
|
275
|
+
subprocess.run(cmd, input=text, text=True, check=True, timeout=5)
|
|
276
|
+
return True
|
|
277
|
+
except (subprocess.SubprocessError, OSError):
|
|
278
|
+
continue
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _emit_context_status_json(policy: Policy, files: list[Path], *, strict: bool) -> None:
|
|
283
|
+
payload = {
|
|
284
|
+
"command": "context",
|
|
285
|
+
"status": "ok",
|
|
286
|
+
"files_discovered": len(files),
|
|
287
|
+
"policy_loaded_from": str(policy.source) if policy.source else None,
|
|
288
|
+
"policy_version": policy.version,
|
|
289
|
+
"strict": strict,
|
|
290
|
+
"implementation_status": "Phase 3 — Python supported; JS/TS in Phase 3.5.",
|
|
291
|
+
}
|
|
292
|
+
print(json.dumps(payload, indent=2))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _emit_scan_json(policy: Policy, files: list[Path], findings: list[Finding]) -> None:
|
|
296
|
+
payload = {
|
|
297
|
+
"command": "scan",
|
|
298
|
+
"status": "ok" if not findings else "findings",
|
|
299
|
+
"files_scanned": len(files),
|
|
300
|
+
"policy_loaded_from": str(policy.source) if policy.source else None,
|
|
301
|
+
"policy_version": policy.version,
|
|
302
|
+
"findings": [f.to_dict() for f in findings],
|
|
303
|
+
}
|
|
304
|
+
print(json.dumps(payload, indent=2))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _emit_scan_terminal(policy: Policy, files: list[Path], findings: list[Finding]) -> None:
|
|
308
|
+
if not findings:
|
|
309
|
+
console.print(
|
|
310
|
+
Panel.fit(
|
|
311
|
+
f"[bold green]✓ Clean — {len(files)} files scanned, 0 findings[/bold green]",
|
|
312
|
+
border_style="green",
|
|
313
|
+
title="cloak scan",
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if not policy.source:
|
|
317
|
+
console.print(" [dim]no .cloakpolicy found; default scanner rules applied[/dim]")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
table = Table(title=f"cloak scan — {len(findings)} finding(s)", header_style="bold red")
|
|
321
|
+
table.add_column("severity", style="bold")
|
|
322
|
+
table.add_column("file")
|
|
323
|
+
table.add_column("line", justify="right")
|
|
324
|
+
table.add_column("rule", style="dim")
|
|
325
|
+
table.add_column("preview")
|
|
326
|
+
for f in findings:
|
|
327
|
+
sev_style = {"high": "red", "medium": "yellow", "low": "cyan"}.get(f.severity, "white")
|
|
328
|
+
table.add_row(
|
|
329
|
+
f"[{sev_style}]{f.severity}[/{sev_style}]",
|
|
330
|
+
f.file,
|
|
331
|
+
str(f.line),
|
|
332
|
+
f.rule_id,
|
|
333
|
+
f.redacted_preview,
|
|
334
|
+
)
|
|
335
|
+
console.print(table)
|
|
336
|
+
console.print(
|
|
337
|
+
f"\n [dim]{len(files)} files scanned • "
|
|
338
|
+
f"policy: {policy.source or 'defaults'}[/dim]\n"
|
|
339
|
+
f" [yellow]Action:[/yellow] rotate any real credentials and remove from source."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _emit_skeleton_status(
|
|
344
|
+
command: str,
|
|
345
|
+
policy: Policy,
|
|
346
|
+
files: list[Path],
|
|
347
|
+
*,
|
|
348
|
+
json_out: bool,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Placeholder output for not-yet-implemented commands."""
|
|
351
|
+
if json_out:
|
|
352
|
+
payload = {
|
|
353
|
+
"command": command,
|
|
354
|
+
"status": "scaffold-only",
|
|
355
|
+
"policy_loaded_from": str(policy.source) if policy.source else None,
|
|
356
|
+
"policy_version": policy.version,
|
|
357
|
+
"files_discovered": len(files),
|
|
358
|
+
"implementation_status": ("Scaffolding only — real logic arrives in a later phase."),
|
|
359
|
+
}
|
|
360
|
+
print(json.dumps(payload, indent=2))
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
console.print(f"[bold cyan]cloak {command}[/bold cyan] [dim](scaffold)[/dim]")
|
|
364
|
+
console.print()
|
|
365
|
+
if policy.source:
|
|
366
|
+
console.print(f" policy: {policy.source}")
|
|
367
|
+
else:
|
|
368
|
+
console.print(" policy: [yellow]none found, using defaults[/yellow]")
|
|
369
|
+
console.print(f" files found: {len(files)}")
|
|
370
|
+
console.print()
|
|
371
|
+
console.print(
|
|
372
|
+
f" [yellow]implementation:[/yellow] not yet wired — "
|
|
373
|
+
f"`cloak {command}` arrives in a later phase.\n"
|
|
374
|
+
" See [link=https://github.com/newtophilly/cloak/blob/main/docs/BUILD_PLAN.md]"
|
|
375
|
+
"docs/BUILD_PLAN.md[/link] for the roadmap."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Safe context generation for LLM workflows. Phase 3."""
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Phase 3 — safe context generator.
|
|
2
|
+
|
|
3
|
+
Walks Python source via the stdlib `ast` module and emits a redacted markdown view safe
|
|
4
|
+
to paste into an LLM:
|
|
5
|
+
- Function/method bodies replaced with `...` (stub-file convention).
|
|
6
|
+
- Module-level UPPER_SNAKE constants holding Dict/List/Set/Tuple literals replaced with `...`
|
|
7
|
+
(this is the "proprietary tables" pattern from the Phase 0 case study).
|
|
8
|
+
- Class bodies preserved with method signatures and (per policy) docstrings.
|
|
9
|
+
- `--strict` mode (or policy `alias_enums: true`): enum values are aliased to opaque names.
|
|
10
|
+
- `--strict` mode also forces `keep_docstrings=False`.
|
|
11
|
+
|
|
12
|
+
Python-only for now. JS/TS lands in Phase 3.5 via tree-sitter; we'll refactor to a shared
|
|
13
|
+
parser interface then.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from cloak import __version__
|
|
20
|
+
from cloak.policy import Policy
|
|
21
|
+
|
|
22
|
+
_ENUM_BASE_NAMES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate(
|
|
26
|
+
paths: list[Path],
|
|
27
|
+
policy: Policy,
|
|
28
|
+
*,
|
|
29
|
+
strict: bool = False,
|
|
30
|
+
repo_root: Path | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Return redacted markdown safe to paste into an LLM."""
|
|
33
|
+
if repo_root is None:
|
|
34
|
+
repo_root = paths[0].parent if paths else Path.cwd()
|
|
35
|
+
|
|
36
|
+
# --strict overrides policy: drop docstrings, alias enums, regardless of policy defaults.
|
|
37
|
+
keep_docstrings = policy.context_defaults.keep_docstrings and not strict
|
|
38
|
+
alias_enums = policy.context_defaults.alias_enums or strict
|
|
39
|
+
|
|
40
|
+
sections: list[str] = [_header(policy, strict=strict)]
|
|
41
|
+
|
|
42
|
+
py_files = [p for p in paths if p.suffix == ".py" and p.is_file()]
|
|
43
|
+
other_files = [p for p in paths if p.is_file() and p.suffix != ".py"]
|
|
44
|
+
|
|
45
|
+
sections.append(_file_tree(py_files + other_files, repo_root))
|
|
46
|
+
|
|
47
|
+
for path in py_files:
|
|
48
|
+
sections.append(
|
|
49
|
+
_python_section(
|
|
50
|
+
path,
|
|
51
|
+
repo_root=repo_root,
|
|
52
|
+
keep_docstrings=keep_docstrings,
|
|
53
|
+
alias_enums=alias_enums,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if other_files:
|
|
58
|
+
sections.append(_unsupported_files_note(other_files, repo_root))
|
|
59
|
+
|
|
60
|
+
return "\n\n".join(sections) + "\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _header(policy: Policy, *, strict: bool) -> str:
|
|
64
|
+
policy_src = str(policy.source) if policy.source else "defaults"
|
|
65
|
+
strict_note = (
|
|
66
|
+
"\n\nThis is `--strict` mode: enum values are aliased and docstrings are stripped."
|
|
67
|
+
if strict
|
|
68
|
+
else ""
|
|
69
|
+
)
|
|
70
|
+
return (
|
|
71
|
+
f"<!-- generated by cloak {__version__}; "
|
|
72
|
+
f"policy: {policy_src}; strict: {str(strict).lower()} -->\n"
|
|
73
|
+
f"# Safe context\n\n"
|
|
74
|
+
f"This is a redacted view of the source, suitable for sharing with an LLM. "
|
|
75
|
+
f"Function and method bodies are replaced with `...`. "
|
|
76
|
+
f"Module-level constants holding dict/list/set/tuple literals are redacted. "
|
|
77
|
+
f"Imports, class shapes, and signatures are preserved."
|
|
78
|
+
f"{strict_note}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _file_tree(files: list[Path], repo_root: Path) -> str:
|
|
83
|
+
if not files:
|
|
84
|
+
return "## Files\n\n_(none)_"
|
|
85
|
+
rels = sorted({_safe_rel(f, repo_root) for f in files})
|
|
86
|
+
body = "\n".join(rels)
|
|
87
|
+
return f"## Files\n\n```\n{body}\n```"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _python_section(
|
|
91
|
+
path: Path,
|
|
92
|
+
*,
|
|
93
|
+
repo_root: Path,
|
|
94
|
+
keep_docstrings: bool,
|
|
95
|
+
alias_enums: bool,
|
|
96
|
+
) -> str:
|
|
97
|
+
rel = _safe_rel(path, repo_root)
|
|
98
|
+
try:
|
|
99
|
+
text = path.read_text(encoding="utf-8")
|
|
100
|
+
except OSError as e:
|
|
101
|
+
return f"## `{rel}`\n\n_(unreadable: {e})_"
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
tree = ast.parse(text)
|
|
105
|
+
except SyntaxError as e:
|
|
106
|
+
return f"## `{rel}`\n\n_(parse error: {e.msg})_"
|
|
107
|
+
|
|
108
|
+
redactor = _Redactor(keep_docstrings=keep_docstrings, alias_enums=alias_enums)
|
|
109
|
+
redacted = redactor.visit(tree)
|
|
110
|
+
ast.fix_missing_locations(redacted)
|
|
111
|
+
return f"## `{rel}`\n\n```python\n{ast.unparse(redacted)}\n```"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unsupported_files_note(files: list[Path], repo_root: Path) -> str:
|
|
115
|
+
rels = sorted(_safe_rel(f, repo_root) for f in files)
|
|
116
|
+
items = "\n".join(f"- `{r}`" for r in rels)
|
|
117
|
+
return (
|
|
118
|
+
"## Files not parsed\n\n"
|
|
119
|
+
"These files were discovered but are not yet supported by `cloak context` "
|
|
120
|
+
"(JS/TS arrives in Phase 3.5):\n\n"
|
|
121
|
+
f"{items}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _safe_rel(p: Path, repo_root: Path) -> str:
|
|
126
|
+
try:
|
|
127
|
+
return str(p.resolve().relative_to(repo_root.resolve()))
|
|
128
|
+
except ValueError:
|
|
129
|
+
return str(p)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Redactor(ast.NodeTransformer):
|
|
133
|
+
"""Redact function bodies, sensitive module-level tables, and (optionally) enum values."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, *, keep_docstrings: bool, alias_enums: bool) -> None:
|
|
136
|
+
self.keep_docstrings = keep_docstrings
|
|
137
|
+
self.alias_enums = alias_enums
|
|
138
|
+
|
|
139
|
+
# --- function/method body redaction -------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
|
|
142
|
+
return self._redact_callable(node)
|
|
143
|
+
|
|
144
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
|
|
145
|
+
return self._redact_callable(node)
|
|
146
|
+
|
|
147
|
+
def _redact_callable(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
|
|
148
|
+
new_body: list[ast.stmt] = []
|
|
149
|
+
if self.keep_docstrings and _is_docstring(node.body[0] if node.body else None):
|
|
150
|
+
new_body.append(node.body[0])
|
|
151
|
+
# `...` ellipsis as the body — Python stub-file convention. Unparses cleanly.
|
|
152
|
+
new_body.append(ast.Expr(value=ast.Constant(value=Ellipsis)))
|
|
153
|
+
node.body = new_body
|
|
154
|
+
return node
|
|
155
|
+
|
|
156
|
+
# --- class redaction (preserve method shapes; optionally alias enum values) ---------
|
|
157
|
+
|
|
158
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST:
|
|
159
|
+
if self.alias_enums and _is_enum_class(node):
|
|
160
|
+
return self._alias_enum_class(node)
|
|
161
|
+
|
|
162
|
+
new_body: list[ast.stmt] = []
|
|
163
|
+
for i, stmt in enumerate(node.body):
|
|
164
|
+
if i == 0 and _is_docstring(stmt):
|
|
165
|
+
if self.keep_docstrings:
|
|
166
|
+
new_body.append(stmt)
|
|
167
|
+
continue
|
|
168
|
+
visited = self.visit(stmt)
|
|
169
|
+
if isinstance(visited, ast.stmt):
|
|
170
|
+
new_body.append(visited)
|
|
171
|
+
node.body = new_body or [ast.Pass()]
|
|
172
|
+
return node
|
|
173
|
+
|
|
174
|
+
def _alias_enum_class(self, node: ast.ClassDef) -> ast.ClassDef:
|
|
175
|
+
new_body: list[ast.stmt] = []
|
|
176
|
+
i = 0
|
|
177
|
+
if i < len(node.body) and _is_docstring(node.body[i]):
|
|
178
|
+
if self.keep_docstrings:
|
|
179
|
+
new_body.append(node.body[i])
|
|
180
|
+
i += 1
|
|
181
|
+
|
|
182
|
+
counter = 0
|
|
183
|
+
for stmt in node.body[i:]:
|
|
184
|
+
if (
|
|
185
|
+
isinstance(stmt, ast.Assign)
|
|
186
|
+
and len(stmt.targets) == 1
|
|
187
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
188
|
+
and isinstance(stmt.value, ast.Constant)
|
|
189
|
+
):
|
|
190
|
+
aliased = ast.Assign(
|
|
191
|
+
targets=[ast.Name(id=f"VALUE_{counter}", ctx=ast.Store())],
|
|
192
|
+
value=ast.Constant(value=f"V{counter}"),
|
|
193
|
+
)
|
|
194
|
+
new_body.append(aliased)
|
|
195
|
+
counter += 1
|
|
196
|
+
else:
|
|
197
|
+
new_body.append(stmt)
|
|
198
|
+
node.body = new_body or [ast.Pass()]
|
|
199
|
+
return node
|
|
200
|
+
|
|
201
|
+
# --- module-level: redact UPPER_SNAKE table constants ------------------------------
|
|
202
|
+
|
|
203
|
+
def visit_Module(self, node: ast.Module) -> ast.AST:
|
|
204
|
+
new_body: list[ast.stmt] = []
|
|
205
|
+
for i, stmt in enumerate(node.body):
|
|
206
|
+
if i == 0 and _is_docstring(stmt):
|
|
207
|
+
if self.keep_docstrings:
|
|
208
|
+
new_body.append(stmt)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if _is_proprietary_table(stmt):
|
|
212
|
+
# _is_proprietary_table guarantees stmt is an ast.Assign.
|
|
213
|
+
assert isinstance(stmt, ast.Assign)
|
|
214
|
+
redacted = ast.Assign(
|
|
215
|
+
targets=stmt.targets,
|
|
216
|
+
value=ast.Constant(value=Ellipsis),
|
|
217
|
+
)
|
|
218
|
+
new_body.append(redacted)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
visited = self.visit(stmt)
|
|
222
|
+
if isinstance(visited, ast.stmt):
|
|
223
|
+
new_body.append(visited)
|
|
224
|
+
node.body = new_body
|
|
225
|
+
return node
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_docstring(stmt: ast.stmt | None) -> bool:
|
|
229
|
+
return (
|
|
230
|
+
isinstance(stmt, ast.Expr)
|
|
231
|
+
and isinstance(stmt.value, ast.Constant)
|
|
232
|
+
and isinstance(stmt.value.value, str)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_enum_class(node: ast.ClassDef) -> bool:
|
|
237
|
+
for base in node.bases:
|
|
238
|
+
if isinstance(base, ast.Name) and base.id in _ENUM_BASE_NAMES:
|
|
239
|
+
return True
|
|
240
|
+
if isinstance(base, ast.Attribute) and base.attr in _ENUM_BASE_NAMES:
|
|
241
|
+
return True
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_proprietary_table(stmt: ast.stmt) -> bool:
|
|
246
|
+
"""Heuristic: module-level UPPER_SNAKE (or _UPPER_SNAKE) name = {dict/list/set/tuple}."""
|
|
247
|
+
if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
|
|
248
|
+
return False
|
|
249
|
+
target = stmt.targets[0]
|
|
250
|
+
if not isinstance(target, ast.Name):
|
|
251
|
+
return False
|
|
252
|
+
name = target.id.lstrip("_")
|
|
253
|
+
if not name or not name.replace("_", "").isupper() or not any(c.isalpha() for c in name):
|
|
254
|
+
return False
|
|
255
|
+
return isinstance(stmt.value, (ast.Dict, ast.List, ast.Set, ast.Tuple))
|