Skip to content

fix(repo-mode): prevent unsafe dir defaults and forced pushes (#85)#88

Open
Nivesh353 wants to merge 3 commits into
open-gitagent:mainfrom
Nivesh353:fix/repo-mode-dir-validation-and-readonly
Open

fix(repo-mode): prevent unsafe dir defaults and forced pushes (#85)#88
Nivesh353 wants to merge 3 commits into
open-gitagent:mainfrom
Nivesh353:fix/repo-mode-dir-validation-and-readonly

Conversation

@Nivesh353

Copy link
Copy Markdown
Contributor

Summary

  • Repo mode (repo: in the SDK, --repo in the CLI) silently defaulted the
    working directory to process.cwd() when dir was omitted. If that
    directory had no .git of its own, git commands would escape upward and
    run destructive operations (reset --hard, remote set-url) against
    whatever 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.
  • Fixes both, closes repo: mode: unsafe dir default can reset an unrelated parent git repo; unconditional push on every call with no read-only mode #85:
    • dir is now required in repo mode; omitting it throws instead of
      silently falling back to cwd.
    • Before touching an existing directory, initLocalSession now verifies
      it's a git repo root in its own right (git rev-parse --show-toplevel,
      compared via realpathSync to handle symlinked paths like macOS's
      /tmp/private/tmp) and that it already tracks the expected
      remote — refusing to proceed otherwise.
    • Added repo.readOnly (SDK) and --read-only (CLI), which skip
      commitChanges()/push() in finalize() while still stripping the
      embedded token from origin.
  • Also fixed two stale test/mcp.test.ts assertions that were checking
    execute() against an old plain-string-return contract; the rest of the
    codebase (e.g. sdk.test.ts) already expects the current structured
    { content, details } shape from buildTool(). 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, found
    while confirming this branch's npm test was clean.

Test plan

  • npm run build — no type errors
  • npm test — 39/39 passing (was 37/39 on unmodified main; the 2
    failures were the stale mcp.test.ts assertions above, now fixed)
  • Manually reproduced the original vulnerability (git-less subfolder
    inside a real repo, pointed at a different real repo) — confirmed it
    threw and left the victim repo untouched, instead of resetting it
  • Ran repo mode 3x with readOnly: true (SDK) and --read-only (CLI)
    against a real public repo — 0 pushes landed remotely, confirmed via
    git ls-remote
  • Ran repo mode without readOnly — confirmed normal commit/push
    behavior is unchanged (no regression)

…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 shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/session.ts
- 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 shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

repo: mode: unsafe dir default can reset an unrelated parent git repo; unconditional push on every call with no read-only mode

2 participants