π File detail
tools/PowerShellTool/gitSafety.ts
π― Use case
This module implements the βPowerShellToolβ tool (Power Shell) β something the model can call at runtime alongside other agent tools. On the API surface it exposes isGitInternalPathPS and isDotGitPathPS β mainly functions, hooks, or classes. Dependencies touch Node path helpers. It composes internal code from utils (relative imports). What the file header says: 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/ h.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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.
π€ Exports (heuristic)
isGitInternalPathPSisDotGitPathPS
π External import roots
Package roots from from "β¦" (relative paths omitted).
path
π₯οΈ Source preview
/**
* 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)
}