fix(repo-mode): prevent unsafe dir defaults and forced pushes (#85)#88
fix(repo-mode): prevent unsafe dir defaults and forced pushes (#85)#88Nivesh353 wants to merge 3 commits into
Conversation
…itagent#85) Repo mode previously defaulted `dir` to process.cwd() when omitted, letting git commands silently escape to an ancestor repo if the target folder had no .git of its own. It also always committed and pushed on finalize() with no way to opt out, even for read-only use. - Require an explicit `dir` in repo mode instead of defaulting to cwd - Refuse to operate unless `dir` is a git repo root in its own right, and unless it already tracks the expected remote - Add `repo.readOnly` (SDK) and `--read-only` (CLI) to skip commit/push in finalize() while still stripping the embedded token
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Three issues worth addressing before merge — one is a URL normalization bug causing false-positive rejections, one is a latent shell injection in commitChanges, and one is a minor CLI/SDK behavioral inconsistency. The readOnly feature and the git-root validation are solid.
1. cleanUrl does not normalize .git suffixes — remote-mismatch check produces false positives
A repo cloned via https://github.com/org/repo.git stores that URL as its origin. If the caller then passes https://github.com/org/repo (without .git), cleanUrl(existingOrigin) !== cleanUrl(url) evaluates to true and the session is refused with a misleading error, even though both point to the same repo. This is a regression vs. the old code path which would overwrite and proceed.
Fix: normalize .git in cleanUrl.
2. Shell injection via commit message in git commit -m "${commitMsg}"
commitChanges interpolates commitMsg directly into a shell command string passed to execSync. The default value is safe, but the function accepts arbitrary caller-supplied messages — a message containing a double-quote or $() breaks out of the shell argument. Use execFileSync with an argument array for this call, or shell-escape the message before interpolation.
3. CLI --dir guard is fragile compared to SDK enforcement
The SDK throws when dir is omitted in repo mode. The CLI silently derives /tmp/gitagent/<repo-name> when --dir is not passed. The guard used is dir === process.cwd() — if the user explicitly passes --dir $(pwd), the guard is bypassed and their actual cwd is used as the repo dir. Compare rawDir === undefined instead.
Security pass: no new dependencies in this PR, no hardcoded secrets, no obvious injection/authz gaps beyond finding #2 above.
- Normalize .git suffix in cleanUrl so remote-mismatch comparison doesn't false-positive on url vs url.git - Use execFileSync for commit -m to avoid shell injection via arbitrary commit messages - Track --dir via an explicit undefined sentinel instead of comparing against process.cwd(), which couldn't tell "no --dir" apart from "--dir explicitly matching cwd" - Use `git remote add` instead of `set-url` when origin isn't configured yet, fixing a crash found while verifying the above
shreyas-lyzr
left a comment
There was a problem hiding this comment.
All three findings from the previous review are addressed. The new 'no origin' fix also looks correct. Here's what I verified:
Finding 1 — cleanUrl missing .git strip: Fixed. cleanUrl now strips both the auth prefix and .git suffix (session.ts line 33). The remote-URL comparison at line 93 will correctly match whether or not the caller supplied a .git suffix.
Finding 2 — shell injection in commitChanges: Fixed. execFileSync with an argv array is used at line 177. The commit message is passed as a discrete argument rather than shell-interpolated, so arbitrary content is safe.
Finding 3 — fragile rawDir guard: Fixed. index.ts now tracks rawDir as undefined when --dir is not passed (parseArgs leaves it unset), and the default-dir branch checks rawDir === undefined (line 354) rather than comparing the string value. Explicit --dir matching cwd is now correctly honoured.
New fix — no-origin crash: Correct. When git remote get-url origin fails (empty repo pointed at this URL for the first time), existingOrigin is set to '' and the ternary at line 102 calls git remote add origin ... instead of git remote set-url origin ..., which would have thrown. The subsequent fetch and reset proceed normally. The finalize path (line 191) always calls set-url, which is safe because by that point origin is guaranteed to exist (either from clone, from add, or from the previous set-url).
One pre-existing note (not introduced by this PR, not blocking): the git() helper interpolates aUrl and cleanUrl(url) into a shell string, so a maliciously crafted repo URL with shell metacharacters could inject commands. That pattern exists in main already; it is out of scope here but worth a follow-up issue.
Security pass: no new dependencies, no hardcoded secrets, no new injection surface, no auth/authz gaps. The aUrl interpolation concern pre-dates this branch.
Tests: 39/39 per the PR description; the two previously failing mcp.test.ts assertions are fixed to match the current structured return shape.
Summary
repo:in the SDK,--repoin the CLI) silently defaulted theworking directory to
process.cwd()whendirwas omitted. If thatdirectory had no
.gitof its own, git commands would escape upward andrun destructive operations (
reset --hard,remote set-url) againstwhatever ancestor repo was found instead — corrupting an unrelated project.
finalize()always committed and pushed on every call, success or failure,with no way to opt out — even for pure read-only use.
diris now required in repo mode; omitting it throws instead ofsilently falling back to cwd.
initLocalSessionnow verifiesit's a git repo root in its own right (
git rev-parse --show-toplevel,compared via
realpathSyncto handle symlinked paths like macOS's/tmp→/private/tmp) and that it already tracks the expectedremote — refusing to proceed otherwise.
repo.readOnly(SDK) and--read-only(CLI), which skipcommitChanges()/push()infinalize()while still stripping theembedded token from
origin.test/mcp.test.tsassertions that were checkingexecute()against an old plain-string-return contract; the rest of thecodebase (e.g.
sdk.test.ts) already expects the current structured{ content, details }shape frombuildTool(). Unrelated to repo: mode: unsafe dir default can reset an unrelated parent git repo; unconditional push on every call with no read-only mode #85, foundwhile confirming this branch's
npm testwas clean.Test plan
npm run build— no type errorsnpm test— 39/39 passing (was 37/39 on unmodifiedmain; the 2failures were the stale
mcp.test.tsassertions above, now fixed)inside a real repo, pointed at a different real repo) — confirmed it
threw and left the victim repo untouched, instead of resetting it
readOnly: true(SDK) and--read-only(CLI)against a real public repo — 0 pushes landed remotely, confirmed via
git ls-remotereadOnly— confirmed normal commit/pushbehavior is unchanged (no regression)