The IXO Virtual Filesystem (VFS) supports hidden files and folders so a domain can keep sensitive content — .env, .config/…, private reports — out of normal listings, reads, and search. Hidden items are reachable only by a token that explicitly asks for them via an nb.hidden UCAN caveat. This is a policy layer on top of the existing namespace, path scope, and UCAN model — not a separate permission system.
.env is a perfectly valid filename. It just isn’t a visible one by default.
When to use it
- Store per-domain secrets or configuration alongside user content without leaking them through generic list, search, or agent tools.
- Ship VFS-backed AI agents that only ever see the non-sensitive subset of a user’s tree, unless the caller mints a token that explicitly opts them in.
- Give the domain owner (or their client) a token with reveal-everything so they can manage their own hidden content, while every delegated agent stays blind by default.
Concept
- A file or folder whose own name starts with
. is hidden by default — the Unix dotfile convention. This default is overridable per item.
- A hidden folder hides its entire subtree. An item is effectively hidden when it, or any ancestor folder, is hidden.
- Hidden items are invisible and unreadable on every read surface unless the caller’s UCAN carries an
nb.hidden reveal caveat that covers them.
- Safe by default: with no reveal caveat, nothing hidden leaks — not even to a whole-tree owner token. Owner clients mint tokens with
nb.hidden: ["*"] so the owner sees their own hidden content.
What “hidden” means on every surface
Without a matching reveal caveat, an effectively-hidden item is filtered from every read surface — REST and MCP alike:
| Surface | Behaviour |
|---|
GET /api/fs/files (list), GET /api/fs/tree, GET /api/fs/glob | Excluded from results |
GET /api/fs/grep, GET /api/fs/search (lexical and semantic) | Never matched; content never returned in snippets |
GET /api/fs/files/:id, /content, /read, /events, /provenance | 404 — existence is never confirmed |
GET /api/fs/cid/:cid, GET /api/fs/cids | Not listed; fetch-by-CID returns 404 |
GET /api/fs/trash | Excluded |
MCP tools (all 13, including vfs_list, vfs_grep, vfs_search, vfs_read, vfs_read_cid) | Same as above |
| Batch operations across any of the above | Same as above |
Hiddenness never affects writes by explicit id or path. You can create, un-hide, or move an item you can address directly — even one you can’t yet list.
Set or unset the hidden flag
Every item can be forced hidden or visible regardless of its name. The dot-prefix is only the default. All calls require Authorization: Bearer <ucan> with fs/write capability and X-Auth-Type: ucan.
Setting the flag is always an explicit set, never a toggle. You must supply the value; omitting it returns 400.
On upload — ?hidden=true|false
# a dotfile kept VISIBLE
curl -X POST "$VFS/api/fs/files?path=/.env&hidden=false" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
-H "Content-Type: text/plain" --data-binary 'PORT=3000'
# → 201 { "id": "…", "path": "/.env", "hidden": false, … }
# a NORMAL file made hidden from birth
curl -X POST "$VFS/api/fs/files?path=/report.pdf&hidden=true" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
-H "Content-Type: application/pdf" --data-binary @report.pdf
Omit ?hidden= to fall back to the dotfile convention (.env → hidden, report.pdf → visible).
Change a file’s flag afterwards — PATCH /api/fs/files/:id/hidden
The value comes from ?hidden=true|false (preferred) or a JSON body { "hidden": <bool> }:
# un-hide via query param
curl -X PATCH "$VFS/api/fs/files/<FILE_ID>/hidden?hidden=false" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
# hide via JSON body (equivalent)
curl -X PATCH "$VFS/api/fs/files/<FILE_ID>/hidden" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
-H "Content-Type: application/json" -d '{ "hidden": true }'
# → 200 { …, "hidden": true }
You get <FILE_ID> from the upload response. To discover the id of an already-hidden file via GET /api/fs/files, the token needs a reveal caveat — but flipping the flag itself needs no reveal, since you supply the id directly.
Change a folder’s flag — PUT /api/fs/folders/hidden
Sets the folder’s own flag and cascades effective-hidden through its whole subtree. Value comes from ?hidden=true|false or a JSON body.
# hide a normal folder (hides everything under /reports)
curl -X PUT "$VFS/api/fs/folders/hidden?path=/reports&hidden=true" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
# → 200 { "path": "/reports", "hidden": true, "hiddenEffective": true }
# un-hide a dot-folder — reveals its non-dotted descendants;
# self-dotted children stay hidden
curl -X PUT "$VFS/api/fs/folders/hidden?path=/.config&hidden=false" \
-H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
Folders are first-class in the data model, so a dotted folder can be un-hidden as a unit and its non-dotted descendants correctly reappear while any self-dotted children stay hidden.
Quick reference
| Goal | Endpoint | Value |
|---|
| Set at upload | POST /api/fs/files?path=…&hidden=true|false | Query param |
| Set a file later | PATCH /api/fs/files/:id/hidden | ?hidden= or JSON { "hidden": bool } |
| Set a folder (cascades) | PUT /api/fs/folders/hidden?path=… | ?hidden= or JSON { "hidden": bool } |
GET /api/fs/files/:id returns a hidden boolean on file metadata — the effective state, so it accounts for any hidden ancestor folder.
From an agent — MCP
Agents get the same behaviour. Every read, list, and search tool honours the session token’s reveal set, and a dedicated tool sets the flag on either a file or a folder:
vfs_set_hidden { path: "/secrets", hidden: true } // file OR folder; folders cascade
A CID-scoped MCP session still needs an nb.hidden entry to reach hidden content by CID.
Reveal hidden items — the nb.hidden UCAN caveat
A hidden item becomes visible to a token whose UCAN capability carries an nb.hidden caveat that covers it. The caveat lives on the capability, alongside with and can:
{
"can": "fs/read",
"with": "ixo:filesystem",
"nb": {
"hidden": ["*"] // reveal EVERYTHING hidden in scope
// or a specific set:
// "hidden": ["/secrets/.env", "/reports"] // paths reveal that item + its subtree
// "hidden": ["bafkrei…"] // a CID reveals matching files
}
}
Reveal entry forms
* — reveal all hidden items in scope. Owner clients typically mint this for the owner’s own tokens.
- A path (
/secrets/.env, /reports) — reveal that item and its subtree.
- A CID (
bafkrei…) — reveal files whose content id matches. Use with GET /api/fs/cid/:cid or vfs_read_cid.
Attenuation
Like nb.cids, the reveal set only narrows down a delegation chain — a delegate can never widen what an ancestor granted. * is the universal set, so the effective reveal is the intersection of the specific (non-*) sets down the chain:
owner mints nb.hidden: ["*"] → delegate nb.hidden: ["/reports"] ⇒ effective ["/reports"]
owner mints nb.hidden: ["/reports"] → delegate nb.hidden: ["/reports", "/secrets"] ⇒ effective ["/reports"]
Composition with other scopes
nb.hidden only lifts the hidden filter. It composes with, never overrides, the other UCAN scopes — the most restrictive wins:
- Namespace isolation still applies: a reveal-all token cannot see another user’s namespace.
- Path scope still applies:
nb.hidden: ["*"] on a token scoped to /reports reveals only hidden items under /reports.
- CID scope still applies: a CID-scoped token still needs a CID or
* in nb.hidden to fetch matching hidden files.
Security properties
- Default-deny. No
nb.hidden caveat means nothing hidden is visible or readable, on any surface, for any token — including a whole-tree * owner token.
- No cross-surface leak. The same reveal check gates listings, grep, semantic search (vector candidates are re-validated against the metadata store with the hidden filter, so hidden content never escapes via embeddings), CID lookup, trash, metadata, and content.
- Attenuation only. A delegate can never widen the reveal set an ancestor granted.
- Composes, never bypasses. Reveal lifts only the hidden filter; namespace isolation, path scope, and CID scope still apply.
- Bounded. Reveal sets are capped, and the folder-toggle cascade is bounded by the subtree size.