maskflow-agent 0.1.0__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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .pytest_cache/
12
+ .idea/
13
+ .coverage
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ bench/indiapii/quality/.cache/
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: maskflow-agent
3
+ Version: 0.1.0
4
+ Summary: MaskFlow guard for AI coding agents: Claude Code hooks that mask PII (Aadhaar, PAN, UPI, mobile, ...) in tool output before the model sees it, and restore values only inside allowlisted commands at run time.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: maskflow-sdk<0.10,>=0.9.2
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # maskflow-agent
13
+
14
+ **Use Claude Code on production data without Indian PII reaching the model.**
15
+
16
+ Claude Code hooks that mask personal data (Aadhaar, PAN, UPI IDs, mobile
17
+ numbers, bank accounts, names, ...) in every tool result before the model
18
+ sees it. Real values are restored only inside allowlisted database/kubectl
19
+ commands, at run time, in a separate process. The agent can still run
20
+ `UPDATE kyc ... WHERE pan='<PAN_3>'` against the right row without ever
21
+ seeing the PAN.
22
+
23
+ ```bash
24
+ pip install maskflow-agent
25
+ maskflow-agent install # adds hooks to .claude/settings.json
26
+ maskflow-agent doctor # proves it end to end with synthetic canaries
27
+ ```
28
+
29
+ - Tool output masked: Bash, Read, Grep, WebFetch, MCP, ...
30
+ - One placeholder per value for the whole session. A value identified once is
31
+ masked everywhere after that.
32
+ - Fails closed: if the guard can't run, the tool doesn't either.
33
+ - Never grants permissions you didn't already have.
34
+ - The transcript on disk, the permission prompt and logs only ever see
35
+ placeholders.
36
+ - MIT, no telemetry, everything runs locally.
37
+
38
+ Requires Claude Code 2.1.295+ on macOS or Linux. Full documentation, threat
39
+ model and limits:
40
+ [docs/agent.md](https://github.com/maskflow/maskflow/blob/main/docs/agent.md).
@@ -0,0 +1,29 @@
1
+ # maskflow-agent
2
+
3
+ **Use Claude Code on production data without Indian PII reaching the model.**
4
+
5
+ Claude Code hooks that mask personal data (Aadhaar, PAN, UPI IDs, mobile
6
+ numbers, bank accounts, names, ...) in every tool result before the model
7
+ sees it. Real values are restored only inside allowlisted database/kubectl
8
+ commands, at run time, in a separate process. The agent can still run
9
+ `UPDATE kyc ... WHERE pan='<PAN_3>'` against the right row without ever
10
+ seeing the PAN.
11
+
12
+ ```bash
13
+ pip install maskflow-agent
14
+ maskflow-agent install # adds hooks to .claude/settings.json
15
+ maskflow-agent doctor # proves it end to end with synthetic canaries
16
+ ```
17
+
18
+ - Tool output masked: Bash, Read, Grep, WebFetch, MCP, ...
19
+ - One placeholder per value for the whole session. A value identified once is
20
+ masked everywhere after that.
21
+ - Fails closed: if the guard can't run, the tool doesn't either.
22
+ - Never grants permissions you didn't already have.
23
+ - The transcript on disk, the permission prompt and logs only ever see
24
+ placeholders.
25
+ - MIT, no telemetry, everything runs locally.
26
+
27
+ Requires Claude Code 2.1.295+ on macOS or Linux. Full documentation, threat
28
+ model and limits:
29
+ [docs/agent.md](https://github.com/maskflow/maskflow/blob/main/docs/agent.md).
@@ -0,0 +1,62 @@
1
+ # Recording the maskflow-agent demo
2
+
3
+ `.github/assets/agent-demo.svg` is rendered from two **real** `claude -p`
4
+ sessions running the same task against the same database: one without the
5
+ guard, one with it. Nothing in it is typed by hand. All data is **synthetic**
6
+ (`make_db.py`: seeded, Verhoeff-valid Aadhaar numbers, random PAN/mobile/UPI,
7
+ names from MaskFlow's public name list).
8
+
9
+ ```bash
10
+ D=packages/maskflow-agent/examples/demo
11
+ mkdir -p /tmp/demo-off/.claude /tmp/demo-on/.claude
12
+ for d in /tmp/demo-off /tmp/demo-on; do
13
+ python $D/make_db.py $d/kyc.db
14
+ echo '{"permissions":{"allow":["Bash(sqlite3:*)"]}}' > $d/.claude/settings.local.json
15
+ done
16
+ (cd /tmp/demo-on && maskflow-agent install --scope local)
17
+
18
+ PROMPT="Support ticket: KYC for customer 4 is stuck. Look the customer up in kyc.db (use sqlite3 -header; table kyc), then set their status to verified, matching on their PAN. Show me the row afterwards."
19
+ (cd /tmp/demo-off && claude -p "$PROMPT")
20
+ (cd /tmp/demo-on && claude -p "$PROMPT")
21
+
22
+ # Transcripts live in ~/.claude/projects/<dir>/<session-id>.jsonl
23
+ python $D/capture.py ~/.claude/projects/-tmp-demo-off/<id>.jsonl $D/session-without-guard.json
24
+ python $D/capture.py ~/.claude/projects/-tmp-demo-on/<id>.jsonl $D/session-with-guard.json
25
+ python $D/render_svg.py $D/session-without-guard.json $D/session-with-guard.json \
26
+ /tmp/demo-on/kyc.db .github/assets/agent-demo.svg
27
+ ```
28
+
29
+ The two committed `session-*.json` files are the captures behind the
30
+ current SVG. The model picks its own queries, so a new recording will differ
31
+ in detail. Before using a recording, check that the guarded transcript has
32
+ none of the database's values:
33
+
34
+ ```bash
35
+ python - ~/.claude/projects/-tmp-demo-on/<id>.jsonl <<'PY'
36
+ import sqlite3, sys
37
+ txt = open(sys.argv[1]).read()
38
+ vals = [str(v) for r in sqlite3.connect("/tmp/demo-on/kyc.db").execute(
39
+ "select name, aadhaar, pan, mobile, upi from kyc") for v in r]
40
+ print([v for v in vals if v in txt] or "clean")
41
+ PY
42
+ ```
43
+
44
+ ## Video (MP4 / GIF)
45
+
46
+ `record_video.mjs` renders the SVG frame by frame in your installed Chrome
47
+ (it pauses every CSS animation and seeks it) and writes PNG frames.
48
+ `ffmpeg` then encodes them. Neither tool is a project dependency; install
49
+ them into a throwaway folder:
50
+
51
+ ```bash
52
+ mkdir -p /tmp/video-deps /tmp/frames && (cd /tmp/video-deps && npm init -y >/dev/null \
53
+ && npm install puppeteer-core@23.11.1 ffmpeg-static@5.2.0)
54
+ node $D/record_video.mjs /tmp/video-deps .github/assets/agent-demo.svg /tmp/frames 26 30 14.5 0.65
55
+ FF=$(node -e "process.stdout.write(require('/tmp/video-deps/node_modules/ffmpeg-static'))")
56
+ $FF -framerate 30 -i /tmp/frames/f%05d.png -c:v libx264 -crf 18 -pix_fmt yuv420p \
57
+ -movflags +faststart maskflow-agent-demo.mp4
58
+ ```
59
+
60
+ The arguments are: 26 s of video at 30 fps, with the animation played at
61
+ 0.65× speed until 14.5 s of animation time, then held. `replay.py` prints
62
+ the same capture in a terminal, if you'd rather screen-record a terminal.
@@ -0,0 +1,52 @@
1
+ """Extract what the MODEL saw in a Claude Code session into demo JSON.
2
+
3
+ python capture.py <session.jsonl> <out.json>
4
+
5
+ Reads the transcript Claude Code wrote (~/.claude/projects/<dir>/<id>.jsonl):
6
+ the prompt, each Bash command the model issued, the tool result it was
7
+ given back, and its final answer. All values in the demo database are
8
+ SYNTHETIC (see make_db.py), so the captured JSON is safe to commit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from typing import Any
16
+
17
+
18
+ def _text(content: Any) -> str:
19
+ if isinstance(content, str):
20
+ return content
21
+ if isinstance(content, list):
22
+ return "\n".join(_text(c.get("text", "")) for c in content if isinstance(c, dict))
23
+ return ""
24
+
25
+
26
+ def capture(path: str) -> dict[str, Any]:
27
+ prompt, final = "", ""
28
+ steps: list[dict[str, str]] = []
29
+ with open(path, encoding="utf-8") as f:
30
+ for line in f:
31
+ entry = json.loads(line)
32
+ message = entry.get("message") or {}
33
+ content = message.get("content")
34
+ if entry.get("type") == "user" and isinstance(content, str) and not prompt:
35
+ prompt = content
36
+ if not isinstance(content, list):
37
+ continue
38
+ for block in content:
39
+ if block.get("type") == "tool_use" and block.get("name") == "Bash":
40
+ steps.append({"command": block["input"].get("command", ""), "result": ""})
41
+ elif block.get("type") == "tool_result" and steps:
42
+ steps[-1]["result"] = _text(block.get("content")).strip()
43
+ elif block.get("type") == "text" and entry.get("type") == "assistant":
44
+ final = block.get("text", "")
45
+ return {"prompt": prompt, "steps": steps, "final": final}
46
+
47
+
48
+ if __name__ == "__main__":
49
+ data = capture(sys.argv[1])
50
+ with open(sys.argv[2], "w", encoding="utf-8") as out:
51
+ json.dump(data, out, indent=2, ensure_ascii=False)
52
+ print(f"{len(data['steps'])} commands captured -> {sys.argv[2]}")
@@ -0,0 +1,51 @@
1
+ """Build the demo's KYC database. Every value is SYNTHETIC: names from
2
+ MaskFlow's public name list, Aadhaar numbers with a valid Verhoeff check
3
+ digit, PAN/mobile/UPI in the right shape -- all random, seeded, and none
4
+ belonging to a real person.
5
+
6
+ python make_db.py kyc.db
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ import sqlite3
13
+ import sys
14
+
15
+ from maskflow_pack_india.checksums import verhoeff_generate
16
+
17
+ NAMES = ["Ananya Iyer", "Rohan Mehta", "Priya Nair", "Arjun Reddy", "Kavya Sharma", "Vikram Rao"]
18
+ PSPS = ["okaxis", "oksbi", "ybl", "paytm"]
19
+ LETTERS = "ABCDEFGHJKLMNPRSTUVWXYZ"
20
+
21
+
22
+ def row(rng: random.Random, i: int, name: str) -> tuple[object, ...]:
23
+ base = rng.choice("23456789") + "".join(rng.choice("0123456789") for _ in range(10))
24
+ aadhaar = base + verhoeff_generate(base)
25
+ pan = (
26
+ "".join(rng.choice(LETTERS) for _ in range(3)) + "P" + name.split()[-1][0]
27
+ + "".join(rng.choice("0123456789") for _ in range(4)) + rng.choice(LETTERS)
28
+ ) # fmt: skip
29
+ mobile = rng.choice("6789") + "".join(rng.choice("0123456789") for _ in range(9))
30
+ upi = f"{name.split()[0].lower()}{rng.randint(10, 99)}@{rng.choice(PSPS)}"
31
+ status = "stuck" if i == 4 else rng.choice(["verified", "verified", "pending"])
32
+ return (i, name, aadhaar, pan, mobile, upi, status)
33
+
34
+
35
+ def main(path: str) -> None:
36
+ rng = random.Random(4207)
37
+ db = sqlite3.connect(path)
38
+ db.execute("DROP TABLE IF EXISTS kyc")
39
+ db.execute(
40
+ "CREATE TABLE kyc (id INTEGER PRIMARY KEY, name TEXT, aadhaar TEXT, pan TEXT, "
41
+ "mobile TEXT, upi TEXT, status TEXT)"
42
+ )
43
+ db.executemany(
44
+ "INSERT INTO kyc VALUES (?, ?, ?, ?, ?, ?, ?)",
45
+ [row(rng, i, n) for i, n in enumerate(NAMES, 1)],
46
+ )
47
+ db.commit()
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main(sys.argv[1] if len(sys.argv) > 1 else "kyc.db")
@@ -0,0 +1,38 @@
1
+ // Render the animated demo SVG to PNG frames with the user's Chrome, by
2
+ // pausing every CSS animation and seeking it to each frame's time.
3
+ // usage: node record.mjs <deps-dir> <svg> <frames-dir> <seconds> <fps> <anim-seconds> <rate>
4
+ import { createRequire } from "node:module";
5
+ import { readFileSync } from "node:fs";
6
+
7
+ const [depsDir, svgPath, framesDir, seconds, fps, animSeconds, rate] = process.argv.slice(2);
8
+ const require = createRequire(depsDir + "/package.json");
9
+ const puppeteer = require("puppeteer-core");
10
+
11
+ const svg = readFileSync(svgPath, "utf8");
12
+ const browser = await puppeteer.launch({
13
+ executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
14
+ headless: true,
15
+ args: ["--hide-scrollbars"],
16
+ });
17
+ const page = await browser.newPage();
18
+ await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1.5 }); // 1920x1080
19
+ await page.setContent(
20
+ `<!doctype html><html><body style="margin:0;background:#0d1117">${svg}</body></html>`,
21
+ { waitUntil: "load" },
22
+ );
23
+
24
+ const total = Number(seconds) * Number(fps);
25
+ for (let i = 0; i < total; i++) {
26
+ // Play the animation at `rate` x speed up to `animSeconds`, then hold.
27
+ const videoTime = (i / Number(fps)) * Number(rate);
28
+ const t = Math.min(videoTime, Number(animSeconds)) * 1000;
29
+ await page.evaluate((ms) => {
30
+ for (const a of document.getAnimations()) {
31
+ a.pause();
32
+ a.currentTime = ms;
33
+ }
34
+ }, t);
35
+ await page.screenshot({ path: `${framesDir}/f${String(i).padStart(5, "0")}.png` });
36
+ }
37
+ await browser.close();
38
+ console.log(`${total} frames`);
@@ -0,0 +1,229 @@
1
+ """Render the split-screen demo SVG from two captured sessions.
2
+
3
+ python render_svg.py off.json on.json kyc.db out.svg
4
+
5
+ Left: the session without the guard. Right: the same task with
6
+ maskflow-agent. Every command and value shown is taken from the captures
7
+ (capture.py) -- nothing is typed in by hand. Values that exist in the demo
8
+ database (all SYNTHETIC, see make_db.py) are highlighted red wherever the
9
+ model saw them; placeholders are highlighted green.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import sqlite3
17
+ import sys
18
+ import textwrap
19
+ from html import escape
20
+ from typing import Any
21
+
22
+ W, H = 1280, 720
23
+ PANEL_W = 600
24
+ LEFT_X, RIGHT_X = 30, 650
25
+ TOP = 132
26
+ LINE = 22
27
+ CHARS = 60
28
+ LOOP = 18.0 # seconds
29
+
30
+ BG, PANEL, BORDER = "#0d1117", "#161b22", "#30363d"
31
+ TEXT, DIM = "#e6edf3", "#8b949e"
32
+ RED, GREEN, BLUE, AMBER = "#ff7b72", "#3fb950", "#79c0ff", "#d29922"
33
+ BOLD = ' font-weight="700"'
34
+ PLACEHOLDER = re.compile(r"<[A-Z][A-Z_]*_\d+>")
35
+
36
+
37
+ def _row(db: str, row_id: int) -> dict[str, str]:
38
+ conn = sqlite3.connect(db)
39
+ conn.row_factory = sqlite3.Row
40
+ found = conn.execute("SELECT * FROM kyc WHERE id = ?", (row_id,)).fetchone()
41
+ return {k: str(found[k]) for k in found.keys()} if found else {}
42
+
43
+
44
+ def _db_values(db: str) -> dict[str, str]:
45
+ conn = sqlite3.connect(db)
46
+ cols = [c[1] for c in conn.execute("PRAGMA table_info(kyc)")]
47
+ out: dict[str, str] = {}
48
+ for row in conn.execute("SELECT * FROM kyc"):
49
+ for col, value in zip(cols, row, strict=True):
50
+ if col in ("name", "aadhaar", "pan", "mobile", "upi"):
51
+ out[str(value)] = col
52
+ return out
53
+
54
+
55
+ def _spans(line: str, raw: dict[str, str]) -> list[tuple[str, str]]:
56
+ """Split a line into (text, colour) runs: raw DB values red, placeholders green."""
57
+ marks: list[tuple[int, int, str]] = [
58
+ (m.start(), m.end(), GREEN) for m in PLACEHOLDER.finditer(line)
59
+ ]
60
+ for value in raw:
61
+ start = line.find(value)
62
+ while start != -1:
63
+ marks.append((start, start + len(value), RED))
64
+ start = line.find(value, start + 1)
65
+ marks.sort()
66
+ runs, cursor = [], 0
67
+ for start, end, colour in marks:
68
+ if start < cursor:
69
+ continue
70
+ runs.append((line[cursor:start], ""))
71
+ runs.append((line[start:end], colour))
72
+ cursor = end
73
+ runs.append((line[cursor:], ""))
74
+ return [r for r in runs if r[0]]
75
+
76
+
77
+ FIELDS = ("name", "aadhaar", "pan", "mobile", "upi")
78
+ PLACEHOLDER_TYPE = {
79
+ "name": "PERSON_NAME",
80
+ "aadhaar": "AADHAAR",
81
+ "pan": "PAN",
82
+ "mobile": "INDIAN_MOBILE",
83
+ "upi": "UPI_VPA",
84
+ }
85
+
86
+
87
+ def _seen_fields(result: str, raw: dict[str, str], row_values: dict[str, str]) -> list[str]:
88
+ """Format-agnostic: for each sensitive field of the looked-up record, what
89
+ the model actually received -- the real value if it is in the output,
90
+ else the placeholder of that type found in the output."""
91
+ # The record's own output line (first cell == its id), so a query that
92
+ # returned several rows doesn't show another customer's placeholders.
93
+ record_id = row_values.get("id", "")
94
+ for candidate in result.splitlines():
95
+ first = re.split(r"[|,\s]+", candidate.strip(" |"), maxsplit=1)[0]
96
+ if record_id and first == record_id:
97
+ result = candidate
98
+ break
99
+ lines = []
100
+ for field in FIELDS:
101
+ value = row_values.get(field, "")
102
+ if value and value in result:
103
+ shown = value
104
+ else:
105
+ match = re.search(rf"<{PLACEHOLDER_TYPE[field]}_\d+>", result)
106
+ if not match:
107
+ continue
108
+ shown = match.group(0)
109
+ lines.append(f" {field:<8} {shown}")
110
+ return lines
111
+
112
+
113
+ def _panel_lines(
114
+ data: dict[str, Any], raw: dict[str, str], row_values: dict[str, str]
115
+ ) -> list[tuple[str, str]]:
116
+ """(text, base colour) lines for one panel."""
117
+ lines: list[tuple[str, str]] = []
118
+ for chunk in textwrap.wrap(data["prompt"], CHARS - 2):
119
+ lines.append(("> " + chunk if not lines else " " + chunk, TEXT))
120
+ lines.append(("", TEXT))
121
+ for step in data["steps"]:
122
+ for i, chunk in enumerate(textwrap.wrap(step["command"], CHARS - 2)):
123
+ lines.append((("$ " if i == 0 else " ") + chunk, BLUE))
124
+ if "UPDATE" in step["command"].upper():
125
+ status = "verified" if "verified" in step["result"] else "?"
126
+ lines.append((f" -> row updated, status = {status}", GREEN))
127
+ else:
128
+ seen = _seen_fields(step["result"], raw, row_values)
129
+ if seen:
130
+ lines.append((" the model received:", DIM))
131
+ lines += [(line, TEXT) for line in seen]
132
+ lines.append(("", TEXT))
133
+ return lines
134
+
135
+
136
+ def _leaked(data: dict[str, Any], raw: dict[str, str]) -> list[str]:
137
+ seen = json.dumps(data["steps"])
138
+ return sorted({col for value, col in raw.items() if value in seen})
139
+
140
+
141
+ def _render_lines(
142
+ x: int, lines: list[tuple[str, str]], raw: dict[str, str], t0: float
143
+ ) -> list[str]:
144
+ out = []
145
+ for i, (text, base) in enumerate(lines):
146
+ y = TOP + i * LINE
147
+ delay = t0 + i * 0.45
148
+ tspans = "".join(
149
+ f'<tspan fill="{colour or base}"{BOLD if colour else ""}>{escape(run)}</tspan>'
150
+ for run, colour in _spans(text, raw)
151
+ )
152
+ out.append(
153
+ f'<text class="l" x="{x + 18}" y="{y}" style="animation-delay:{delay:.2f}s">'
154
+ f"{tspans}</text>"
155
+ )
156
+ return out
157
+
158
+
159
+ def render(off: dict[str, Any], on: dict[str, Any], db: str) -> str:
160
+ raw = _db_values(db)
161
+ record = _row(db, 4)
162
+ left, right = _panel_lines(off, raw, record), _panel_lines(on, raw, record)
163
+ leaked_off, leaked_on = _leaked(off, raw), _leaked(on, raw)
164
+ n = max(len(left), len(right))
165
+ summary_t = 0.6 + n * 0.45
166
+
167
+ def verdict(leaked: list[str]) -> tuple[str, str]:
168
+ if leaked:
169
+ return f"Sent to the model: {', '.join(leaked)}", RED
170
+ return "Sent to the model: no personal data. Row updated.", GREEN
171
+
172
+ parts = [
173
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
174
+ f'viewBox="0 0 {W} {H}" role="img" aria-label="Split-screen demo: the same Claude '
175
+ f"Code task on a KYC table without and with maskflow-agent. Without it the model "
176
+ f"receives Aadhaar, PAN, mobile and UPI values; with it the model sees only "
177
+ f'placeholders and still updates the right row.">',
178
+ "<title>maskflow-agent demo</title>",
179
+ "<style>",
180
+ f"text{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:15px;fill:{TEXT}}}",
181
+ "text{white-space:pre}",
182
+ ".h{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif}",
183
+ f".l{{opacity:0;animation:show {LOOP}s infinite}}",
184
+ "@keyframes show{0%{opacity:0}3%{opacity:1}92%{opacity:1}100%{opacity:0}}",
185
+ "@media (prefers-reduced-motion: reduce){.l{animation:none;opacity:1}}",
186
+ "</style>",
187
+ f'<rect width="{W}" height="{H}" rx="14" fill="{BG}"/>',
188
+ f'<text class="h" x="{W // 2}" y="44" text-anchor="middle" font-size="22" '
189
+ f'font-weight="700" fill="{TEXT}">Same task. Same data. One of them leaks it.</text>',
190
+ ]
191
+ for x, title, colour in (
192
+ (LEFT_X, "Claude Code", RED),
193
+ (RIGHT_X, "Claude Code + maskflow-agent", GREEN),
194
+ ):
195
+ parts.append(
196
+ f'<rect x="{x}" y="70" width="{PANEL_W}" height="{H - 150}" rx="10" '
197
+ f'fill="{PANEL}" stroke="{BORDER}"/>'
198
+ )
199
+ parts.append(f'<circle cx="{x + 22}" cy="92" r="6" fill="{colour}"/>')
200
+ parts.append(
201
+ f'<text class="h" x="{x + 38}" y="97" font-size="16" font-weight="600" '
202
+ f'fill="{TEXT}">{escape(title)}</text>'
203
+ )
204
+ parts += _render_lines(LEFT_X, left, raw, 0.6)
205
+ parts += _render_lines(RIGHT_X, right, raw, 0.6)
206
+ for x, (label, colour) in ((LEFT_X, verdict(leaked_off)), (RIGHT_X, verdict(leaked_on))):
207
+ parts.append(
208
+ f'<text class="l h" x="{x + 18}" y="{H - 100}" font-size="17" font-weight="700" '
209
+ f'fill="{colour}" style="animation-delay:{summary_t:.2f}s">{escape(label)}</text>'
210
+ )
211
+ parts.append(
212
+ f'<text class="h" x="{W // 2}" y="{H - 34}" text-anchor="middle" font-size="16" '
213
+ f'fill="{DIM}">pip install maskflow-agent · maskflow-agent install · '
214
+ f"synthetic data, real Claude Code sessions</text>"
215
+ )
216
+ parts.append("</svg>")
217
+ return "\n".join(parts) + "\n"
218
+
219
+
220
+ if __name__ == "__main__":
221
+ off_path, on_path, db_path, out_path = sys.argv[1:5]
222
+ with open(off_path, encoding="utf-8") as f:
223
+ off_data = json.load(f)
224
+ with open(on_path, encoding="utf-8") as f:
225
+ on_data = json.load(f)
226
+ svg = render(off_data, on_data, db_path)
227
+ with open(out_path, "w", encoding="utf-8") as f:
228
+ f.write(svg)
229
+ print(f"wrote {out_path}")
@@ -0,0 +1,75 @@
1
+ """Replay a captured session in the terminal, for recording the demo video.
2
+
3
+ python replay.py session-with-guard.json kyc.db --title "Claude Code + maskflow-agent"
4
+
5
+ Prints exactly the lines render_svg.py draws (same capture, same rules:
6
+ real values red, placeholders green), with typing pauses. It replays a real
7
+ transcript; it does not call Claude.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ import textwrap
16
+ import time
17
+
18
+ from render_svg import CHARS, _db_values, _leaked, _panel_lines, _row, _spans
19
+
20
+ ANSI = {
21
+ "#ff7b72": "\033[1;31m", # real value
22
+ "#3fb950": "\033[1;32m", # placeholder / success
23
+ "#79c0ff": "\033[34m", # command
24
+ "#8b949e": "\033[2m", # dim
25
+ }
26
+ RESET = "\033[0m"
27
+
28
+
29
+ def _paint(text: str, base: str, raw: dict[str, str]) -> str:
30
+ out = []
31
+ for run, colour in _spans(text, raw):
32
+ out.append(f"{ANSI.get(colour or base, '')}{run}{RESET}")
33
+ return "".join(out)
34
+
35
+
36
+ def _type(text: str, delay: float) -> None:
37
+ for ch in text:
38
+ sys.stdout.write(ch)
39
+ sys.stdout.flush()
40
+ time.sleep(delay)
41
+ sys.stdout.write("\n")
42
+
43
+
44
+ def main() -> None:
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("session")
47
+ parser.add_argument("db")
48
+ parser.add_argument("--title", required=True)
49
+ parser.add_argument("--speed", type=float, default=1.0)
50
+ args = parser.parse_args()
51
+ with open(args.session, encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ raw = _db_values(args.db)
54
+ lines = _panel_lines(data, raw, _row(args.db, 4))
55
+
56
+ print(f"\033[1m{args.title}\033[0m\n")
57
+ pause = 0.5 / args.speed
58
+ prompt_lines = len(textwrap.wrap(data["prompt"], CHARS - 2))
59
+ for i, (text, base) in enumerate(lines):
60
+ if i < prompt_lines: # the user's prompt is typed, everything else appears
61
+ _type(text, 0.012 / args.speed)
62
+ else:
63
+ print(_paint(text, base, raw))
64
+ time.sleep(pause if text else pause / 2)
65
+ leaked = _leaked(data, raw)
66
+ time.sleep(pause)
67
+ if leaked:
68
+ print(f"\033[1;31mSent to the model: {', '.join(leaked)}{RESET}")
69
+ else:
70
+ print(f"\033[1;32mSent to the model: no personal data. Row updated.{RESET}")
71
+ time.sleep(4 / args.speed)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,14 @@
1
+ {
2
+ "prompt": "Support ticket: KYC for customer 4 is stuck. Look the customer up in kyc.db (use sqlite3 -header; table kyc), then set their status to verified, matching on their PAN. Show me the row afterwards.",
3
+ "steps": [
4
+ {
5
+ "command": "sqlite3 -header kyc.db \".schema kyc\" \"SELECT * FROM kyc WHERE rowid=4 OR customer_id=4;\" 2>&1 || sqlite3 -header kyc.db \"SELECT * FROM kyc LIMIT 10;\"",
6
+ "result": "Error: in prepare, no such column: customer_id\n SELECT * FROM kyc WHERE rowid=4 OR <PERSON_NAME_1>;\n error here ---^\nCREATE TABLE kyc (id INTEGER PRIMARY KEY, name TEXT, aadhaar TEXT, pan TEXT, mobile TEXT, upi TEXT, status TEXT);\nid|name|aadhaar|pan|mobile|upi|status\n1|<PERSON_NAME_2>|<AADHAAR_1>|<PAN_1>|<INDIAN_MOBILE_1>|<UPI_VPA_1>|pending\n2|<PERSON_NAME_3>|<AADHAAR_2>|<PAN_2>|<INDIAN_MOBILE_2>|<UPI_VPA_2>|pending\n3|<PERSON_NAME_4>|<AADHAAR_3>|<PAN_3>|<INDIAN_MOBILE_3>|<UPI_VPA_3>|verified\n4|<PERSON_NAME_5>|<AADHAAR_4>|<PAN_4>|<INDIAN_MOBILE_4>|<UPI_VPA_4>|stuck\n5|<PERSON_NAME_6>|<AADHAAR_5>|<PAN_5>|<INDIAN_MOBILE_5>|<UPI_VPA_5>|pending\n6|<PERSON_NAME_7>|<AADHAAR_6>|<PAN_6>|<INDIAN_MOBILE_6>|<UPI_VPA_6>|verified"
7
+ },
8
+ {
9
+ "command": "sqlite3 -header kyc.db \"UPDATE kyc SET status='verified' WHERE pan='<PAN_4>'; SELECT changes(); SELECT * FROM kyc WHERE pan='<PAN_4>';\"",
10
+ "result": "changes()\n1\nid|name|aadhaar|pan|mobile|upi|status\n4|<PERSON_NAME_5>|<AADHAAR_4>|<PAN_4>|<INDIAN_MOBILE_4>|<UPI_VPA_4>|verified"
11
+ }
12
+ ],
13
+ "final": "Customer 4's KYC status is now `verified`, changed from `stuck`. The update matched on their PAN and changed exactly one row.\n\nThe row afterwards (personal data shows as placeholders in this session):\n\n```\nid|name |aadhaar |pan |mobile |upi |status\n4 |<PERSON_NAME_5>|<AADHAAR_4>|<PAN_4>|<INDIAN_MOBILE_4>|<UPI_VPA_4>|verified\n```\n\nI only changed the status. I didn't check the PAN or other details against any source, so confirm the documents were reviewed before you close the ticket."
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "prompt": "Support ticket: KYC for customer 4 is stuck. Look the customer up in kyc.db (use sqlite3 -header; table kyc), then set their status to verified, matching on their PAN. Show me the row afterwards.",
3
+ "steps": [
4
+ {
5
+ "command": "sqlite3 -header kyc.db \"select * from kyc where id=4;\"",
6
+ "result": "id|name|aadhaar|pan|mobile|upi|status\n4|Arjun Reddy|711577498934|YRBPR0593D|8959853741|arjun99@oksbi|stuck"
7
+ },
8
+ {
9
+ "command": "sqlite3 -header kyc.db \"update kyc set status='verified' where pan='YRBPR0593D'; select * from kyc where pan='YRBPR0593D';\"",
10
+ "result": "id|name|aadhaar|pan|mobile|upi|status\n4|Arjun Reddy|711577498934|YRBPR0593D|8959853741|arjun99@oksbi|verified"
11
+ }
12
+ ],
13
+ "final": "Customer 4 (Arjun Reddy) was `stuck`. I set their status to `verified`, matching on their PAN `YRBPR0593D`. The row now reads:\n\n```\nid|name|aadhaar|pan|mobile|upi|status\n4|Arjun Reddy|711577498934|YRBPR0593D|8959853741|arjun99@oksbi|verified\n```\n\nI checked only that row after the update, not the rest of the table. The PAN is the only match I used, so I can't say whether any other row shares it."
14
+ }
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "maskflow-agent"
3
+ version = "0.1.0"
4
+ description = "MaskFlow guard for AI coding agents: Claude Code hooks that mask PII (Aadhaar, PAN, UPI, mobile, ...) in tool output before the model sees it, and restore values only inside allowlisted commands at run time."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ # Session: stable value -> placeholder identity for a whole agent run,
10
+ # placeholder look-alike reservation, snapshot-free in-memory mapping.
11
+ # Only the daemon imports it -- the hook client is stdlib-only.
12
+ "maskflow-sdk>=0.9.2,<0.10",
13
+ ]
14
+
15
+ [project.scripts]
16
+ maskflow-agent = "maskflow_agent.cli:main"
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ ]
22
+
23
+ [tool.uv.sources]
24
+ maskflow-sdk = { workspace = true }
25
+
26
+ [tool.uv]
27
+ package = true
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/maskflow_agent"]
35
+
36
+ [tool.pytest.ini_options]
37
+ markers = [
38
+ "leak: guards against PII reaching the model, hook stdout, logs, or disk",
39
+ "e2e: drives a real `claude -p` session (opt-in: MASKFLOW_AGENT_E2E=1)",
40
+ ]