Skip to content

std::permissions

What the manifest allows, as data the program carries.

[permissions] in khora.toml says which paths a package may read and write. The answer itself lives in std::permissions::grants, which khora build writes; this module is the part that decides what an answer means, and std::fs’s real handlers consult it before touching a file.

The grants are compiled in, not read at run time. A file the program consults for its own permissions is a file an attacker edits; a list the compiler wrote into the binary is one nothing at run time can widen.

Why this is a file you can read rather than a check inside the compiler

Section titled “Why this is a file you can read rather than a check inside the compiler”

The compiler contributes data. Everything that decides whether a path is allowed is here, in Khora, and can be read, tested and stepped through – which is the whole reason the enforcement is not a pass in khora-types that nobody outside the compiler can see. The design document is explicit about it: “ordinary Khora in a module you can read, not a second enforcement engine hidden in the compiler”.

Everything. A missing [permissions] table grants everything, which is opt-in tightening rather than a tax on starting – the same bargain Rust makes with unsafe. The default below is the one a program with no manifest, or no [permissions.fs], compiles with, and khora build narrows it only when the manifest asks.

Functions: granted · granted_name · granted_host · normalized

pub fn granted(grants: List<String>, path: String) -> Bool

Whether any grant covers path.

The glob dialect is .gitignore’s, because it is the one everybody already has in their fingers:

  • * matches within one path segment and stops at a separator.
  • ** matches across separators, so data/** covers data/a/b.txt – though not data itself, which is .gitignore’s reading too.
  • Everything else matches itself.

Separators are normalized first, so a grant written with / covers a Windows path spelled with \\. granted_path in khora-manifest answers the same question for the compiler and the two have to agree; the tests name the cases where agreement is not obvious.

A path with a .. segment is refused, whatever the grants say, and it is refused here rather than left to the matcher, because the matcher gets it wrong. ** spans separators, so the ./logs/** the capabilities guide teaches for granting a tree matches logs/../secret.txt literally: the ** swallows ../secret.txt, and a grant that reads like one directory covers the disk. A program that joins a path it was handed – an argument, a config entry, a request path – under a granted prefix was passing that reach on to whoever handed it the path. normalized says why the .. is refused rather than resolved away.

pub fn granted_name(grants: List<String>, name: String) -> Bool

Whether any grant covers name.

For things that are not paths – an environment variable, a command – where there are no segments and so * spans everything. DATABASE_* covers DATABASE_URL, and * covers all of them.

granted_name in khora-manifest answers the same question for the compiler, and the two are tested against one table.

pub fn granted_host(grants: List<String>, host: String) -> Bool

Whether any grant covers host, which is name or name:port.

Two rules, and both are the reading that costs a newcomer least:

  • * spans dots, so *.internal covers db.eu.internal and not only db.internal. That is what a Content-Security-Policy origin means by it; the one-label reading belongs to TLS certificates, and surprising somebody into a denied connection is the worse failure.
  • A grant with no port covers every port, which is what Deno’s --allow-net=example.com does. :* says the same thing explicitly.
pub fn normalized(path: String) -> String

One spelling of a path, so a grant and a request can be compared.

Two things are leveled, and both were reported as traps by somebody who hit them:

  • \\ becomes /, so a grant written with forward slashes covers a path Windows spelled with back ones.
  • A . segment is dropped, so ./data/** grants data/foo.txt and data/** grants ./data/foo.txt. Before this, the ./ was significant on both sides: two readers of capabilities.md wrote the example’s read = ["./data/**"] into a manifest, opened data/foo.txt, and were refused by a grant that looks like it says yes.

.. is neither resolved nor leveled here; granted refuses the path outright. Both of the other answers widen a grant, which is the one direction a permission check must never be wrong in.

Resolving a/../b to b is only correct if a is a real directory. Where a is a symlink, a/.. is the parent of what a points at, so the open lands on the target’s neighbor b while the check approved the b beside a – it would be answering about a file the filesystem does not open. Telling those apart needs the filesystem, and this function has only the string.

Comparing the .. as written is not the safe half it looks like. ** crosses separators, so ./logs/** matches logs/../secret.txt as text and grants it. No spelling of the comparison is right for both logs/a.txt and logs/../secret.txt, so a path with a .. segment is not compared at all. A caller that means a/../b can pass b; one that cannot is asking a question only the filesystem can answer.

Nor is anything resolved against a working directory or a package root. A grant is matched against the path the program passes, so a program that builds its paths from its own arguments needs a grant wide enough to cover what its users will type. The permissions design note records why that is the boundary and what it costs a CLI.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource