Privacy and file control
Labels, sealed content and .nexapolicy: how a path is protected by encryption rather than by a permission check, and what a peer without the key can see.
Nexa keeps a file private by encrypting it, not by asking a server to refuse a request. A path assigned to a label is encrypted under that label's key. Anyone without the key holds the object, stores it, forwards it and can verify it — and cannot read it. That is what sealed content means.
This distinction is the whole point. A permission check protects data only while the server enforcing it is between you and the data. Encryption protects it even from someone who has the entire repository.
Labels
A label is a named protection class for paths, with its own key. Creating one writes the rule and mints the key in a single step:
nexa label create secrets secrets/secrets/ and secrets/** always mean the directory and everything under it, whether or not that directory exists yet. Prefer them: they
are unambiguous, and they let you declare a protection before you create the secret.
An unmarked path like secrets is resolved against your working tree:
- if it names a directory, you get the subtree rule
secrets/**; - if it names a file, you get a rule for exactly that file;
- if it names nothing, the command is refused — file or directory cannot be told apart, and guessing either way is a silent privacy decision.
Whatever you typed, nexa label create prints the rule it actually wrote on its protecting: line, and nexa label list shows that same resolved rule.
nexa label create secrets secretserror: cannot do that here: nothing by that name is in the working tree, so whether you mean a file or a directory cannot be told; write `<path>/` for a directory and everything under it, or create the file first to protect exactly it
That refusal is the whole point: the earlier behaviour wrote a rule for a file named secrets, which — because a repository only ever stores file paths — matched nothing
at all and protected nothing, while reporting success.
| You write | What it protects |
|---|---|
secrets/ | The directory and everything beneath it. Correct. |
secrets/** | Identical in effect to the trailing slash. Also correct. |
secrets | Resolved against the working tree: the whole subtree if secrets is a
directory, that one file if it is a file, and refused if it is neither. |
config/keys.json | Exactly that one file, when it exists. Correct when a file is what you meant. |
nexa label list shows the configured labels and whether this device can read each
of them. nexa label inspect <name> shows one label's identifier, its paths and
its key availability.
Sharing a key with another device
A label's key starts out on the device that created it. Giving it to another device is done from Rinko Hub: a repository's Label access page grants a label key to a collaborator's device, expands a grant across a team, and rotates a label to withdraw access. See Managing label access on Rinko Hub.
From the CLI
The recipient prints their identity and hands it over. A short fingerprint is not enough: a label key is encrypted to the recipient's encryption key, so the full value is what the granting command needs.
nexa device identityDevice: 1431-d9e7-dc6f-d7d4 Identity: 48d71f54…c053d5c033 Give the identity line to whoever holds a label you need; they run `nexa label grant <label> --device <identity>`.
Whoever holds the label then grants it. The key is wrapped to that device and written as an ordinary object, so it travels with the repository like anything else — there is no separate channel, and no server ever sees the key:
nexa label grant secrets --device <identity>Granted secrets to 338a-4b04-acb1-c341. epoch: 0 grant: 4ad676c6…a2ef1732 The grant is an ordinary object. It reaches that device when the repository does -- `nexa push`, or a sync with whoever holds it.
nexa label grants secrets lists who holds a grant and at which epoch. It deliberately
needs no key of its own: a grant's recipient and epoch are public by design, and only the wrapped
bytes are secret — so reviewing access never requires access.
If someone told you their device identifier separately — over a call, in person — pass it as --expect-id. It is re-derived from the keys you supplied and a mismatch is
refused, which turns a fingerprint you compared out of band into something the command
actually enforces. It never resolves an identifier to keys.
The .nexapolicy file
The file shown below is the version 0 grammar, which still parses and still means exactly what
it meant. A file whose first statement is policy 1 uses the block grammar
instead, which adds glob paths, a named-key form and destination rules for workspaces. The
grammar, the merge rules and the upgrade path are covered in Labels, workspaces and .nexapolicy.
.nexapolicy is the file that binds paths to labels. It names labels and never contains keys, so the file itself grants nothing. nexa label create secrets secrets/ writes exactly two lines:
label "secrets" = "3f9a1c7e2b8d4a6f0e5c9b1d7a3f8e2c"
path "secrets/**" { label = "secrets" }The identifier is minted per label; yours will differ.
Where the file lives
Under a Development Root, one .nexapolicy sits at the root. Projects are named inside that one file rather than each
being given a policy file of its own, because nothing Nexa owns is written into a project directory:
policy 1
project "my-project" {
label "secrets" { paths = [".env", "config/private/**"] }
}
project "another" {
label "secrets" { paths = [".env"] } # another's own, unrelated label
}Labels are repository-specific. Each project is its own repository, and a label
belongs to one repository — my-project's secrets and another's are two unrelated labels with two different keys. So every label rule
sits in the block of the project whose label it names, and reaches that project alone. A label
rule in global is refused with labels are repository-specific … scope it under the project instead: it would reach
every project and be resolved against each one's own labels. A repository used on its own,
outside any Development Root, keeps its .nexapolicy at its own root with its rules
in global, which there means that repository — and a project that already carries a
file of its own is still read from where it is, never moved or rewritten.
Wherever you edit it, the policy that was in force is recorded into each version, so a collaborator who clones or a peer who syncs receives the rules — including which paths are protected — without needing your Development Root. Because it is recorded per version, an old version keeps the policy it was recorded under; changing the file today does not retroactively reinterpret what was sealed yesterday.
Hand-written policy files are preserved byte for byte. Comments, blank lines and unrelated rules survive, and new labels are appended rather than the file being rewritten.
Declaring a path that is already protected under a different label is rejected: the policy already protects one of those paths under a different label; remove that rule first if you mean to change it. Precedence is never left to the order lines happen to appear in.
Inspecting and explaining policy
| Command | What it tells you |
|---|---|
nexa policy show | The policy in force, and which labels this device can read. |
nexa policy check | Whether the policy document is valid, before you record it. |
nexa policy explain <path> | Whether one path is protected, and whether this device can read it. |
nexa policy explain secrets/api-keys.jsonexplain answers honestly on both sides. On a device holding the key it reports This device holds a key…; on one without it, This device holds no key for that label and cannot read it. Its JSON output is stable, so
a script can rely on it.
Both show and explain read the working tree's .nexapolicy — before any version is recorded, and unchanged afterwards. You can therefore
write a rule and confirm it covers what you meant before committing to it.
If the policy declares a label whose key is missing, nexa record refuses, rather than
writing that content in the clear. Failing loudly is the intended behaviour here.
Matching is exact, character for character: a rule for .app.json does not protect app.json. If a rule names an exact path and no such file exists, nexa record refuses and names the path, and nothing is recorded. A pattern such
as secrets/** that matches nothing yet produces a warning instead, because it may be
written ahead of its files. nexa policy show and nexa policy validate state how many files each rule matches.
Protection starts at the next record
A label protects a file from the version it is recorded in onwards. A version recorded before the rule existed keeps that file readable, permanently — history is not rewritten, and publishing uploads that version with the rest. A credential that was recorded or published unprotected should be treated as exposed: rotate it, then protect the file before recording the replacement. Protect private files before hosting is the order that avoids this.
A project's .git directory is skipped automatically, at any depth. It holds every
file the project ever committed — including ones deleted long ago — so recording it would
publish that history, and no policy rule reaches inside it. Other dotfiles, such as .gitignore or .github/, are recorded like any other file.
What sealed content looks like
The proof below was produced on a peer that holds no key for the label, after
syncing the repository in full. The version contains two files: secrets/…, which is
sealed, and docs/readme.txt, which is not.
nexa restore .Restored 2 file(s) from f984f95e1495. .nexapolicy docs/readme.txt 1 sealed entry/entries could not be named or written without a key.
The unprotected file is the control, and it is what makes this evidence rather than an anecdote: it restores to identical, readable plaintext on the same peer in the same command. The method can plainly detect readable content — so the sealed entry's absence is a real result.
Through every other path, the keyless peer gets the same answer:
nexa showon the sealed object reports sealed content: no key available to open it here.nexa findreturns nothing for the sealed name.- Browsing the repository shows a sealed entry with no name to display.
The omission is reported on screen but not in the exit code, so a script that checks only the exit status will conclude the restore was complete. Parse the output, or run the restore on a device you know holds the key.
Limits
Sealing hides sealed file names and their content, and the entries of a sealed subtree. It hides nothing else. To a peer without the key, all of the following are plainly visible:
| Visible without the key | Hidden |
|---|---|
| The parent directory name | Sealed file names, their content, and the entries inside a sealed subtree — and nothing beyond this. |
| The label identifier | |
The full .nexapolicy text, including which patterns are protected | |
| Version messages | |
| Author device identifiers | |
| The number of sealed entries |
A directory called acquisition-northwind/ tells a reader what you are working on
before a single file is opened. The policy text spells out which patterns are protected, so
the shape of what you are hiding is public even when its contents are not. Put secrets
behind neutral names.
What verification does not prove
verify is an integrity check: it confirms objects match their
identifiers and the history is intact. It passes on a peer holding no keys at all. A
successful verify says nothing about whether anything is sealed. To check protection, use nexa policy explain on the specific path.
Comparison tells each device what it is entitled to know
On a device holding the label's key, compare reads sealed paths exactly like any other
file — one edit to one sealed file is one modification, named by its real path:
nexa compare 33d6dc7a6cdd 7124a18e0412M public.txt M secrets/.env 2 difference(s)
The same comparison on a peer holding no key still reports that sealed content changed — it is
not hidden — but by the sealed object's identifier rather than by path, with the line marked path unavailable without the label key. Under --format json those
entries carry "path": null, the result is marked "complete": false, and the labels involved are listed in indeterminate_labels.
That difference is the protection working, not a defect: the keyless peer learns a change happened under a label it cannot read, which is the most it is entitled to know.
Recording without the key
A device without the label's key can still record. Running nexa record with no path arguments records the whole working tree, and sealed content it cannot read
is carried into the new version untouched — placed by identity, never decrypted. A collaborator who
holds the key can restore that version and find the sealed content intact, alongside whatever the
keyless device changed.
This holds even though nexa restore could not write the sealed file to disk in the first
place:
nexa restore .Restored 2 file(s) from 5b12b988c09f. .nexapolicy public.txt 1 sealed entry/entries could not be named or written without a key.
The sealed directory is left empty on disk, and the following nexa record reports only
the files it could see:
nexa recordCreated version e428e63fb085 workspace: main files: 2 Selected on branch main.
The sealed: line counts paths in your working tree that the policy protects.
A device holding no key has nothing on disk for that label, so the count is zero and the line is
omitted. Its absence says nothing about what the recorded version contains — the sealed entries
are still there. That the counter reads this way is a reporting defect, and it is being corrected.
The safe working rule is unaffected by any of the above: only devices holding a label's key should record versions in a repository that uses it. A device that cannot read a label cannot review what it is carrying forward, and the remedy is to obtain a grant for the label and restore again.
Protection is per repository, not per workspace Planned
A label applies across the repository wherever its paths match. You cannot make a path sealed in one workspace and readable in another. What the version 1 grammar can do is keep labelled work out of a workspace altogether — a destination rule, described in Labels, workspaces and .nexapolicy. If you need a genuinely separate audience, use a separate repository.
Verifying your own protection
Before trusting a label with anything that matters, confirm it end to end:
- Create the label with an explicit directory spelling:
nexa label create secrets secrets/. It is unambiguous whether or not the directory exists yet. - Check the
protecting:line the command prints, and confirm it sayssecrets/**rather thansecrets. - Run
nexa policy explain secrets/nested/file.txton a nested file — not a top-level one. This confirms the rule reaches descendants. - Confirm it reports the path as protected, and that this device holds the key.
- Record a version, and include an unprotected control file so a later check can distinguish "nothing readable" from "nothing there".
- On a device without the key, run
nexa restore .and confirm the sealed entry is reported as unwritable while the control file restores normally.
Step 5 is the only step that proves protection against someone else. The rest confirm you configured what you intended.