cloak-cli 0.1.2__tar.gz → 0.2.1__tar.gz

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 (48) hide show
  1. cloak_cli-0.2.1/.pre-commit-hooks.yaml +35 -0
  2. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/CHANGELOG.md +15 -0
  3. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/PKG-INFO +29 -2
  4. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/README.md +28 -1
  5. cloak_cli-0.2.1/examples/README.md +61 -0
  6. cloak_cli-0.2.1/examples/js-api-client/.cloakpolicy +25 -0
  7. cloak_cli-0.2.1/examples/js-api-client/client.js +34 -0
  8. cloak_cli-0.2.1/examples/python-pricing-engine/.cloakpolicy +24 -0
  9. cloak_cli-0.2.1/examples/python-pricing-engine/pricing.py +43 -0
  10. cloak_cli-0.2.1/examples/python-pricing-engine/test_pricing.py +19 -0
  11. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/pyproject.toml +1 -1
  12. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/__init__.py +1 -1
  13. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/cli.py +1 -1
  14. cloak_cli-0.2.1/src/cloak/obfuscate/js_transformer.py +175 -0
  15. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/obfuscate/runner.py +22 -3
  16. cloak_cli-0.2.1/tests/test_obfuscate_js.py +201 -0
  17. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/.cloakpolicy.example +0 -0
  18. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  19. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/.github/workflows/release.yml +0 -0
  20. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/.gitignore +0 -0
  21. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/CONTRIBUTING.md +0 -0
  22. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/LICENSE +0 -0
  23. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/AGENT_INTEGRATION.md +0 -0
  24. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/BUILD_PLAN.md +0 -0
  25. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/COMPETITOR_RESEARCH.md +0 -0
  26. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/PROMPTS.md +0 -0
  27. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/quotecraft.py +0 -0
  28. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/quotecraft.redacted.py +0 -0
  29. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/result_prompt1.md +0 -0
  30. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/docs/research/result_prompt2.md +0 -0
  31. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/__main__.py +0 -0
  32. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/context/__init__.py +0 -0
  33. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/context/generator.py +0 -0
  34. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/context/js_redactor.py +0 -0
  35. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/filesystem.py +0 -0
  36. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/obfuscate/__init__.py +0 -0
  37. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/obfuscate/manifest.py +0 -0
  38. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/obfuscate/transformer.py +0 -0
  39. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/policy.py +0 -0
  40. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/policy_init.py +0 -0
  41. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/scan/__init__.py +0 -0
  42. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/src/cloak/scan/scanner.py +0 -0
  43. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/__init__.py +0 -0
  44. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/test_cli.py +0 -0
  45. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/test_context.py +0 -0
  46. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/test_context_js.py +0 -0
  47. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/test_obfuscate.py +0 -0
  48. {cloak_cli-0.1.2 → cloak_cli-0.2.1}/tests/test_policy_init.py +0 -0
@@ -0,0 +1,35 @@
1
+ # pre-commit hook definitions for CLOAK.
2
+ #
3
+ # Add to your .pre-commit-config.yaml:
4
+ #
5
+ # repos:
6
+ # - repo: https://github.com/newtophilly/cloak
7
+ # rev: v0.2.0
8
+ # hooks:
9
+ # - id: cloak-scan
10
+ #
11
+ # See https://pre-commit.com for general pre-commit setup.
12
+
13
+ - id: cloak-scan
14
+ name: cloak scan
15
+ description: >-
16
+ Find secrets and proprietary markers in code before commit. Wraps detect-secrets and
17
+ layers in policy.secret_rules from .cloakpolicy. Exits 1 on findings (blocks commit).
18
+ entry: cloak scan
19
+ language: python
20
+ pass_filenames: false
21
+ args: ["."]
22
+ always_run: true
23
+ stages: [pre-commit]
24
+
25
+ - id: cloak-context-preview
26
+ name: cloak context (preview only)
27
+ description: >-
28
+ Render a redacted markdown view of the repo. Useful as a manual check that the redaction
29
+ output looks right. Does not block commits — `stages: [manual]` means run only when you
30
+ invoke it explicitly: `pre-commit run cloak-context-preview --hook-stage manual`.
31
+ entry: cloak context
32
+ language: python
33
+ pass_filenames: false
34
+ args: [".", "--json"]
35
+ stages: [manual]
@@ -6,6 +6,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-05-08
10
+
11
+ ### Added
12
+ - `.pre-commit-hooks.yaml` at repo root. Users can add `cloak-scan` to their `.pre-commit-config.yaml` and `cloak scan` runs as a pre-commit gate (exits 1 on findings, blocks the commit). A second `cloak-context-preview` hook is provided at `stages: [manual]` for opt-in invocation.
13
+ - `examples/` directory with two end-to-end demos: `python-pricing-engine/` (with pytest coverage so `--verify "pytest"` works) and `js-api-client/`. Each has its own `.cloakpolicy`. New users can `cd examples/python-pricing-engine && cloak scan .` and see CLOAK in action in 30 seconds. See `examples/README.md` for the walkthrough.
14
+ - README "Try it on the included examples" + "Use as a pre-commit hook" sections.
15
+
16
+ ## [0.2.0] - 2026-05-08
17
+
18
+ Feature matrix complete: all three headline commands now work for both Python and JS/TS.
19
+
20
+ ### Added
21
+ - Phase 5: `cloak obfuscate` now handles JavaScript and TypeScript files, dispatched by extension. Per-file rename of module-level identifiers starting with `_` (function declarations, class declarations, generator functions, `const`/`let`/`var` simple bindings) using tree-sitter for parsing and byte-splice for output. Skips dunder-like names (`__version`), all-underscore names, and anything matching `policy.public_api` (with trailing-`*` wildcard support). Property access (`obj._foo`), shorthand object properties, and destructuring patterns are deliberately not renamed in v1 — they would silently change object shapes and risk runtime breakage. Limitations are caught by `--verify` (cross-file imports, local-variable shadowing of module-level `_names`).
22
+ - `cloak obfuscate` against a mixed Python+JS repo now transforms both languages in one pass with one manifest, one rename map, and one verify command.
23
+
9
24
  ## [0.1.2] - 2026-05-08
