Skip to content

feat: restore command sandbox preview and improve unread attention - #286

Merged
alwaysmavs merged 3 commits into
mainfrom
feat/command-sandbox-preview
Jul 28, 2026
Merged

feat: restore command sandbox preview and improve unread attention#286
alwaysmavs merged 3 commits into
mainfrom
feat/command-sandbox-preview

Conversation

@alwaysmavs

Copy link
Copy Markdown
Contributor

Summary

  • restore the macOS Command Sandbox preview on top of the latest main
  • isolate OpenCode Bash commands with signed per-session policies, a managed home, an allowlisted environment, process-tree cleanup, and Wanta-owned network enforcement
  • keep Default Access sandboxed on macOS while Full Access runs directly for the current session
  • make unread task results clear reliably when users select the current conversation or return focus to Wanta
  • expose cross-team unread state in the workspace switcher so the global Dock badge has a discoverable source

Context

PR #281 introduced the Command Sandbox preview and was reverted by PR #285 after the rollout appeared to make the product unusable. This branch restores the sandbox implementation on top of the current main branch for continued validation, together with the unread-attention fixes found during manual testing.

The unread issue had two related causes. The Dock badge counted unread tasks globally, while the sidebar only rendered sessions from the active team, leaving no visible route to an unread task in another team. Separately, selecting a conversation did not directly acknowledge it as viewed; clearing depended on a React visibility effect and matching renderer/main-process focus state, so reselecting an already-active conversation could leave its blue dot stuck.

Changes

Command Sandbox

  • package and pin the macOS sandbox runtime behind a Wanta-owned command-shell adapter
  • generate authenticated, per-session policies outside sandbox-writable roots
  • scrub the command environment and keep the credential-bearing OpenCode sidecar outside the command sandbox
  • enforce project, attachment, managed-home, network, and process-tree boundaries
  • preserve native OpenCode command parsing and permission evaluation through the configured-shell seam
  • expose the preview state in Settings and retain the existing Default Access / Full Access control

Unread attention

  • acknowledge a session immediately whenever the user selects it, including repeated selection of the already-active session
  • retry the visible-session acknowledgement when the Electron window receives focus
  • publish unique unread team IDs alongside unread session IDs
  • show a blue unread marker on the workspace switcher and on the specific teams that contain unread tasks
  • roll back the in-memory removal when attention-state persistence fails
  • add structured diagnostics for unread additions and removals without recording message content, commands, paths, or environment data
  • add localized accessible copy and regression coverage for unread-team aggregation

Verification

  • corepack pnpm run lint
  • corepack pnpm run format
  • corepack pnpm run ts-check
  • corepack pnpm test (287 files, 2144 tests)
  • corepack pnpm run build
  • git diff --check

The Electron development build was also launched from this branch. The renderer, main process, preload, command-shell adapter, and OpenCode sidecar started successfully.

Known external observation

Manual testing still observed TLS connection resets when reaching llm.oomol.com and connector.oomol.com. Those network failures are not hidden by this PR and remain separately diagnosable from the command-sandbox and unread-attention changes.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR introduces a macOS-only Command Sandbox Preview with authenticated policies, restricted environments, process cleanup, private-network approval, HTTP/SOCKS5 proxying, OpenCode shell integration, and end-to-end verification. Permission modes now synchronize with sandbox execution. Runtime capabilities and settings expose sandbox availability, while full-access confirmation UI is removed. Attention state now aggregates unread teams and displays indicators in workspace navigation. Documentation and packaging are updated accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatService
  participant AgentManager
  participant OpenCode
  participant CommandSandbox
  User->>ChatService: select permission mode or send message
  ChatService->>AgentManager: update sandbox policy
  AgentManager->>OpenCode: configure shell and plugin
  OpenCode->>CommandSandbox: execute shell command
  CommandSandbox->>AgentManager: request private-network approval
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it omits the required Safety and Compatibility section from the template. Add the Safety and Compatibility checklist items, including BYOK/OOMOL consideration, credential exposure review, tool/prompt alignment, packaging implications, and updated docs/tests.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main change and uses the required English commit-style format.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/command-sandbox-preview

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

@alwaysmavs
alwaysmavs marked this pull request as ready for review July 28, 2026 14:52
@alwaysmavs
alwaysmavs merged commit c95b509 into main Jul 28, 2026
2 of 3 checks passed
@alwaysmavs
alwaysmavs deleted the feat/command-sandbox-preview branch July 28, 2026 15:04

@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: 11

🧹 Nitpick comments (7)
electron/chat/node.ts (1)

950-956: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Detaching the method reference risks binding to a swapped agent.

updatePolicy is captured from this.agent, but the later updatePolicy.call(this.agent, …) re-reads this.agent; if setAgent() runs in between (sign-out/runtime restart), the old method is invoked against the new manager instance. A direct optional call keeps the receiver and the guard consistent:

♻️ Proposed simplification
-    const updatePolicy = this.agent?.updateCommandSandboxPolicy
-    if (!updatePolicy) return
+    const agent = this.agent
+    if (!agent?.updateCommandSandboxPolicy) return
@@
-    await updatePolicy.call(this.agent, {
+    await agent.updateCommandSandboxPolicy({
🤖 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 `@electron/chat/node.ts` around lines 950 - 956, Update the policy update flow
around updateCommandSandboxPolicy to avoid storing the method separately from
its receiver. Guard and invoke this.agent?.updateCommandSandboxPolicy directly
so the method and bound agent instance remain consistent if setAgent() changes
the agent before the call.
scripts/verify-command-sandbox.ts (1)

31-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fail fast on unsupported platforms and missing probe prerequisites.

The script hard-codes /bin/zsh and depends on curl and nc (lines 47-48); on Linux/Windows the runtime throws "Command Sandbox (Preview) is currently available only on macOS" deep inside a spawned child, surfacing as an opaque non-zero exit. An explicit process.platform !== "darwin" guard plus an existence check for the built wrapper (dist-electron/wanta-command-shell.js) would make the failure self-explanatory.

🤖 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 `@scripts/verify-command-sandbox.ts` around lines 31 - 61, Update the
verification script before creating or running the sandbox probe to exit clearly
when process.platform is not "darwin", and validate that the built wrapper at
dist-electron/wanta-command-shell.js exists before use. Report actionable
messages for either unsupported platforms or missing wrapper prerequisites,
while preserving the existing macOS probe flow and dependency checks.
electron/agent/manager.ts (1)

1302-1305: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Per-session policy, grant, and home directories are never reclaimed.

dispose closes the broker but leaves command-sandbox/{policies,network-grants,home} entries behind, and commandSandboxPolicyInputs grows for the process lifetime. Deleted sessions in particular keep a signed policy plus a managed home dir on disk indefinitely. A cleanup hook alongside the existing session-forget path would keep this bounded.

🤖 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 `@electron/agent/manager.ts` around lines 1302 - 1305, Extend the session
disposal flow around the async cleanup that closes commandSandboxBroker to also
reclaim that session’s command-sandbox policy, network-grant, and home
directories, and remove its entry from commandSandboxPolicyInputs. Reuse the
existing session-forget cleanup path or helper if available, ensuring cleanup
runs for deleted sessions and preserves broker shutdown behavior.
package.json (1)

48-49: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Scope @vscode/sandbox-runtime to macOS builds only. It’s only used behind the darwin gate, so shipping it in Windows/Linux artifacts is unnecessary.

🤖 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 `@package.json` around lines 48 - 49, Update the `@vscode/sandbox-runtime`
dependency declaration in package.json to be included only for macOS (darwin)
builds, using the package manager’s platform-specific dependency configuration.
Keep the dependency version unchanged and leave `@pondwader/socks5-server`
unaffected.
electron/agent/command-sandbox/network-proxy.ts (2)

49-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

CONNECT and SOCKS5 tunnel handling duplicate the same pipe/cleanup logic.

handleConnect and the SOCKS5 setConnectionHandler callback both establish an upstream Duplex, pipe it bidirectionally, and wire the same close/error teardown. A future fix (e.g., the timeout above) has to be applied twice, and it's easy for the two paths to drift.

♻️ Suggested extraction
+function pipeTunnel(client: Duplex, upstream: Duplex): void {
+  upstream.pipe(client)
+  client.pipe(upstream)
+  client.once("close", () => upstream.destroy())
+  client.once("error", () => upstream.destroy())
+  upstream.once("error", () => client.destroy())
+}

Call pipeTunnel(client, upstream) from both handleConnect and the SOCKS5 connection handler (using connection.socket as client).

Also applies to: 87-122

🤖 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 `@electron/agent/command-sandbox/network-proxy.ts` around lines 49 - 69,
Extract the duplicated bidirectional piping and teardown logic into a shared
pipeTunnel(client, upstream) helper. Replace the inline piping and close/error
cleanup in both handleConnect and the SOCKS5 setConnectionHandler success path
with calls to pipeTunnel, passing connection.socket for the SOCKS5 client while
preserving the existing status and failure handling.

376-390: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid depending on createSocksServer()’s internal .server field. listenSocksServer reaches into a non-public wrapper to read the bound port, so this helper is brittle across @pondwader/socks5-server upgrades. Keep the cast isolated here or switch to a documented accessor if one is added.

🤖 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 `@electron/agent/command-sandbox/network-proxy.ts` around lines 376 - 390,
Update listenSocksServer so its dependency on createSocksServer’s non-public
server field is isolated to this helper, preferably through a single localized
typed adapter/accessor; use a documented accessor instead if the library
provides one. Keep the existing error handling and bound-port resolution
behavior unchanged.
electron/agent/command-sandbox/plugin.ts (1)

6-20: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No error handling around the session-walk in shell.env.

If client.session.get (Line 13) rejects or throws for any reason (transient IPC hiccup, etc.), the whole "shell.env" hook throws with no fallback, and downstream runtime.ts will fail the command outright (its requireEnvironment treats a missing WANTA_COMMAND_SANDBOX_SESSION_ID as "shell path is not supported"). Consider wrapping the walk in try/catch and falling back to input.sessionID on failure.

How does OpenCode's plugin system handle an exception thrown from a "shell.env" hook — does it fail the shell command, or ignore the hook and continue?
🤖 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 `@electron/agent/command-sandbox/plugin.ts` around lines 6 - 20, Update the
"shell.env" hook in the default exported plugin to catch failures from the
session-walk, including client.session.get, and fall back to input.sessionID as
the policy session ID before setting WANTA_COMMAND_SANDBOX_SESSION_ID. Preserve
the existing parent traversal and call ID assignment when lookups succeed.
🤖 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 `@electron/agent/command-sandbox/network-address.ts`:
- Around line 123-127: Extract and export a shared grant-matching helper from
electron/agent/command-sandbox/network-address.ts, replacing the local
hasPrivateGrant predicate at lines 123-127 while preserving address
normalization, wildcard ports, and exact port matching. In
electron/agent/command-sandbox/broker.ts lines 171-176, replace hasGrant with an
import/use of that shared helper and remove the duplicate local implementation.

In `@electron/agent/command-sandbox/network-proxy.ts`:
- Around line 202-286: Add explicit timeouts to all outbound connection and
proxy negotiation paths: the direct socket created in connectTarget, both socket
variants in connectProxy, and the response wait managed by
waitForConnectResponse. Reuse the existing timeout pattern or shared timeout
configuration, and ensure expiration cleans up listeners/sockets and rejects
with an appropriate timeout error instead of leaving the sandboxed command
hanging.
- Around line 34-48: Require authentication on both listeners created in the
network proxy setup: configure createSocksServer to validate the existing
session secret through SOCKS5 auth, and update the HTTP request and CONNECT
handling around handleHttpRequest and handleConnect to require a valid
Proxy-Authorization header using that same secret. Reject unauthenticated
connections before invoking resolve or forwarding requests.

In `@electron/agent/command-sandbox/policy.test.ts`:
- Around line 43-75: Update both environment-mode tests around
buildCommandSandboxEnvironment and buildDirectCommandEnvironment to include
OPENCODE_CONFIG_CONTENT, OPENCODE_SERVER_PASSWORD, OO_API_KEY, and
WANTA_BROWSER_CONTROL_TOKEN as credential sentinels in each input. Assert every
listed credential is undefined in both resulting environments, while preserving
the existing non-credential environment assertions and secret-persistence check.

In `@electron/agent/command-sandbox/runtime.ts`:
- Around line 217-243: Update spawnCommand to avoid installing per-command
SIGINT and SIGTERM listeners on the shared process object; route signals through
a centralized, concurrency-safe mechanism that tracks active child processes and
forwards each signal only to its intended command. Ensure command cleanup
removes its tracking state on exit or error without affecting other concurrent
sandboxed commands.

In `@electron/agent/command-sandbox/shell-bin.ts`:
- Around line 14-30: Update ensureCommandSandboxShellBin in
electron/agent/command-sandbox/shell-bin.ts:14-30 to write the generated shell
script to a temporary file within binDir, apply chmod, then atomically rename it
over commandPath. Apply the same temp-file, chmod, and rename pattern to
pluginPath in electron/agent/command-sandbox/plugin.ts:24-30; do not write
either fixed path directly.

In `@electron/agent/manager.ts`:
- Around line 455-477: The updateCommandSandboxPolicy method must serialize the
complete per-session read, merge, broker synchronization, and write through a
per-session promise chain, following the existing queueTeamUpdate pattern so
overlapping callers cannot lose fields or persist stale policies. Also update
the broker setSession condition to register or refresh the session when
private-network grants require it even if next.userMessage is absent, while
preserving the existing reviewer context behavior when a user message exists.
- Around line 602-634: Ensure commandSandboxRuntimeReadPaths is initialized
before any updateCommandSandboxPolicy call, including startup and runtime
recovery, using rootDir, cliPath, process.execPath, and resolved PATH
directories; otherwise defer policy writes until populated. In
prepareCommandSandbox, preserve or refresh the same paths consistently. Validate
that process.env.SHELL is an absolute executable before selecting it as
delegateShell, falling back to /bin/zsh when invalid.

In `@patches/`@vscode__sandbox-runtime@0.0.1.patch:
- Around line 18-20: The allowLoopbackOutbound rule in generateSandboxProfile
currently permits access to every localhost port; restrict it to
localhost:httpProxyPort and localhost:socksProxyPort only. Preserve the intended
proxy egress while removing blanket loopback access, using the existing in-scope
proxy port values when constructing the network-outbound rules.

In `@scripts/verify-command-sandbox.ts`:
- Around line 85-107: Update probeNestedControlDirectory so store.write receives
an allowedRoot that does not contain or overlap the policy control directory,
preventing the expected overlap error from short-circuiting the probe. Preserve
the existing spawned read-denial check and ensure the function reaches run to
verify sandbox enforcement rather than merely validating store.write rejection.
- Around line 62-71: Replace the fixed 1.4-second delay before constructing
assertions in the verification flow with bounded polling for detachedMarker.
Ensure the probe waits until the marker appears or the timeout expires, then
tears down the process group and verifies detachedMarker never appears
afterward; preserve detachedReaped as a failure if the marker was written before
teardown.

---

Nitpick comments:
In `@electron/agent/command-sandbox/network-proxy.ts`:
- Around line 49-69: Extract the duplicated bidirectional piping and teardown
logic into a shared pipeTunnel(client, upstream) helper. Replace the inline
piping and close/error cleanup in both handleConnect and the SOCKS5
setConnectionHandler success path with calls to pipeTunnel, passing
connection.socket for the SOCKS5 client while preserving the existing status and
failure handling.
- Around line 376-390: Update listenSocksServer so its dependency on
createSocksServer’s non-public server field is isolated to this helper,
preferably through a single localized typed adapter/accessor; use a documented
accessor instead if the library provides one. Keep the existing error handling
and bound-port resolution behavior unchanged.

In `@electron/agent/command-sandbox/plugin.ts`:
- Around line 6-20: Update the "shell.env" hook in the default exported plugin
to catch failures from the session-walk, including client.session.get, and fall
back to input.sessionID as the policy session ID before setting
WANTA_COMMAND_SANDBOX_SESSION_ID. Preserve the existing parent traversal and
call ID assignment when lookups succeed.

In `@electron/agent/manager.ts`:
- Around line 1302-1305: Extend the session disposal flow around the async
cleanup that closes commandSandboxBroker to also reclaim that session’s
command-sandbox policy, network-grant, and home directories, and remove its
entry from commandSandboxPolicyInputs. Reuse the existing session-forget cleanup
path or helper if available, ensuring cleanup runs for deleted sessions and
preserves broker shutdown behavior.

In `@electron/chat/node.ts`:
- Around line 950-956: Update the policy update flow around
updateCommandSandboxPolicy to avoid storing the method separately from its
receiver. Guard and invoke this.agent?.updateCommandSandboxPolicy directly so
the method and bound agent instance remain consistent if setAgent() changes the
agent before the call.

In `@package.json`:
- Around line 48-49: Update the `@vscode/sandbox-runtime` dependency declaration
in package.json to be included only for macOS (darwin) builds, using the package
manager’s platform-specific dependency configuration. Keep the dependency
version unchanged and leave `@pondwader/socks5-server` unaffected.

In `@scripts/verify-command-sandbox.ts`:
- Around line 31-61: Update the verification script before creating or running
the sandbox probe to exit clearly when process.platform is not "darwin", and
validate that the built wrapper at dist-electron/wanta-command-shell.js exists
before use. Report actionable messages for either unsupported platforms or
missing wrapper prerequisites, while preserving the existing macOS probe flow
and dependency checks.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 55d3662a-fa80-431a-834b-444f64082e05

📥 Commits

Reviewing files that changed from the base of the PR and between feb5bf8 and ecd8615.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (50)
  • AGENTS.md
  • docs/ai/README.md
  • docs/ai/command-sandbox-implementation.md
  • docs/architecture.md
  • docs/conventions.md
  • docs/key-decisions.md
  • electron-builder.ts
  • electron/agent/command-sandbox-shell.ts
  • electron/agent/command-sandbox/broker.test.ts
  • electron/agent/command-sandbox/broker.ts
  • electron/agent/command-sandbox/network-address.test.ts
  • electron/agent/command-sandbox/network-address.ts
  • electron/agent/command-sandbox/network-proxy.test.ts
  • electron/agent/command-sandbox/network-proxy.ts
  • electron/agent/command-sandbox/plugin.ts
  • electron/agent/command-sandbox/policy-reviewer.test.ts
  • electron/agent/command-sandbox/policy-reviewer.ts
  • electron/agent/command-sandbox/policy.test.ts
  • electron/agent/command-sandbox/policy.ts
  • electron/agent/command-sandbox/runtime.ts
  • electron/agent/command-sandbox/shell-bin.test.ts
  • electron/agent/command-sandbox/shell-bin.ts
  • electron/agent/config.ts
  • electron/agent/manager.ts
  • electron/attention/common.ts
  • electron/attention/node.ts
  • electron/attention/policy.test.ts
  • electron/attention/policy.ts
  • electron/chat/node.test.ts
  • electron/chat/node.ts
  • electron/chat/trusted-local-access.test.ts
  • electron/chat/trusted-local-access.ts
  • electron/main.ts
  • electron/runtime/common.test.ts
  • electron/runtime/common.ts
  • package.json
  • patches/@vscode__sandbox-runtime@0.0.1.patch
  • scripts/verify-command-sandbox.ts
  • src/components/app-shell/AppShell.tsx
  • src/components/app-shell/AppShellNavigationSidebar.tsx
  • src/components/app-shell/SidebarAccountControls.tsx
  • src/hooks/useAttention.ts
  • src/i18n/app-messages.en.ts
  • src/i18n/app-messages.zh.ts
  • src/i18n/i18n.test.ts
  • src/routes/Chat/FullAccessConfirmDialog.tsx
  • src/routes/Chat/PermissionModePicker.tsx
  • src/routes/Chat/index.tsx
  • src/routes/Settings/index.tsx
  • vite.config.ts
💤 Files with no reviewable changes (1)
  • src/routes/Chat/FullAccessConfirmDialog.tsx

Comment on lines +123 to +127
function hasPrivateGrant(grants: readonly PrivateNetworkGrant[], address: string, port: number): boolean {
return grants.some(
(grant) => normalizeAddress(grant.address) === address && (grant.port === undefined || grant.port === port),
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Extract a shared private-network grant-matching helper. Both files independently implement the identical "does an existing grant cover this address+port" predicate. Since one enforces actual connections and the other gates whether the broker re-runs LLM review, letting these diverge could desync the broker's cached "already granted" state from what connections actually enforce.

  • electron/agent/command-sandbox/network-address.ts#L123-L127: replace hasPrivateGrant with a call to a shared, exported grant-matching helper.
  • electron/agent/command-sandbox/broker.ts#L171-L176: replace hasGrant with the same shared helper (e.g. re-export/import from network-address.ts) instead of a local reimplementation.
📍 Affects 2 files
  • electron/agent/command-sandbox/network-address.ts#L123-L127 (this comment)
  • electron/agent/command-sandbox/broker.ts#L171-L176
🤖 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 `@electron/agent/command-sandbox/network-address.ts` around lines 123 - 127,
Extract and export a shared grant-matching helper from
electron/agent/command-sandbox/network-address.ts, replacing the local
hasPrivateGrant predicate at lines 123-127 while preserving address
normalization, wildcard ports, and exact port matching. In
electron/agent/command-sandbox/broker.ts lines 171-176, replace hasGrant with an
import/use of that shared helper and remove the duplicate local implementation.

Comment on lines +34 to +48
const httpServer = createServer()
httpServer.on("connect", (request, client, head) => {
void handleConnect(request.url, client, head, resolve, options.upstreamProxy)
})
httpServer.on("request", (request, response) => {
void handleHttpRequest(request, response, resolve, options.upstreamProxy)
})
const httpPort = await listenHttpServer(httpServer)

const socksServer = createSocksServer()
socksServer.setRulesetValidator(async (connection) => {
const target = await resolve(connection.destAddress, connection.destPort)
connection.metadata = { target }
return target !== null
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does @pondwader/socks5-serverenforce theauth option before setRulesetValidator runs, or can rulesets be bypassed without authenticating?

💡 Result:

Based on the provided documentation for @pondwader/socks5-server, the library handles the SOCKS5 protocol and provides mechanisms for both authentication and connection validation (rulesets) [1][2]. The authentication option (passed via the auth object in createServer or via setAuthHandler) is a fundamental part of the SOCKS5 handshake [1][2]. In a standard SOCKS5 implementation, the client must successfully complete the authentication phase before it can send a connection request (which includes the destination address and port) [3]. The setRulesetValidator function is specifically designed to validate these connection requests (e.g., checking destAddress and destPort) [1][2]. Because the SOCKS5 protocol requires the authentication handshake to precede the connection request, authentication is enforced before the server processes the connection request, and therefore before any ruleset validation logic is executed [1][2][3]. There is no evidence in the provided documentation that rulesets can be bypassed without authenticating. If authentication fails, the connection is rejected by the server during the handshake phase, preventing the client from reaching the state where a connection request—and thus ruleset validation—would occur [1][2][3]. Users have granular control, but they must implement the handlers correctly to maintain security [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file structure first
ast-grep outline electron/agent/command-sandbox/network-proxy.ts --view expanded || true

echo
echo "---- relevant file slice ----"
sed -n '1,160p' electron/agent/command-sandbox/network-proxy.ts

echo
echo "---- auth references ----"
rg -n "WANTA_COMMAND_SANDBOX_AUTH|Proxy-Authorization|createSocksServer|auth:" electron/agent/command-sandbox/network-proxy.ts electron/agent/command-sandbox -S

Repository: oomol-lab/wanta

Length of output: 9022


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- usages of network proxies ----"
rg -n "startCommandSandboxNetworkProxies|httpPort|socksPort|WANTA_COMMAND_SANDBOX_AUTH|Proxy-Authorization" electron/agent/command-sandbox -S

echo
echo "---- runtime slice around auth/env propagation ----"
sed -n '1,140p' electron/agent/command-sandbox/runtime.ts

echo
echo "---- any proxy auth helper in this file ----"
sed -n '200,260p' electron/agent/command-sandbox/network-proxy.ts

Repository: oomol-lab/wanta

Length of output: 10024


Require auth on both local proxy listeners.
Any local process can connect to these ports and reuse this sandbox’s approved network access. Gate SOCKS5 with auth and require Proxy-Authorization on the HTTP/CONNECT path using the existing session secret.

🤖 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 `@electron/agent/command-sandbox/network-proxy.ts` around lines 34 - 48,
Require authentication on both listeners created in the network proxy setup:
configure createSocksServer to validate the existing session secret through
SOCKS5 auth, and update the HTTP request and CONNECT handling around
handleHttpRequest and handleConnect to require a valid Proxy-Authorization
header using that same secret. Reject unauthenticated connections before
invoking resolve or forwarding requests.

Comment on lines +202 to +286
async function connectTarget(
target: ApprovedNetworkTarget,
configuration: UpstreamProxyConfiguration | undefined,
preferHttps: boolean,
): Promise<Duplex> {
const parentProxy = selectUpstreamProxy(configuration, target, preferHttps)
if (!parentProxy) {
return new Promise((resolve, reject) => {
const socket = connect({ host: target.address, port: target.port, family: target.family })
socket.once("connect", () => resolve(socket))
socket.once("error", reject)
})
}
const proxySocket = await connectProxy(parentProxy)
const destination = `${formatHost(target.address)}:${target.port}`
const authorization = proxyAuthorizationHeader(parentProxy)
proxySocket.write(
[
`CONNECT ${destination} HTTP/1.1`,
`Host: ${destination}`,
...(authorization ? [`Proxy-Authorization: ${authorization}`] : []),
"Connection: keep-alive",
"",
"",
].join("\r\n"),
)
return await waitForConnectResponse(proxySocket)
}

function connectProxy(proxy: URL): Promise<Duplex> {
return new Promise((resolve, reject) => {
if (proxy.protocol === "https:") {
const socket = connectTls({ host: proxy.hostname, port: proxyPort(proxy), servername: proxy.hostname })
socket.once("secureConnect", () => resolve(socket))
socket.once("error", reject)
return
}
const socket = connect({ host: proxy.hostname, port: proxyPort(proxy) })
socket.once("connect", () => resolve(socket))
socket.once("error", reject)
})
}

function waitForConnectResponse(socket: Duplex): Promise<Duplex> {
return new Promise((resolve, reject) => {
let response = Buffer.alloc(0)
const onData = (chunk: Buffer) => {
response = Buffer.concat([response, chunk])
if (response.length > 16_384) {
cleanup()
socket.destroy()
reject(new Error("Upstream proxy response is too large."))
return
}
const headerEnd = response.indexOf("\r\n\r\n")
if (headerEnd === -1) return
cleanup()
const statusLine = response.subarray(0, response.indexOf("\r\n")).toString("ascii")
if (!/^HTTP\/1\.[01] 2\d\d(?: |$)/u.test(statusLine)) {
socket.destroy()
reject(new Error("Upstream proxy rejected the connection."))
return
}
const remaining = response.subarray(headerEnd + 4)
if (remaining.length > 0) socket.unshift(remaining)
resolve(socket)
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onClose = () => {
cleanup()
reject(new Error("Upstream proxy closed before establishing the tunnel."))
}
const cleanup = () => {
socket.off("data", onData)
socket.off("error", onError)
socket.off("close", onClose)
}
socket.on("data", onData)
socket.once("error", onError)
socket.once("close", onClose)
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeouts on outbound connect / upstream CONNECT negotiation.

connect() in connectTarget (Line 210), connectTls/connect in connectProxy (234-241), and the data/error/close wait in waitForConnectResponse (245-286) all rely solely on the OS/library default timeout (which for TCP connect() can be very long). A slow or unresponsive target or upstream proxy will hang the sandboxed command's network call indefinitely, blocking the whole command.

🕒 Suggested fix
 function connectProxy(proxy: URL): Promise<Duplex> {
   return new Promise((resolve, reject) => {
     if (proxy.protocol === "https:") {
       const socket = connectTls({ host: proxy.hostname, port: proxyPort(proxy), servername: proxy.hostname })
+      socket.setTimeout(10_000, () => socket.destroy(new Error("Timed out connecting to upstream proxy.")))
       socket.once("secureConnect", () => resolve(socket))
       socket.once("error", reject)
       return
     }
     const socket = connect({ host: proxy.hostname, port: proxyPort(proxy) })
+    socket.setTimeout(10_000, () => socket.destroy(new Error("Timed out connecting to upstream proxy.")))
     socket.once("connect", () => resolve(socket))
     socket.once("error", reject)
   })
 }

Apply the same pattern to the direct-connect branch in connectTarget and to the response wait in waitForConnectResponse.

🤖 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 `@electron/agent/command-sandbox/network-proxy.ts` around lines 202 - 286, Add
explicit timeouts to all outbound connection and proxy negotiation paths: the
direct socket created in connectTarget, both socket variants in connectProxy,
and the response wait managed by waitForConnectResponse. Reuse the existing
timeout pattern or shared timeout configuration, and ensure expiration cleans up
listeners/sockets and rejects with an appropriate timeout error instead of
leaving the sandboxed command hanging.

Comment on lines +43 to +75
const environment = await buildCommandSandboxEnvironment(policy, {
LANG: "en_US.UTF-8",
PATH: "/usr/bin:/bin",
OO_API_KEY: "secret",
WANTA_BROWSER_CONTROL_TOKEN: "secret",
WANTA_NODE_BIN: "/Applications/Wanta.app/Contents/MacOS/Wanta",
})

expect(environment.HOME).toBe(policy.homeDir)
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.LANG).toBe("en_US.UTF-8")
expect(environment.WANTA_NODE_BIN).toContain("Wanta")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
expect(await readFile(path.join(store.pathForSession("session-1")), "utf8")).not.toContain("secret")
})

it("builds a direct environment with the real home without sidecar credentials", () => {
const environment = buildDirectCommandEnvironment({
HOME: "/managed/home",
HTTP_PROXY: "http://127.0.0.1:7890",
OO_API_KEY: "secret",
OPENCODE_SERVER_PASSWORD: "secret",
PATH: "/usr/bin:/bin",
SSH_AUTH_SOCK: "/tmp/ssh-agent",
})

expect(environment.HOME).toBe(os.homedir())
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.HTTP_PROXY).toBe("http://127.0.0.1:7890")
expect(environment.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cover every sidecar credential carrier in both environment modes.

OPENCODE_CONFIG_CONTENT carries injected provider credentials, but neither test asserts it is stripped. The sandbox test also omits OPENCODE_SERVER_PASSWORD; the direct test omits WANTA_BROWSER_CONTROL_TOKEN. Add all sentinels to both inputs and assert each is absent.

Proposed test additions
     const environment = await buildCommandSandboxEnvironment(policy, {
       LANG: "en_US.UTF-8",
       PATH: "/usr/bin:/bin",
       OO_API_KEY: "secret",
       WANTA_BROWSER_CONTROL_TOKEN: "secret",
+      OPENCODE_CONFIG_CONTENT: '{"apiKey":"secret"}',
+      OPENCODE_SERVER_PASSWORD: "secret",
       WANTA_NODE_BIN: "/Applications/Wanta.app/Contents/MacOS/Wanta",
     })
@@
     expect(environment.OO_API_KEY).toBeUndefined()
     expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
+    expect(environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
+    expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
@@
       OO_API_KEY: "secret",
+      WANTA_BROWSER_CONTROL_TOKEN: "secret",
+      OPENCODE_CONFIG_CONTENT: '{"apiKey":"secret"}',
       OPENCODE_SERVER_PASSWORD: "secret",
@@
     expect(environment.OO_API_KEY).toBeUndefined()
+    expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
+    expect(environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
     expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const environment = await buildCommandSandboxEnvironment(policy, {
LANG: "en_US.UTF-8",
PATH: "/usr/bin:/bin",
OO_API_KEY: "secret",
WANTA_BROWSER_CONTROL_TOKEN: "secret",
WANTA_NODE_BIN: "/Applications/Wanta.app/Contents/MacOS/Wanta",
})
expect(environment.HOME).toBe(policy.homeDir)
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.LANG).toBe("en_US.UTF-8")
expect(environment.WANTA_NODE_BIN).toContain("Wanta")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
expect(await readFile(path.join(store.pathForSession("session-1")), "utf8")).not.toContain("secret")
})
it("builds a direct environment with the real home without sidecar credentials", () => {
const environment = buildDirectCommandEnvironment({
HOME: "/managed/home",
HTTP_PROXY: "http://127.0.0.1:7890",
OO_API_KEY: "secret",
OPENCODE_SERVER_PASSWORD: "secret",
PATH: "/usr/bin:/bin",
SSH_AUTH_SOCK: "/tmp/ssh-agent",
})
expect(environment.HOME).toBe(os.homedir())
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.HTTP_PROXY).toBe("http://127.0.0.1:7890")
expect(environment.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
const environment = await buildCommandSandboxEnvironment(policy, {
LANG: "en_US.UTF-8",
PATH: "/usr/bin:/bin",
OO_API_KEY: "secret",
WANTA_BROWSER_CONTROL_TOKEN: "secret",
OPENCODE_CONFIG_CONTENT: '{"apiKey":"secret"}',
OPENCODE_SERVER_PASSWORD: "secret",
WANTA_NODE_BIN: "/Applications/Wanta.app/Contents/MacOS/Wanta",
})
expect(environment.HOME).toBe(policy.homeDir)
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.LANG).toBe("en_US.UTF-8")
expect(environment.WANTA_NODE_BIN).toContain("Wanta")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
expect(environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
expect(await readFile(path.join(store.pathForSession("session-1")), "utf8")).not.toContain("secret")
})
it("builds a direct environment with the real home without sidecar credentials", () => {
const environment = buildDirectCommandEnvironment({
HOME: "/managed/home",
HTTP_PROXY: "http://127.0.0.1:7890",
OO_API_KEY: "secret",
WANTA_BROWSER_CONTROL_TOKEN: "secret",
OPENCODE_CONFIG_CONTENT: '{"apiKey":"secret"}',
OPENCODE_SERVER_PASSWORD: "secret",
PATH: "/usr/bin:/bin",
SSH_AUTH_SOCK: "/tmp/ssh-agent",
})
expect(environment.HOME).toBe(os.homedir())
expect(environment.PATH).toBe("/usr/bin:/bin")
expect(environment.HTTP_PROXY).toBe("http://127.0.0.1:7890")
expect(environment.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent")
expect(environment.OO_API_KEY).toBeUndefined()
expect(environment.WANTA_BROWSER_CONTROL_TOKEN).toBeUndefined()
expect(environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
🤖 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 `@electron/agent/command-sandbox/policy.test.ts` around lines 43 - 75, Update
both environment-mode tests around buildCommandSandboxEnvironment and
buildDirectCommandEnvironment to include OPENCODE_CONFIG_CONTENT,
OPENCODE_SERVER_PASSWORD, OO_API_KEY, and WANTA_BROWSER_CONTROL_TOKEN as
credential sentinels in each input. Assert every listed credential is undefined
in both resulting environments, while preserving the existing non-credential
environment assertions and secret-persistence check.

Comment on lines +217 to +243
function spawnCommand(command: string, delegateShell: string, environment: NodeJS.ProcessEnv): Promise<number> {
return new Promise((resolve, reject) => {
const child = spawn(delegateShell, ["-c", command], {
cwd: process.cwd(),
detached: true,
env: environment,
stdio: "inherit",
})
const relay = (signal: NodeJS.Signals) => signalProcessGroup(child.pid, signal)
const onInterrupt = () => relay("SIGINT")
const onTerminate = () => relay("SIGTERM")
process.on("SIGINT", onInterrupt)
process.on("SIGTERM", onTerminate)
child.once("error", reject)
child.once("exit", (code, signal) => {
process.off("SIGINT", onInterrupt)
process.off("SIGTERM", onTerminate)
void terminateProcessGroup(child.pid).finally(() => {
if (signal) {
resolve(128 + (os.constants.signals[signal] ?? 0))
} else {
resolve(code ?? 1)
}
})
})
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Global process signal listeners accumulate across concurrent sandboxed commands.

Each spawnCommand call adds its own SIGINT/SIGTERM listeners to the shared process object and removes them on exit, but with several sessions running sandboxed commands concurrently, listener count grows with concurrency (risking MaxListenersExceededWarning) and every signal handler runs on every process signal regardless of which command it targets. This is worth hardening given concurrent-agent sessions are a first-class scenario for this app.

Based on learnings: "Treat worktree isolation and concurrent agents as first-class concerns."

🤖 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 `@electron/agent/command-sandbox/runtime.ts` around lines 217 - 243, Update
spawnCommand to avoid installing per-command SIGINT and SIGTERM listeners on the
shared process object; route signals through a centralized, concurrency-safe
mechanism that tracks active child processes and forwards each signal only to
its intended command. Ensure command cleanup removes its tracking state on exit
or error without affecting other concurrent sandboxed commands.

Source: Learnings

Comment thread electron/agent/manager.ts
Comment on lines +455 to +477
public async updateCommandSandboxPolicy(input: CommandSandboxSessionPolicyInput): Promise<void> {
if (!this.commandSandboxPolicyStore) return
const previous = this.commandSandboxPolicyInputs.get(input.sessionId)
const persistedGrants = previous ? [] : await this.commandSandboxPolicyStore.readNetworkGrants(input.sessionId)
const next = {
...previous,
...input,
privateNetworkGrants: input.privateNetworkGrants ?? previous?.privateNetworkGrants ?? persistedGrants,
}
this.commandSandboxPolicyInputs.set(input.sessionId, next)
if (next.userMessage && this.commandSandboxBroker) {
this.commandSandboxBroker.setSession(
input.sessionId,
{
modelTarget: this.resolvePolicyReviewerTarget(next.model),
origin: "main",
userMessage: next.userMessage,
},
next.privateNetworkGrants,
)
}
await this.writeCommandSandboxPolicy(next)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize per-session policy updates; concurrent callers can lose fields and write stale policies.

updateCommandSandboxPolicy does an unguarded read-modify-write across an await (readNetworkGrants), and ChatServiceImpl invokes it from several independent paths (sendMessage, answerPermission, answerLocalAccessPermission, setPermissionMode) that can overlap for the same session. Two in-flight calls both read commandSandboxPolicyInputs.get(sessionId) before either stores, so the later set drops the earlier caller's readWritePaths/executionMode, and the two writeCommandSandboxPolicy calls can land out of order — leaving the signed on-disk policy narrower (turn dirs missing → sandbox denies writes) or wider (direct after a downgrade to sandbox) than intended.

A per-session promise chain (same pattern as queueTeamUpdate) around the merge + write closes both windows.

Separately, setSession is gated on next.userMessage: a policy sync triggered by a permission answer before the session's first prompt registers no broker context, so private-network review for that session silently denies until a message is sent.

🤖 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 `@electron/agent/manager.ts` around lines 455 - 477, The
updateCommandSandboxPolicy method must serialize the complete per-session read,
merge, broker synchronization, and write through a per-session promise chain,
following the existing queueTeamUpdate pattern so overlapping callers cannot
lose fields or persist stale policies. Also update the broker setSession
condition to register or refresh the session when private-network grants require
it even if next.userMessage is absent, while preserving the existing reviewer
context behavior when a user message exists.

Comment thread electron/agent/manager.ts
Comment on lines +602 to +634
private async prepareCommandSandbox({
baseCommandPath,
cliPath,
workspaceDir,
}: {
baseCommandPath: string
cliPath: string
workspaceDir: string
}): Promise<{ delegateShell: string; pluginUrl: string; shellPath: string }> {
if (!this.commandSandboxPolicyStore) {
throw new Error("Command Sandbox (Preview) is not initialized.")
}
await this.commandSandboxPolicyStore.initialize()
const runtimeDir = path.join(this.options.rootDir, "command-sandbox", "runtime")
const shellPath = await ensureCommandSandboxShellBin({
binDir: path.join(this.options.rootDir, "bin"),
cliPath,
nodeBin: process.execPath,
})
const pluginUrl = await ensureCommandSandboxPlugin(runtimeDir)
const delegateShell =
process.env.SHELL && path.isAbsolute(process.env.SHELL) && process.env.SHELL !== shellPath
? process.env.SHELL
: "/bin/zsh"
this.commandSandboxRuntimeReadPaths = [
workspaceDir,
path.dirname(cliPath),
path.dirname(process.execPath),
path.dirname(shellPath),
...baseCommandPath.split(path.delimiter),
]
return { delegateShell, pluginUrl, shellPath }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

commandSandboxRuntimeReadPaths is only populated during startSidecar.

Any updateCommandSandboxPolicy that lands before prepareCommandSandbox (or while a runtime recovery is mid-restart) writes a signed policy with an empty runtime read set, so the sandboxed shell loses read access to node/CLI/PATH directories and commands fail until the next policy write. Consider computing these paths in the constructor (they only depend on rootDir, cliPath, execPath, plus the resolved PATH) or refusing to write a policy until they are set.

Also worth noting: process.env.SHELL is accepted as the delegate shell purely on path.isAbsolute, with no check that it is executable — a user with an exotic or stale SHELL gets a hard failure rather than the /bin/zsh fallback.

🤖 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 `@electron/agent/manager.ts` around lines 602 - 634, Ensure
commandSandboxRuntimeReadPaths is initialized before any
updateCommandSandboxPolicy call, including startup and runtime recovery, using
rootDir, cliPath, process.execPath, and resolved PATH directories; otherwise
defer policy writes until populated. In prepareCommandSandbox, preserve or
refresh the same paths consistently. Validate that process.env.SHELL is an
absolute executable before selecting it as delegateShell, falling back to
/bin/zsh when invalid.

Comment on lines +18 to +20
+ if (allowLoopbackOutbound) {
+ profile.push('(allow network-outbound (remote ip "localhost:*"))');
+ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

allowLoopbackOutbound opens every loopback port, not just the sandbox proxies.

(allow network-outbound (remote ip "localhost:*")) lets a sandboxed command connect to any service bound on 127.0.0.1 — the command-sandbox broker, the OpenCode sidecar's HTTP API, the app's own dev server, and any unrelated local daemon (databases, SSH forwards, other agents). That bypasses the HTTP/SOCKS5 policy enforcement the rest of this feature builds, and the private-network classifier explicitly treats loopback as out-of-band.

Since httpProxyPort/socksProxyPort are already in scope in generateSandboxProfile, scoping the rule to those two ports (localhost:<httpProxyPort> / localhost:<socksProxyPort>) preserves the intended egress path without granting blanket local access.

🤖 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 `@patches/`@vscode__sandbox-runtime@0.0.1.patch around lines 18 - 20, The
allowLoopbackOutbound rule in generateSandboxProfile currently permits access to
every localhost port; restrict it to localhost:httpProxyPort and
localhost:socksProxyPort only. Preserve the intended proxy egress while removing
blanket loopback access, using the existing in-scope proxy port values when
constructing the network-outbound rules.

Comment on lines +62 to +71
await new Promise((resolve) => setTimeout(resolve, 1_400))
const assertions = {
attachmentRead: result.code === 0,
attachmentWriteBlocked: (await readFile(attachment, "utf8")) === "attachment",
controlDirProtected: await probeNestedControlDirectory(store, root),
detachedReaped: !(await exists(detachedMarker)),
outsideReadBlocked: !(await exists(outsideReadMarker)),
outsideWriteBlocked: (await readFile(outside, "utf8")) === "outside-secret",
projectWrite: (await readFile(path.join(project, "created.txt"), "utf8")) === "project-ok",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fixed 1.4 s sleep makes detachedReaped a flaky assertion.

The detached child sleeps 1 s before touching the marker; a loaded machine can push that past the 1.4 s budget, so the probe reports "reaped" for a process that simply hadn't written yet — a false pass on the most security-relevant assertion here. Polling for the marker for a bounded window (and asserting it never appears after the process group is torn down) removes the timing dependency.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process"
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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 `@scripts/verify-command-sandbox.ts` around lines 62 - 71, Replace the fixed
1.4-second delay before constructing assertions in the verification flow with
bounded polling for detachedMarker. Ensure the probe waits until the marker
appears or the timeout expires, then tears down the process group and verifies
detachedMarker never appears afterward; preserve detachedReaped as a failure if
the marker was written before teardown.

Comment on lines +85 to +107
async function probeNestedControlDirectory(store: CommandSandboxPolicyStore, allowedRoot: string): Promise<boolean> {
try {
await store.write({
executionMode: "sandbox",
sessionId,
readWritePaths: [allowedRoot],
runtimeReadPaths: [path.resolve("."), path.dirname(process.execPath)],
})
} catch {
return true
}
const result = await run(process.execPath, [wrapper, "-c", `! cat ${quote(store.pathForSession(sessionId))}`], {
...process.env,
PATH: process.env.PATH,
WANTA_COMMAND_SANDBOX_AUTH: authKey,
WANTA_COMMAND_SANDBOX_BROKER_URL: "http://127.0.0.1:9",
WANTA_COMMAND_SANDBOX_CALL_ID: "probe-control-dir",
WANTA_COMMAND_SANDBOX_DELEGATE_SHELL: "/bin/zsh",
WANTA_COMMAND_SANDBOX_POLICY_DIR: store.policyDir,
WANTA_COMMAND_SANDBOX_SESSION_ID: sessionId,
})
return result.code === 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

probeNestedControlDirectory returns true without ever exercising the sandbox.

allowedRoot is root, which contains policyRoot, so store.write reliably throws the control-directory overlap error and the catch short-circuits to true. The spawned read-denial check below it is dead code, and the assertion no longer proves the sandbox blocks policy-file reads — only that the store rejects the overlapping input (already unit-tested). Point the write at a root that does not enclose the control dir so the runtime path is actually reached.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "node:child_process"
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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 `@scripts/verify-command-sandbox.ts` around lines 85 - 107, Update
probeNestedControlDirectory so store.write receives an allowedRoot that does not
contain or overlap the policy control directory, preventing the expected overlap
error from short-circuiting the probe. Preserve the existing spawned read-denial
check and ensure the function reaches run to verify sandbox enforcement rather
than merely validating store.write rejection.

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.

2 participants