codemask masks a code diff so you can send it to a third-party model without sending customer data.
It keeps the code's structure: keywords, identifiers, operators, YAML keys, SQL statements, shell commands and flags. It replaces everything that could hold customer data with a placeholder: string literals, comments, long numbers, JSX text, YAML values, shell arguments and heredocs.
It doesn't try to spot PII. A pattern can catch an email address but never a bare name, so codemask removes the places PII lives instead. If it can't mask a file with certainty, it sends only the path and line counts.
A test gains a customer:
+ {
+ name: "subject with a bank account resolves",
+ subject: Subject{
+ Name: "Jane Okafor",
+ Email: "jane.okafor@example.com",
+ DOB: "1987-04-12",
+ Postcode: "SW1A 1AA",
+ SortCode: 401234,
+ Account: 41000032,
+ },
+ want: StatusResolved,
+ },What codemask sends:
# masked go · code changed, literals changed, comments unchanged · tags: test
+ {
+ name: "<str>",
+ subject: Subject{
+ Name: "<str>",
+ Email: "<str>",
+ DOB: "<str>",
+ Postcode: "<str>",
+ SortCode: <num>,
+ Account: <num>,
+ },
+ want: StatusResolved,
+ },A model can still see this is a test adding a case with personal and bank details. It can't see whose.
To see all twelve worked examples on your own machine:
codemask demo # all of them
codemask demo 06 # oneThe repo is private, so Go needs to know not to use the public proxy for it:
go env -w GOPRIVATE=github.com/thirdfort/*
go install github.com/thirdfort/codemask/cmd/codemask@latestIn CI, set the same GOPRIVATE and give git a token that can read thirdfort/codemask.
codemask git main...HEAD # mask the branch's changes, as JSON
codemask git -format text --cached # mask what's staged, for a person to read
git diff -U1000000 main | codemask # mask a diff you made yourselfcodemask git passes its arguments to git diff and adds -U1000000, which puts each whole
file in the diff. That's what makes masking exact: see
Why full context matters.
| Flag | Default | Meaning |
|---|---|---|
-format |
json |
json for programs, text for people |
-context |
3 |
Unchanged lines kept around each change; -1 for none |
-max-lines |
400 |
Cap on masked lines across all files, shared evenly between them; -1 for none |
res, err := codemask.Mask(diff, codemask.Options{})
if err != nil {
return err
}
payload, _ := json.Marshal(res) // or res.Text() for a promptClassifying a pull request cheaply. purr asks Jev what kind of PR it's looking at (copy, tooling, UI, infra and so on) before deciding how hard to review it. The paths and tags alone are often enough. When they aren't, the masked hunks and the facts settle it:
codemask git origin/main...HEAD | jq '[.files[] | {path, tags, facts}]'A file with codeChanged: false, literalsChanged: true is a copy change. A file tagged
migration is never a copy change, however small.
A second opinion from a model that isn't cleared for customer data. Paste the text output into the prompt. The model sees what the code does and how it changed, which is most of what a review needs.
codemask git -format text main...HEAD | pbcopyDrafting a PR description or release note with a model whose data terms you haven't checked. Same command, different prompt.
Checking what would leave before you send it. Run codemask git -format text and read it.
If something you didn't expect is visible, that's a bug: add an example
that reproduces it.
Inside a service. Anything in blue-skies that sends code to a model can import the package
and mask first. Use the Facts and Tags on their own when the model only needs to know what
kind of change it is.
| Language | Kept | Masked |
|---|---|---|
| Go, TypeScript, JavaScript, Python, proto, CSS/SCSS | Keywords, identifiers, operators, numbers under 6 digits | Strings, comments, numbers of 6+ digits, regex literals |
| TSX/JSX | The above, plus element and attribute names, {expressions} |
Text between tags, attribute strings |
| SQL | Statements, table and column names | Strings, quoted identifiers, $$ bodies, comments, long numbers |
| Terraform/HCL | Block types and labels (resource "google_secret_manager_secret" "x"), attributes, references |
String values, heredocs, comments |
| YAML | Keys, indentation, dashes, booleans, nulls, small numbers, versions (v1.4.2), quantities (512Mi) |
Every other value, block scalars, keys with spaces, comments |
| Shell, Dockerfile | Command names, flags, keywords, operators, variable names | Arguments, flag values, strings, heredocs, ${…} expressions, comments |
| JSON | Punctuation | Every string |
| go.mod | Module paths and versions | Comments |
Delimiters stay, so "<str>" still reads as a string and // <comment> as a comment.
| Reason | When |
|---|---|
data-file |
Under testdata/, fixtures/, seed/, seeddata/, __snapshots__/, or a CSV, snapshot or golden file |
lockfile |
go.sum, package-lock.json, bun.lock and the like |
generated |
.pb.go, zz_generated, .min.js and the like |
prose |
Markdown, reStructuredText, plain text |
unsupported-language |
No lexer for the file type yet |
partial-context |
The diff doesn't start at the top of the file, and the language has constructs that span lines |
lexer-state |
The lexer ended inside a string or element, or hit a string that never closed. Its view of the file is wrong somewhere, so none of the file is sent |
pii-pattern |
Something shaped like an email, phone number, postcode, date, NI number, card number or long digit run survived masking |
The last check runs on everything that would leave, paths included. A path segment that matches
is replaced with <redacted>.
A hunk is a fragment of a file. If it starts inside a multi-line string, nothing in the hunk says so. Example 06 has a YAML fixture in a Go raw string, and the change is in the middle of it:
kind: individual
- name: Test Subject
+ name: Jane OkaforLexed from the hunk, name: Jane Okafor looks like code. Lexed from the top of the file,
codemask knows it's inside a string:
<str>
-<str>
+<str>So codemask only masks a file when it has seen it from line 1. Given a normal 3-line-context diff (example 07), it sends counts instead. JSON and go.mod have nothing that spans lines, so they're masked from any hunk.
Each masked file carries three facts. codemask works them out locally by comparing the old and new versions of the changed lines, and only the booleans leave:
| Fact | Meaning |
|---|---|
codeChanged |
The code's structure changed, ignoring literals, comments and whitespace |
literalsChanged |
A string, number, value or piece of text changed |
commentsChanged |
A comment changed |
Only literals changed means a copy change. Only comments changed means a docs change. All three false means only formatting moved.
Tags come from the path alone: test, data, manifest, lockfile, generated,
migration, ci, iac, docs, styles, ui, i18n.
{
"files": [
{
"path": "portal/src/components/SaveButton.tsx",
"status": "modified",
"language": "tsx",
"mode": "masked",
"tags": ["ui"],
"added": 2,
"removed": 2,
"facts": { "codeChanged": false, "literalsChanged": true, "commentsChanged": false },
"hunks": [
{
"header": "@@ -8,9 +8,9 @@",
"lines": [
" ",
" export function SaveButton({ saving, onSave }: SaveButtonProps) {",
" return (",
"- <Button aria-label=\"<str>\" disabled={saving} onClick={onSave}>",
"+ <Button aria-label=\"<str>\" disabled={saving} onClick={onSave}>",
" {saving ? <Spinner /> : null}",
"- <text>",
"+ <text>",
" </Button>",
" );",
" }"
]
}
]
}
],
"summary": { "files": 1, "added": 2, "removed": 2, "masked": 1, "countsOnly": 0 }
}mode is masked, counts or binary. A counts file has a reason and no hunks. Hunk
headers are rebuilt by codemask. The input's own section headings (@@ … @@ func Foo) are
dropped, because they're source lines that were never lexed.
Each example is a before and after tree under examples/testdata/, the diff made from them,
and the expected output in .golden.txt (for reading) and .golden.json (for the tests).
| Example | Shows |
|---|---|
| 01-copy-change | A button label change in TSX, detected as copy-only |
| 02-test-fixture | A Go test case full of personal and bank details |
| 03-comment-with-pii | An email and phone number in a comment |
| 04-sql-migration | DDL kept, backfill values masked |
| 05-terraform | Resource types kept, values masked |
| 06-raw-string | Unquoted data inside a multi-line string |
| 07-partial-context | The same change without full context, sent as counts |
| 08-python-docstring | A docstring example record |
| 09-helm-values | Version and replica bumps visible, values masked |
| 10-shell-script | Unquoted arguments and a heredoc |
| 11-data-file | A testdata file, sent as counts |
| 12-dependency-bump | go.mod readable, go.sum as counts |
The tests also check that none of the planted names, emails, numbers or addresses appear in any output.
- Identifiers are sent. A variable called
janeOkaforgoes as it is. Customer data almost never ends up in identifiers, but it can. - Paths are sent, apart from segments that match the PII patterns.
- Shell is approximate. Shell has no clean line between code and data, so codemask masks every argument. A misread costs signal, not data.
- One placeholder per line. A string that spans lines becomes one
<str>per line, so line counts are visible. - Languages not in the table are sent as counts. Adding one is usually a
langConfig.
- Make
examples/testdata/NN-name/a/andb/with the files before and after. Addcontextholding a number to diff with less than full context, orfromholding another example's name to reuse its trees. - Run
examples/gen.shto buildNN-name.diff. - Add it to
examples.Allinexamples/examples.go. - Run
go test ./... -update, then read the new.golden.txtbefore committing it. - If you planted new fake PII, add it to
plantedinexamples/examples_test.go.
Most languages are a langConfig in lang.go: comment markers, string delimiters and whether
strings can span lines. Map the file extension in languages, add cases to TestMaskLanguages,
and add an example if the language has anything unusual.
Before merging, run:
go test ./...
golangci-lint run ./...