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`.
205 changes: 184 additions & 21 deletions packages/opencode/src/kilocode/sandbox/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,166 @@ const MUTATING = new Set([
"reset",
"restore",
"rm",
"stash",
"switch",
"update-index",
])

const BRANCH_WRITE = new Set([
"-c",
"-C",
"-d",
"-D",
"-f",
"-m",
"-M",
"-t",
"-u",
"--copy",
"--create-reflog",
"--delete",
"--edit-description",
"--force",
"--move",
"--set-upstream",
"--set-upstream-to",
"--track",
"--unset-upstream",
])

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

const BRANCH_CONSUME = new Set(["--contains", "--format", "--merged", "--no-merged", "--points-at", "--sort"])
const BRANCH_PATTERN = new Set(["-l", "--list"])

const TAG_WRITE = new Set([
"-F",
"-a",
"-d",
"-e",
"-f",
"-m",
"-s",
"-u",
"--annotate",
"--cleanup",
"--create-reflog",
"--delete",
"--edit",
"--file",
"--force",
"--local-user",
"--message",
"--sign",
])

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

const TAG_CONSUME = new Set([
"-v",
"--contains",
"--format",
"--merged",
"--no-merged",
"--points-at",
"--sort",
"--verify",
])
const TAG_PATTERN = new Set(["-l", "-n", "--list"])

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(["get-ref", "list", "show"])
const NOTES_FLAGS = new Set(["--ref"])

function flag(value: string) {
return value.split("=")[0]
}

function expand(values: string[]) {
return values.flatMap((value) => {
if (/^-[A-Za-z]{2,}$/.test(value))
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
return value
.slice(1)
.split("")
.map((char) => `-${char}`)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
if (/^-[A-Za-z]\d+$/.test(value)) return [value.slice(0, 2)]
return [value]
})
}

function strip(values: string[], consume: Set<string>) {
const out: string[] = []
let skip = false
for (const value of values) {
if (skip) {
skip = false
continue
}
out.push(value)
skip = consume.has(flag(value)) && !value.includes("=")
}
return out
}

function scan(values: string[], write: Set<string>, read: Set<string>, consume: Set<string>, pattern: Set<string>) {
if (values.length === 1) return false
const tokens = expand(values.slice(1))
if (tokens.some((token) => write.has(flag(token)))) return true
const rest = strip(tokens, consume)
if (!rest.some((token) => read.has(flag(token)))) return true
if (rest.some((token) => pattern.has(flag(token)))) return false
return rest.some((token) => !token.startsWith("-"))
}

function verb(values: string[], takes: Set<string> = new Set()) {
let skip = false
for (const value of values.slice(1)) {
if (skip) {
skip = false
continue
}
if (value.startsWith("-")) {
skip = takes.has(flag(value)) && !value.includes("=")
continue
}
return value
}
return
}

function args(text: string) {
const match = text
.trim()
Expand All @@ -69,26 +224,8 @@ 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,
),
)
)
}
if (subcommand === "tag") {
return (
values.length > 1 &&
!values
.slice(1)
.some((value) => ["-l", "--list", "--contains", "--points-at", "--merged", "--no-merged"].includes(value))
)
}
if (subcommand === "branch") return scan(values, BRANCH_WRITE, BRANCH_READ, BRANCH_CONSUME, BRANCH_PATTERN)
if (subcommand === "tag") return scan(values, TAG_WRITE, TAG_READ, TAG_CONSUME, TAG_PATTERN)
if (subcommand === "config") {
if (values.length === 1) return false
return !values
Expand All @@ -107,6 +244,32 @@ 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 rest = values.slice(1)
if (rest.some((value) => value === "-m" || value.startsWith("--message"))) return true
const name = rest.find((value) => !value.startsWith("-"))
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, NOTES_FLAGS)
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
135 changes: 110 additions & 25 deletions packages/opencode/test/kilocode/sandbox/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,123 @@
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 branch --list feature*",
"git branch -av",
"git branch -avv",
"git tag",
"git tag -l",
"git tag --list",
"git tag -n5",
"git tag -ln",
"git tag --format %(refname)",
"git tag --points-at HEAD",
"git tag --verify v1.0.0",
"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 notes get-ref",
"git notes --ref notes list",
"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 stash -m list",
"git stash --message show",
"git branch feature",
"git branch -d feature",
"git branch -D feature",
"git branch -d feature -v",
"git branch -d feature --column",
"git branch -d feature --format x",
"git branch -D feature -v",
"git branch -v -d feature",
"git branch --sort=-committerdate -D feature",
"git branch foo -v",
"git branch -m -v old new",
"git branch -m old new",
"git branch --set-upstream-to origin/main",
"git branch --set-upstream-to=origin/main",
"git tag v1.0.0",
"git tag -d v1.0.0",
"git tag -d v1 --column",
"git tag -d v1 --format x",
"git tag -n -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 notes --ref list add -m note 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