Skip to content

fix(security): create the remaining plaintext transcript artifacts owner-only - #77655

Closed
ZHJay wants to merge 1 commit into
NousResearch:mainfrom
ZHJay:fix/at-rest-remaining-transcript-sites
Closed

fix(security): create the remaining plaintext transcript artifacts owner-only#77655
ZHJay wants to merge 1 commit into
NousResearch:mainfrom
ZHJay:fix/at-rest-remaining-transcript-sites

Conversation

@ZHJay

@ZHJay ZHJay commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Follow-up to #77520, which hardened four plaintext transcript-artifact write paths. The sites below are the same defect at sibling call paths; they were deliberately scoped out of that PR to keep it narrow, and are called out there as explicit follow-ups.

Measured on upstream/main under umask 022, before -> after:

site artifact before after
plugins/platforms/a2a/protocol.py persist_message a2a_conversations/ dir 0o755 0o700
" <ctx>.jsonl - verbatim peer turns 0o644 0o600
plugins/platforms/a2a/security.py audit a2a_audit.jsonl - 500-char exchange summaries 0o644 0o600
batch_runner.py _process_batch_worker data/<run>/batch_N.jsonl - full trajectories, in the CWD 0o644 0o600
cli.py save_conversation sessions/saved/ dir 0o755 0o700

