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..
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 mandatorysession?: 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..