@veilio-inc/engine
v1.16.0
Published
Two-way code anonymizer for AI: replace real identifiers with role-typed placeholders (__CLS__1, __FN__2, ...) before sending source code to an LLM, restore them afterward. Pure, local, no network, no telemetry, zero runtime dependencies.
Maintainers
Readme
@veilio-inc/engine
Two-way code anonymizer for AI. Replace real identifiers in source code with role-typed
placeholder tokens (__CLS__1, __FN__2, …) before sending it to an LLM,
then restore them in the reply.
import { anonymize, restore } from '@veilio-inc/engine'
const { anonymized, map } = anonymize('class PaymentService { chargeOrder(orderId) {} }')
// anonymized → "class __CLS__1 { __FN__1(__VAR__1) {} }"
// ...send `anonymized` to an AI, get a reply that still contains the tokens...
const { restored } = restore(aiReply, map)If you would rather not write code
Two ready-made surfaces wrap this package, in the same repository. Both run the engine in your own process, and neither opens a network connection.
| | |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @veilio-inc/cli | veilio scrub \| pbcopy, restore, and a scan that exits non-zero on a live credential — for pipes, pre-commit hooks and CI. |
| @veilio-inc/mcp | An MCP server for coding agents. Its tools take a file path, so the server reads the file and the agent sees __CLS__1.__FN__2(), not the real names. |
They share one symbol map, so you can mask inside an agent and restore from a terminal, or the reverse.
npm install -g @veilio-inc/cli # the `veilio` command
npx -y @veilio-inc/mcp --root . # the MCP server, as an agent launches itWhat it hides, and what it does not
Masked:
- every identifier the language does not define — classes, functions,
variables, properties, and the words of import paths — as
__CLS__n,__FN__n,__VAR__n,__PKG__n, unless it is a single ordinary word (below); - every word of a value — inside a string literal (URL paths, error
messages, the table and column names in SQL), after
=or:in a dotenv, INI, properties, TOML or YAML file, in XML attribute values and element text, in a shell command'sKEY=val— as__STR__nand the like, ordinary words included; only a path-shaped string ('./src/utils') keeps them readable; - bank account, card and PESEL numbers (reversibly), and credentials (irreversibly — see below).
Not masked:
- single ordinary words — a name that is one common English word, a word
every codebase shares, or a toolchain's name, written lower-case, Capitalised
or in capitals:
refund,Ledger,TOTAL,billing,src,vitest. They are nobody's secret, and they let a model find its way around the code. A name with an inner capital, an underscore or a digit (refundInvoice,customer_invoices,invoice2) is a choice somebody made and is masked, as are invented and rare words (tarnwick,quayward). A custom rule can still mask any word. This holds for names and path segments only, never for a value (above). A value whose key names a credential — the key containspass,pwd,secret,token,key,auth,cred,private,salt,jwt,bearerormnemonic, or a camel, snake or kebab segment of it starts or ends withpw,otp,pin,seedorcert(password: sunshine,const DB_PASS = "Summer2024!",dbPw,DBPW,--db-password=,_authToken=,Password=sunshine;inside a connection string) — is a secret: it is redacted one-way, whole, in everysecretsmode, so the map never holds it. Outside code a credential net reads every line: the credential-named name, any separator (whitespace,=,:=,:,->,=>, quotes, a\continuation,<![CDATA[, a tag's>, and(,[or,before a quoted value), and the value -password sunshinein a properties file,.netrc'spassword sunshine, a Makefile'sPASSWORD := sunshine. A quoted value ends at its closing quote, element text at<, CDATA at]]>. A.pgpassline's fifth field is a password. Prose is over-masked on purpose: "password reset" redacts "reset". The net fails closed: only a value of one or two characters, an unquoted$NAMEor${NAME}, a language keyword (null,true,None), text already masked, and - in code only, as the whole value- a call or member access (
os.getenv("KEY"),config.secret) is left; a path, a URL, a number, a CI expression or a shell command under a credential word is redacted. An unquoted value runs to the next whitespace (sun,shineis one); a quote, brace or paren ends it only when it was opened before the key (a"KEY=value"string, an object, a call). Text pasted with no path whose language is only guessed is read by the net too, for a line's ownKEY=valueorKEY := value. In code a barekey = wordmay name a variable and keeps a reversible placeholder;
- a call or member access (
- comments, on purpose (below). The result's
commentscounts how much prose goes out as written; - names written entirely in capitals — constants, enum members, environment
variable names:
ACME_TENANT_ID,process.env.ACME_API_KEY. They stay readable so that SQL keywords andMAX_RETRIESstay code, which means a company or customer name inside one goes out as written; - names of one or two characters, language keywords, and standard-library names;
- numbers, and the shape of the program: its control flow and the structure of its SQL and URLs.
So what holds is: no mixed- or lower-case internal identifier other than a single ordinary word, no word of a value other than a path's ordinary segments, and no credential reaches the model. Not "the model cannot tell what this code does" — the shape and one comment will often say it is billing code.
Ten languages, not one
The engine masks every identifier it does not recognise, so the keyword set is
load-bearing: a missing reserved word gets masked, and masking func or def
leaves output no model can read as source code. Keyword sets and comment syntax
are per-language, and the language is detected from the source:
anonymize(goSource).language // → 'go'
anonymize(source, { language: 'rust' }) // or force itSupported: TypeScript/JavaScript, Python, Go, Java/Kotlin, C#, Rust, Ruby, PHP,
C/C++, SQL. Detection falls back to TypeScript when the source is ambiguous, and
says so: languageFallback is true when no marker matched, which means the
file was tokenised with rules that do not describe it and the masking is
partial. It rides on the result rather than in UI state, so the web app, the CLI
and the MCP server all see the same fact rather than each deciding whether to
mention it.
Comments are never masked — they are prose, and turning them into ciphertext is
what makes a downstream model refuse to help. That applies to # comments,
Python docstrings, SQL --, and Ruby =begin blocks, not just //.
Credential guard
Identifier masking protects a domain model. It does nothing about the leak that actually costs money — a live key in the payload. Worse, a credential that happens to be identifier-shaped would otherwise be masked reversibly, i.e. stored in the symbol map.
So credentials are found before masking and replaced irreversibly:
const { anonymized, secrets } = anonymize(code)
// anonymized → 'const k = "__REDACTED_STRIPE_KEY_1__"'
// secrets → [{ type: 'stripe-key', severity: 'critical', line: 1,
// preview: 'sk_l…MNOP (31 chars)', redacted: true }]The replacement never enters the map, so restore() cannot bring it back. AWS,
Stripe, GitHub, Slack, OpenAI, Anthropic, Google and npm keys, JWTs, bearer
tokens, PEM private keys and connection-string passwords are redacted; emails
and private IPs are reported but left in place. Findings carry a truncated
preview, never the full value — they are rendered in UIs and may be logged.
Every finding also says what the scan did with the value, in disposition:
'destroy' (redacted, unrecoverable), 'mask' (masked reversibly — see below),
or 'report' (the scan left it in place). redacted remains the coarse boolean
a badge can read; disposition is the finer answer.
Policy is configurable: { secrets: 'redact' } (default), 'warn', or 'off'.
Under 'warn' no rule's finding is redacted: findings come back with disposition:
'report' and each credential a rule finds is left in the text as written. A
value found by its credential-named key alone (above) is redacted one-way in
every mode all the same: it is never readable and never mapped. Under 'off'
nothing is reported either. In no mode does a credential go into the symbol
map: identifier masking leaves every credential the rules find alone. A name
written exactly as a destroyed credential is replaced by that credential's
redaction token: every other occurrence of a destroyed credential is, in a
string, a URL, as a name, anywhere. A credential of 4 or more characters is
replaced wherever it stands, inside a longer name or a URL too; a shorter one
only as a whole word. Every occurrence of every credential is found on its
own, and occurrences that overlap or touch are replaced as one span: by the
covering credential's token, or by a new generic token where several meet, so
no character of any of them is left. The one exception is a well-known default
(postgres, root, admin, mysql, sa, guest, test, user,
password, changeme, secret, demo) or common weak password (pass,
pwd, passw0rd, password1, 1234 to 12345678, qwerty, letmein,
welcome, abc123, P@ssw0rd), replaced only as a whole word, and
left as written where it stands in a position that
is not a secret: an image name, a URL scheme, a URL user, a URL host, a user
field, or a compose service name. Beside POSTGRES_PASSWORD: postgres, image: postgres
and a postgres:// scheme stay, and are not mapped. Where the credential is left in the text by design (warn,
off, a fixture), the equal name is left too and is not mapped. A name that only
contains a credential, or is part of one, is masked and mapped like any other
name. An entry an existing map
holds for a credential is dropped, whether it is one on its own or one found in
the text, and its number is not reused. So nothing secret is written to
a saved map or uploaded with it. (Earlier versions masked an identifier-shaped
key reversibly, into the map, under 'warn' and 'off'.)
Passwords in a secret position
Some slots can only hold a credential, so the value there is removed whatever
its strength. A weak password (postgres, hunter22) is exactly what a person
types into one:
- password flags:
-p<pw>(attached only) after a mysql or mariadb client, where-p <word>is the database name;-p <pw>attached or not after a mongo client or sshpass;-P <pw>for sqlcmd, bcp, osql, isql and ipmitool;redis-cli -a <pw>;--password,--passwdand--passwith=or a space. Each of these is matched in an argv array too, the flag and its value as one element or two (mysql's-ponly as one). The same rule holds in every file and with no path: the value after--passwordis a password unless it is a help-text metavar (PASSWORD,PASS,PASSWD,PWD,TEXT,VALUE,SECRET,STRING,TOKEN,KEY,[…],<…>) or a common prose word (to,for,is,in,and,or,on,with,when,if,as,of,the,a,an,that,this,from). So--password TEXTin help text is left alone and--password hunter2in a README is redacted. Port flags (mysql -P 3306,psql -p 5432,ssh -p 22,docker run -p) and flags with no value (--password-stdin,--password-file,--no-password) are left alone. A command continued with a trailing backslash is one command; - well-known password environment variables:
PGPASSWORD,MYSQL_PWD,POSTGRES_PASSWORD,MYSQL_ROOT_PASSWORD,MSSQL_SA_PASSWORD,REDISCLI_AUTH,SSHPASSand others, in shell, dotenv, compose and PowerShell forms (a bare YAML block header such as|or>-is not a value); - in a dotenv file (
.env,.env.<word>,*.env), a YAML file and a shell script or Dockerfile - known fromoptions.pathoroptions.fileKind- any other environment-style name that is, or ends in, a credential word (DB_PASSWORD,STRIPE_SECRET_KEY,GITHUB_TOKEN,SECRET_KEY,APP_PASSPHRASE,…_API_KEY,…_ENCRYPTION_KEY,…_CLIENT_SECRETand the like; not a bare…_KEY,PWD,PASSor…_CREDENTIALS, which hold paths), at the start of a line:NAME=vandNAME = vin dotenv (any case),NAME: vand a compose- NAME=vitem in YAML, andNAME=vafterexport,declare -x,readonly,local,ENVorARGin a script. In dotenv and YAML the value is the rest of the line minus a# comment; in a script one shell word. YAML syntax (|,>, a bare!tag,&anchoror*alias) is never a value. Unlike the fixed names above, the value is judged as the quotedpassword = "…"form is: a strong value is redacted (a passphrase and a number of 8 or more digits included); a value under 6 characters (a boolean, a short number,~), a reference (${DB_PASSWORD},$X), a command substitution and a file path (./keys/jwt.pem,$HOME/key.asc) are not values; a weak value is reported as a possible credential and left as written, or, when it is a default word, masked as a name. A file kind never relaxes a test file, but it can turn a weak value that was masked as a name into one reported and left as written. Quoted values have no length limit but are read on one line. A unified-diff line (+,-or a space first) is read as the line it holds. Without apathorfileKind, and in source, Markdown or other files, these names are read as before. Not covered yet: assignments in the middle of a line (docker run -e NAME=v), PowerShell$env:, cmdset, fishset -x,setx, a quoted value spanning lines, the body of a YAML block scalar, and the Kubernetesname:/value:pair; - the password in URL userinfo (
scheme://user:password@host, the user may be empty), never the user or the host; - auth headers:
Authorization/Proxy-AuthorizationwithBearerfrom 8 characters orBasiccredentials of any length,X-Api-Keywith a literal value (one that starts at a code root such as an environment lookup,config.,settings.,secrets.,$or{{is left alone; a JWT never is), the same inside curl-H, and the password of curl-u user:pass.
The user half is a credential too when it is a token (https://<token>@host,
curl -u <token>:, the user of Basic credentials): a user that holds a
provider-format key, or passes the engine's high-entropy token test (24+ token
characters), is redacted in every file. A user name stays, however long or
mixed (deploy_user_2024, Build-Agent01, x-access-token).
Obvious placeholders are neither redacted nor reported: changeme,
password, xxx…, ***, <your-password>, <your password>, ${VAR},
${VAR:?message}, ${{ secrets.X }}, {{ x }}, $VAR, %VAR%, $(…),
REDACTED, an ellipsis, an empty value. A placeholder that spans spaces is
taken whole, and only when it is the whole value: <pw>hunter2 is a value. In
${VAR:-default} (and -, :=, and Spring's ${VAR:default}) the default
is the value and is judged as one, spaces included.
Redaction by context. Pass the file the text came from as path
(anonymize(code, { path }), scanSecrets(code, policy, { path }),
detectSecrets(code, { path })), relative to the project. For an absolute
path, also pass the project root: only folders inside it count, and without
it only the file name does, so /home/test/app/src/db.ts is real source. In a
test, fixture or example file
(isExamplePath: test/, tests/, __tests__/, fixtures/, examples/,
*.test.*, *.spec.*, *_test.*, .env.example, .env.sample, *.example
and similar) a weak value in one of these positions is only reported, as a
possible-credential finding that does not block: there it is a fixture, and
destroying it would corrupt the test. A strong value (12+ characters of
three character classes, or 20+ near-random characters) is redacted there as
everywhere. A user:password pair (Basic, URL userinfo, curl -u) is judged
half by half, never as the joined string: the password half alone decides,
as for PGPASSWORD, and a token-shaped user is redacted on its own. The
verdict holds where the generic password = "..." rule reads the same value,
so a quoted weak value in a test file is report-only too. With no path the
text is treated as real source and every value in these positions is
redacted. The generic password = "..." rule keeps its own judgement
elsewhere (a single lowercase word there is only reported, in any file).
Regulated identifiers
A bank account or card number is not a credential — it is material a regulation
cares about, and it is what this tool is most often reached for. It is also not
an identifier a grammar produced, so const iban = "GB29…" had its name
masked and its value passed through verbatim.
Three formats are found by arithmetic over the value — never by inference, which would cost the zero-dependency guarantee — and masked reversibly, into the same map, so the round trip returns them:
| Finding | Placeholder | Confirmed by |
| -------------- | ------------ | ---------------------- |
| iban | __IBAN__n | IBAN mod-97 check |
| payment-card | __PAN__n | issuer prefix and Luhn |
| pesel | __PESEL__n | PESEL check digit |
const { anonymized, map } = anonymize('const acct = "GB29NWBK60161331926819"')
// anonymized → 'const __VAR__1 = "__IBAN__1"'
// restore(anonymized, map).restored → the original line, account number includedEach format gets its own placeholder base rather than a shared one, because the output's job is to be worked on by a model: "this is a bank account number" is information it can use, where "this is a redacted something" is not. Published test card numbers are not flagged at all — a fixture is not a leak.
Three rules bound what the reversibility can cost:
- A credential wins. Where a credential rule reads the same span — a
password assignment, a connection string, a bearer token — the value is
destroyed rather than masked, so it never reaches the map. An ambiguous
verdict (
possible-credential) is destroyed too: masking might write a live secret into the map, and reporting it would send that secret to the model verbatim. - A manual mark wins. A value already marked by hand keeps its
__MANUAL__nplaceholder rather than gaining a second one. - They do not block a paste.
hasBlockingSecretsexists to stop a live credential reaching a model; refusing the billing code this tool was built for is not that.
Under 'warn' and 'off' these values are left in place like any other finding
— masking them is part of acting on a scan, not part of reporting one.
Privacy & security properties
This package is the security-critical core of Veilio, and is designed to be audited:
- Local only. No network calls, ever — it is a pure in-process transform.
- No telemetry. It reads no environment, sends no analytics.
- Zero runtime dependencies. Nothing is pulled in at install time.
These invariants are enforced in CI by tests/purity.test.ts.
Note (known limitation): custom-rule patterns are compiled with
RegExp. A pathological user-supplied pattern can backtrack (ReDoS). See the repoSECURITY.md. Treat rule patterns as untrusted input in hostile contexts.
API
anonymize(code, options?)→{ anonymized, map, identifierCount, language, languageFallback, secrets, comments, rulesStopped }options.language— a language name or'auto'(default)options.secrets—'redact'(default) |'warn'|'off'options.path— the file the code came from; a weak password in a secret position is only reported in a test, fixture or example file (see above)options.root— the project root, for an absolutepathoptions.fileKind—'dotenv'|'yaml'|'shell'|'ini'|'xml'|'pgpass'|'text', for a caller that knows the file but passes nopath(fileKindOf(path)): turns on the environment-style credential rules for that kind (dotenv, YAML, shell) and where its grammar keeps values (every kind;textreads by every grammar) - it never relaxes a test file. Adds to the kind apathimplies; it never replaces it. Text of no kind -text, or no path and no language the text reads as - is read by every value grammar:DB_PASSWORD=Summer2024!pasted on its own is redacted whole.options.existingMap— continue numbering from a previous session:anonymize(next, { existingMap: map })options.team—{ blocks, tag, retired? }, a team checkout's numbering: each new number of a base comes from its reserved block while one is left, and otherwise from the checkout's local family (see "Names numbered without a team block").retiredlists placeholders a publish moved out of the map (aliases.json); numbering stays above them, so none is minted again for another name. A tag that is not six ofA-Z0-9throws a TypeError. Without it, numbering is unchangedoptions.ruleBudget— aruleBudget()the call draws its rule-matching steps from, for a caller that shares one allowance across several calls (the guard does, per hook invocation). Without it, each call gets its ownoptions.rules— whitelist / named-replacement rulesoptions.manual— literal strings to mask by hand (see below)- an unknown key throws a TypeError that names it and lists the valid
options. It used to be ignored, or worse: an object of unknown keys was
taken as a positional map, so
{ style: 'plain' }(an option removed before 1.0.0) was stored in the map.restore,scanSecretsanddetectSecretscheck their options the same way. anonymize(code, existingMap)— the positional map form is deprecated; pass{ existingMap }. An object whose keys are all placeholders is still read as a map. The form is removed in the next major version.comments—{ total, inline, characters, severity }: how much comment prose left unmasked. Comments are prose and are deliberately not masked, which makes this the largest thing the engine does not do for you — reported rather than left to be discovered.inlinecounts blocks sitting after the file's first line of code; a licence header above it gradeslow, anything in the body gradesmedium, and it never goes higher: the engine cannot read the prose, so it never claims a comment is sensitive. Consecutive line comments count as one block; blocks with no letters or digits in them, and placeholders standing in for terms already marked, are not counted.- Throws
ManualMaskErrorwhen a term inoptions.manualscans as a credential, is already a placeholder, or is a keyword in the resolved language. Marks replayed fromexistingMapnever throw — a mark made in one language must not make a file in another language impossible to anonymize.
restore(text, map, options?)→{ restored, strippedItems, strippedCount, report }options.strip— which AI artifacts to remove:'all'(default),'none', or an explicit list ofStrippedItemType. Worth setting:'all'deletes JSDoc, and when a model was asked to document its output that is destroying requested work rather than removing noise.report—{ resolved, missing, unresolved, altered }: which placeholders came back, which never appeared, which placeholder-shaped tokens the map cannot explain, and which placeholders the model changed the case or underscores of (__fn__1,_FN__1) — no map can restore those. A model that renames__FN__1leaves no trace in the restored text, somissingis the only place that failure is visible.alteredis new in 1.8.0 and always present: code that builds a report by hand (a test fixture, say) needsaltered: []to type-check.
alteredPlaceholders(text)→string[]— thealteredrule on its own: tokens of a kind the engine mints (CLS,FN,VAR,PKG,STR,MANUAL,IBAN,PAN,PESEL) with changed case or underscores. An ordinary name (tmp_fn_1), a dunder or a redacted credential never matches.alteredPlaceholders(text, map)also leaves out the map's real names, asrestore()does: your own_str_1restored exactly is not altered.isPlaceholder(token)→boolean— whether a string is a placeholder this engine could have produced. For validating a map that arrived from somewhere else before trusting its keys.manualTermsIn(map)→string[]— terms previously marked by handMANUAL_BASE/ManualMaskError— the manual placeholder base, and the error thrown when a mark is refused
Marking spans by hand
Custom rules can only rename identifiers the extractor already found. That leaves out the two things most often needing masking: a name inside a comment, and a bare account or case number. Both are prose as far as extraction is concerned.
anonymize('// escalated by Kowalska, acct 88412037', {
manual: ['Kowalska', '88412037'],
})
// → '// escalated by __MANUAL__1, acct __MANUAL__2'Matching is literal, case-sensitive and longest-first, and runs before
extraction — so a marked token beats whatever role the classifier would have
given it. Marks are stored in the map under __MANUAL__n, which means
restore() reverses them with no special handling and they survive export,
import and sync without a second store. Pass manualTermsIn(previousMap) back
in, or just reuse the map as existingMap, and prior marks re-apply.
Three kinds of term are refused with a ManualMaskError:
- A credential. A manual mask is reversible and is written to the map, so masking a live key would persist the secret. Credentials take the one-way redaction path instead.
- An existing placeholder. Mapping one placeholder to another survives
anonymizeand then loses the real name onrestore, which is a single pass. - A keyword in the resolved language. Marks match literal text anywhere,
which is the feature — and is why this case is catastrophic rather than merely
wrong: marking
ifbecause you read it in a comment rewrites everyifin the code and the file stops compiling.
A mark replayed from existingMap is never refused for being a keyword. A
map outlives the file it was made against — def is an ordinary word in a
TypeScript comment and is Python's grammar — so a stale mark is skipped, not
thrown on. It stays in the map and applies again where it is valid.
measureCommentExposure(code, language?)→CommentExposure— the same measurementanonymizereturns, for text it did not produce (after a manual mark is undone, say)detectSecrets(code, options?)→SecretFinding[]— scan without modifyingscanSecrets(code, policy?, options?)→{ findings, code, regulated }—options.path,options.rootandoptions.fileKindas foranonymize;regulatedholds the values handed to the masking pass;anonymizecalls this first and masks them itself, so a caller doing its own masking is the only one that needs the fieldREGULATED_BASES— the placeholder base per regulated formathasBlockingSecrets(findings)/summarizeSecrets(findings)isExamplePath(path, root?)→ whether a path is a test, fixture or example filefileKindOf(path)→'dotenv'|'yaml'|'shell'|'ini'|'xml'|'pgpass'|'text'|undefined: the file kind the engine reads a path as (.env,.env.local,*.env;.yml;.sh, a Dockerfile, shell rc files;.ini,.cfg,.conf,.properties,.toml;.xml,.config,.xaml,.csproj,.props,.targets,.plist;textfor.md,.mdx,.rstand any other file with no code extension -.txt,.tfvars,.bat, a Makefile;pgpassfor.pgpass) -undefinedfor code and data such as.env.tsor.env.example.jsondetectCredentials(code, options?)→detectSecrets, plus every value under a credential-named key thatanonymizewould redact and no rule reports (password: Summer2024!in text), aspassword-assignmentfindings. Takeslanguagetoo; whatveilio scanandscan_secretsruncredentialSpans(code, options?)→{ start, end, ambiguous }[], every credential the rules find, whatever the policy: whatanonymizekeeps out of the map.ambiguousmarks a single word afterpassword =redactionTokensOf(scan)→ each value ascanSecretsresult destroyed, with the token that replaced itdetectLanguage(code)/guessLanguage(code)→ detection with a scoreextractIdentifiers(code, language?)→string[]withAiPreamble(anonymized, map?)/AI_PREAMBLE— a note to paste above masked code so a downstream AI treats the placeholders as intentional.
Shared team namespaces
Used by the CLI and the MCP server so teammates agree on placeholders. The engine holds one implementation because two copies can drift between the test asserting they agree and the next commit, and the symptom would be a team silently forking its numbering with both repositories green.
mergeTeamNamespace(entries)/TeamMapEntry— merge team maps into one namespace, oldest first, so an edited map cannot steal a placeholder an older one already claimedanalyzeTeamNamespace(entries)→{ namespace, aliases, conflicts, highest, localDropped }— the same merge, plus what the merge had to decide: the placeholders two maps disagree about, the aliases that still restore, and the highest number per base so new names are minted above the team's. A__LOCAL_key is dropped before any of it and counted inlocalDropped: no team map should hold onerestoreLayers({ own, team })→ the one restore rule, shared by the web app, the CLI and the MCP server: the team's layer under this project's own, withdisputedIn(text)andlocallyNumberedIn(text)naming the placeholders that must be left alone rather than guessed.disputedNote/locallyNumberedNoterender the message each caller shows. The team layer never gives a__LOCAL_placeholder a meaning;otherLocalIn(text)names the ones the own map lacks (another checkout numbered them), andlocalFamilyNotesays sounwrapPrivateKey,deriveWrappingKey,importPublicKey,unwrapTeamKey,importTeamKey/exportTeamKey,decryptTeamMap,decryptTeamMapWithAny,encryptTeamMap,TeamKeyError— opening team-key envelopes, and sealing one under a key you already hold. Minting a key, granting it to a teammate and confirming a teammate's key need a person present to approve them and stay in the browser: a client that can open maps has no business handing out access to them. Sealing a map is not a grant — it widens nothing about who can read the team's maps — which is why a terminal may do it.
Names numbered without a team block
A team member whose tools hold no usable block of reserved team numbers
(refused, offline, a lapsed plan) numbers new names in a family of its own,
__LOCAL_<TAG>_<KIND>__n (__LOCAL_K7Q2XA_VAR__3). The tag names the
checkout, so two checkouts never mint the same placeholder, and no team map
ever holds one: a teammate leaves it as written, and the next publish gives it
a team number.
isLocalPlaceholder(token)— a numbered placeholder of that family;LOCAL_PREFIXis its prefix,__LOCAL_localBaseOf(base, tag)/teamBaseOf(localBase)—__VAR__andK7Q2XAto__LOCAL_K7Q2XA_VAR__and back;isLocalTag(tag)is the tag's shape, six ofA-Z0-9isRolePlaceholder(token)— a role-typed name (__CLS__n,__FN__n,__PKG__n,__VAR__n,__STR__n) in either familyusableBlocks(map, blocks)/numbersLeft(map, blocks)/highestPerKind(map)— which reserved blocks (Block,{ start, end }) a map can still number inside, and how many numbers they have left
Encrypted .veilio files and the vault
The crypto behind CE's export/import and Cloud's map sync lives here so both editions run the same code — a second implementation of a format that has to open files the first one wrote is a second thing to get subtly wrong. WebCrypto only, no dependency added.
parseSymbolMap(raw)→SymbolMap— validate an untrusted map before use; throwsInvalidMapErrorVeilioFile/KdfParams/parseKdfParams— the.veilioenvelope shape and its KDF parameters,CURRENT_FILE_KDFfor what new files get andLEGACY_FILE_KDFfor what older ones still open withassertUsablePassphrase(value)/MIN_PASSPHRASE_LENGTH/WeakPassphraseError— refuse a passphrase too weak for a file nobody can recoverVaultEnvelope/parseVaultEnvelope/VaultEnvelopeError,CURRENT_VAULT_KDF/LEGACY_VAULT_KDF,VAULT_SALT_BYTES,randomVaultSalt()— the account vault a personal map is sealed under, so the server stores ciphertext it cannot readwebCryptoSubtle(),randomBytes(n),toBase64/fromBase64— the small platform layer the above are built on
Custom rules sealed under your keys
A custom rule (a name to keep readable, or a pattern to mask under your own placeholder) is sealed before it leaves your machine, under the same keys as your maps, so a server storing it cannot read it.
sealRuleForVault(key, rule, bind)/openRuleFromVault(key, envelope, bind)— a personal rule under the personal-maps keysealTeamRule(teamKey, rule, bind)/openTeamRule(keys, envelope, bind)— a team rule under the team key, opened with whichever held version fits- the last argument names the rule's row,
{ id, teamId }(aRuleBinding); it is sealed into the body and checked on opening, so a server that cannot read a rule cannot move it to another row either - opening returns
{ ok: true, rule }or{ ok: false, reason }, never throws on bad content. The reason is "unopenable" (wrong key, damaged), "not-a-rule" (something else was sealed there) or "refused" (a rule no tool may apply, or one sealed for another row) ruleRefusal(rule)→ the sentence why a rule may not be saved or applied, ornull;isEngineBase(placeholder)(a placeholder base the engine uses for its own names, including anything under__REDACTED_or__LOCAL_) andisSafeRegex(pattern)are the checks it makescompileRulePattern(pattern)→ a matcher whosetest(name)gives the platform regex's answer in time linear in the name's length, ornullfor a pattern outside its language. Rule patterns run on it, never on the platform regex, so a teammate's pattern such as(a+)+$cannot freeze the tool applying it.test(name, budget?)takes its steps from aruleBudget()when given one. Oneanonymizecall shares a budget ofRULE_WORK_BUDGET(50,000,000) steps across all its rules and names; once spent, every rule test answers "no match" (no name is kept readable or given a rule's placeholder, and an ordinary word a replace rule might have matched is masked), and the result carriesrulesStopped: true-RULES_STOPPED_NOTEis the sentence to show. The language: literals,., classes,\d \w \sand their negations,^ $, groups, alternation, and quantifiers with bounds up to 50. Backreferences, lookaround and\bare refused;isSafeRegexis true exactly when this compiles
A lost .veilio passphrase is not recoverable, by construction. Nothing here
holds an escrow copy and nothing in the format allows one.
License
Veilio Community License 1.1 — free to use for any purpose, including inside
your own business commercially. You may not resell it, host it as a service for
others, rebrand it, or republish it as a product that competes with Veilio Cloud;
for that, contact [email protected]. See the LICENSE file. This is a
source-available, community-developed license, not an OSI-approved "open source"
license.
