Documentation menu

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:

Syntax
nexa label create secrets secrets/
Note: A path without a trailing slash is resolved, not guessed

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.

Command
nexa label create secrets secrets
Expected output
error: 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 writeWhat it protects
secrets/The directory and everything beneath it. Correct.
secrets/**Identical in effect to the trailing slash. Also correct.
secretsResolved 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.jsonExactly 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.

Command
nexa device identity
Expected output
Device:   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:

Command
nexa label grant secrets --device <identity>
Expected output
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.

Note: `--expect-id` checks a fingerprint, it does not look one up

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

Note: The grammar has a version 1, documented separately

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:

.nexapolicy
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:

~/Development/.nexapolicy
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.

Note: The policy still travels with the repository

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.

Note: Conflicting rules are refused, not merged

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

CommandWhat it tells you
nexa policy showThe policy in force, and which labels this device can read.
nexa policy checkWhether 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.
Syntax
nexa policy explain secrets/api-keys.json

explain 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.

Warning: Record refuses rather than leaking

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.

Warning: A rule that matches no file protects nothing

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

Important: Labelling is not retroactive

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.

Note: Git metadata is never recorded

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.

Command
nexa restore .
Expected output
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 show on the sealed object reports sealed content: no key available to open it here.
  • nexa find returns nothing for the sealed name.
  • Browsing the repository shows a sealed entry with no name to display.
Warning: Restore exits 0 even when it could not write a sealed file

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 keyHidden
The parent directory nameSealed 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
Important: Name your directories as if everyone can read the names

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

Warning: `nexa verify` does not prove your files are protected

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:

Command
nexa compare 33d6dc7a6cdd 7124a18e0412
Expected output
M  public.txt
M  secrets/.env

2 difference(s)
Note: A device without the key is told that something changed, not what

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:

Command
nexa restore .
Expected output
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:

Command
nexa record
Expected output
Created version e428e63fb085
  workspace: main
  files:     2

Selected on branch main.
Note: The `sealed:` count describes your disk, not the version

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

Planned: There is no workspace-scoped exclusion

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:

  1. Create the label with an explicit directory spelling: nexa label create secrets secrets/. It is unambiguous whether or not the directory exists yet.
  2. Check the protecting: line the command prints, and confirm it says secrets/** rather than secrets.
  3. Run nexa policy explain secrets/nested/file.txt on a nested file — not a top-level one. This confirms the rule reaches descendants.
  4. Confirm it reports the path as protected, and that this device holds the key.
  5. Record a version, and include an unprotected control file so a later check can distinguish "nothing readable" from "nothing there".
  6. 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.