10
25
 
11
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloak-cli
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Local CLI for safer LLM workflows: redact code before pasting, generate verified obfuscated copies, enforce policy from your repo.
5
5
  Project-URL: Homepage, https://github.com/newtophilly/cloak
6
6
  Project-URL: Repository, https://github.com/newtophilly/cloak
@@ -324,6 +324,33 @@ $ cloak obfuscate src/payments --out /tmp/payments.cloaked --verify "pytest test
324
324
  $ cloak scan . --json # exits 1 if any secrets, JSON for parsing.
325
325
  ```
326
326
 
327
+ ### Try it on the included examples
328
+
329
+ The repo ships [`examples/`](examples/) with one Python and one JS project, each with its own `.cloakpolicy`. Clone, install, and run end-to-end against either in 30 seconds:
330
+
331
+ ```bash
332
+ cd examples/python-pricing-engine
333
+ cloak scan .
334
+ cloak context . --copy
335
+ cloak obfuscate . --out /tmp/pricing.cloaked --verify "pytest"
336
+ ```
337
+
338
+ See [examples/README.md](examples/README.md) for the full walkthrough.
339
+
340
+ ### Use as a pre-commit hook
341
+
342
+ Drop `cloak scan` into `.pre-commit-config.yaml` to block commits that introduce secrets:
343
+
344
+ ```yaml
345
+ repos:
346
+ - repo: https://github.com/newtophilly/cloak
347
+ rev: v0.2.0
348
+ hooks:
349
+ - id: cloak-scan
350
+ ```
351
+
352
+ Then `pre-commit install` and you're done. See [pre-commit.com](https://pre-commit.com) for general setup.
353
+
327
354
  ## How `.cloakpolicy` works
328
355
 
329
356
  The policy lives in a `.cloakpolicy` YAML file at the repo root. It's checked into git, versioned with your code, and reviewed via the same PR process as everything else. Authority = whoever has merge access.
@@ -385,7 +412,7 @@ CLOAK is designed to be called as a subprocess from other developer tools and AI
385
412
  | 3 | `cloak context` for Python | ✅ Done |
386
413
  | 3.5 | `cloak context` JS/TS via tree-sitter | ✅ Done |
387
414
  | 4 | `cloak obfuscate` Python with `--verify` | ✅ Done (v1) |
388
- | 5 | `cloak obfuscate` JS/TS (javascript-obfuscator) | |
415
+ | 5 | `cloak obfuscate` JS/TS via tree-sitter | Done (v1) |
389
416
  | 6 | `cloak eval` (LLM-prompt-based regression harness) | ⏳ |
390
417
 
391
418
  ## Contributing
@@ -83,6 +83,33 @@ $ cloak obfuscate src/payments --out /tmp/payments.cloaked --verify "pytest test
83
83
  $ cloak scan . --json # exits 1 if any secrets, JSON for parsing.
84
84
  ```
85
85
 
86
+ ### Try it on the included examples
87
+
88
+ The repo ships [`examples/`](examples/) with one Python and one JS project, each with its own `.cloakpolicy`. Clone, install, and run end-to-end against either in 30 seconds:
89
+
90
+ ```bash
91
+ cd examples/python-pricing-engine
92
+ cloak scan .
93
+ cloak context . --copy
94
+ cloak obfuscate . --out /tmp/pricing.cloaked --verify "pytest"
95
+ ```
96
+
97
+ See [examples/README.md](examples/README.md) for the full walkthrough.
98
+
99
+ ### Use as a pre-commit hook
100
+
101
+ Drop `cloak scan` into `.pre-commit-config.yaml` to block commits that introduce secrets:
102
+
103
+ ```yaml
104
+ repos:
105
+ - repo: https://github.com/newtophilly/cloak
106
+ rev: v0.2.0
107
+ hooks:
108
+ - id: cloak-scan
109
+ ```
110
+
111
+ Then `pre-commit install` and you're done. See [pre-commit.com](https://pre-commit.com) for general setup.
112
+
86
113
  ## How `.cloakpolicy` works
