Skip to content

fix(trajectory): keep trajectory JSONL out of git-tracked working directories - #78408

Closed
ZHJay wants to merge 4 commits into
NousResearch:mainfrom
ZHJay:fix/trajectory-cwd-git-guard
Closed

fix(trajectory): keep trajectory JSONL out of git-tracked working directories#78408
ZHJay wants to merge 4 commits into
NousResearch:mainfrom
ZHJay:fix/trajectory-cwd-git-guard

Conversation

@ZHJay

@ZHJay ZHJay commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

agent/trajectory.py's save_trajectory() opens a bare relative filename, so
it resolves against whatever CWD the agent was launched in:

filename = "trajectory_samples.jsonl" if completed else "failed_trajectories.jsonl"
...
with open(filename, "a", encoding="utf-8") as f:   # ← CWD, not HERMES_HOME

It is called from finalize_turn (agent/turn_finalizer.py:250run_agent.py
_save_trajectoryagent/trajectory.py), so an agent run started inside a
source checkout appends a full verbatim transcript — message text, tool
results and tool-call arguments — next to the user's code as an untracked
trajectory_samples.jsonl, one git add -A away from being published.

This PR resolves a relative filename through a new resolve_trajectory_path()
first. When the resolved target sits inside a git work tree, the write goes
under <HERMES_HOME>/trajectories/<work-tree>/ instead, and a one-time notice
names the destination on the terminal.

Redirect, not refusal — deliberately. Trajectories are training data and
exist to be full-fidelity, so refusing to write them by default would silently
break legitimate datagen runs inside a checkout. Nothing here is dropped,
truncated or redacted; only the destination changes, to the private directory
the rest of Hermes' state already uses. agent/moa_trace.py — the sibling
writer in the same issue cluster, carrying the same class of data — already
defaults under <HERMES_HOME>/moa-traces/, so this makes trajectory the
consistent case rather than the outlier.

A user who wants trajectories in the CWD keeps three supported paths:

Path Behaviour
absolute filename= honoured as-is — an explicit path is a deliberate choice, not an ambient default
agent.trajectory_allow_git_cwd: true restores the old behaviour globally
CWD outside any git work tree untouched (a scratch dir, /tmp, a datagen box — already the common datagen shape)

The knob is a config.yaml behavioural setting with a documented default in
hermes_cli/config_defaults.py, per AGENTS.md. No new HERMES_* env var.

One dataset per work tree — not one flat shared file

The pre-fix path was CWD-relative, so projA/ and projB/ each accumulated
their own dataset. A single flat file under trajectories/ would have merged
them irreversibly: the entry schema carries no cwd/repo/session field, so
after the merge the only discriminator is model, which is usually identical.

The relocated path is therefore keyed per work tree:

<HERMES_HOME>/trajectories/<basename>-<sha256(git_root)[:8]>/trajectory_samples.jsonl

Basename so the directory is recognisable, digest so ~/a/proj and ~/b/proj
cannot land in the same place. This follows agent/moa_trace.py:128, which keys
its trace file per session.

Why key the path instead of adding a provenance field to the entry. The
JSONL is a documented format consumed by training pipelines
(website/docs/developer-guide/trajectory-format.md), so widening the schema
has a much larger blast radius than changing a directory name — and a
cwd/repo field would write local filesystem paths into the dataset, which is
a privacy regression in a PR whose whole purpose is not leaking local context.
Path keying also restores exactly the shape users already had. The entry schema
is unchanged and asserted so by a test.

Failing closed

If containment cannot be established — unreadable ancestor, symlink loop,
unwritable or full HERMES_HOMEresolve_trajectory_path() returns None
and the save is skipped, reported on the terminal and in errors.log. It
never writes into the checkout as a fallback, which was the pre-fix behaviour.

Skipping is chosen over falling back to a scratch location because a transcript
in /tmp is data the OS deletes and no pipeline reads — a silent loss dressed
up as a save. A skip is a returned decision, never an exception:
save_trajectory is a side effect of turn finalization, and turning a failed
trace into a raise would trade a leak for a broken turn.

Scope boundary vs our other open PRs

This PR is about where the file is written. It does not change file modes:

Directory mode, for the record: mkdir(parents=True, exist_ok=True) creates
trajectories/ at 0755 inside a 0700 HERMES_HOME, so the parent still
gates access and (with #77520) the file itself is 0600.
agent/moa_trace.py:127 does the same, so this is consistent with existing
code. Deliberately not tightened here — it would collide with
#77520/#77655's directory-mode work.

Upgrading an existing pipeline (please read if you collect trajectories)

If you already have a ./trajectory_samples.jsonl in a checkout, it is left
exactly as it is and stops receiving new entries.
That is the nastiest
possible failure mode — worse than an empty dataset, because the file still
exists with plausible content and simply stops growing while a pipeline keeps
reading it. So Hermes now prints a distinct one-time notice naming both paths
and the three ways out (repoint the pipeline, concatenate, or set the opt-out),
logs it to errors.log, and documents it in the trajectory-format guide.

Your file is not moved or modified — it is your data — and nothing new is
created in your checkout, because not leaving files in checkouts is the entire
point of this change. The signal is deliberately out-of-band for that reason.

Bug class: what I included and what I left out

Included run_agent.py --save_sample. It builds its payload from the same
_convert_to_trajectory_format under a relative sample_<uuid>.json and
open(...,"w")s it into the CWD — the same leak, so it routes through the same
helper.

Deliberately left out batch_runner.py's Path("data") / run_name. It is
CWD-relative too, but it is not the same bug: it is the primary declared
output
of an explicitly-invoked CLI tool, the path is printed on startup
(Output directory: …), and --resume re-derives it to glob batch_*.jsonl
and rebuild the completed-prompt set (batch_runner.py:745). It also passes
save_trajectories=False (batch_runner.py:331), so it never routes through
save_trajectory at all. Relocating it would silently orphan every in-progress
run — a "fix" that breaks the feature it protects. Happy to revisit batch output
placement as its own change if maintainers want it.

Related Issue

Refs #77472

Type of Change

  • 🔒 Security fix

Changes Made

  • agent/trajectory.pyresolve_trajectory_path() (returns Optional[str];
    None = do not write), _find_git_root() (mirrors agent/prompt_builder.py's
    helper; .exists() so a linked worktree/submodule .git file counts;
    raises _GitRootUndetermined when the walk can't complete),
    _explicit_work_tree() (GIT_DIR/GIT_WORK_TREE), _work_tree_key(),
    _contained_relative(), _shield_trajectories_dir(),
    _warn_pre_existing_dataset(), _notify_once(),
    describe_trajectory_destination(), and _allow_git_cwd() (lazy read-only
    config read, per the moa_trace precedent).
  • agent/agent_init.py — the save_trajectories status line now names the
    destination instead of a bare "Trajectory saving enabled".
  • run_agent.py--save_sample routed through the same helper and handles a
    skipped destination; --save_trajectories banner updated.
  • hermes_cli/config_defaults.pyagent.trajectory_allow_git_cwd: False.
  • cli-config.yaml.example — documents the key and the upgrade path.
  • website/docs/developer-guide/trajectory-format.md,
    website/docs/guides/python-library.md and both zh-Hans mirrors — the
    docs said trajectories are written to the CWD and the copy-paste snippets
    (load_trajectories(...), data_files=...) pointed at that path.
  • tests/agent/test_trajectory_git_cwd_guard.py — 54 tests.

How to Test

Measured before → after with a frozen timestamp so the comparison is exact,
same throwaway git repo, real save_trajectory() in a subprocess from
repo/src with HERMES_HOME pointed at a temp dir:

###### BEFORE (upstream/main, pre-PR) ######
  BEFORE landed: BEFORE-repo/src/trajectory_samples.jsonl
  BEFORE sha256: 966b8a4f90056500433e763749e5d94942c7f51af4e0fbedfef60982b9aafeb2  bytes: 5313
  BEFORE repo status: ?? src/trajectory_samples.jsonl
###### AFTER (this branch) ######
  AFTER landed: AFTER-home/trajectories/AFTER-repo-fe2feb65/trajectory_samples.jsonl
  AFTER sha256: 966b8a4f90056500433e763749e5d94942c7f51af4e0fbedfef60982b9aafeb2  bytes: 5313
  AFTER repo status: (clean)
###### BYTE COMPARISON ######
  cmp: IDENTICAL — relocation is byte-for-byte lossless

Identical sha256 on both sides is the point: the transcript is unchanged, it
just isn't in your repo any more. (Payload includes CJK, escaped quotes, a
backslash, a tab and a 5 KB tool result.)

Reproduce by hand:

cd $(mktemp -d) && git init -q . && mkdir src && cd src
python -c "from agent.trajectory import save_trajectory; \
  save_trajectory([{'from':'human','value':'secret'},{'from':'gpt','value':'ok'}],'m',True)"
git status --porcelain        # before: ?? src/trajectory_samples.jsonl / after: clean
ls -R ~/.hermes/trajectories  # after: <repo>-<hash>/trajectory_samples.jsonl

Automated:

./scripts/run_tests.sh tests/agent/test_trajectory_git_cwd_guard.py
# 54 passed

Tests exercise the real save_trajectory()open() path against a real
git init repo and a temp HERMES_HOME — nothing on the write path is mocked.
Exposure is asserted with git status --porcelain and git add -An, since a
file physically inside a work tree but git-ignored is not actually committable.

Teeth check — the same tests against the previous head of this branch
(4cf34daf3): 31 failed, 21 passed, so every behaviour added in this round
is pinned. Against unmodified upstream/main the module doesn't import at all.
Per finding: fail-closed 6, destination post-condition 4, terminal notice 4,
staleness 1, per-work-tree keying 6 (+2 shared), docs 8.

Mutation testing — 12 mutants, all caught: identity resolver (32 failed),
_find_git_rootNone (31), inverted opt-out (35), restore fail-open (4),
swallow EACCES in the walk (1), skip the destination .gitignore (2), log-only
notice (2), drop the staleness notice (1), flat shared path (5), basename-only
key (1), ignore GIT_DIR/GIT_WORK_TREE (2), re-flatten interior .. (1).

Blast radius — the same 86 test files as before: 2220 passed, 2 failed
(was 2183/2; the +37 is this round's new tests). Both failures
(tests/tools/test_wake_word.py::test_openwakeword_ensures_base_models_for_custom_path,
tests/tools/test_web_providers.py::…test_web_extract_tool_runs_discovery_before_registry_lookup)
reproduce identically on a clean upstream/main worktree — pre-existing,
unrelated. Plus all 26 test files that reference trajectory saving: 496 passed,
0 failed.

Concurrency — two processes appending simultaneously to one dataset (same
repo), 40 entries × 40 KB and 15 × 1 MB, plus the two-repo case: zero corrupt
lines, zero lost entries, exact per-model counts in every configuration.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run the tests (./scripts/run_tests.sh, CI parity) and they pass
  • I've added tests for my changes
  • I've tested on my platform: macOS 27.0 (Darwin 27.0.0, arm64), Python 3.11.15

Documentation & Housekeeping

  • I've updated relevant documentation (trajectory-format + python-library
    guides and both zh-Hans mirrors, docstrings, banners)
  • I've updated cli-config.yaml.example for the new config key
  • N/A — no architecture/workflow change
  • Cross-platform considered — see below
  • N/A — no tool schema change

Cross-platform

pathlib only (Path.resolve(), .parents, .exists(), .parts) plus
os.getcwd()/os.path.normpath — no POSIX-only calls, no chmod, no shell out
on the write path (git is only invoked inside the tests, which follow 12
existing test files that do the same). get_hermes_home() already resolves
%LOCALAPPDATA%\hermes on Windows, so the redirect target is correct there. The
work-tree key is restricted to [A-Za-z0-9._-] and stripped of leading/trailing
dots and spaces, so it is a valid directory name on Windows too.
scripts/check-windows-footguns.py is clean on all changed files. The
.git-as-a-file case matters on every platform (linked worktrees, submodules).

Windows append atomicity — untested, stated honestly. The no-corruption
result above rests on POSIX O_APPEND, where each write() to a file opened
"a" is atomic with respect to the file offset. Windows emulates append with
seek-then-write
, so concurrent appends to a shared file are a materially
different risk profile there, and I have not tested it — I'm on macOS and cannot.
Two notes on scope:

  • This is not a regression introduced here. save_trajectory has always
    appended to a single shared path; the pre-fix path was shared per CWD, this
    one is shared per work tree.
  • Per-work-tree keying reduces the sharing surface in the common case: two
    different projects no longer contend for one file at all. What remains is two
    processes in the same repo, which was already the pre-existing shape.

If a maintainer wants belt-and-braces on Windows, an msvcrt.locking() /
portalocker wrapper around the append would be the fix, but that is a separate
change to the write call (which #77520 is already rewriting) and I'd rather not
land untested platform-specific locking inside a placement fix.

Behaviour verified on macOS 15 arm64 / Python 3.11; the logic is
platform-independent and should hold on Linux and WSL2 — I have not run the
suite on Windows or WSL2.

…ectories

save_trajectory() opens a bare relative filename, so it resolves against the
CWD the agent was launched in. Called from finalize_turn, an agent run started
inside a source checkout appended a full verbatim transcript — message text,
tool results and tool-call arguments — next to the user's code as an untracked
trajectory_samples.jsonl, one `git add -A` away from being published.

Resolve a relative filename through resolve_trajectory_path() first: when the
resolved target sits inside a git work tree, write under
<HERMES_HOME>/trajectories/ instead and warn once with the destination.
Nothing is dropped or truncated — trajectories are training data and stay
full-fidelity; only the destination changes, to the private directory the rest
of Hermes' state already uses.

CWD placement stays supported three ways: an absolute filename is honoured
as-is, agent.trajectory_allow_git_cwd: true restores the old behaviour
globally, and a CWD outside any work tree is untouched (the common datagen
shape). `.git` is probed with exists() so a linked worktree or submodule,
where it is a file, is covered too.

run_agent.py's --save_sample builds the same payload from
_convert_to_trajectory_format under a relative sample_<uuid>.json, so it goes
through the same helper.

Refs NousResearch#77472
Copilot AI lite review requested due to automatic review settings August 4, 2026 10:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 4, 2026
…rect

Review of the original guard found six should-fix gaps. All confirmed with
reproductions on the previous head and each fixed with a behaviour-contract
test that fails without the change.

1. The guard failed OPEN into the repo. `_find_git_root`'s `try` covered only
   `start.resolve()`, not the `.exists()` walk. `Path.exists()` swallows
   ENOENT/ENOTDIR but not EACCES, and `resolve()` raises `RuntimeError` (not
   `OSError`) on a symlink loop, so both escaped into the broad handler, which
   returned the original filename — writing the transcript into the checkout,
   logged only at debug. Measured: an unreadable ancestor left `?? locked/`;
   a read-only HERMES_HOME left `?? trajectory_samples.jsonl`.
   `resolve_trajectory_path` now returns `Optional[str]`, where `None` means
   "do not write", and every unknown-containment path takes it. `os.getcwd()`
   is included because it raises EACCES under an unreadable ancestor while a
   relative `open()` still succeeds against the CWD file descriptor — so the
   write really would land in the repo. A skip is a returned decision, never a
   raise: `save_trajectory` runs during turn finalization.

2. The destination had no post-condition. With HERMES_HOME inside a work tree
   (or `git init ~`), `git add -A` would stage
   `.hermes/trajectories/trajectory_samples.jsonl` — the warning's promise was
   false. `trajectories/` now carries a self-ignoring `.gitignore` (`*`, which
   ignores itself, so no tracked file is added), and the notice names the
   containing checkout and the remedy. Refusing to write there instead would
   have broken trajectory saving for every dotfiles user.

3. The notice never reached a terminal. `setup_logging` installs no stderr
   StreamHandler unless `--verbose`, so `logger.warning` only reached
   errors.log. Each distinct notice now also prints one line to stderr, once
   per process, deduped as before. `AIAgent(save_trajectories=True)` — the path
   datagen uses — now names the destination instead of printing a bare
   "Trajectory saving enabled".

4. An existing pipeline went stale in silence. A pre-existing
   ./trajectory_samples.jsonl kept its plausible content and simply stopped
   growing. A distinct notice now names both paths and all three remedies. The
   user's file is not moved or modified and nothing is created in their
   checkout — leaving files in checkouts is what this guard is for — so the
   signal is out-of-band: terminal, errors.log, and the docs.

5. Two repos merged into one dataset, unrecoverably: the entry schema carries
   no repo/session field, so after the merge only `model` distinguishes them.
   The path is now keyed per work tree (`<basename>-<sha256[:8]>`, following
   `agent/moa_trace.py`'s per-session naming), restoring the pre-fix
   one-dataset-per-repo shape. Keying the path rather than adding a provenance
   field keeps a documented, pipeline-consumed format unchanged and keeps repo
   paths out of the dataset. The old comment claiming subdirectory preservation
   prevented collisions was false and is gone.

6. Docs contradicted shipped behaviour. Updated
   developer-guide/trajectory-format.md and guides/python-library.md plus both
   zh-Hans mirrors, including the `load_trajectories(...)` /
   `data_files=...` snippets a datagen user copies.

Also: honour `GIT_DIR`/`GIT_WORK_TREE` (verified with git — `GIT_DIR` alone
makes the CWD the work tree, so a CI runner or wrapper got no protection);
normalize interior `..` so `a/../b/keep.jsonl` keeps `b/` instead of silently
flattening to the basename; stop claiming "the git work tree at X" when a
stray `.git` is not a real repo; drop a change-detector `inspect.getsource`
assert; and correct the config-default rationale — `get_missing_config_fields()`
calls `load_config()`, which deep-merges `DEFAULT_CONFIG` first, so a declared
key is never reported missing (measured: 0).

Refs NousResearch#77472
@ZHJay

ZHJay commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

An independent adversarial review of this PR found six should-fix problems, each
with a reproduction. They were real gaps in my change, not nitpicks — the first
one meant the guard could do the exact thing it was written to prevent. All six
are fixed in 3ee7818 on top of 4cf34daf3, and I've updated the PR body, which
previously overstated collision handling and said nothing about Windows.

Point by point.

1. The guard failed open into the repo. _find_git_root's try wrapped
only start.resolve(), not the .exists() walk. Path.exists() swallows
ENOENT/ENOTDIR but not EACCES, and resolve() raises RuntimeError
(not OSError) on a symlink loop — so both escaped into the broad except,
which returned the original filename and wrote the transcript into the
checkout
, logged only at debug. Measured on the old head: an unreadable
ancestor left ?? locked/; a read-only HERMES_HOME left
?? trajectory_samples.jsonl. For a change labelled a security fix, that is
the wrong failure mode.

resolve_trajectory_path now returns Optional[str], None meaning do not
write
, and every unknown-containment path takes it. I chose skipping over
falling back to a scratch location: a transcript in /tmp is data the OS
deletes and no pipeline reads, which is a silent loss dressed up as a save. A
skip is a returned decision, never a raise — save_trajectory runs during turn
finalization and that broad except exists for a reason.

Two things I found while fixing it that the reproduction made visible:

  • os.getcwd() itself raises EACCES under an unreadable ancestor, while a
    relative open() still succeeds — it resolves against the process's CWD
    file descriptor and needs no path traversal. So "can't determine containment"
    genuinely coexists with "the write would still land in the repo", and that
    branch has to fail closed too.
  • A merely read-only HERMES_HOME often self-heals: _allow_git_cwd()
    load_config()ensure_hermes_home() recreates the skeleton and repairs
    the mode to 0700, after which the write succeeds. The durable failure is a
    full disk or a corrupt home, so my test uses a regular file where
    trajectories/ must be (deterministic ENOTDIR) and keeps a permission
    variant with the config cache pre-warmed, which is the ordering that
    reproduces.

2. The redirect target was never checked. With HERMES_HOME inside a work
tree, or git init ~ for dotfiles, git add -A would stage
.hermes/trajectories/trajectory_samples.jsonl — so the warning's promise
("so a full transcript is not left in your checkout") was false.

I did not make this refuse to write: that would break trajectory saving for
every dotfiles user, which is the "fix that destroys the feature it protects"
shape. Instead trajectories/ now carries a self-ignoring .gitignore (*,
which ignores itself, so no tracked file is added), and the notice names the
containing checkout plus the remedy. Verified with git rather than by
inspection: after the change git add -A stages 0 transcripts and
git check-ignore -v confirms the rule matches.

Worth flagging precisely, because the reviewer's script still prints "STILL
INSIDE THE WORK TREE": the file is physically inside the tree, and that check
is presence-based (rglob). What changed is committability, which is the actual
exposure. If someone has already committed .hermes/, a .gitignore cannot
untrack it — the notice says so and points at moving HERMES_HOME.

3. The notice never reached a terminal. Correct, and this was the one I was
most wrong about. setup_logging installs no stderr StreamHandler unless
--verbose, so logger.warning only reached errors.log: measured zero lines
on the terminal. Worse, AIAgent(save_trajectories=True) — the path datagen
actually uses — printed 📝 Trajectory saving enabled with no destination,
so a user whose file had moved had nothing to go on.

Each distinct notice now also prints one line to stderr, once per process
(stderr so piped trajectory data stays clean), and the startup line names the
destination. The existing dedupe contract still holds — three saves produce
exactly one notice.

4. An existing pipeline going stale — agreed this was the worst of the six.
A pre-existing ./trajectory_samples.jsonl kept its plausible content and
simply stopped growing, so a pipeline would read a frozen dataset indefinitely.
That is worse than an empty dataset, which someone notices.

On the tension you flagged: the most discoverable place for a pointer is next
to the stale file, i.e. inside the checkout — but creating a file there is
exactly what this PR exists to stop, and the stale file is the user's data, so
moving or rewriting it is out. I resolved it by keeping the signal entirely
out-of-band: a distinct once-per-process notice naming both paths and all
three remedies (repoint / concatenate / opt out), the same line in
errors.log, and an "Upgrading an existing pipeline" section in the
trajectory-format docs. Nothing in the checkout is created, moved or modified —
asserted by a test that byte-compares the user's file before and after and
checks git status --porcelain shows only their own pre-existing file.

I also considered dropping a marker inside HERMES_HOME/trajectories/ for
durability, and decided against it: it adds a file nobody looks for, while the
person who needs the information is looking at the repo, where we've agreed not
to write.

5. Cross-repo merge — you were right that the comment was false. Two repos
using the same relative subdir still collided, and the default (no subdir)
always collided. I've taken the per-source path keying option:

<HERMES_HOME>/trajectories/<basename>-<sha256(git_root)[:8]>/trajectory_samples.jsonl

Reasoning, since you asked for it explicitly: the JSONL is a documented format
consumed by training pipelines, so adding a field to the entry schema is a much
wider blast radius than changing a directory name — and a cwd/repo field would
write local filesystem paths into the dataset, which is a privacy regression
in a PR about not leaking local context. Path keying also restores exactly what
users had before (one dataset per repo) and follows agent/moa_trace.py:128.
The entry schema is unchanged, and a test asserts both that its keys are still
{conversations, timestamp, model, completed} and that no repo path appears
anywhere in a serialized entry.

Basename plus digest because basename alone would silently merge ~/a/proj
and ~/b/proj — same bug, one layer down. Keying is per work tree, not per
CWD, so runs from repo/ and repo/src/deep/ still accumulate into one
dataset; fragmenting a single project would have been its own bug. The false
comment is gone.

6. Docs. Updated trajectory-format.md and python-library.md plus both
zh-Hans mirrors
, including the load_trajectories(...) and data_files=...
snippets — those are exactly what a datagen user copies. Contract tests now pin
all four files, so a future behaviour change can't silently desync them.

7. Nits — all taken:

  • Dropped the inspect.getsource change-detector; kept the behavioural half
    (resolving sample_deadbeef.json against a real repo) and strengthened it to
    assert the file is relocated and the repo stays clean.
  • Fixed the hermes update rationale. You were right: get_missing_config_fields()
    calls load_config(), which deep-merges DEFAULT_CONFIG first, so a declared
    key is never reported missing — I measured 0 on a near-empty config.
    Declaring it there is still correct for the effective default; only my stated
    reason was wrong.
  • The notice no longer claims "the git work tree at X" — it says
    "a git checkout (.git found at X)", which is what was actually observed. A
    stray or broken .git still redirects (safe direction) without the message
    asserting a work tree that may not exist.
  • $GIT_DIR/$GIT_WORK_TREE: handled, not skipped. I checked git's actual
    behaviour rather than assuming: with only GIT_DIR set,
    git rev-parse --show-toplevel reports the CWD and git add stages a
    file there. So a CI runner or wrapper driving a repo from a .git-less
    directory got no protection at all. GIT_WORK_TREE takes precedence when set;
    GIT_DIR alone treats the CWD as the work tree. The test asserts the premise
    against real git before asserting our behaviour.
  • a/../b/keep.jsonl now keeps b/: normalized with os.path.normpath instead
    of the blanket ".." in path.parts flattening, and only a path that still
    escapes after normalization falls back to the basename. Containment is
    unchanged — verified that ../../../../../../tmp/pwned.jsonl still resolves
    outside any repo and is correctly left alone.
  • Directory mode (0755 inside a 0700 home): left as-is and documented in the
    PR body, as you suggested — tightening it would collide with fix(security): create plaintext transcript artifacts owner-only #77520/fix(security): create the remaining plaintext transcript artifacts owner-only #77655's
    directory-mode work, and agent/moa_trace.py:127 does the same.

Windows. Called out in the PR body now rather than implied away: the
no-corruption result rests on POSIX O_APPEND atomicity, Windows emulates
append with seek-then-write
, and nobody has tested it — I'm on macOS and
can't. Two scoping notes: this isn't a regression (the file has always been
shared, previously per CWD, now per work tree), and per-work-tree keying
reduces the sharing surface, since two different projects no longer contend
for one file at all. If maintainers want belt-and-braces there, an
msvcrt.locking()/portalocker wrapper around the append is the fix, but that
belongs on the write call #77520 is already rewriting, not in a placement fix.

Re-verified after the changes — everything the review confirmed as already
correct still holds:

  • byte-identity before→after, frozen timestamp, matching sha256
    966b8a4f… / 5313 bytes, repo clean after;
  • no corruption under concurrency: two processes on one dataset, 40×40 KB and
    15×1 MB, plus the two-repo case — 0 corrupt lines, 0 lost entries, exact
    per-model counts;
  • profile isolation, including the in-process set_hermes_home_override
    gateway path; opt-out still config.yaml-only with no env-var backdoor
    (checked with two plausible HERMES_* names set);
  • the edge matrix: linked worktree and submodule (.git as a file), bare
    repo, /, 120-deep path (1.2 ms), both symlink directions, deleted CWD;
    .exists() over .is_dir() kept;
  • composition with fix(security): create plaintext transcript artifacts owner-only #77520: resolve_trajectory_path still runs before the
    open(), so the relocated file gets 0600 once that lands.

Numbers. 54 tests in the file, all passing. Teeth check against the previous
head 4cf34daf3: 31 failed, 21 passed. 12 mutants, all caught (the three
from the original review plus nine aimed at this round's fixes — including one
that survived my first attempt and made me find the right test shape for the
EACCES-during-walk case). Blast radius across the same 86 files: 2220 passed,
2 failed
, both reproducing on clean upstream/main
(test_wake_word.py::test_openwakeword_ensures_base_models_for_custom_path,
test_web_providers.py::…test_web_extract_tool_runs_discovery_before_registry_lookup).
scripts/check-windows-footguns.py clean on all changed files. No workflow has
run on this PR yet (fork PRs are gated on maintainer approval), so these are
local runs, not CI.

Thanks for the depth here — items 1 and 4 in particular were the difference
between a fix and a fix that quietly breaks datagen.

…silently

The resolver fails closed and tells the user where the transcript went,
but the append it authorizes had no such treatment: `open(..., "a")`
failures went to `logger.warning` only, and
`hermes_logging.setup_logging` installs no stderr handler without
`--verbose` (root handlers are a queue handler feeding rotating files).
Measured on a real setup_logging: a chmod 0400 destination lost the turn
with stdout and stderr both empty — the same silent drop the guard
refuses to allow one line earlier.

Route the failure through the same notice mechanism, naming the path and
the reason. Split the print out of `_notify_once` so this path logs
EVERY occurrence — errors.log is how a user learns how many turns were
lost — while the terminal shows one line per destination, so a datagen
run saving every turn is not flooded.

Tests drive the real save path: a lost turn appears on stderr with its
path, three failures produce three log records and one terminal line,
and a successful save prints no failure notice.
…n prose

The doc guards were change-detectors, which AGENTS.md rejects outright, and
the worst of them asserted the *absence* of two literal sentences:

    assert "Trajectories are written to files ... directory:" not in text
    assert "轨迹写入当前工作目录下的文件:" not in text

Two things were wrong with that. A faithful reword of either page failed the
test with zero behavior change — verified: renaming the heading to "Migrating
a pipeline you already run" and rephrasing the destination paragraph, tokens
intact, fails the old form on the upgrade-path assertion and passes the new
one. And the guard could not run on the diff that would break it:

    $ printf 'website/docs/developer-guide/trajectory-format.md\n' \
        | python3 scripts/ci/classify_changes.py
    python=false

``website/`` is in ``_PY_SKIP``, so a docs-only PR skips the whole Python
lane, goes green, and lands red on main (where the classifier fails open).
Freezing prose bought nothing in the one direction it claimed to cover.

Replaced with one assertion derived from the code at runtime: write a real
trajectory in a real git work tree, read the destination directory and the
default dataset filename *off the resolved path*, and require all four pages
(EN + zh-Hans) to name them. It can now only fail when code and docs actually
disagree — rename ``trajectories/`` or the default filename and the pages
fail until updated; reword the surrounding prose and nothing fails. Derived
via a HERMES_HOME-wide glob rather than the ``_landed`` helper, which knows
the literal name ``trajectories`` and would have made the derivation
circular. Matching is delimiter-aware, not substring: mutating the default to
``samples.jsonl`` still matched the documented ``trajectory_samples.jsonl``
under a bare ``in``, so that assertion had been passing against code that no
longer wrote the file it named.

``test_reference_docs_name_the_opt_out`` is kept as-is — a config key is an
identifier the reader looks up verbatim, not prose.

Also collapsed the duplicated default-config literal:

    assert DEFAULT_CONFIG["agent"]["trajectory_allow_git_cwd"] is False
    assert trajectory._allow_git_cwd() is False

into the invariant plus one direction statement — the reader must resolve to
whatever DEFAULT_CONFIG declares (pins the two readers to each other; ``is``
so a truthy non-bool fails), and separately, the secure default is False with
a message saying why. The security default genuinely is the contract here, so
the meaning is preserved while the literal is stated once. Added the
``hermes_home`` fixture so the assertion reads a config-less home rather than
whatever the ambient one holds, and asserted that premise.

Tests only; no production change. 57 -> 55 tests (two deleted parametrizations
minus the replacement's four), all passing.
@ZHJay ZHJay closed this Aug 5, 2026
@ZHJay
ZHJay deleted the fix/trajectory-cwd-git-guard branch August 5, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants