Filehigh importancesource

gitSafety.ts

tools/PowerShellTool/gitSafety.ts

177
Lines
7695
Bytes
2
Exports
3
Imports
10
Keywords

What this is

This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.

Beginner explanation

This file is part of the tool layer, which means it describes actions the system can perform for the user or model.

How it is used

Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.

Expert explanation

Architecturally, this file intersects with tool-system, shell-safety. It contains 177 lines, 3 detected imports, and 2 detected exports.

Important relationships

Detected exports

  • isGitInternalPathPS
  • isDotGitPathPS

Keywords

hooksheadstartswithpathresolvereplacenormalizednormalizeprefixthen

Detected imports

  • path
  • ../../utils/cwd.js
  • ../../utils/powershell/parser.js

Source notes

This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.

Open parent directory

Full source

/**
 * Git can be weaponized for sandbox escape via two vectors:
 * 1. Bare-repo attack: if cwd contains HEAD + objects/ + refs/ but no valid
 *    .git/HEAD, Git treats cwd as a bare repository and runs hooks from cwd.
 * 2. Git-internal write + git: a compound command creates HEAD/objects/refs/
 *    hooks/ then runs git — the git subcommand executes the freshly-created
 *    malicious hooks.
 */

import { basename, posix, resolve, sep } from 'path'
import { getCwd } from '../../utils/cwd.js'
import { PS_TOKENIZER_DASH_CHARS } from '../../utils/powershell/parser.js'

/**
 * If a normalized path starts with `../<cwd-basename>/`, it re-enters cwd
 * via the parent — resolve it to the cwd-relative form. posix.normalize
 * preserves leading `..` (no cwd context), so `../project/hooks` with
 * cwd=/x/project stays `../project/hooks` and misses the `hooks/` prefix
 * match even though it resolves to the same directory at runtime.
 * Check/use divergence: validator sees `../project/hooks`, PowerShell
 * resolves against cwd to `hooks`.
 */
function resolveCwdReentry(normalized: string): string {
  if (!normalized.startsWith('../')) return normalized
  const cwdBase = basename(getCwd()).toLowerCase()
  if (!cwdBase) return normalized
  // Iteratively strip `../<cwd-basename>/` pairs (handles `../../p/p/hooks`
  // when cwd has repeated basename segments is unlikely, but one-level is
  // the common attack).
  const prefix = '../' + cwdBase + '/'
  let s = normalized
  while (s.startsWith(prefix)) {
    s = s.slice(prefix.length)
  }
  // Also handle exact `../<cwd-basename>` (no trailing slash)
  if (s === '../' + cwdBase) return '.'
  return s
}

/**
 * Normalize PS arg text → canonical path for git-internal matching.
 * Order matters: structural strips first (colon-bound param, quotes,
 * backtick escapes, provider prefix, drive-relative prefix), then NTFS
 * per-component trailing-strip (spaces always; dots only if not `./..`
 * after space-strip), then posix.normalize (resolves `..`, `.`, `//`),
 * then case-fold.
 */
function normalizeGitPathArg(arg: string): string {
  let s = arg
  // Normalize parameter prefixes: dash chars (–, —, ―) and forward-slash
  // (PS 5.1). /Path:hooks/pre-commit → extract colon-bound value. (bug #28)
  if (s.length > 0 && (PS_TOKENIZER_DASH_CHARS.has(s[0]!) || s[0] === '/')) {
    const c = s.indexOf(':', 1)
    if (c > 0) s = s.slice(c + 1)
  }
  s = s.replace(/^['"]|['"]$/g, '')
  s = s.replace(/`/g, '')
  // PS provider-qualified path: FileSystem::hooks/pre-commit → hooks/pre-commit
  // Also handles fully-qualified form: Microsoft.PowerShell.Core\FileSystem::path
  s = s.replace(/^(?:[A-Za-z0-9_.]+\\){0,3}FileSystem::/i, '')
  // Drive-relative C:foo (no separator after colon) is cwd-relative on that
  // drive. C:\foo (WITH separator) is absolute and must NOT match — the
  // negative lookahead preserves it.
  s = s.replace(/^[A-Za-z]:(?![/\\])/, '')
  s = s.replace(/\\/g, '/')
  // Win32 CreateFileW per-component: iteratively strip trailing spaces,
  // then trailing dots, stopping if the result is `.` or `..` (special).
  // `.. ` → `..`, `.. .` → `..`, `...` → '' → `.`, `hooks .` → `hooks`.
  // Originally-'' (leading slash split) stays '' (absolute-path marker).
  s = s
    .split('/')
    .map(c => {
      if (c === '') return c
      let prev
      do {
        prev = c
        c = c.replace(/ +$/, '')
        if (c === '.' || c === '..') return c
        c = c.replace(/\.+$/, '')
      } while (c !== prev)
      return c || '.'
    })
    .join('/')
  s = posix.normalize(s)
  if (s.startsWith('./')) s = s.slice(2)
  return s.toLowerCase()
}

const GIT_INTERNAL_PREFIXES = ['head', 'objects', 'refs', 'hooks'] as const

/**
 * SECURITY: Resolve a normalized path that escapes cwd (leading `../` or
 * absolute) against the actual cwd, then check if it lands back INSIDE cwd.
 * If so, strip cwd and return the cwd-relative remainder for prefix matching.
 * If it lands outside cwd, return null (genuinely external — path-validation's
 * concern). Covers `..\<cwd-basename>\HEAD` and `C:\<full-cwd>\HEAD` which
 * posix.normalize alone cannot resolve (it leaves leading `..` as-is).
 *
 * This is the SOLE guard for the bare-repo HEAD attack. path-validation's
 * DANGEROUS_FILES deliberately excludes bare `HEAD` (false-positive risk
 * on legitimate non-git files named HEAD) and DANGEROUS_DIRECTORIES
 * matches per-segment `.git` only — so `<cwd>/HEAD` passes that layer.
 * The cwd-resolution here is load-bearing; do not remove without adding
 * an alternative guard.
 */
function resolveEscapingPathToCwdRelative(n: string): string | null {
  const cwd = getCwd()
  // Reconstruct a platform-resolvable path from the posix-normalized form.
  // `n` has forward slashes (normalizeGitPathArg converted \\ → /); resolve()
  // handles forward slashes on Windows.
  const abs = resolve(cwd, n)
  const cwdWithSep = cwd.endsWith(sep) ? cwd : cwd + sep
  // Case-insensitive comparison: normalizeGitPathArg lowercased `n`, so
  // resolve() output has lowercase components from `n` but cwd may be
  // mixed-case (e.g. C:\Users\...). Windows paths are case-insensitive.
  const absLower = abs.toLowerCase()
  const cwdLower = cwd.toLowerCase()
  const cwdWithSepLower = cwdWithSep.toLowerCase()
  if (absLower === cwdLower) return '.'
  if (!absLower.startsWith(cwdWithSepLower)) return null
  return abs.slice(cwdWithSep.length).replace(/\\/g, '/').toLowerCase()
}

function matchesGitInternalPrefix(n: string): boolean {
  if (n === 'head' || n === '.git') return true
  if (n.startsWith('.git/') || /^git~\d+($|\/)/.test(n)) return true
  for (const p of GIT_INTERNAL_PREFIXES) {
    if (p === 'head') continue
    if (n === p || n.startsWith(p + '/')) return true
  }
  return false
}

/**
 * True if arg (raw PS arg text) resolves to a git-internal path in cwd.
 * Covers both bare-repo paths (hooks/, refs/) and standard-repo paths
 * (.git/hooks/, .git/config).
 */
export function isGitInternalPathPS(arg: string): boolean {
  const n = resolveCwdReentry(normalizeGitPathArg(arg))
  if (matchesGitInternalPrefix(n)) return true
  // SECURITY: leading `../` or absolute paths that resolveCwdReentry and
  // posix.normalize couldn't fully resolve. Resolve against actual cwd — if
  // the result lands back in cwd at a git-internal location, the guard must
  // still fire.
  if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
    const rel = resolveEscapingPathToCwdRelative(n)
    if (rel !== null && matchesGitInternalPrefix(rel)) return true
  }
  return false
}

/**
 * True if arg resolves to a path inside .git/ (standard-repo metadata dir).
 * Unlike isGitInternalPathPS, does NOT match bare-repo-style root-level
 * `hooks/`, `refs/` etc. — those are common project directory names.
 */
export function isDotGitPathPS(arg: string): boolean {
  const n = resolveCwdReentry(normalizeGitPathArg(arg))
  if (matchesDotGitPrefix(n)) return true
  // SECURITY: same cwd-resolution as isGitInternalPathPS — catch
  // `..\<cwd-basename>\.git\hooks\pre-commit` that lands back in cwd.
  if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
    const rel = resolveEscapingPathToCwdRelative(n)
    if (rel !== null && matchesDotGitPrefix(rel)) return true
  }
  return false
}

function matchesDotGitPrefix(n: string): boolean {
  if (n === '.git' || n.startsWith('.git/')) return true
  // NTFS 8.3 short names: .git becomes GIT~1 (or GIT~2, etc. if multiple
  // dotfiles start with "git"). normalizeGitPathArg lowercases, so check
  // for git~N as the first component.
  return /^git~\d+($|\/)/.test(n)
}