Skip to content

Stop a cloned repository from writing outside itself or choosing the model - #176

Merged
devmaxxx merged 4 commits into
mainfrom
fix/untrusted-store
Oct 14, 2026
Merged

devmaxxx merged 4 commits into
mainfrom
fix/untrusted-store

Conversation

@devmaxxx

@devmaxxx devmaxxx commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

A cloned repository controls everything under its own .repograph/. Four paths treated that directory as trusted; this closes them.

  • Writes through a committed symlink (critical): background.log, vectors.f32, writer.lock, .gitignore and the temp files were opened by path, so a link to ~/.zshrc carried a detached refresh's output — including a line a crafted file name chose — outside the repository. Every store file now opens through open_unlinked (a link check, then O_NOFOLLOW / FILE_FLAG_OPEN_REPARSE_POINT); a .repograph that is itself a link is refused, by serve before it touches its socket too. A taken temp name is skipped, never written through or removed.
  • The recorded model bypassed the trust gate (high): vectors.json could name any hub repo and ask would download it and hand it to onnxruntime. The recorded model now passes model_is_trusted; when it does not, readers answer lexical-only and say why, dump and a dense bench fail, and no reader re-embeds the store under another model.
  • A linked writer.lock is read as a barred store, not a read-only checkout, so a reader never refreshes and saves without the lock.
  • Repository text on stderr (low): file names and model names a clone wrote are escaped before they reach a terminal or background.log, so a newline in a name cannot forge a log line the MCP progress reader parses.

Known and left out: reads still follow links (a graph.json -> /dev/zero is a denial of service), and install-agent follows a linked .claude parent, which users with dotfile links rely on.

Test plan

  • cargo test — all suites pass; new unix tests plant links at each store file, the store dir, the lock and the log
  • cargo clippy --all-targets -- -D warnings
  • End to end: a hostile clone's update --detach is refused and the link's target is unchanged
  • Windows CI compiles the reparse-point branch (not compiled on macOS)

The model a reader opens came from .repograph/vectors.json, which a clone
can commit, without the check repograph.toml's embed_model passes. A clone
could therefore name any Hugging Face repository for this machine to
download and hand to onnxruntime. The recorded model is now used only when
it is catalogued or trusted on this machine; otherwise the configured model
is opened and a notice says how to trust the recorded one.
A cloned repository controls every entry under .repograph/. A committed
symlink there carried this process's writes to a file outside the
repository. The detached refresh appended its log (including lines a
crafted file name chose) to the link's target; append_after truncated and
extended vectors.f32's target; the lock and .gitignore opens followed
links too.

Store files are now opened through open_unlinked: refused when the path is
a symlink, and opened with O_NOFOLLOW (FILE_FLAG_OPEN_REPARSE_POINT on
Windows) to close the gap after the check. Temporary files are created with
create_new, and a .repograph that is itself a link is refused. Repository
paths printed on stderr are escaped or passed through terminal_safe.
- set_embed_model's temporary file is opened unlinked too.
- serve refuses a linked .repograph before it unlinks or binds its socket.
- A temporary name already taken is skipped, never written through or
  removed, and the test that claimed to cover it now does.
- A failed .gitignore write removes the empty file it created.
- The override is weighed before the trust gate, and the untrusted-model
  notice reaches ask's notices instead of the server's stderr.
- Model names read from a store are escaped once where they are read, and
  the notice suggests `repograph model` only for a plain token.
- Repository file names on stderr pass through log_line, so a name with a
  newline cannot forge a log line the MCP progress reader parses.
- `repograph model` says when the store's model is not trusted.
Opening the configured model in place of an untrusted recorded one
searched the store's rows with another model's queries, and a refreshing
reader re-embedded the whole store under it. Readers now answer
lexical-only and say why; dump and a dense bench fail. Writers keep the
configured model through writer_model.

A writer.lock that is a symlink is now read as a barred store rather
than a read-only checkout, so a reader no longer refreshes and saves
without the lock, and it promises no background refresh that would be
refused.
@devmaxxx
devmaxxx merged commit 22be005 into main Oct 14, 2026
4 checks passed
@devmaxxx
devmaxxx deleted the fix/untrusted-store branch October 14, 2026 09:43
@devmaxxx devmaxxx added this to the 0.6.0 milestone Oct 14, 2026
devmaxxx added a commit that referenced this pull request Oct 16, 2026
…model (#176)

* fix(embed): gate the store's recorded model through the trust list

The model a reader opens came from .repograph/vectors.json, which a clone
can commit, without the check repograph.toml's embed_model passes. A clone
could therefore name any Hugging Face repository for this machine to
download and hand to onnxruntime. The recorded model is now used only when
it is catalogued or trusted on this machine; otherwise the configured model
is opened and a notice says how to trust the recorded one.

* fix(store): never write through a symlink a clone committed

A cloned repository controls every entry under .repograph/. A committed
symlink there carried this process's writes to a file outside the
repository. The detached refresh appended its log (including lines a
crafted file name chose) to the link's target; append_after truncated and
extended vectors.f32's target; the lock and .gitignore opens followed
links too.

Store files are now opened through open_unlinked: refused when the path is
a symlink, and opened with O_NOFOLLOW (FILE_FLAG_OPEN_REPARSE_POINT on
Windows) to close the gap after the check. Temporary files are created with
create_new, and a .repograph that is itself a link is refused. Repository
paths printed on stderr are escaped or passed through terminal_safe.

* fix(store): close the gaps the review found in the link and trust fixes

- set_embed_model's temporary file is opened unlinked too.
- serve refuses a linked .repograph before it unlinks or binds its socket.
- A temporary name already taken is skipped, never written through or
  removed, and the test that claimed to cover it now does.
- A failed .gitignore write removes the empty file it created.
- The override is weighed before the trust gate, and the untrusted-model
  notice reaches ask's notices instead of the server's stderr.
- Model names read from a store are escaped once where they are read, and
  the notice suggests `repograph model` only for a plain token.
- Repository file names on stderr pass through log_line, so a name with a
  newline cannot forge a log line the MCP progress reader parses.
- `repograph model` says when the store's model is not trusted.

* fix(embed): read a store whose model is untrusted lexical-only

Opening the configured model in place of an untrusted recorded one
searched the store's rows with another model's queries, and a refreshing
reader re-embedded the whole store under it. Readers now answer
lexical-only and say why; dump and a dense bench fail. Writers keep the
configured model through writer_model.

A writer.lock that is a symlink is now read as a barred store rather
than a read-only checkout, so a reader no longer refreshes and saves
without the lock, and it promises no background refresh that would be
refused.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant