Skip to content

Commit 42b7d9e

Browse files
committed
Add in-place file anonymization CLI and mapping command
1 parent 2573c4b commit 42b7d9e

5 files changed

Lines changed: 167 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,33 @@ echo "Call me at +1-415-555-1234" | shieldprompt mask
111111
shieldprompt mask --file input.txt --save-vault vault.json
112112
```
113113

114+
### Anonymize a file in-place (creates sidecar vault automatically)
115+
116+
```bash
117+
shieldprompt mask --file secrets.txt --in-place --no-ner
118+
# writes masked text back to secrets.txt
119+
# saves mappings to secrets.txt.shieldprompt.vault.json
120+
```
121+
114122
### Unmask with vault
115123

116124
```bash
117125
shieldprompt unmask "[EMAIL_ADDRESS_1]" --vault vault.json
118126
```
119127

128+
### Restore original text in-place
129+
130+
```bash
131+
shieldprompt unmask --file secrets.txt --in-place
132+
# by default reads secrets.txt.shieldprompt.vault.json
133+
```
134+
135+
### Show token-to-original mappings
136+
137+
```bash
138+
shieldprompt map --file secrets.txt --json
139+
```
140+
120141
### Inspect detected entities
121142

122143
```bash

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "shieldprompt"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Privacy bridge for LLMs — mask PII before it leaves your machine."
99
readme = "README.md"
1010
license = {text = "MIT"}

‎src/shieldprompt/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .shield import Shield
99
from .vault import Vault, get_vault
1010

11-
__version__ = "0.1.0"
11+
__version__ = "0.1.1"
1212

