Skip to content

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

Description

@harshit-lyzr

query()'s repo: option has two related problems that make it unsafe to use from anything other than an interactive one-shot CLI session: (1) dir is typed as required but the runtime silently defaults to process.cwd(), which can resolve to an unrelated repo's working tree and get git reset --hard; (2) finalize() (commit + push) runs unconditionally at the end of every call, success or failure, with no way to opt out.

Bug 1 — LocalRepoOptions.dir type says required, runtime treats it as optional and defaults to process.cwd()
session.d.ts:

export interface LocalRepoOptions {
url: string;
token: string;
dir: string; // <- no ?, looks mandatory
session?: string;
}
sdk.js:

let dir = options.dir ?? process.cwd();
if (options.repo) {
...
localSession = initLocalSession({
url: options.repo.url,
token,
dir: options.repo.dir || dir, // <- silently falls back to cwd if unset
session: options.repo.session,
});
If the calling process's cwd has no .git of its own (e.g. it's a subdirectory like frontend/ inside a monorepo), git's normal directory-walk-up behavior means every command initLocalSession runs (checkout, reset --hard) executes against whatever parent repo git finds — which may have nothing to do with the repo.url being cloned.

Reproduction:

From a directory with no .git of its own, but nested inside a parent repo (e.g. some-monorepo/apps/web/), call:

query({ prompt: "hi", repo: { url: "https://github.com/org/some-other-repo", token } })
(no dir passed)
initLocalSession finds some-monorepo/apps/web already "exists" (it's a real directory), so it takes the update branch: git remote set-url origin , git fetch, git checkout , git reset --hard origin/ — all run with cwd: some-monorepo/apps/web.
Because that directory has no .git, these commands resolve to some-monorepo's actual .git, rewriting some-monorepo's origin remote (with the token embedded in the URL) and attempting to hard-reset the entire monorepo onto some-other-repo's content.
In our case this happened three times in one session; it was only prevented from actually destroying data because git checkout refused to proceed due to uncommitted local changes — that's git's own safety net, not gitagent's. A directory with committed state, or a clean tree, would not have been protected.

Suggested fix: Make dir actually required at the type level and at runtime (throw if options.repo.dir is unset — don't fall back to process.cwd()). At minimum, before running any destructive git command, verify the resolved dir either doesn't exist yet (safe to clone into) or its own git rev-parse --show-toplevel equals dir itself (i.e., it's not inside a different repo's working tree).

Bug 2 — finalize() (commit + push) is unconditional, no read-only mode
In sdk.js, localSession.finalize() is called at the end of every successful run and in the top-level .catch() handler on error — there's no option to skip the commit/push step:

// success path
if (localSession) {
try { localSession.finalize(); } catch { /* best-effort / }
}
...
// error path
.catch(async (err) => {
if (localSession) {
try { localSession.finalize(); } catch { /
best-effort */ }
}
...
finalize() → commitChanges() (no-op if nothing staged) then unconditionally push(). When session isn't passed, initLocalSession mints a fresh random branch (gitagent/session-<8-hex>) every call — meaning every single query() invocation with repo: set pushes a new branch to the real remote, even if the model made zero changes (tools disabled, pure Q&A). There's no way to run repo: mode read-only.

Suggested fix: Add an option like repo.readOnly or repo.autoPush: false that skips finalize()/push() entirely, for callers that only want gitagent to read an agent definition from a repo without mutating it.

Why this matters
The combination of these two makes repo: unsafe to wire into any server/request path (as opposed to a one-shot interactive CLI session) — which is a reasonable thing to want, since the SDK is marketed as embeddable ("mirrors the Claude Agent SDK pattern but runs in-process"). Right now the only safe pattern is: never omit dir, always pass a pinned session, and accept that every call still pushes to the remote regardless of intent. PS: Its wiped out my frontend..

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions