Skip to content
5 changes: 5 additions & 0 deletions .changeset/sandbox-git-readonly-classifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Stop asking to leave the sandbox for read-only git commands such as `git remote -v`, `git stash list`, and `git branch -v`.
87 changes: 71 additions & 16 deletions packages/opencode/src/kilocode/sandbox/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@ const MUTATING = new Set([
"update-index",
])

const BRANCH_READ = new Set([
"-a",
"-l",
"-r",
"-v",
"-vv",
"--all",
"--column",
"--contains",
"--format",
"--list",
"--merged",
"--no-merged",
"--points-at",
"--remotes",
"--show-current",
"--sort",
"--verbose",
])

const TAG_READ = new Set([
"-l",
"-n",
"-v",
"--column",
"--contains",
"--format",
"--list",
"--merged",
"--no-merged",
"--points-at",
"--sort",
"--verbose",
])

const REMOTE_READ = new Set(["get-url", "show"])
const REMOTE_FLAGS = new Set(["-v", "--verbose"])
const STASH_READ = new Set(["list", "show"])
const REFLOG_READ = new Set(["exists", "show"])
const NOTES_READ = new Set(["list", "show"])

function verb(values: string[]) {
return values.slice(1).find((value) => !value.startsWith("-"))
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
}

function args(text: string) {
const match = text
.trim()
Expand All @@ -70,24 +115,10 @@ export function mutates(text: string) {
const subcommand = values[0]?.toLowerCase()
if (!subcommand) return false
if (subcommand === "branch") {
return (
values.length > 1 &&
!values
.slice(1)
.some((value) =>
["-a", "-r", "-l", "--all", "--list", "--show-current", "--contains", "--merged", "--no-merged"].includes(
value,
),
)
)
return values.length > 1 && !values.slice(1).some((value) => BRANCH_READ.has(value))
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
}
if (subcommand === "tag") {
return (
values.length > 1 &&
!values
.slice(1)
.some((value) => ["-l", "--list", "--contains", "--points-at", "--merged", "--no-merged"].includes(value))
)
return values.length > 1 && !values.slice(1).some((value) => TAG_READ.has(value))
}
if (subcommand === "config") {
if (values.length === 1) return false
Expand All @@ -107,6 +138,30 @@ export function mutates(text: string) {
].includes(value),
)
}
if (subcommand === "remote") {
if (values.length === 1) return false
const name = verb(values)
if (name) return !REMOTE_READ.has(name)
return values.slice(1).some((value) => !REMOTE_FLAGS.has(value))
}
if (subcommand === "stash") {
const name = verb(values)
if (!name) return true
return !STASH_READ.has(name)
}
if (subcommand === "worktree") {
return verb(values) !== "list"
}
if (subcommand === "reflog") {
const name = verb(values)
if (!name) return false
return !REFLOG_READ.has(name)
}
if (subcommand === "notes") {
const name = verb(values)
if (!name) return false
return !NOTES_READ.has(name)
}
if (READONLY.has(subcommand)) return false
if (MUTATING.has(subcommand)) return true
return true
Expand Down
110 changes: 85 additions & 25 deletions packages/opencode/test/kilocode/sandbox/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,98 @@
import { describe, expect, test } from "bun:test"
import { mutates } from "@/kilocode/sandbox/git"

const read = [
"git status",
"git diff --cached",
"git log --oneline -5",
"git show HEAD:file.ts",
"git rev-parse --show-toplevel",
"git remote",
"git remote -v",
"git remote --verbose",
"git remote show origin",
"git remote get-url origin",
"git stash list",
"git stash show",
"git stash show -p",
"git branch",
"git branch -v",
"git branch -vv",
"git branch -a",
"git branch -r",
"git branch --list",
"git tag",
"git tag -l",
"git tag --list",
"git ls-remote",
"git ls-remote --heads origin",
"git config --get user.name",
"git config --list",
"git worktree list",
"git worktree list --porcelain",
"git reflog",
"git reflog show",
"git reflog exists HEAD",
"git notes",
"git notes list",
"git notes show HEAD",
"GIT_DIR=/repo/.git git status",
"GIT_INDEX_FILE=/tmp/index git diff",
]

const write = [
"git add src/index.ts",
"git commit -m message",
"git checkout -b feature",
"git merge main",
"git rebase main",
"git fetch origin",
"git pull",
"git push origin main",
"git clone https://example.com/repo.git",
"git remote add origin url",
"git remote set-url origin url",
"git remote remove origin",
"git remote rename origin upstream",
"git remote prune origin",
"git stash",
"git stash push",
"git stash pop",
"git stash drop",
"git stash apply",
"git stash clear",
"git branch feature",
"git branch -d feature",
"git branch -D feature",
"git branch -m old new",
"git branch --set-upstream-to origin/main",
"git tag v1.0.0",
"git tag -d v1.0.0",
"git tag -a v1.0.0 -m release",
"git config user.name Agent",
"git config --unset user.name",
"git worktree add ../wt",
"git worktree remove ../wt",
"git worktree prune",
"git reflog expire --all",
"git reflog delete HEAD@{0}",
"git notes add -m note",
"git notes remove HEAD",
"git -C /repo reset --hard HEAD",
"git unknown-subcommand",
"GIT_INDEX_FILE=/tmp/index git commit -m message",
"KILO_TEST=1 GIT_INDEX_FILE=/tmp/index git add src/index.ts",
]

describe("sandbox Git mutation classification", () => {
test("allows read-only Git commands to remain sandboxed", () => {
for (const command of [
"git status",
"git diff --cached",
"git log --oneline -5",
"git show HEAD:file.ts",
"git rev-parse --show-toplevel",
"git branch --all",
"git tag --list",
"git config --get user.name",
"GIT_DIR=/repo/.git git status",
"GIT_INDEX_FILE=/tmp/index git diff",
]) {
for (const command of read) {
expect(mutates(command)).toBe(false)
}
})

test("requires escalation for Git state mutations", () => {
for (const command of [
"git add src/index.ts",
"git commit -m message",
"git checkout -b feature",
"git merge main",
"git rebase main",
"git stash push",
"git -C /repo reset --hard HEAD",
"git config user.name Agent",
"git unknown-subcommand",
"GIT_INDEX_FILE=/tmp/index git commit -m message",
"KILO_TEST=1 GIT_INDEX_FILE=/tmp/index git add src/index.ts",
]) {
for (const command of write) {
expect(mutates(command)).toBe(true)
}
})
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/test/kilocode/tool/shell-unparsed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ describe("shell permission scanner fails closed on unparsed commands", () => {
expect(requests[1]?.metadata?.sandboxEscalation).toBe(true)
})

test("does not escalate read-only Git commands when the session sandbox is enabled", async () => {
await using tmp = await tmpdir({ git: true })
const requests = await scan(tmp.path, "git remote -v && git stash list && git branch -v", "bash", true)
expect(requests.map((request) => request.permission)).toEqual(["bash"])
})

test("pwsh: bare '--' git commands now produce a denied pattern", async () => {
await using tmp = await tmpdir()
for (const command of ["git checkout -- file", "git restore -- file", "git log -- file", "git checkout -- ."]) {
Expand Down
Loading