Scoped to file modes only. No redaction is added anywhere - these artifacts are replayed and audited full-fidelity, and masking a credential in a replayed path poisons the replay (#43083, guarded by tests/agent/test_tool_call_arg_no_redaction.py). Two tests here assert content stays verbatim, so this can't drift.

Severity, stated accurately

The two a2a sites are materially lower severity than #77520's trajectories and I don't want to oversell them: the plugin is opt-in (only when that platform is enabled), and both paths write under HERMES_HOME, which is 0o700 by default - so on a stock install the parent already blocks the traversal.

The concrete exposure is the documented HERMES_HOME_MODE hatch: _secure_dir's docstring supports e.g. HERMES_HOME_MODE=0701 so a web server can traverse HERMES_HOME to reach a served subdir. Under that config a 0o755 child is genuinely world-readable - the execute-only bit on the parent is specifically chosen to allow cd-through, and a 0o755 a2a_conversations/ then lists every peer conversation to any local account.

batch_runner's data/<run>/batch_N.jsonl needs no such precondition. Like save_trajectory() it appends to the CWD, not under HERMES_HOME, so a datagen run in a checkout drops world-readable full trajectories with no protective parent at all. That one is the direct sibling of the file #77520 fixed.

Why this is one PR

One logical change: finish the owner-only-at-creation mode class for plaintext transcript artifacts. It is one mechanism (open_private_append / mode-at-mkdir) applied at the sibling call paths of an already-open fix, which is what AGENTS.md asks for - "fix the whole bug class ... sibling call paths included." Splitting by directory would produce two PRs reviewed against the identical rationale and the same helper, and would leave the class half-finished in the interim. No refactor, no feature, no behavior change beyond permission bits.

One site I did NOT change, deliberately

agent/agent_runtime_helpers.py calls atomic_json_write(dump_file, ...) for request_dump_*.json without mode=. That looks like the same bug and isn't - measured, not assumed:

atomic_json_write, fresh file, no mode=      -> 0o600
atomic_json_write, existing 0o644, no mode=  -> 0o644  (preserved)

tempfile.mkstemp creates the temp file 0o600, and _restore_file_mode is a no-op when there was no pre-existing file, so a fresh dump already lands 0o600. The only case an explicit mode=0o600 would change is overwriting an existing relaxed file - and request-dump filenames carry a microsecond timestamp, so that path is unreachable in practice; forcing it would also re-tighten a file the user widened, which is exactly what #77520's create-only contract avoids. Adding the argument would be a no-op. Instead I pinned the invariant the absent argument silently depends on, so a future refactor of atomic_json_write's temp-file creation can't regress request dumps unnoticed.

Also verified and excluded as a weaker class: hermes_cli/session_export_md.py and tools/delegation_live_log.py. delegation_live_log redacts unconditionally with force=True at a single choke point (event()), so its content exposure is bounded. session_export_md's line 277 is manifest.jsonl, which holds only session_id / path / sha256 / counts - no transcript content - and its export path is a directory the user names with --output, which puts it on #74897's side of the line.

Managed / NixOS disclosure

sessions/saved and a2a_conversations are both under HERMES_HOME, so the new secure_mkdir() skips managed mode, exactly as _secure_dir does. This is deliberate and worth flagging because an unconditional mode= at creation would change managed behavior: nix/nixosModules.nix creates HERMES_HOME's subdirs setgid group-writable (2770, lines 711-719 and 743) and runs the service with UMask = "0007" (line 907) so interactive users in the hermes group can share state with the gateway. Measured:

unmanaged, umask 022      : sessions/saved -> 0o700
managed, umask 007,
  parent sessions/ = 2770 : sessions/saved -> 0o770   (group access + setgid preserved)
  if forced mode=0o700    : sessions/saved -> 0o700   (would revoke hermes-group access)

Unlike _secure_dir's chmod, a mode passed at creation is not re-reconciled by a later activation pass, so getting this wrong would be silent. _is_container() is not consulted: it gates _secure_file only, and this module has no container carve-out for directories. HERMES_HOME_MODE is also not honoured - that hatch is for traversal to a served subdir, and nothing is served out of these two artifact dirs.

Related Issue

Follow-up to #77520 (no separate issue). Refs #77472 - the remaining file-mode items; the "exact-value redaction on every persistence path" ask there remains deliberately not implemented, per #43083.

Type of Change

  • Security fix

Changes Made

  • utils.py - open_private_append(). Identical to the helper in fix(security): create plaintext transcript artifacts owner-only #77520; carried here so this branch stands alone on main. If fix(security): create plaintext transcript artifacts owner-only #77520 lands first, the hunk is byte-identical and drops out on rebase.
  • hermes_cli/config.py - new secure_mkdir(), the directory analogue of _secure_dir: mode at mkdir (no chmod-after TOCTOU window), never re-applied to an existing dir, skipped in managed mode. Docstring records the _is_container / HERMES_HOME_MODE reasoning above.
  • plugins/platforms/a2a/protocol.py - persist_message() uses secure_mkdir + open_private_append.
  • plugins/platforms/a2a/security.py - audit() uses open_private_append. File only: path.parent is HERMES_HOME itself, whose mode belongs to ensure_hermes_home() / _secure_dir and is user-overridable - not to this call site.
  • batch_runner.py - _process_batch_worker() uses open_private_append. File only; data/<run>/ is a workspace directory the user names via --run_name, and fix(security): create plaintext transcript artifacts owner-only #77520 set the same precedent for agent/trajectory.py (harden the artifact, not the CWD).
  • tests/test_transcript_artifact_file_modes_remaining.py - new, 12 tests.

How to Test

  1. scripts/run_tests.sh tests/test_transcript_artifact_file_modes_remaining.py -q - 12 passed. They assert the contract (no group/other bits on a freshly created artifact) rather than a frozen octal, exercise the real write paths against a temp HERMES_HOME / temp CWD under a deliberately permissive umask 022, and are skipif(os.name != "posix").

  2. Teeth check, per mechanism. Each hunk reverted individually, suite re-run, then restored. All eight turn something red - none of these is load-bearing only in aggregate:

    reverted mechanism result
    a2a dir mode (secure_mkdir -> bare mkdir) 11p / 1f
    a2a log file mode 11p / 1f
    a2a audit file mode 11p / 1f
    batch_runner file mode 11p / 1f
    cli sessions/saved dir mode 11p / 1f
    secure_mkdir managed carve-out (-> unconditional 0o700) 11p / 1f
    secure_mkdir create-only (-> chmod-after) 11p / 1f
    open_private_append create-only (-> chmod-after) 10p / 2f

    The harness asserted each revert string matched exactly once before editing, so no "revert" was a silent no-op.

  3. Regression, direct blast radius - every test file mentioning a touched module (rg -l 'batch_runner|a2a|save_conversation|atomic_json_write|open_private_append|secure_mkdir' tests): 20 files, 321 passed, 0 failed.

  4. Regression, hermes_cli/config.py blast radius (it is imported nearly everywhere): rg -l 'hermes_cli.config|_secure_dir|ensure_hermes_home' tests -> 300 files, 5579 passed, 7 failed. The same 7 failures reproduce on a clean upstream/main worktree - macOS-environmental: test_voice_mode (3, sounddevice/WSL2), test_wake_word, test_web_providers, test_approval, and the known load-flaky test_api_server::test_health_detailed_returns_ok. I did not run the full suite; the two sets above are what I ran.

  5. Lint on changed files: uvx ruff check (ruff 0.16.1) - all checks passed. scripts/check-windows-footguns.py --diff upstream/main - 0 findings (it caught 6 bare read_text() / write_text() calls in my test file on the first pass; fixed with encoding="utf-8"). scripts/check_subprocess_stdin.py - passed.

  6. Manual, real write paths, temp HERMES_HOME, umask 022:

    SITE1 dir  a2a_conversations: 0o700   (was 0o755)
    SITE1 file ctx-abc.jsonl    : 0o600   (was 0o644)
    SITE1 content preserved     : PGPASSWORD='hunter2' psql -h db
    SITE2 file a2a_audit.jsonl  : 0o600   (was 0o644)
    SITE2 content preserved     : summary sk-proj-secretvalue
    SITE4 file batch_0.jsonl    : 0o600   (was 0o644)
    SITE4 dir  data/myrun       : 0o755   (unchanged, deliberate)
    SITE5 dir  sessions/saved   : 0o700   (was 0o755)
    

Precedent for this exact pattern

Verified on upstream/main, not from memory - gateway/shutdown_flush.py already writes a verbatim-content artifact with both primitives:

  • line 44: flush_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
  • lines 67-70: atomic_json_write(final_path, payload, mode=0o600, default=str)

Same modes, same reasoning: pending gateway messages are verbatim user content, so the directory is born 0o700 and each payload 0o600. atomic_json_write's mode= docstring names the motivation ("avoiding chmod-after-write TOCTOU exposure for secret-bearing files"). #77520 added open_private_append for the append-mode cases atomic_json_write can't cover; this PR applies both to the paths that were left.

Why this isn't a reversal of #74897

#74897 moved write_file's new files off 0600 onto umask-derived 0644, so the direction looks opposite. It isn't the same class of file, and the detail matters: _atomic_write's chmod branch only ran if [ -e "$t" ], so new files silently kept mktemp's 0600. That 0600 was an accident of the temp-file mechanism, not a policy - which is why restoring umask-derived permissions was a fix and not a loosening.

What #74897 protected was a path the user named, with a documented interop contract: cross-process readers by design (#70856 - Obsidian LiveSync, Docker volumes, NAS mounts). Nothing in it generalizes to artifacts the agent names and the user never types: a2a_conversations/<ctx>.jsonl, a2a_audit.jsonl, data/<run>/batch_N.jsonl, sessions/saved/. No cross-process reader is implied by any of them, and there is no interop contract to break.

Both changes also agree on the invariant #74897 actually defended - never override permissions the user set. Its regression guard is "overwrite 0755 file -> preserved." Everything here applies its mode only at creation, asserted directly by three tests (..._existing_relaxed_file_is_not_retightened for the a2a log and the batch file, plus secure_mkdir preserving an existing 0o755 dir). An operator who widens a batch trajectory to feed a training pipeline keeps that.

Prior art searched

gh search prs for a2a, owner-only, 0600, batch_runner, mkdir mode; gh search issues for a2a permissions, a2a_conversations, world-readable transcript, file permissions 0644 umask. Nothing overlaps. The a2a plugin landed recently (837003b1e, "closes #514") and the only other open a2a PR is #77526 (feat(a2a): inject conversation history on context resume), which touches persist_message's module but not its mkdir / open - it adds a reader alongside. Adjacent owner-only work in flight is on disjoint files: #77520 (parent), #77579 (browser profile / media cache), #77622 (desktop connection.json), #77527 (Windows ACLs in _secure_file).

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 pytest tests/ -q and all tests pass - not the full suite. I ran the two regression sets in "How to Test" (20 files / 321 passed, and 300 files / 5579 passed with 7 failures confirmed baseline on clean upstream/main).
  • 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

  • Docs - docstrings on secure_mkdir carry the managed-mode / HERMES_HOME_MODE / _is_container reasoning. No user-facing behavior change, so no docs/ edit.
  • cli-config.yaml.example - N/A, no config keys added.
  • CONTRIBUTING.md / AGENTS.md - N/A.
  • Cross-platform: POSIX mode bits are advisory on Windows (os.open honours only the read-only bit; mkdir(mode=) is ignored), so every mode test is skipif(os.name != "posix") and the docstrings say so. Windows at-rest protection is ACL-based and belongs to fix(security): enforce owner-only ACLs on Windows in _secure_file #77527 - not duplicated here.
  • Tool descriptions / schemas - N/A, no tool surface touched.

…ner-only

Follow-up to NousResearch#77520, which hardened four plaintext transcript-artifact write
paths and deliberately scoped these sibling call paths out to stay narrow.
Same defect, same mechanism, same two primitives:

- plugins/platforms/a2a/protocol.py (persist_message) — verbatim peer
  conversation turns; mkdir and append open were both at umask (0o755 dir /
  0o644 file). Now secure_mkdir + open_private_append.
- plugins/platforms/a2a/security.py (audit) — 500-char summaries of each peer
  exchange, append open at umask (0o644). Now open_private_append. Only the
  file: path.parent is HERMES_HOME, whose mode belongs to ensure_hermes_home().
- batch_runner.py (_process_batch_worker) — full trajectories appended under
  the CWD's data/<run>/, 0o644. Closest sibling to agent/trajectory.py.
- cli.py (save_conversation) — sessions/saved was created 0o755. NousResearch#77520 fixed
  the snapshot file inside it and left the directory as an explicit follow-up.

hermes_cli/config.py gains secure_mkdir(), the directory analogue of
_secure_dir: mode at creation (no chmod-after TOCTOU window), no re-tightening
of an existing dir, and skipped in managed mode so the NixOS module's setgid
group-sharing (2770 + UMask=0007) survives.

agent/agent_runtime_helpers.py is deliberately NOT changed: its
atomic_json_write call has no mode=, but mkstemp already creates at 0o600 and
_restore_file_mode is a no-op with no pre-existing file, so a fresh request
dump already lands 0o600. Measured, not assumed. A test pins that invariant.

File modes only — no redaction anywhere (NousResearch#43083).
Copilot AI review requested due to automatic review settings August 3, 2026 12:31

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: A2A (Agent-to-Agent) Protocol Support — Remote Agent Discovery, Communication & Interoperability

3 participants