87
114
 
88
115
  The policy lives in a `.cloakpolicy` YAML file at the repo root. It's checked into git, versioned with your code, and reviewed via the same PR process as everything else. Authority = whoever has merge access.
@@ -144,7 +171,7 @@ CLOAK is designed to be called as a subprocess from other developer tools and AI
144
171
  | 3 | `cloak context` for Python | ✅ Done |
145
172
  | 3.5 | `cloak context` JS/TS via tree-sitter | ✅ Done |
146
173
  | 4 | `cloak obfuscate` Python with `--verify` | ✅ Done (v1) |
147
- | 5 | `cloak obfuscate` JS/TS (javascript-obfuscator) | |
174
+ | 5 | `cloak obfuscate` JS/TS via tree-sitter | Done (v1) |
148
175
  | 6 | `cloak eval` (LLM-prompt-based regression harness) | ⏳ |
149
176
 
150
177
  ## Contributing
@@ -0,0 +1,61 @@
1
+ # CLOAK examples
2
+
3
+ Two tiny projects to demonstrate `cloak scan`, `cloak context`, and `cloak obfuscate` end to end.
4
+
5
+ Both have their own `.cloakpolicy` so you can run CLOAK against them right away.
6
+
7
+ ## `python-pricing-engine/`
8
+
9
+ A simplified pricing engine: a public `calculate_total` plus private `_apply_tier` and `_apply_region` helpers, with `_TIER_DISCOUNTS` and `_REGIONAL_MARKUPS` proprietary tables. Includes pytest coverage so `--verify` has something real to check.
10
+
11
+ ```bash
12
+ cd examples/python-pricing-engine
13
+
14
+ # 1. Scan for secrets / proprietary markers
15
+ cloak scan .
16
+
17
+ # 2. Generate redacted markdown safe to paste into ChatGPT/Claude
18
+ cloak context . --copy
19
+ # Bodies replaced with `...`. Tables (_TIER_DISCOUNTS, _REGIONAL_MARKUPS) replaced
20
+ # with `...`. Public API + signatures preserved.
21
+
22
+ # 3. Obfuscate, gated on the pytest suite passing
23
+ cloak obfuscate . --out /tmp/pricing.cloaked --verify "pytest"
24
+ # Private helpers renamed (_apply_tier → _a000, _apply_region → _a001).
25
+ # Public `calculate_total` preserved (it's in .cloakpolicy public_api).
26
+ # Tests pass against the obfuscated copy.
27
+ ls /tmp/pricing.cloaked/cloak-manifest.json # audit trail with hashes + rename map
28
+ ```
29
+
30
+ ## `js-api-client/`
31
+
32
+ A small API client with a public `fetchJson` plus private `_buildHeaders` and `_normalizePath` helpers, and `_BASE_HEADERS` / `_TIMEOUT_MS` proprietary constants.
33
+
34
+ ```bash
35
+ cd examples/js-api-client
36
+
37
+ cloak scan .
38
+ cloak context . --copy
39
+ cloak obfuscate . --out /tmp/client.cloaked
40
+ # Note: no --verify here because the example doesn't ship a Node test runner setup.
41
+ # In your real project, you'd pass --verify "npm test" or similar.
42
+ ```
43
+
44
+ ## What you should observe
45
+
46
+ - `cloak scan` exits 0 (no secrets in these examples) and prints a green "Clean" panel.
47
+ - `cloak context` produces markdown where signatures and class shapes survive, but bodies and proprietary tables are replaced with `...` (Python) or `/* [REDACTED BY CLOAK] */` (JS/TS).
48
+ - `cloak obfuscate` produces a transformed copy in the output dir with `_a000`/`_a001`/... identifiers replacing the original `_names`. Public-API names listed in `.cloakpolicy` are preserved.
49
+ - The `cloak-manifest.json` in the output dir records: cloak version, source/output sha256s, the rename map, the policy snapshot, and (if `--verify` was passed) the verify command + result.
50
+
51
+ ## Want to try `--verify` failing?
52
+
53
+ Edit `pricing.py` to break the math (e.g., return `subtotal` instead of the discounted total) and rerun:
54
+
55
+ ```bash
56
+ cloak obfuscate . --out /tmp/pricing.cloaked --verify "pytest"
57
+ # Exit code 1, panel shows the failing pytest output.
58
+ # Output is still written for inspection but the operation is reported as failed.
59
+ ```
60
+
61
+ This is the differentiator: `cloak obfuscate` only succeeds if your tests pass against the transformed copy.
@@ -0,0 +1,25 @@
1
+ # Example policy for the JS API client.
2
+ version: 1
3
+
4
+ sensitive_paths:
5
+ - "*.js"
6
+ - "*.ts"
7
+
8
+ # `fetchJson` is the public API — never let obfuscate rename it.
9
+ public_api:
10
+ - "fetchJson"
11
+
12
+ secret_rules: []
13
+ allow_strings: []
14
+
15
+ context_defaults:
16
+ keep_docstrings: true
17
+ redact_function_bodies: true
18
+ alias_enums: false
19
+
20
+ obfuscate_defaults:
21
+ rename_private: true
22
+ rename_public_api: false
23
+ encode_strings: false
24
+ strip_docstrings: false
25
+ profile: standard
@@ -0,0 +1,34 @@
1
+ // Tiny example API client — public function plus underscore-private helpers.
2
+ //
3
+ // Demonstrates how `cloak context` and `cloak obfuscate` treat JS files.
4
+
5
+ const _BASE_HEADERS = {
6
+ "User-Agent": "example-client/1.0",
7
+ "Accept": "application/json",
8
+ };
9
+
10
+ const _TIMEOUT_MS = 5000;
11
+
12
+ function _buildHeaders(token) {
13
+ return {
14
+ ..._BASE_HEADERS,
15
+ Authorization: `Bearer ${token}`,
16
+ };
17
+ }
18
+
19
+ function _normalizePath(path) {
20
+ if (!path.startsWith("/")) return `/${path}`;
21
+ return path;
22
+ }
23
+
24
+ export async function fetchJson(baseUrl, path, token) {
25
+ const url = baseUrl + _normalizePath(path);
26
+ const response = await fetch(url, {
27
+ headers: _buildHeaders(token),
28
+ signal: AbortSignal.timeout(_TIMEOUT_MS),
29
+ });
30
+ if (!response.ok) {
31
+ throw new Error(`HTTP ${response.status}`);
32
+ }
33
+ return response.json();
34
+ }
@@ -0,0 +1,24 @@
1
+ # Example policy for the Python pricing engine.
2
+ version: 1
3
+
4
+ sensitive_paths:
5
+ - "*.py"
6
+
7
+ # `calculate_total` is the public entry point — never let obfuscate rename it.
8
+ public_api:
9
+ - "calculate_total"
10
+
11
+ secret_rules: []
12
+ allow_strings: []
13
+
14
+ context_defaults:
15
+ keep_docstrings: true
16
+ redact_function_bodies: true
17
+ alias_enums: false
18
+
19
+ obfuscate_defaults:
20
+ rename_private: true
21
+ rename_public_api: false
22
+ encode_strings: false
23
+ strip_docstrings: false
24
+ profile: standard
@@ -0,0 +1,43 @@
1
+ """Tiny example pricing engine — public API + private helpers + 'proprietary tables'.
2
+
3
+ This file exists to demonstrate how `cloak context` and `cloak obfuscate` treat each kind
4
+ of definition. It is intentionally small and self-contained.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ _TIER_DISCOUNTS = {
11
+ "basic": 0.00,
12
+ "pro": 0.10,
13
+ "enterprise": 0.20,
14
+ }
15
+
16
+ _REGIONAL_MARKUPS = {
17
+ "domestic": 1.00,
18
+ "international": 1.15,
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class Customer:
24
+ customer_id: str
25
+ tier: str
26
+ region: str
27
+
28
+
29
+ def calculate_total(customer: Customer, subtotal: float) -> float:
30
+ """Public API: produce a final price for a customer + subtotal."""
31
+ discount = _apply_tier(customer.tier)
32
+ markup = _apply_region(customer.region)
33
+ return subtotal * (1 - discount) * markup
34
+
35
+
36
+ def _apply_tier(tier: str) -> float:
37
+ """Return the discount fraction for a tier."""
38
+ return _TIER_DISCOUNTS.get(tier, 0.0)
39
+
40
+
41
+ def _apply_region(region: str) -> float:
42
+ """Return the price multiplier for a region."""
43
+ return _REGIONAL_MARKUPS.get(region, 1.0)
@@ -0,0 +1,19 @@
1
+ """Tests for the example pricing engine — used by `cloak obfuscate --verify`."""
2
+
3
+ from pricing import Customer, calculate_total
4
+
5
+
6
+ def test_basic_domestic() -> None:
7
+ c = Customer(customer_id="C1", tier="basic", region="domestic")
8
+ assert calculate_total(c, 100.0) == 100.0
9
+
10
+
11
+ def test_pro_domestic_gets_10_percent() -> None:
12
+ c = Customer(customer_id="C2", tier="pro", region="domestic")
13
+ assert calculate_total(c, 100.0) == 90.0
14
+
15
+
16
+ def test_enterprise_international() -> None:
17
+ c = Customer(customer_id="C3", tier="enterprise", region="international")
18
+ # 100 * 0.80 * 1.15 = 92.0
19
+ assert calculate_total(c, 100.0) == 92.0
@@ -65,7 +65,7 @@ packages = ["src/cloak"]
65
65
  [tool.ruff]
66
66
  line-length = 100
67
67
  target-version = "py311"
68
- extend-exclude = ["docs", ".venv"]
68
+ extend-exclude = ["docs", ".venv", "examples"]
69
69
 
70
70
  [tool.ruff.lint]
71
71
  select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
@@ -1,3 +1,3 @@
1
1
  """CLOAK — local CLI for safer LLM workflows."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.1"
