Background
PR #2394 resolved #2389 by treating HERMES_HOME as the global Hermes profile root across MCP, hooks, permissions, skills, commands, subagents, and conversion. It also lifted resolveToolOutputRoot out of kimi-code.ts into a neutral src/utils/tool-output-root.ts, so the two tools with a home override now share one resolver.
The PR was reviewed and merged: all four of the maintainer's earlier inline points were resolved and verified, CI was green, the full e2e suite passed (794 tests, up from 789), and no tests were weakened or disabled. The findings below were all mid or low severity, so they did not block the merge — this issue is where they go instead.
Details
Correctness / behavior
1. (mid) Windows default profile root disagrees with upstream Hermes.
src/utils/hermesagent.ts:6-9, docs/reference/supported-tools.md:67-68
getHermesagentHome() reads only HERMES_HOME; the unset fallback is $HOME + .hermes on every platform. Upstream _get_platform_default_hermes_home() in hermes_constants.py returns %LOCALAPPDATA%\hermes on win32 and ~/.hermes only elsewhere. So on native Windows with HERMES_HOME unset, rulesync writes global output to %USERPROFILE%\.hermes\, which Hermes never reads.
The behavior predates the PR, but the PR is what added the sentence "When it is unset, the default remains ~/.hermes." — which is false on Windows. Mitigated in practice because the Windows installer sets HERMES_HOME.
2. (mid) Registry-derived shared-file key becomes env-dependent and diverges from SHARED_CONFIG_OWNERSHIP.
src/utils/hermesagent.ts:21-36, consumed via getSettablePaths in hermesagent-mcp.ts:333, hermesagent-hooks.ts:260, hermesagent-permissions.ts:181
With HERMES_HOME set at module-load time, getSettablePaths({ global: true }) returns relativeDirPath: "", so sharedFileKey() (shared-file-derive.ts:63-67) produces the bare config.yaml. Writes still use the hardcoded HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml" (shared-config-gateway.ts:254), so the documented invariant at shared-config-gateway.ts:288 ("keys are dir/file tokens matching deriveSharedFileWriters()") no longer holds.
No production impact today — the write path passes the constant, so applySharedConfigPatch never throws, and the e2e proves generation works. The cost is that the drift-detection guards go false-positive in exactly the configuration this feature is about. Reproduced with HERMES_HOME set:
shared-config-gateway.test.ts — "accounts for every registry-derived shared file with an ownership declaration" (reports unaccounted config.yaml)
shared-config-gateway.test.ts — "declares exactly the writer features..." (subagents drops off .hermes/config.yaml)
shared-file-contract.test.ts — "no generation step deletes another step's key paths (global scope)"
7. (low) convert and generate now disagree on checks.
src/lib/convert.ts:430 vs src/lib/generate.ts:1169
buildChecksStrategy was updated for symmetry during review, but generateChecksCore still passes outputRoot verbatim. Harmless today — hermesagent checks is supportsGlobal: false (checks-processor.ts:78) and amp is the only global checks tool, with no home override — but the two entry points have drifted. Leave generateIgnoreCore (generate.ts:770) alone; it has an explicit comment justifying verbatim pass-through.
8. (low) HERMES_HOME silently overrides an explicit --output-roots.
src/utils/tool-output-root.ts:11-21
Under --global with HERMES_HOME set, resolveToolOutputRoot discards a user-specified --output-roots hermesagent=.... Identical to existing kimi-code behavior, so not a regression, but the precedence is documented nowhere.
Hardening
9. (low) Env-var-derived outputRoot bypasses validateOutputRoot.
src/utils/tool-output-root.ts:11-21, src/utils/hermesagent.ts:6-9
CLI/config outputRoot is validated at src/config/config-resolver.ts:419,429 (rejects empty, unnormalized absolute, filesystem root), but resolveToolOutputRoot substitutes HERMES_HOME afterwards using only resolve(), skipping that validation — HERMES_HOME=/ or ../.. becomes the output root verbatim. The same hole already exists for KIMI_CODE_HOME on main. Calling validateOutputRoot() on the resolved value inside resolveToolOutputRoot closes both at once.
10. (low) De-prefix guard is weaker than checkPathTraversal, and fails open at the call site.
src/utils/hermesagent.ts:30-35
The guard rejects only an exact .., a .. + separator prefix, and absolute paths; the codebase standard checkPathTraversal (src/utils/file.ts:207-225) rejects any .. segment. Inputs are compile-time constants today, so there is no exploitable path. But src/lib/shared-file-derive.ts:93-118 swallows exceptions from getSettablePaths / getExtraSharedWritePaths at module load (SHARED_WRITE_STEPS, src/lib/generate.ts:339), so if a Hermes global path ever moves outside .hermes, config.yaml silently drops out of the shared-write ordering graph and features would overwrite each other. Fragile coupling rather than a current vulnerability.
Note the structural divergence this leaves behind: hermesagent strips the .hermes prefix while kimi-code prefixes instead. De-prefixing the hermesagent constants to match kimi-code would make the class of bug impossible rather than merely asserted against.
Code quality
3. (mid) The same path-comparison expression is copy-pasted five times.
src/features/commands/hermesagent-command.ts:178-240
In HermesagentCommandAuxiliaryFile, this six-line block repeats five times (three of them character-for-character identical):
this.getRelativePathFromCwd() ===
toPosixPath(getHermesagentRelativeFilePath({ global: this.global, relativeFilePath: <CONST> }))
across shouldMergeExistingFileContent, setFileContent, and three branches of getFileContent. On main each was a single line. A private matchesPath(relativeFilePath: string): boolean helper collapses 40+ lines to a handful and structurally prevents passing the wrong constant.
4. (low) Duplication against the kimi-code utility module.
src/utils/hermesagent.ts:55-60 vs src/utils/kimi-code.ts:34-41
getHermesagentRulesyncOutputRoot and getKimiCodeRulesyncOutputRoot are identical apart from which home getter they call. Now that src/utils/tool-output-root.ts exists, a shared getToolRulesyncOutputRoot({ nativeOutputRoot, global, toolHome }) belongs there. There is local duplication too: getHermesagentRelativeDirPath({ global, relativeDirPath: HERMESAGENT_RULESYNC_COMMANDS_PLUGIN_DIR_PATH }) is spelled out three times in hermesagent-command.ts:336-364 and twice in hermesagent-subagent.ts:225-241, where one local const would do.
Tests
5. (low) Unit tests are not hermetic with respect to HERMES_HOME.
Running the unit suite with HERMES_HOME set produces 12 failures: hermesagent-mcp.test.ts (6), hermesagent-skill.test.ts (1), hermesagent-subagent.test.ts (1), plus the 4 from finding 2. CI is unaffected, but CLAUDE.md tells developers to run pnpm cicheck locally, and the developers most likely to have HERMES_HOME exported are exactly the Hermes users this feature targets. Precedent exists — main has the same problem for KIMI_CODE_HOME — but at 2 failures, not 12.
6. (low) No unit-level coverage of the new global + HERMES_HOME behavior on the per-tool classes.
New tests are src/utils/hermesagent.test.ts (helper-level only) plus e2e. None of HermesagentMcp / HermesagentSkill / HermesagentSubagent / HermesagentHooks / HermesagentPermissions has a getSettablePaths({ global: true }) case with HERMES_HOME set.
Release notes
11. (informational) Users with HERMES_HOME set will have stale files left at ~/.hermes/* after upgrading, since --delete no longer reaches them. Worth documenting alongside the fact that --global --delete orphan removal now operates under $HERMES_HOME.
Solution / Next Steps
Roughly in the order I would take them:
- Fix the docs sentence for finding 1 (one line), and decide separately whether to implement the platform default for Windows — that part is a real behavior change and could be its own issue.
- Settle finding 2 deliberately: either make
HERMES_CONFIG_SHARED_FILE_KEY derive from the same registry path the guards read, or teach sharedFileKey() to normalize an empty relativeDirPath. The point is that the drift guards should stay meaningful when HERMES_HOME is set.
- Apply the
matchesPath helper refactor (finding 3) and the shared getToolRulesyncOutputRoot extraction (finding 4).
- Add
validateOutputRoot() inside resolveToolOutputRoot (finding 9) — this also closes the pre-existing kimi-code hole. Consider tightening the de-prefix guard to checkPathTraversal semantics at the same time (finding 10).
- Clear
HERMES_HOME in the affected specs' beforeEach (finding 5) and add the per-class getSettablePaths({ global: true }) cases (finding 6). Doing 6 first makes 5 unavoidable.
- Align
generateChecksCore with buildChecksStrategy (finding 7), and document the HERMES_HOME vs --output-roots precedence (finding 8).
- Add the migration note from finding 11 to the next release.
Background
PR #2394 resolved #2389 by treating
HERMES_HOMEas the global Hermes profile root across MCP, hooks, permissions, skills, commands, subagents, and conversion. It also liftedresolveToolOutputRootout ofkimi-code.tsinto a neutralsrc/utils/tool-output-root.ts, so the two tools with a home override now share one resolver.The PR was reviewed and merged: all four of the maintainer's earlier inline points were resolved and verified, CI was green, the full e2e suite passed (794 tests, up from 789), and no tests were weakened or disabled. The findings below were all mid or low severity, so they did not block the merge — this issue is where they go instead.
Details
Correctness / behavior
1. (mid) Windows default profile root disagrees with upstream Hermes.
src/utils/hermesagent.ts:6-9,docs/reference/supported-tools.md:67-68getHermesagentHome()reads onlyHERMES_HOME; the unset fallback is$HOME+.hermeson every platform. Upstream_get_platform_default_hermes_home()inhermes_constants.pyreturns%LOCALAPPDATA%\hermeson win32 and~/.hermesonly elsewhere. So on native Windows withHERMES_HOMEunset, rulesync writes global output to%USERPROFILE%\.hermes\, which Hermes never reads.The behavior predates the PR, but the PR is what added the sentence "When it is unset, the default remains
~/.hermes." — which is false on Windows. Mitigated in practice because the Windows installer setsHERMES_HOME.2. (mid) Registry-derived shared-file key becomes env-dependent and diverges from
SHARED_CONFIG_OWNERSHIP.src/utils/hermesagent.ts:21-36, consumed viagetSettablePathsinhermesagent-mcp.ts:333,hermesagent-hooks.ts:260,hermesagent-permissions.ts:181With
HERMES_HOMEset at module-load time,getSettablePaths({ global: true })returnsrelativeDirPath: "", sosharedFileKey()(shared-file-derive.ts:63-67) produces the bareconfig.yaml. Writes still use the hardcodedHERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml"(shared-config-gateway.ts:254), so the documented invariant atshared-config-gateway.ts:288("keys are dir/file tokens matchingderiveSharedFileWriters()") no longer holds.No production impact today — the write path passes the constant, so
applySharedConfigPatchnever throws, and the e2e proves generation works. The cost is that the drift-detection guards go false-positive in exactly the configuration this feature is about. Reproduced withHERMES_HOMEset:shared-config-gateway.test.ts— "accounts for every registry-derived shared file with an ownership declaration" (reports unaccountedconfig.yaml)shared-config-gateway.test.ts— "declares exactly the writer features..." (subagents drops off.hermes/config.yaml)shared-file-contract.test.ts— "no generation step deletes another step's key paths (global scope)"7. (low)
convertandgeneratenow disagree on checks.src/lib/convert.ts:430vssrc/lib/generate.ts:1169buildChecksStrategywas updated for symmetry during review, butgenerateChecksCorestill passesoutputRootverbatim. Harmless today — hermesagent checks issupportsGlobal: false(checks-processor.ts:78) and amp is the only global checks tool, with no home override — but the two entry points have drifted. LeavegenerateIgnoreCore(generate.ts:770) alone; it has an explicit comment justifying verbatim pass-through.8. (low)
HERMES_HOMEsilently overrides an explicit--output-roots.src/utils/tool-output-root.ts:11-21Under
--globalwithHERMES_HOMEset,resolveToolOutputRootdiscards a user-specified--output-roots hermesagent=.... Identical to existing kimi-code behavior, so not a regression, but the precedence is documented nowhere.Hardening
9. (low) Env-var-derived
outputRootbypassesvalidateOutputRoot.src/utils/tool-output-root.ts:11-21,src/utils/hermesagent.ts:6-9CLI/config
outputRootis validated atsrc/config/config-resolver.ts:419,429(rejects empty, unnormalized absolute, filesystem root), butresolveToolOutputRootsubstitutesHERMES_HOMEafterwards using onlyresolve(), skipping that validation —HERMES_HOME=/or../..becomes the output root verbatim. The same hole already exists forKIMI_CODE_HOMEon main. CallingvalidateOutputRoot()on the resolved value insideresolveToolOutputRootcloses both at once.10. (low) De-prefix guard is weaker than
checkPathTraversal, and fails open at the call site.src/utils/hermesagent.ts:30-35The guard rejects only an exact
.., a..+ separator prefix, and absolute paths; the codebase standardcheckPathTraversal(src/utils/file.ts:207-225) rejects any..segment. Inputs are compile-time constants today, so there is no exploitable path. Butsrc/lib/shared-file-derive.ts:93-118swallows exceptions fromgetSettablePaths/getExtraSharedWritePathsat module load (SHARED_WRITE_STEPS,src/lib/generate.ts:339), so if a Hermes global path ever moves outside.hermes,config.yamlsilently drops out of the shared-write ordering graph and features would overwrite each other. Fragile coupling rather than a current vulnerability.Note the structural divergence this leaves behind: hermesagent strips the
.hermesprefix while kimi-code prefixes instead. De-prefixing the hermesagent constants to match kimi-code would make the class of bug impossible rather than merely asserted against.Code quality
3. (mid) The same path-comparison expression is copy-pasted five times.
src/features/commands/hermesagent-command.ts:178-240In
HermesagentCommandAuxiliaryFile, this six-line block repeats five times (three of them character-for-character identical):across
shouldMergeExistingFileContent,setFileContent, and three branches ofgetFileContent. On main each was a single line. A privatematchesPath(relativeFilePath: string): booleanhelper collapses 40+ lines to a handful and structurally prevents passing the wrong constant.4. (low) Duplication against the kimi-code utility module.
src/utils/hermesagent.ts:55-60vssrc/utils/kimi-code.ts:34-41getHermesagentRulesyncOutputRootandgetKimiCodeRulesyncOutputRootare identical apart from which home getter they call. Now thatsrc/utils/tool-output-root.tsexists, a sharedgetToolRulesyncOutputRoot({ nativeOutputRoot, global, toolHome })belongs there. There is local duplication too:getHermesagentRelativeDirPath({ global, relativeDirPath: HERMESAGENT_RULESYNC_COMMANDS_PLUGIN_DIR_PATH })is spelled out three times inhermesagent-command.ts:336-364and twice inhermesagent-subagent.ts:225-241, where one localconstwould do.Tests
5. (low) Unit tests are not hermetic with respect to
HERMES_HOME.Running the unit suite with
HERMES_HOMEset produces 12 failures:hermesagent-mcp.test.ts(6),hermesagent-skill.test.ts(1),hermesagent-subagent.test.ts(1), plus the 4 from finding 2. CI is unaffected, butCLAUDE.mdtells developers to runpnpm cichecklocally, and the developers most likely to haveHERMES_HOMEexported are exactly the Hermes users this feature targets. Precedent exists — main has the same problem forKIMI_CODE_HOME— but at 2 failures, not 12.6. (low) No unit-level coverage of the new global +
HERMES_HOMEbehavior on the per-tool classes.New tests are
src/utils/hermesagent.test.ts(helper-level only) plus e2e. None ofHermesagentMcp/HermesagentSkill/HermesagentSubagent/HermesagentHooks/HermesagentPermissionshas agetSettablePaths({ global: true })case withHERMES_HOMEset.Release notes
11. (informational) Users with
HERMES_HOMEset will have stale files left at~/.hermes/*after upgrading, since--deleteno longer reaches them. Worth documenting alongside the fact that--global --deleteorphan removal now operates under$HERMES_HOME.Solution / Next Steps
Roughly in the order I would take them:
HERMES_CONFIG_SHARED_FILE_KEYderive from the same registry path the guards read, or teachsharedFileKey()to normalize an emptyrelativeDirPath. The point is that the drift guards should stay meaningful whenHERMES_HOMEis set.matchesPathhelper refactor (finding 3) and the sharedgetToolRulesyncOutputRootextraction (finding 4).validateOutputRoot()insideresolveToolOutputRoot(finding 9) — this also closes the pre-existing kimi-code hole. Consider tightening the de-prefix guard tocheckPathTraversalsemantics at the same time (finding 10).HERMES_HOMEin the affected specs'beforeEach(finding 5) and add the per-classgetSettablePaths({ global: true })cases (finding 6). Doing 6 first makes 5 unavoidable.generateChecksCorewithbuildChecksStrategy(finding 7), and document theHERMES_HOMEvs--output-rootsprecedence (finding 8).