1313
__all__ = [
1414
"Shield",

‎src/shieldprompt/cli.py‎

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
echo "some text" | shieldprompt mask
66
shieldprompt unmask "[EMAIL_ADDRESS_1]" --vault vault.json
77
shieldprompt mask --file input.txt --save-vault vault.json
8+
shieldprompt mask --file secrets.txt --in-place --no-ner
9+
shieldprompt unmask --file secrets.txt --in-place
10+
shieldprompt map --file secrets.txt
811
"""
912

1013
from __future__ import annotations
@@ -24,7 +27,6 @@ def _load_vault(path: str) -> Vault:
2427
vault = Vault()
2528
data = json.loads(Path(path).read_text())
2629
for token, real in data.items():
27-
entity_type = token.split("_")[0].lstrip("[")
2830
vault._token_to_real[token] = real
2931
vault._real_to_token[real] = token
3032
return vault
@@ -35,6 +37,21 @@ def _save_vault(vault: Vault, path: str) -> None:
3537
Path(path).write_text(json.dumps(vault.mappings, indent=2))
3638

3739

40+
def _default_vault_path(file_path: str) -> str:
41+
"""Default sidecar vault path for a source file."""
42+
return f"{file_path}.shieldprompt.vault.json"
43+
44+
45+
def _resolve_vault_path(file_path: str | None, explicit_path: str | None) -> str:
46+
"""Resolve vault path from an explicit argument or a file sidecar."""
47+
if explicit_path:
48+
return explicit_path
49+
if file_path:
50+
return _default_vault_path(file_path)
51+
print("Error: provide --vault when not using --file", file=sys.stderr)
52+
sys.exit(1)
53+
54+
3855
def main() -> None:
3956
parser = argparse.ArgumentParser(
4057
prog="shieldprompt",
@@ -53,14 +70,24 @@ def main() -> None:
5370
mask_p.add_argument(
5471
"--save-vault", "-s", help="Save vault mappings to JSON file"
5572
)
73+
mask_p.add_argument(
74+
"--in-place",
75+
"-i",
76+
action="store_true",
77+
help="When used with --file, overwrite the file with masked text",
78+
)
5679
mask_p.add_argument("--no-ner", action="store_true", help="Disable NER model")
5780

5881
# --- unmask ---
5982
unmask_p = sub.add_parser("unmask", help="Unmask tokens back to real values")
6083
unmask_p.add_argument("text", nargs="?", help="Text to unmask (or pipe via stdin)")
6184
unmask_p.add_argument("--file", "-f", help="Read text from file")
85+
unmask_p.add_argument("--vault", "-v", help="Path to vault JSON file")
6286
unmask_p.add_argument(
63-
"--vault", "-v", required=True, help="Path to vault JSON file"
87+
"--in-place",
88+
"-i",
89+
action="store_true",
90+
help="When used with --file, overwrite the file with unmasked text",
6491
)
6592

6693
# --- inspect ---
@@ -69,6 +96,15 @@ def main() -> None:
6996
inspect_p.add_argument("--file", "-f", help="Read text from file")
7097
inspect_p.add_argument("--no-ner", action="store_true", help="Disable NER model")
7198

99+
# --- map ---
100+
map_p = sub.add_parser(
101+
"map",
102+
help="Show token->original mappings from a vault file",
103+
)
104+
map_p.add_argument("--file", "-f", help="Original file path (uses default sidecar vault)")
105+
map_p.add_argument("--vault", "-v", help="Path to vault JSON file")
106+
map_p.add_argument("--json", action="store_true", help="Print mappings as JSON")
107+
72108
args = parser.parse_args()
73109

74110
if args.command == "mask":
@@ -77,6 +113,8 @@ def main() -> None:
77113
_cmd_unmask(args)
78114
elif args.command == "inspect":
79115
_cmd_inspect(args)
116+
elif args.command == "map":
117+
_cmd_map(args)
80118

81119

82120
def _get_text(args) -> str:
@@ -92,6 +130,10 @@ def _get_text(args) -> str:
92130

93131

94132
def _cmd_mask(args) -> None:
133+
if args.in_place and not args.file:
134+
print("Error: --in-place requires --file", file=sys.stderr)
135+
sys.exit(1)
136+
95137
text = _get_text(args)
96138
entities = {EntityType(e) for e in args.entities} if args.entities else None
97139
vault = Vault()
@@ -101,17 +143,35 @@ def _cmd_mask(args) -> None:
101143
vault=vault,
102144
)
103145
masked = shield.mask(text)
104-
print(masked)
146+
if args.in_place:
147+
Path(args.file).write_text(masked)
148+
else:
149+
print(masked)
105150

106-
if args.save_vault:
107-
_save_vault(vault, args.save_vault)
108-
print(f"Vault saved to {args.save_vault}", file=sys.stderr)
151+
should_save_vault = bool(args.save_vault or args.in_place)
152+
if should_save_vault:
153+
vault_path = args.save_vault or _default_vault_path(args.file)
154+
_save_vault(vault, vault_path)
155+
if args.in_place:
156+
print(f"Masked file in-place: {args.file}", file=sys.stderr)
157+
print(f"Vault saved to {vault_path}", file=sys.stderr)
109158

110159

111160
def _cmd_unmask(args) -> None:
161+
if args.in_place and not args.file:
162+
print("Error: --in-place requires --file", file=sys.stderr)
163+
sys.exit(1)
164+
112165
text = _get_text(args)
113-
vault = _load_vault(args.vault)
114-
print(vault.restore_text(text))
166+
vault_path = _resolve_vault_path(args.file, args.vault)
167+
vault = _load_vault(vault_path)
168+
unmasked = vault.restore_text(text)
169+
170+
if args.in_place:
171+
Path(args.file).write_text(unmasked)
172+
print(f"Unmasked file in-place: {args.file}", file=sys.stderr)
173+
else:
174+
print(unmasked)
115175

116176

117177
def _cmd_inspect(args) -> None:
@@ -127,5 +187,18 @@ def _cmd_inspect(args) -> None:
127187
print(f" {d.entity_type:20s} {d.value!r:30s} (pos {d.start}-{d.end}, score={d.score:.2f})")
128188

129189

190+
def _cmd_map(args) -> None:
191+
vault_path = _resolve_vault_path(args.file, args.vault)
192+
mappings = _load_vault(vault_path).mappings
193+
if args.json:
194+
print(json.dumps(mappings, indent=2))
195+
return
196+
if not mappings:
197+
print("No mappings found.")
198+
return
199+
for token, real in mappings.items():
200+
print(f"{token} -> {real}")
201+
202+
130203
if __name__ == "__main__":
131204
main()

‎tests/test_cli_roundtrip.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Tests for file-based CLI roundtrip workflows."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from shieldprompt import cli
8+
9+
10+
def _run_cli(monkeypatch, *argv):
11+
monkeypatch.setattr("sys.argv", ["shieldprompt", *argv])
12+
cli.main()
13+
14+
15+
def test_file_mask_unmask_roundtrip_with_default_sidecar(tmp_path, monkeypatch, capsys):
16+
original = (
17+
"Name Alice\n"
18+
"Phone +1-415-555-1234\n"
19+
"Email alice@example.com\n"
20+
"API sk-abcdefghijklmnopqrstuvwxyz\n"
21+
)
22+
text_path = tmp_path / "secrets.txt"
23+
text_path.write_text(original)
24+
25+
_run_cli(
26+
monkeypatch,
27+
"mask",
28+
"--file",
29+
str(text_path),
30+
"--entities",
31+
"EMAIL_ADDRESS",
32+
"PHONE_NUMBER",
33+
"API_KEY",
34+
"--in-place",
35+
"--no-ner",
36+
)
37+
38+
masked = text_path.read_text()
39+
assert "[EMAIL_ADDRESS_1]" in masked
40+
assert "[PHONE_NUMBER_1]" in masked
41+
assert "[API_KEY_1]" in masked
42+
assert "alice@example.com" not in masked
43+
assert "+1-415-555-1234" not in masked
44+
45+
vault_path = tmp_path / "secrets.txt.shieldprompt.vault.json"
46+
assert vault_path.exists()
47+
mappings = json.loads(vault_path.read_text())
48+
assert mappings["[EMAIL_ADDRESS_1]"] == "alice@example.com"
49+
50+
_run_cli(monkeypatch, "map", "--file", str(text_path), "--json")
51+
out = capsys.readouterr().out
52+
printed = json.loads(out)
53+
assert printed["[PHONE_NUMBER_1]"] == "+1-415-555-1234"
54+
55+
_run_cli(
56+
monkeypatch,
57+
"unmask",
58+
"--file",
59+
str(text_path),
60+
"--in-place",
61+
)
62+
restored = text_path.read_text()
63+
assert restored == original

0 commit comments

Comments
 (0)