@@ -247,7 +247,7 @@ def _emit_obfuscate_terminal(policy: Policy, result: ObfuscateResult) -> None:
247
247
  "[bold green]✓ Obfuscated[/bold green]",
248
248
  f"output: {result.output_dir}",
249
249
  f"manifest: {result.manifest_path}",
250
- f"transformed: {result.files_transformed} python files",
250
+ f"transformed: {result.files_transformed} source files",
251
251
  f"copied: {result.files_copied} other files",
252
252
  f"renames: {len(result.rename_map)} module-private identifiers",
253
253
  ]
@@ -0,0 +1,175 @@
1
+ """Phase 5 — JS/TS obfuscation transformer.
2
+
3
+ Per-file rename of module-level identifiers that start with `_` (and aren't dunder-like or
4
+ all underscores). Same shape as the Python obfuscator: collect candidate names, build a
5
+ deterministic rename map (`_a000`, `_a001`, ...), then byte-splice every reference of those
6
+ names in the file with their new identifier.
7
+
8
+ Tree-sitter is used as a parser only — we don't unparse. Edits happen at the byte level so
9
+ all formatting and comments outside renamed identifiers survive.
10
+
11
+ v1 limitations (deferred — caught by `--verify`):
12
+ - Cross-file rename. If `import { _helper } from './lib'`, the import keeps `_helper` but
13
+ the target file renames it; tests will fail and the user knows immediately.
14
+ - Local-variable shadowing. If a function body defines `const _helper = ...` shadowing a
15
+ module-level `_helper`, both get renamed; tests will catch logic bugs that arise.
16
+ - Shorthand property in object literals (`{ _helper }`) and destructuring patterns
17
+ (`const { _helper } = ...`) are NOT renamed (would change object shape silently). This
18
+ means a module-level `_helper` referenced via shorthand goes un-renamed there.
19
+ - Class methods are NOT renamed (could be public API).
20
+ """
21
+
22
+ from dataclasses import dataclass, field
23
+
24
+ from tree_sitter import Node, Parser
25
+
26
+ from cloak.context.js_redactor import JsLikeKind, _get_language
27
+ from cloak.policy import Policy
28
+
29
+ _RENAMEABLE_IDENTIFIER_TYPES = {"identifier", "type_identifier"}
30
+
31
+ _DEFINITION_NODE_TYPES = {
32
+ "function_declaration",
33
+ "generator_function_declaration",
34
+ "class_declaration",
35
+ "lexical_declaration",
36
+ "variable_declaration",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class JsTransformResult:
42
+ """Result of transforming a single JS/TS file."""
43
+
44
+ source_text: str
45
+ output_text: str
46
+ rename_map: dict[str, str] = field(default_factory=dict)
47
+
48
+
49
+ def transform_js_like_source(
50
+ source: str,
51
+ kind: JsLikeKind,
52
+ policy: Policy,
53
+ ) -> JsTransformResult:
54
+ """Apply v1 obfuscation to a JS/TS source string. Returns transformed text + rename map."""
55
+ src_bytes = source.encode("utf-8")
56
+ parser = Parser(_get_language(kind))
57
+ tree = parser.parse(src_bytes)
58
+
59
+ names = _collect_top_level_private_names(tree.root_node, src_bytes, policy)
60
+ rename_map = _build_rename_map(names)
61
+
62
+ if not rename_map:
63
+ return JsTransformResult(source_text=source, output_text=source)
64
+
65
+ edits: list[tuple[int, int, bytes]] = []
66
+ _collect_rename_edits(tree.root_node, src_bytes, rename_map, edits)
67
+
68
+ if not edits:
69
+ return JsTransformResult(source_text=source, output_text=source, rename_map=rename_map)
70
+
71
+ edits.sort(key=lambda e: e[0], reverse=True)
72
+ out = bytearray(src_bytes)
73
+ for start, end, replacement in edits:
74
+ out[start:end] = replacement
75
+ return JsTransformResult(
76
+ source_text=source,
77
+ output_text=out.decode("utf-8", errors="replace"),
78
+ rename_map=rename_map,
79
+ )
80
+
81
+
82
+ def _collect_top_level_private_names(root_node: Node, src: bytes, policy: Policy) -> list[str]:
83
+ """Find top-level definitions whose name starts with `_`. Skips dunders, all-underscore."""
84
+ names: list[str] = []
85
+ seen: set[str] = set()
86
+ public_api = set(policy.public_api)
87
+
88
+ for child in root_node.children:
89
+ # `export ...` wraps a real declaration — peek through one level.
90
+ target = child
91
+ if child.type == "export_statement":
92
+ for inner in child.children:
93
+ if inner.type in _DEFINITION_NODE_TYPES:
94
+ target = inner
95
+ break
96
+
97
+ for name in _extract_definition_names(target, src):
98
+ if _should_rename(name, public_api) and name not in seen:
99
+ seen.add(name)
100
+ names.append(name)
101
+
102
+ return names
103
+
104
+
105
+ def _extract_definition_names(node: Node, src: bytes) -> list[str]:
106
+ """Return the symbol names this top-level definition introduces."""
107
+ if node.type in {
108
+ "function_declaration",
109
+ "generator_function_declaration",
110
+ "class_declaration",
111
+ }:
112
+ name_node = node.child_by_field_name("name")
113
+ if name_node is not None:
114
+ return [_text(name_node, src)]
115
+ return []
116
+
117
+ if node.type in {"lexical_declaration", "variable_declaration"}:
118
+ result: list[str] = []
119
+ for declarator in node.children:
120
+ if declarator.type != "variable_declarator":
121
+ continue
122
+ name_node = declarator.child_by_field_name("name")
123
+ # Only handle the simple `const _foo = ...` case. Destructuring patterns
124
+ # (object_pattern, array_pattern) are skipped — too risky to rename in v1.
125
+ if name_node is not None and name_node.type == "identifier":
126
+ result.append(_text(name_node, src))
127
+ return result
128
+
129
+ return []
130
+
131
+
132
+ def _should_rename(name: str, public_api: set[str]) -> bool:
133
+ if not name.startswith("_"):
134
+ return False
135
+ if name.startswith("__"):
136
+ # Dunder-like; conservative skip (e.g. `__dirname` in CommonJS).
137
+ return False
138
+ if all(c == "_" for c in name):
139
+ return False
140
+ if name in public_api:
141
+ return False
142
+ for pattern in public_api:
143
+ if pattern.endswith("*") and name.startswith(pattern.rstrip("*")):
144
+ return False
145
+ return True
146
+
147
+
148
+ def _build_rename_map(names: list[str]) -> dict[str, str]:
149
+ return {name: f"_a{i:03d}" for i, name in enumerate(names)}
150
+
151
+
152
+ def _collect_rename_edits(
153
+ node: Node,
154
+ src: bytes,
155
+ rename_map: dict[str, str],
156
+ edits: list[tuple[int, int, bytes]],
157
+ ) -> None:
158
+ """Walk the tree; replace `identifier`/`type_identifier` nodes whose text is in the map.
159
+
160
+ Critically: we do NOT touch `property_identifier` (member access — `.foo` is a different
161
+ scope), `shorthand_property_identifier` (would silently change object shape), or
162
+ destructuring identifiers — see v1 limitations in module docstring.
163
+ """
164
+ if node.type in _RENAMEABLE_IDENTIFIER_TYPES:
165
+ text = _text(node, src)
166
+ if text in rename_map:
167
+ edits.append((node.start_byte, node.end_byte, rename_map[text].encode("utf-8")))
168
+ return # leaf
169
+
170
+ for child in node.children:
171
+ _collect_rename_edits(child, src, rename_map, edits)
172
+
173
+
174
+ def _text(node: Node, src: bytes) -> str:
175
+ return src[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
@@ -17,7 +17,9 @@ from dataclasses import dataclass, field
17
17
  from pathlib import Path
18
18
 
19
19
  from cloak import __version__
20
+ from cloak.context.js_redactor import language_for_extension
20
21
  from cloak.filesystem import walk_repo
22
+ from cloak.obfuscate.js_transformer import transform_js_like_source
21
23
  from cloak.obfuscate.manifest import Manifest
22
24
  from cloak.obfuscate.transformer import transform_python_source
23
25
  from cloak.policy import Policy
@@ -81,6 +83,8 @@ def run_obfuscate(
81
83
 
82
84
  source_hashes[str(rel)] = _sha256_file(src_file)
83
85
 
86
+ js_like_kind = language_for_extension(src_file.suffix)
87
+
84
88
  if src_file.suffix == ".py":
85
89
  try:
86
90
  source_text = src_file.read_text(encoding="utf-8")
@@ -91,7 +95,7 @@ def run_obfuscate(
91
95
  continue
92
96
 
93
97
  try:
94
- result = transform_python_source(source_text, policy, file_label=str(rel))
98
+ py_result = transform_python_source(source_text, policy, file_label=str(rel))
95
99
  except SyntaxError:
96
100
  # Don't break on un-parseable files. Copy them through unchanged.
97
101
  shutil.copy2(src_file, out_file)
@@ -99,9 +103,24 @@ def run_obfuscate(
99
103
  files_copied += 1
100
104
  continue
101
105
 
102
- out_file.write_text(result.output_text, encoding="utf-8")
106
+ out_file.write_text(py_result.output_text, encoding="utf-8")
107
+ output_hashes[str(rel)] = _sha256_file(out_file)
108
+ for orig, new in py_result.rename_map.items():
109
+ rename_map_global[f"{rel}:{orig}"] = new
110
+ files_transformed += 1
111
+ elif js_like_kind is not None:
112
+ try:
113
+ source_text = src_file.read_text(encoding="utf-8")
114
+ except (UnicodeDecodeError, OSError):
115
+ shutil.copy2(src_file, out_file)
116
+ output_hashes[str(rel)] = _sha256_file(out_file)
117
+ files_copied += 1
118
+ continue
119
+
120
+ js_result = transform_js_like_source(source_text, js_like_kind, policy)
121
+ out_file.write_text(js_result.output_text, encoding="utf-8")
103
122
  output_hashes[str(rel)] = _sha256_file(out_file)
104
- for orig, new in result.rename_map.items():
123
+ for orig, new in js_result.rename_map.items():
105
124
  rename_map_global[f"{rel}:{orig}"] = new
106
125
  files_transformed += 1
107
126
  else:
@@ -0,0 +1,201 @@
1
+ """Tests for `cloak obfuscate` JS/TS support (Phase 5)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from cloak.cli import app
9
+ from cloak.obfuscate.js_transformer import transform_js_like_source
10
+ from cloak.obfuscate.runner import run_obfuscate
11
+ from cloak.policy import Policy
12
+
13
+ runner = CliRunner()
14
+
15
+
16
+ # ---------- transformer unit tests ----------
17
+
18
+
19
+ def test_renames_module_level_function() -> None:
20
+ src = (
21
+ "function _helper(x) {\n"
22
+ " return x + 1;\n"
23
+ "}\n"
24
+ "\n"
25
+ "export function publicFn(x) {\n"
26
+ " return _helper(x);\n"
27
+ "}\n"
28
+ )
29
+ result = transform_js_like_source(src, "javascript", Policy())
30
+ assert "_helper" not in result.output_text
31
+ assert "_a000" in result.output_text
32
+ # Public name preserved
33
+ assert "publicFn" in result.output_text
34
+ # Reference inside publicFn correctly rewritten
35
+ assert "_a000(x)" in result.output_text
36
+ assert result.rename_map == {"_helper": "_a000"}
37
+
38
+
39
+ def test_renames_module_level_const() -> None:
40
+ src = "const _internal = 42;\nexport const value = _internal * 2;\n"
41
+ result = transform_js_like_source(src, "javascript", Policy())
42
+ assert "_internal" not in result.output_text
43
+ assert "_a000" in result.output_text
44
+ assert "value = _a000 * 2" in result.output_text
45
+
46
+
47
+ def test_renames_module_level_class() -> None:
48
+ src = (
49
+ "class _Internal {\n"
50
+ " constructor() { this.x = 1; }\n"
51
+ "}\n"
52
+ "\n"
53
+ "export function make() {\n"
54
+ " return new _Internal();\n"
55
+ "}\n"
56
+ )
57
+ result = transform_js_like_source(src, "javascript", Policy())
58
+ assert "_Internal" not in result.output_text
59
+ assert "_a000" in result.output_text
60
+ assert "new _a000()" in result.output_text
61
+
62
+
63
+ def test_does_not_rename_dunder_like_names() -> None:
64
+ src = "const __version = '1.0';\nconst _foo = 1;\n"
65
+ result = transform_js_like_source(src, "javascript", Policy())
66
+ assert "__version" in result.output_text # preserved
67
+ assert "_foo" not in result.output_text # renamed
68
+ assert "_a000" in result.output_text
69
+
70
+
71
+ def test_respects_public_api_in_policy() -> None:
72
+ src = "export function _publicHelper(x) { return x; }\n"
73
+ policy = Policy(public_api=["_publicHelper"])
74
+ result = transform_js_like_source(src, "javascript", policy)
75
+ assert "_publicHelper" in result.output_text
76
+ assert not result.rename_map
77
+
78
+
79
+ def test_handles_export_statement_with_lexical_declaration() -> None:
80
+ src = "export const _privateUtil = (x) => x * 2;\n"
81
+ result = transform_js_like_source(src, "javascript", Policy())
82
+ # The name should still be renamed even though it's wrapped in `export`.
83
+ assert "_privateUtil" not in result.output_text
84
+ assert "_a000" in result.output_text
85
+
86
+
87
+ def test_preserves_property_access_with_same_name() -> None:
88
+ """obj._helper is a member access — different scope; we don't rename it."""
89
+ src = (
90
+ "function _helper(x) { return x * 2; }\n"
91
+ "const obj = { _helper: 'something else' };\n"
92
+ "export function go() {\n"
93
+ " return _helper(obj._helper);\n"
94
+ "}\n"
95
+ )
96
+ result = transform_js_like_source(src, "javascript", Policy())
97
+ # The bare `_helper` reference (function call) should be renamed.
98
+ # The property access `obj._helper` should NOT be renamed (different scope).
99
+ assert "obj._helper" in result.output_text
100
+ # The standalone calls should be renamed.
101
+ assert "_a000(obj._helper)" in result.output_text
102
+
103
+
104
+ def test_typescript_renames_top_level_types() -> None:
105
+ """TypeScript: rename top-level _-prefixed type identifiers as well."""
106
+ src = (
107
+ "type _Internal = { x: number };\nexport function make(): _Internal { return { x: 1 }; }\n"
108
+ )
109
+ result = transform_js_like_source(src, "typescript", Policy())
110
+ # type_alias_declaration: tree-sitter-typescript should expose this; for v1, our
111
+ # _DEFINITION_NODE_TYPES doesn't include it, so the rename won't happen. This is
112
+ # documented as a limitation. The point of this test: confirm we don't crash.
113
+ assert result.output_text # non-empty
114
+ # The visible behavior may or may not rename; either is acceptable for v1.
115
+
116
+
117
+ def test_returns_unchanged_when_no_private_names() -> None:
118
+ src = "export function pub(x) { return x; }\n"
119
+ result = transform_js_like_source(src, "javascript", Policy())
120
+ assert result.output_text == src
121
+ assert not result.rename_map
122
+
123
+
124
+ def test_handles_invalid_source_gracefully() -> None:
125
+ src = "function broken( {{{"
126
+ # Should not raise.
127
+ transform_js_like_source(src, "javascript", Policy())
128
+
129
+
130
+ # ---------- pipeline integration ----------
131
+
132
+
133
+ def _make_js_repo(tmp_path: Path) -> Path:
134
+ """Sample JS repo: lib + matching test that imports the public function."""
135
+ repo = tmp_path / "src_repo"
136
+ repo.mkdir()
137
+ (repo / "lib.js").write_text(
138
+ "function _double(x) {\n"
139
+ " return x * 2;\n"
140
+ "}\n"
141
+ "\n"
142
+ "export function publicFn(x) {\n"
143
+ " return _double(x);\n"
144
+ "}\n"
145
+ )
146
+ (repo / "package.json").write_text('{"type":"module"}\n')
147
+ return repo
148
+
149
+
150
+ def test_runner_handles_mixed_python_and_js(tmp_path: Path) -> None:
151
+ repo = tmp_path / "repo"
152
+ repo.mkdir()
153
+ (repo / "lib.py").write_text(
154
+ "def _helper(x):\n return x + 1\n\ndef pub(x):\n return _helper(x)\n"
155
+ )
156
+ (repo / "lib.js").write_text(
157
+ "function _helper(x) { return x + 1; }\nexport function pub(x) { return _helper(x); }\n"
158
+ )
159
+ out = tmp_path / "obfuscated"
160
+
161
+ result = run_obfuscate(repo, out, Policy())
162
+ assert result.files_transformed == 2 # both py and js were transformed
163
+ py_text = (out / "lib.py").read_text()
164
+ js_text = (out / "lib.js").read_text()
165
+ assert "_helper" not in py_text
166
+ assert "_helper" not in js_text
167
+ assert "_a000" in py_text
168
+ assert "_a000" in js_text
169
+
170
+
171
+ def test_runner_writes_manifest_with_js_renames(tmp_path: Path) -> None:
172
+ repo = _make_js_repo(tmp_path)
173
+ out = tmp_path / "obfuscated"
174
+ run_obfuscate(repo, out, Policy())
175
+
176
+ manifest = json.loads((out / "cloak-manifest.json").read_text())
177
+ assert "lib.js:_double" in manifest["rename_map"]
178
+ assert manifest["rename_map"]["lib.js:_double"] == "_a000"
179
+
180
+
181
+ # ---------- CLI integration ----------
182
+
183
+
184
+ def test_cli_obfuscate_against_js_repo(tmp_path: Path) -> None:
185
+ repo = _make_js_repo(tmp_path)
186
+ out = tmp_path / "obf"
187
+ result = runner.invoke(app, ["obfuscate", str(repo), "--out", str(out)])
188
+ assert result.exit_code == 0, result.stdout
189
+ obfuscated = (out / "lib.js").read_text()
190
+ assert "_double" not in obfuscated
191
+ assert "_a000" in obfuscated
192
+ assert "publicFn" in obfuscated # public preserved
193
+
194
+
195
+ def test_cli_obfuscate_panel_shows_renames(tmp_path: Path) -> None:
196
+ repo = _make_js_repo(tmp_path)
197
+ out = tmp_path / "obf"
198
+ result = runner.invoke(app, ["obfuscate", str(repo), "--out", str(out)])
199
+ assert result.exit_code == 0
200
+ assert "Obfuscated" in result.stdout
201
+ assert "1 module-private" in result.stdout or "renames:" in result.stdout
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes