Agentic AI

Agentic AI

Chapter 4: Working Directory & File-Path Resolution (Claude Code vs. Hermes Agent)

Ken Huang's avatar
Ken Huang
Jun 01, 2026
∙ Paid

This is chapter 4 of total 10 chapters book to be published soon. The previous chpaters’ links are below:

chapter 1 and chapter 2, Chapter 3,

1. Pattern Summary

Working directory resolution is the harness's answer to a deceptively simple question: when the model writes Read("README.md") or Bash("cat config.json"), what does that string actually mean on disk? An LLM produces tokens, not file handles. Every relative path, tilde, glob, symlink, and cd between turns must be resolved by the harness into an unambiguous absolute path before any syscall fires — and the resolution must stay consistent across hundreds of tool calls, parallel subagents, container hops, and shell sessions that may or may not preserve their own cwd. Without this layer, an agent that runs cd /tmp in turn 5 and cat README.md in turn 6 will surprise everyone: the human sees /tmp/README.md, the model thinks it's still in the project root, and the permission system validates a third path entirely. With this layer, the agent's mental model of the filesystem stays aligned with the harness's enforcement model and the user's expectation. This pattern is distinct from permissions (which decides whether a path is allowed) and tool architecture (which decides what a tool does) — it is purely about interpretation: turning a string the model produced into a stable, audited filesystem location.

Figure 4.1: Path resolution anchor across stateless tool calls

Figure 4.1. The harness sits between the model's raw path strings and the filesystem, canonicalizing every input through expandPath, the live cwd state, an immutable session anchor, and a containment check. Every relative path, tilde, glob, and cd between turns flows through the same anchored pipeline before any syscall fires.

Before you read on — two quick announcements. First, you can now get 50% off a yearly subscription and unlock all paid content on Agentic AI, AI Security, and more: https://kenhuangus.substack.com/subscribe?coupon=302342d9.

Second, all previously written 15 Hermes Agent and Claude Code agentic harness design patterns are now available in a single book on Amazon: https://www.amazon.com/Agentic-AI-Harness-Pattern-Patterns-ebook/dp/B0H13XWS8W

2. Claude Code Implementation

Claude Code treats cwd as session-global state with strict invariants: there is exactly one current working directory at any moment for the synchronous code path, but each subagent can carry its own override via AsyncLocalStorage so concurrent agents never race. The state lives in three layers — a global STATE.cwd, a per-async-context override store, and a getCwd() accessor that resolves to whichever applies.

The cwd accessor and per-async override

// src/utils/cwd.ts
import { AsyncLocalStorage } from 'async_hooks'
import { getCwdState, getOriginalCwd } from '../bootstrap/state.js'

const cwdOverrideStorage = new AsyncLocalStorage<string>()

/**
 * Run a function with an overridden working directory for the current async context.
 * All calls to pwd()/getCwd() within the function (and its async descendants) will
 * return the overridden cwd instead of the global one. This enables concurrent
 * agents to each see their own working directory without affecting each other.
 */
export function runWithCwdOverride<T>(cwd: string, fn: () => T): T {
  return cwdOverrideStorage.run(cwd, fn)
}

export function pwd(): string {
  return cwdOverrideStorage.getStore() ?? getCwdState()
}

AsyncLocalStorage is the load-bearing primitive. Without it, two parallel subagents that ran in different worktrees would clobber each other's process.chdir. With it, each agent inherits a frozen snapshot of cwd at spawn time, and that snapshot follows every async tool call originating from that agent.

Path expansion as the single ingress point

Every file path the model produces flows through expandPath() before any syscall. This function is the harness's interpreter: it accepts raw input and emits a canonical absolute path in the platform-native format.

The opening of expandPath does two jobs: it picks a base directory to resolve against (falling back through cwd-override, global cwd, and finally Node's process cwd), then enforces the most basic safety property of all — rejecting null bytes that could truncate the path early in a syscall.

// src/utils/path.ts — every model-produced path passes through here.
export function expandPath(path: string, baseDir?: string): string {
  const actualBaseDir = baseDir ?? getCwd() ?? getFsImplementation().cwd()

  // Security: Check for null bytes
  if (path.includes('\0') || actualBaseDir.includes('\0')) {
    throw new Error('Path contains null bytes')
  }

Next come the user-friendliness layers: tilde expansion to the user's home directory, and a Windows-specific rewrite that converts Git-Bash-style paths like /c/Users/... into native C:\Users\.... Note the .normalize('NFC') — every return path is NFC-normalized so downstream string comparisons are stable.

  // Handle home directory notation
  if (trimmedPath === '~') return homedir().normalize('NFC')
  if (trimmedPath.startsWith('~/'))
    return join(homedir(), trimmedPath.slice(2)).normalize('NFC')

  // On Windows, convert POSIX-style paths (/c/Users/...) to Windows format
  if (getPlatform() === 'windows' && trimmedPath.match(/^\/[a-z]\//i)) {
    processedPath = posixPathToWindowsPath(trimmedPath)
  }

Finally, the resolution proper: absolute paths are normalized in place, relative paths are joined against the base directory and then normalized. This is the function's whole point — every code path emits an absolute, NFC-normalized, platform-native string.

  if (isAbsolute(processedPath)) return normalize(processedPath).normalize('NFC')
  return resolve(actualBaseDir, processedPath).normalize('NFC')
}

Two details matter. First, NFC Unicode normalization: macOS APFS returns NFD for accented filenames, while user input is usually NFC, so without normalization a path comparison can falsely report "different" for the same file. Second, the platform branch: a model trained on Linux examples will sometimes emit /c/Users/... on Windows, and the harness silently rewrites these so the agent doesn't have to know which OS it's on.

Tracking cwd across BashTool calls via a marker file

The Bash tool runs each command in a fresh subprocess, but the harness reconstructs persistent shell state by injecting a pwd -P marker after the user's command and reading it back. This is how cd /tmp in turn 5 still affects turn 6 even though there is no long-lived shell.

The outer shape is a .then() on the shell command's result promise. The first guard is the backgroundTaskId check: cwd updates only fire for foreground commands, because a backgrounded cd would leave the agent's logical cwd out of sync with what the user actually sees.

// src/utils/Shell.ts
void shellCommand.result.then(async result => {
  // Only foreground tasks update the cwd
  if (result && !preventCwdChanges && !result.backgroundTaskId) {
    try {
      let newCwd = readFileSync(nativeCwdFilePath, { encoding: 'utf8' }).trim()
      if (getPlatform() === 'windows') {
        newCwd = posixPathToWindowsPath(newCwd)
      }

Then the comparison is performed in NFC space — without this normalize, a UTF-8 filename like café written as NFD on macOS APFS would always read back as "changed" even when the user never cd'd, triggering spurious hook fires every command.

      // cwd is NFC-normalized (setCwdState); newCwd from `pwd -P` may be
      // NFD on macOS APFS. Normalize before comparing so Unicode paths
      // don't false-positive as "changed" on every command.
      if (newCwd.normalize('NFC') !== cwd) {
        setCwd(newCwd, cwd)
        invalidateSessionEnvCache()
        void onCwdChangedForHooks(cwd, newCwd)
      }
    } catch {
      logEvent('tengu_shell_set_cwd', { success: false })
    }
  }
})

pwd -P is the physical path — symlinks resolved — which is what the harness wants for permission comparisons. The readFileSync/unlinkSync are intentionally synchronous to avoid a microtask boundary that would let a follow-up tool call observe a stale cwd.

setCwd canonicalizes through realpathSync

When the harness commits a new cwd, it resolves symlinks via realpathSync so the stored value is always canonical. This is the deliberate symmetry to pwd -P on the bash side.

// src/utils/Shell.ts
export function setCwd(path: string, relativeTo?: string): void {
  const resolved = isAbsolute(path)
    ? path
    : resolve(relativeTo || getFsImplementation().cwd(), path)
  // Resolve symlinks to match the behavior of pwd -P.
  let physicalPath: string
  try {
    physicalPath = getFsImplementation().realpathSync(resolved)
  } catch (e) {
    if (isENOENT(e)) {
      throw new Error(`Path "${resolved}" does not exist`)
    }
    throw e
  }
  setCwdState(physicalPath)
}

Sandbox boundary: snap back when cwd escapes

After every Bash tool result, the harness checks whether the agent has wandered out of its allowed working directories and silently resets if so. This is the bridge between path resolution and path containment.

// src/tools/BashTool/utils.ts
export function resetCwdIfOutsideProject(
  toolPermissionContext: ToolPermissionContext,
): boolean {
  const cwd = getCwd()
  const originalCwd = getOriginalCwd()
  const shouldMaintain = shouldMaintainProjectWorkingDir()
  if (
    shouldMaintain ||
    (cwd !== originalCwd &&
      !pathInAllowedWorkingPath(cwd, toolPermissionContext))
  ) {
    setCwd(originalCwd)
    if (!shouldMaintain) {
      logEvent('tengu_bash_tool_reset_to_original_dir', {})
      return true
    }
  }
  return false
}

getOriginalCwd() is the immutable startup anchor — it is never mutated by cd, so the harness always has a known-safe directory to fall back to.

Subagent cwd inheritance via wrapWithCwd

When the AgentTool spawns a subagent — including for explicit cwd overrides or worktree isolation — it wraps the entire agent lifecycle in a runWithCwdOverride so every nested tool call sees the right directory.

// src/tools/AgentTool/AgentTool.tsx
// Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS)
// takes precedence over worktree isolation path.
const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath;
const wrapWithCwd = <T,>(fn: () => T): T =>
  cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn();

Because AsyncLocalStorage propagates through the entire async tree, a Read tool call deep inside a subagent's nested generator still sees the override — even if other subagents are concurrently using a different override.

Symlink-resolved permission checks

The permission layer uses the same canonicalization the cwd layer uses. safeResolvePath calls realpathSync defensively, with explicit handling for FIFOs and sockets that would block.

The function signature returns three flags rather than just a path: the resolved path itself, whether it was a symlink, and whether the result is canonical. The first guard short-circuits on UNC paths (\\server\share\…), which on Windows would trigger a DNS or SMB lookup before we've even decided whether to touch the file.

// src/utils/fsOperations.ts
export function safeResolvePath(
  fs: FsOperations,
  filePath: string,
): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } {
  // Block UNC paths before any filesystem access to prevent network requests
  if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
    return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
  }

The next chunk does a defensive lstatSync before any realpathSync — realpathSync will block indefinitely on a FIFO waiting for a writer, so the harness inspects the file type first and bails out for anything that could hang.

  try {
    // realpathSync can block on FIFOs waiting for a writer.
    const stats = fs.lstatSync(filePath)
    if (stats.isFIFO() || stats.isSocket() ||
        stats.isCharacterDevice() || stats.isBlockDevice()) {
      return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
    }
    const resolvedPath = fs.realpathSync(filePath)
    return { resolvedPath, isSymlink: resolvedPath !== filePath, isCanonical: true }
  } catch (_error) {
    return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
  }
}

The isCanonical flag is performance-load-bearing: when set, callers skip five redundant syscalls during permission checks.

3. Hermes Agent Implementation

Hermes solves the same problem with a different topology. Where Claude Code uses a global TypeScript module with AsyncLocalStorage, Hermes uses a per-environment Python object that wraps the actual execution backend (local subprocess, Docker container, Modal sandbox, SSH session, etc.). Each environment carries its own self.cwd, and after every command an injected bash epilogue writes the new pwd to a temp file or stdout marker.

Figure 4.2: Two implementations reconstruct cwd across stateless subprocesses

Figure 4.2. The two harnesses reach the same property by different routes. Claude Code chains an immutable getOriginalCwd() anchor with global state and an AsyncLocalStorage override, all consulted through one getCwd() accessor. Hermes injects a bash epilogue that emits pwd -P to both a marker file and a stdout marker, then parses whichever channel the backend supports.

User's avatar

Continue reading this post for free, courtesy of Ken Huang.

Or purchase a paid subscription.
© 2026 ken · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture