Skip to content

Security model

Thiago Botelho edited this page Aug 15, 2026 · 1 revision

Security model

Mestre Yoda reads repositories it did not write. That is the whole threat model: a hostile or merely broken repository should not be able to make the tool execute code, escape its root, exhaust memory, or leak what it found.

What it never does

It never executes project code. No file from the analyzed repository is imported, evaluated, spawned, or passed to a shell. Every file is read as text and matched against patterns.

It never runs a package manager. Dependencies are read by parsing package.json as data. There is no npm install, no lockfile resolution, no registry call.

It never writes to the analyzed project. There is no write API in 0.1. The inspector opens files read-only and the planner returns tasks.

It never makes a network call. The catalog ships inside the package. Schema $id values look like URLs but are never fetched.

Inspection limits

The inspector is the only component that touches foreign files, so its limits are part of the contract rather than an implementation detail.

Limit Value What it prevents
Files walked 5000 An unbounded walk on a monorepo or a generated tree
Bytes per file 512 KiB Reading a vendored blob or a generated bundle
Bytes per corpus 8 MiB Unpredictable peak memory
Symbolic links refused Escaping the requested root
Binary content dropped Matching patterns against nonsense

Exceeding the file limit is an error, not a truncation:

Error: Project inventory exceeds 5000 files.

Failing loudly matters more than finishing. A truncated walk would produce a report that looks complete and quietly missed half the repository.

The size limits behave differently: a file over 512 KiB is skipped silently, and once the corpus reaches 8 MiB no further files are read. Compare inventory.inspectedTextFiles against inventory.files to see when that happened.

Symbolic links

Every symlink is recorded in inventory.skippedSymlinks and never followed. The root itself is checked with lstat and rejected if it is a link or not a directory:

Error: Project root must be a real directory.

This closes the obvious escape. A repository containing evil -> /etc gets that path listed in the inventory and nothing read through it.

The practical cost is that a repository whose source lives behind a symlink inspects as nearly empty. That is why skippedSymlinks is in the output rather than dropped: the surprising result comes with its explanation attached.

Skipped directories

.git, .idea, .next, .vscode, bin, coverage, dist, node_modules, and obj.

Build output and installed dependencies are not evidence about source, and node_modules alone would blow the file limit on most repositories.

Worth knowing: bin is skipped for .NET build output, so source that lives under a directory named bin is invisible to detection.

Path traversal in the catalog

A profile manifest references its own policy, recipe, and gate files by relative path. Each reference is checked against ^(policies|recipes|gates)/[a-z0-9-]+\.json$ before the file is opened:

Error: Profile <id> has an unsafe reference.

The check is a pattern match rather than resolve-and-compare, so there is no window in which a crafted path is opened and then judged. ../../../etc/passwd fails on the pattern.

What the output can leak

Success payloads are about the project, so some of it is project data by definition. Two fields deserve attention before a report leaves the machine that produced it.

inspect.result.root is the resolved absolute path. On a CI runner that is a build path, and on a laptop it contains a username.

inventory.skippedSymlinks contains relative paths from the repository, which is repository content.

Nothing else in a success payload carries file contents. Signals are booleans, and rules carry text from the catalog rather than from the project.

What failure envelopes never carry

Local paths, stack traces, and project content. The failure envelope is deliberately thin:

{
  "status": "failure",
  "error": {
    "code": "INVALID_REQUEST",
    "summary": "Unknown Golden Path profile: missing-profile."
  }
}

A test in tests/cli.test.mjs asserts the summary contains no stack frame, so a regression that starts leaking internals fails the build.

The read-only guarantee, mechanically

Three facts, each asserted by a test that reads the source as text:

  • protocol.mjs contains mutations: false;
  • evaluator.mjs contains readOnly: true;
  • evaluator.mjs contains effects: Object.freeze([]).

tests/boundary.test.mjs fails the build if any of them disappears. That is a crude check, and it is crude on purpose: it survives refactors that behavioral tests would let through, because it asserts the guarantee still exists in the code rather than that one code path happened to produce the right value.

The same file asserts the package declares no dependencies of any kind, which is the supply-chain half of the story. A tool that reads other people's repositories with zero transitive dependencies has a very short list of things that can go wrong in its own tree.

Consuming a plan safely

plan.effects is always empty and plan.readOnly is always true. Task text is written for humans.

The failure mode to avoid is a consumer parsing "Add versioned migrations and validate them before deployment." into a filesystem operation. Task strings are prose from the catalog, they change with a patch release, and nothing validates them as commands. A future protocol version will declare typed operations explicitly, and until then, treating the text as instructions for a person is the only supported reading.

Reporting a vulnerability

Report privately to the maintainers rather than in a public issue. Do not include credentials, customer code, private repository contents, or production data. See SECURITY.md.

Clone this wiki locally