fix(exec): allow uploaded files in active workspaces (#748)

Shell-aware command parsing, dynamic workspace exemptions, and symlink canonicalization for exec path denial. Fixes #739.
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-08 13:35:19 +07:00
1 parent e9155e0c5d
commit 8730a90acb
9 files changed
+990 -49

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
=== RUN TestSetupToolRegistryExecWorkspacePaths
2026/04/07 21:24:13 INFO memory + knowledge graph tools registered (PG-backed)
2026/04/07 21:24:13 INFO web_search tool enabled
2026/04/07 21:24:13 INFO web_fetch tool enabled policy="" blocked=0
2026/04/07 21:24:13 INFO tts enabled provider=edge auto=off
2026/04/07 21:24:13 INFO tool rate limiting enabled per_hour=150
2026/04/07 21:24:13 INFO exec approval enabled security=full ask=off
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' file=@\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/personal/.uploads/Quarterly Report.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' --input=\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/teams/team-123/Quarterly Report.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="cp \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/.goclaw/goclaw-workspace/ws/system/uploads/Quarterly Report.png\" \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/002/partner.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied
--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.02s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.01s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.01s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s)
PASS
ok github.com/nextlevelbuilder/goclaw/cmd 0.041s
+94
View File
@@ -0,0 +1,94 @@
go: downloading github.com/golang-migrate/migrate/v4 v4.19.1
go: downloading github.com/jackc/pgx/v5 v5.6.0
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
go: downloading github.com/charmbracelet/huh v0.8.0
go: downloading github.com/spf13/cobra v1.10.2
go: downloading github.com/bwmarrin/discordgo v0.29.0
go: downloading golang.org/x/sync v0.19.0
go: downloading github.com/slack-go/slack v0.19.0
go: downloading github.com/mattn/go-runewidth v0.0.16
go: downloading github.com/mymmrac/telego v1.6.0
go: downloading github.com/fsnotify/fsnotify v1.9.0
go: downloading github.com/titanous/json5 v1.0.0
go: downloading github.com/disintegration/imaging v1.6.2
go: downloading golang.org/x/image v0.27.0
go: downloading golang.org/x/time v0.14.0
go: downloading github.com/mark3labs/mcp-go v0.44.0
go: downloading github.com/adhocore/gronx v1.19.6
go: downloading github.com/go-rod/rod v0.116.2
go: downloading github.com/lib/pq v1.10.9
go: downloading github.com/mattn/go-shellwords v1.0.12
go: downloading golang.org/x/net v0.49.0
go: downloading golang.org/x/text v0.33.0
go: downloading github.com/rivo/uniseg v0.4.7
go: downloading github.com/catppuccin/go v0.3.0
go: downloading github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
go: downloading github.com/charmbracelet/bubbletea v1.3.6
go: downloading github.com/charmbracelet/lipgloss v1.1.0
go: downloading github.com/charmbracelet/x/cellbuf v0.0.13
go: downloading github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0
go: downloading github.com/mitchellh/hashstructure/v2 v2.0.2
go: downloading github.com/spf13/pflag v1.0.9
go: downloading golang.org/x/crypto v0.47.0
go: downloading golang.org/x/sys v0.42.0
go: downloading github.com/valyala/fasthttp v1.69.0
go: downloading github.com/invopop/jsonschema v0.13.0
go: downloading github.com/spf13/cast v1.7.1
go: downloading github.com/yosida95/uritemplate/v3 v3.0.2
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/charmbracelet/x/ansi v0.9.3
go: downloading github.com/charmbracelet/x/term v0.2.1
go: downloading github.com/atotto/clipboard v0.1.4
go: downloading github.com/grbit/go-json v0.11.0
go: downloading github.com/valyala/fastjson v1.6.7
go: downloading github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
go: downloading github.com/muesli/cancelreader v0.2.2
go: downloading github.com/muesli/termenv v0.16.0
go: downloading github.com/wk8/go-ordered-map/v2 v2.1.8
go: downloading github.com/ysmood/goob v0.4.0
go: downloading github.com/ysmood/got v0.40.0
go: downloading github.com/ysmood/gson v0.7.3
go: downloading github.com/ysmood/fetchup v0.2.3
go: downloading github.com/ysmood/leakless v0.9.0
go: downloading github.com/andybalholm/brotli v1.2.0
go: downloading github.com/klauspost/compress v1.18.2
go: downloading github.com/valyala/bytebufferpool v1.0.0
go: downloading github.com/jackc/pgpassfile v1.0.0
go: downloading github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761
go: downloading github.com/jackc/puddle/v2 v2.2.2
go: downloading github.com/lucasb-eyer/go-colorful v1.2.0
go: downloading github.com/bahlo/generic-list-go v0.2.0
go: downloading github.com/buger/jsonparser v1.1.1
go: downloading github.com/mailru/easyjson v0.7.7
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/aymanbagabas/go-osc52/v2 v2.0.1
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc
go: downloading github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
=== RUN TestSetupToolRegistryExecWorkspacePaths
2026/04/08 01:24:40 INFO memory + knowledge graph tools registered (PG-backed)
2026/04/08 01:24:40 INFO web_search tool enabled
2026/04/08 01:24:40 INFO web_fetch tool enabled policy="" blocked=0
2026/04/08 01:24:40 INFO tts enabled provider=edge auto=off
2026/04/08 01:24:40 INFO tool rate limiting enabled per_hour=150
2026/04/08 01:24:40 INFO exec approval enabled security=full ask=off
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied
--- FAIL: TestSetupToolRegistryExecWorkspacePaths (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s)
FAIL
FAIL github.com/nextlevelbuilder/goclaw/cmd 0.019s
FAIL
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR #748 — fix(exec): allow uploaded files in active workspaces</title>
<style>
:root {
--bg: #0d0d0d;
--fg: #e8e4df;
--card: #1a1714;
--border: #2d2923;
--accent: #ef4444;
--good: #22c55e;
--muted: #8a837d;
--code: #151210;
}
.light {
--bg: #f8f6f3;
--fg: #1a1714;
--card: #ffffff;
--border: #e5e0da;
--accent: #dc2626;
--good: #15803d;
--muted: #6b6560;
--code: #f3f0eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
padding: 2rem;
line-height: 1.5;
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
background: var(--card);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
z-index: 100;
}
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); margin-bottom: 2rem; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.summary-card, .env, .comparison, .supporting {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
}
.summary-card { padding: 1rem; }
.summary-card h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.5rem;
letter-spacing: 0.04em;
}
.env, .supporting, .comparison { padding: 1.25rem; margin-bottom: 1.5rem; }
.env { color: var(--muted); font-size: 0.92rem; }
.comparison h2, .supporting h2 {
font-size: 1.05rem;
margin-bottom: 1rem;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 980px) { .grid { grid-template-columns: 1fr; } }
.col-header {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.before { color: var(--muted); }
.after { color: var(--accent); }
.panel {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--code);
}
.panel img {
display: block;
width: 100%;
height: auto;
}
.terminal {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 1rem;
min-height: 320px;
}
.hl-bad {
display: inline-block;
background: rgba(239, 68, 68, 0.14);
border-left: 3px solid var(--accent);
padding: 0.05rem 0.35rem;
}
.hl-good {
display: inline-block;
background: rgba(34, 197, 94, 0.14);
border-left: 3px solid var(--good);
padding: 0.05rem 0.35rem;
}
.desc {
color: var(--muted);
font-size: 0.88rem;
margin-top: 0.65rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--code);
padding: 0.12rem 0.32rem;
border-radius: 4px;
}
ul { margin-left: 1.1rem; color: var(--muted); }
</style>
</head>
<body>
<button class="theme-toggle" onclick="document.documentElement.classList.toggle('light'); this.textContent = document.documentElement.classList.contains('light') ? '● Dark' : '○ Light'">○ Light</button>
<h1>fix(exec): allow uploaded files in active workspaces</h1>
<p class="subtitle">PR #748 — visual proof pack for issue #739</p>
<div class="summary">
<div class="summary-card">
<h3>Bug</h3>
<p>Active workspace files under deny-roots were rejected by <code>exec</code> even when they belonged to the current upload/team scope.</p>
</div>
<div class="summary-card">
<h3>Root Cause</h3>
<p>Path exemptions were static and non-shell-aware, so quoted, prefixed, and legacy screenshot-era upload paths still matched the deny root.</p>
</div>
<div class="summary-card">
<h3>Fix</h3>
<p>Shell-aware parsing + canonical path checks now exempt the active personal <code>.uploads</code> subtree, the legacy <code>uploads</code> subtree, and the active team workspace.</p>
</div>
</div>
<div class="env">
Before source: stable <code>claw:3000</code> backed by the unfixed <code>/opt/goclaw</code> deployment (<code>v2.7.1</code>). After source: temporary PR deployment on <code>claw:3001</code>, backed by <code>goclaw-pr748:local</code> built from the fixed code commit <code>83fa1342</code>. Both UI captures use fresh chats with the same user and <code>GLM thục bô</code>. Callout border marks the reviewed <code>exec</code> row.
</div>
<div class="comparison">
<h2>UI Before / After</h2>
<div class="grid">
<div>
<div class="col-header before">Before</div>
<div class="panel">
<img src="pr748-before-absolute-goclaw-blocked.png" alt="Before: stable claw blocks absolute .goclaw upload path">
</div>
<p class="desc">Stable <code>claw</code> release, fresh chat with <code>GLM thục bô</code>: the agent runs the exact absolute <code>/app/.goclaw/.../.uploads/...jpg</code> copy command and the <code>exec</code> row fails with the safety-policy <code>.goclaw/</code> denial.</p>
</div>
<div>
<div class="col-header after">After</div>
<div class="panel">
<img src="pr748-after-absolute-goclaw-allowed.png" alt="After: fixed temporary claw deployment allows absolute .goclaw upload path">
</div>
<p class="desc">Temporary fixed deployment on <code>claw:3001</code>, fresh chat with the same agent: the same absolute <code>/app/.goclaw/.../.uploads/...jpg</code> copy command now completes successfully and returns <code>/tmp/partner-abs.png</code>.</p>
</div>
</div>
</div>
<div class="comparison">
<h2>Gateway Reproduction</h2>
<div class="grid">
<div>
<div class="col-header before">Before</div>
<div class="panel">
<pre class="terminal">$ ssh claw 'docker run --rm -v /tmp/goclaw-739-before:/src -w /src golang:1.26 sh -lc "/usr/local/go/bin/go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v"'
=== RUN TestSetupToolRegistryExecWorkspacePaths
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
FAIL</pre>
</div>
<p class="desc">Baseline source on <code>claw</code>: the exact gateway wiring still rejects all three representative workspace-path forms.</p>
</div>
<div>
<div class="col-header after">After</div>
<div class="panel">
<pre class="terminal">$ go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v
=== RUN TestSetupToolRegistryExecWorkspacePaths
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
<span class="hl-good">--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.01s)</span>
<span class="hl-good"> --- PASS: personal_workspace_prefixed_uploads_allowed</span>
<span class="hl-good"> --- PASS: team_workspace_quoted_input_allowed</span>
<span class="hl-good"> --- PASS: legacy_dotgoclaw_uploads_layout_allowed</span>
PASS</pre>
</div>
<p class="desc">Current worktree: the same gateway path handling now passes for personal uploads, team workspace paths, and the screenshot-era legacy <code>.goclaw/.../uploads</code> layout.</p>
</div>
</div>
</div>
<div class="supporting">
<h2>Supporting Live Recreation on <code>claw</code></h2>
<div class="panel">
<img src="claw-live-glm-thuc-bo-before.png" alt="Live claw recreation with GLM thục bô">
</div>
<p class="desc">
This earlier live run documents the same production agent and workspace context on stable <code>claw</code>. The report’s primary Before/After pair above is the clean isolated proof you asked for; this screenshot is kept as supporting context for the reproduction journey.
</p>
</div>
<div class="comparison">
<h2>What This Proves</h2>
<ul>
<li>Quoted/prefixed upload arguments now survive the deny matcher when they point into the active personal <code>.uploads</code> subtree, the legacy <code>.goclaw/.../uploads</code> subtree, or the active team workspace.</li>
<li>Symlink escapes, unrelated <code>dataDir</code> files, and workspace-local <code>.goclaw/*</code> remain denied.</li>
<li>The visual behavior difference is now shown on two real frontends: stable <code>claw:3000</code> Before and temporary fixed <code>claw:3001</code> After, both using fresh conversations.</li>
</ul>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+144
View File
@@ -0,0 +1,144 @@
package cmd
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
func testExecToolFromGatewaySetup(t *testing.T, workspace, dataDir string) *tools.ExecTool {
t.Helper()
cfg := config.Default()
cfg.DataDir = dataDir
cfg.Agents.Defaults.Workspace = workspace
cfg.Tools.Browser.Enabled = false
providerRegistry := providers.NewRegistry(nil)
toolsReg, _, _, _, _, _, _, _, _, _, _ := setupToolRegistry(cfg, workspace, providerRegistry)
execToolAny, ok := toolsReg.Get("exec")
if !ok {
t.Fatal("exec tool not registered")
}
execTool, ok := execToolAny.(*tools.ExecTool)
if !ok {
t.Fatalf("exec tool type = %T, want *tools.ExecTool", execToolAny)
}
return execTool
}
func TestSetupToolRegistryExecWorkspacePaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink setup requires elevated privileges on Windows")
}
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "personal")
teamWorkspace := filepath.Join(dataDir, "teams", "team-123")
for _, dir := range []string{workspace, teamWorkspace} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
}
execTool := testExecToolFromGatewaySetup(t, workspace, dataDir)
uploadPath := filepath.Join(workspace, ".uploads", "Quarterly Report.png")
if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(uploadPath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
teamFilePath := filepath.Join(teamWorkspace, "Quarterly Report.png")
if err := os.WriteFile(teamFilePath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
protectedPath := filepath.Join(dataDir, "config.json")
if err := os.WriteFile(protectedPath, []byte("secret"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
symlinkPath := filepath.Join(teamWorkspace, "leak.txt")
if err := os.Symlink(protectedPath, symlinkPath); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
legacyWorkspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system")
legacyUploadPath := filepath.Join(legacyWorkspace, "uploads", "Quarterly Report.png")
legacyCopyTarget := filepath.Join(t.TempDir(), "partner.png")
if err := os.MkdirAll(filepath.Dir(legacyUploadPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(legacyUploadPath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tests := []struct {
name string
ctx context.Context
command string
wantDenied bool
wantPath string
}{
{
name: "personal_workspace_prefixed_uploads_allowed",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' file=@\"" + uploadPath + "\"",
},
{
name: "team_workspace_quoted_input_allowed",
ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace),
command: "printf '%s' --input=\"" + teamFilePath + "\"",
},
{
name: "legacy_dotgoclaw_uploads_layout_allowed",
ctx: tools.WithToolWorkspace(context.Background(), legacyWorkspace),
command: "cp \"" + legacyUploadPath + "\" \"" + legacyCopyTarget + "\"",
wantPath: legacyCopyTarget,
},
{
name: "team_workspace_symlink_escape_denied",
ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace),
command: "printf '%s' " + symlinkPath,
wantDenied: true,
},
{
name: "unrelated_data_dir_path_denied",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' " + protectedPath,
wantDenied: true,
},
{
name: "workspace_local_dotgoclaw_denied",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"),
wantDenied: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := execTool.Execute(tc.ctx, map[string]any{
"command": tc.command,
})
denied := strings.Contains(result.ForLLM, "command denied by safety policy")
if denied != tc.wantDenied {
t.Fatalf("denied = %v, want %v; output = %s", denied, tc.wantDenied, result.ForLLM)
}
if tc.wantPath != "" {
if _, err := os.Stat(tc.wantPath); err != nil {
t.Fatalf("expected copied file at %q, got stat error: %v", tc.wantPath, err)
}
} else if !tc.wantDenied && !strings.Contains(result.ForLLM, "Quarterly Report.png") {
t.Fatalf("expected output to contain quoted file path, got: %s", result.ForLLM)
}
})
}
}
+276 -17
View File
@@ -6,11 +6,14 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
shellwords "github.com/mattn/go-shellwords"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
"golang.org/x/text/unicode/norm"
@@ -31,30 +34,31 @@ func DefaultDenyPatterns() []*regexp.Regexp {
// ExecTool executes shell commands, optionally inside a sandbox container.
type ExecTool struct {
workspace string
workspace string
timeout time.Duration
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
denyExemptions []string // substrings that exempt a command from deny
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
pathDenyRoots []string // raw deny roots for nested workspace exemptions
denyExemptions []string // substrings that exempt a command from deny
restrict bool
sandboxMgr sandbox.Manager // nil = no sandbox, execute on host
approvalMgr *ExecApprovalManager // nil = no approval needed
agentID string // for approval request context
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
}
// NewExecTool creates an exec tool that runs commands directly on the host.
func NewExecTool(workspace string, restrict bool) *ExecTool {
return &ExecTool{
workspace: workspace,
timeout: 60 * time.Second,
restrict: restrict,
timeout: 60 * time.Second,
restrict: restrict,
}
}
// NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container.
func NewSandboxedExecTool(workspace string, restrict bool, mgr sandbox.Manager) *ExecTool {
return &ExecTool{
workspace: workspace,
workspace: workspace,
timeout: 300 * time.Second, // sandbox allows longer timeout
restrict: restrict,
sandboxMgr: mgr,
@@ -70,6 +74,7 @@ func (t *ExecTool) DenyPaths(paths ...string) {
for _, p := range paths {
escaped := regexp.QuoteMeta(p)
t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped))
t.pathDenyRoots = append(t.pathDenyRoots, p)
}
}
@@ -96,6 +101,256 @@ func normalizeCommand(s string) string {
return s
}
func (t *ExecTool) dynamicPathExemptions(ctx context.Context) []string {
var exemptions []string
seen := make(map[string]struct{}, 4)
workspace := ToolWorkspaceFromCtx(ctx)
teamWorkspace := ToolTeamWorkspaceFromCtx(ctx)
var dirs []string
if teamWorkspace != "" {
dirs = append(dirs, teamWorkspace)
}
if workspace != "" && filepath.Clean(workspace) != filepath.Clean(teamWorkspace) {
dirs = append(dirs, filepath.Join(workspace, ".uploads"))
dirs = append(dirs, filepath.Join(workspace, "uploads"))
}
for _, dir := range dirs {
if dir == "" || strings.Contains(dir, "..") {
continue
}
for _, variant := range pathAliasVariants(filepath.Clean(dir)) {
if !t.isNestedUnderDeniedRoot(variant) {
continue
}
for _, ex := range []string{variant, variant + string(filepath.Separator)} {
if _, ok := seen[ex]; ok {
continue
}
seen[ex] = struct{}{}
exemptions = append(exemptions, ex)
}
}
}
return exemptions
}
func pathAliasVariants(path string) []string {
variants := []string{path}
for _, mapping := range [][2]string{
{"/app/workspace", "/app/.goclaw"},
{"/app/.goclaw", "/app/workspace"},
} {
from, to := mapping[0], mapping[1]
if path == from {
variants = append(variants, to)
continue
}
if strings.HasPrefix(path, from+string(filepath.Separator)) {
variants = append(variants, to+strings.TrimPrefix(path, from))
}
}
return variants
}
func (t *ExecTool) isNestedUnderDeniedRoot(path string) bool {
for _, root := range t.pathDenyRoots {
cleanRoot := filepath.Clean(root)
if cleanRoot == "." || cleanRoot == string(filepath.Separator) {
continue
}
if !filepath.IsAbs(cleanRoot) {
marker := string(filepath.Separator) + cleanRoot + string(filepath.Separator)
if strings.Contains(path, marker) {
return true
}
continue
}
if path == cleanRoot {
continue
}
if strings.HasPrefix(path, cleanRoot+string(filepath.Separator)) {
return true
}
}
return false
}
func matchesPathExemption(path string, exemptions []string) bool {
sep := string(filepath.Separator)
for _, ex := range exemptions {
if ex == "" {
continue
}
if path == ex {
return true
}
if strings.HasSuffix(ex, sep) {
if strings.HasPrefix(path, ex) {
return true
}
continue
}
if strings.HasPrefix(path, ex+sep) {
return true
}
}
return false
}
func parseExecCommandWords(command string) []string {
var words []string
for _, segment := range splitExecCommandSegments(command) {
parser := shellwords.NewParser()
parser.ParseBacktick = false
parser.ParseEnv = false
segmentWords, err := parser.Parse(segment)
if err != nil || len(segmentWords) == 0 {
words = append(words, strings.Fields(segment)...)
continue
}
words = append(words, segmentWords...)
}
if len(words) == 0 {
return strings.Fields(command)
}
return words
}
func splitExecCommandSegments(command string) []string {
var segments []string
start := 0
inSingle := false
inDouble := false
for i := 0; i < len(command); i++ {
ch := command[i]
switch {
case inSingle:
if ch == '\'' {
inSingle = false
}
case inDouble:
if ch == '\\' && i+1 < len(command) {
i++
} else if ch == '"' {
inDouble = false
}
default:
switch ch {
case '\\':
if i+1 < len(command) {
i++
}
case '\'':
inSingle = true
case '"':
inDouble = true
case ';', '|', '&', '<', '>', '\n', '\r':
if segment := strings.TrimSpace(command[start:i]); segment != "" {
segments = append(segments, segment)
}
start = i + 1
}
}
}
if tail := strings.TrimSpace(command[start:]); tail != "" {
segments = append(segments, tail)
}
return segments
}
func extractPathCandidates(word string) []string {
if word == "" {
return nil
}
queue := []string{word}
seen := make(map[string]struct{}, 4)
var out []string
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current == "" {
continue
}
if _, ok := seen[current]; ok {
continue
}
seen[current] = struct{}{}
if looksLikePathCandidate(current) {
out = append(out, current)
}
for _, sep := range []string{"=", "@"} {
if idx := strings.Index(current, sep); idx >= 0 && idx+1 < len(current) {
queue = append(queue, current[idx+1:])
}
}
}
return out
}
func looksLikePathCandidate(s string) bool {
if s == "" {
return false
}
if filepath.IsAbs(s) {
return true
}
return strings.HasPrefix(s, "./") ||
strings.HasPrefix(s, "../") ||
strings.HasPrefix(s, ".uploads/") ||
strings.HasPrefix(s, ".goclaw/") ||
strings.HasPrefix(s, "teams/") ||
strings.HasPrefix(s, "tenants/") ||
strings.HasPrefix(s, "~/") ||
strings.Contains(s, string(filepath.Separator))
}
func canonicalizeExecPath(path, baseDir string) (string, error) {
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(homeDir, strings.TrimPrefix(path, "~/"))
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
absPath, _ := filepath.Abs(filepath.Clean(path))
if real, err := filepath.EvalSymlinks(absPath); err == nil {
return real, nil
}
return resolveThroughExistingAncestors(absPath)
}
func matchesAnyPathExemption(word string, exemptions []string, baseDir string) bool {
for _, candidate := range extractPathCandidates(word) {
if strings.Contains(candidate, "..") {
continue
}
realCandidate, err := canonicalizeExecPath(candidate, baseDir)
if err != nil {
continue
}
for _, exemption := range exemptions {
realExemption, err := canonicalizeExecPath(exemption, baseDir)
if err != nil {
continue
}
if matchesPathExemption(realCandidate, []string{realExemption}) {
return true
}
}
}
return false
}
// SetApprovalManager sets the exec approval manager for this tool.
func (t *ExecTool) SetApprovalManager(mgr *ExecApprovalManager, agentID string) {
t.approvalMgr = mgr
@@ -155,8 +410,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
allPatterns := make([]*regexp.Regexp, 0, len(groupPatterns)+len(t.pathDenyPatterns))
allPatterns = append(allPatterns, groupPatterns...)
allPatterns = append(allPatterns, t.pathDenyPatterns...)
exemptions := append([]string{}, t.denyExemptions...)
exemptions = append(exemptions, t.dynamicPathExemptions(ctx)...)
// Check for dangerous commands (applies to both host and sandbox).
wordFields := parseExecCommandWords(normalizedCommand)
pathBaseDir := ToolWorkspaceFromCtx(ctx)
if pathBaseDir == "" {
pathBaseDir = t.workspace
}
for _, pattern := range allPatterns {
if pattern.MatchString(normalizedCommand) {
// Check if exemption applies. Only exempt if EVERY field that
@@ -167,23 +429,20 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
// path traversal ("..") to prevent exemption escape.
exempt := false
trimmed := strings.TrimSpace(normalizedCommand)
fields := strings.Fields(trimmed)
fields := wordFields
if len(fields) == 0 {
fields = strings.Fields(trimmed)
}
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue // field doesn't trigger this deny pattern
}
matchingFields++
if strings.Contains(clean, "..") {
continue // path traversal — never exempt
}
for _, ex := range t.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, exemptions, pathBaseDir) {
exemptFields++
}
}
// Exempt only if at least one field matched AND all matched fields are exempt.
+223 -32
View File
@@ -2,7 +2,10 @@ package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)
@@ -19,10 +22,10 @@ func TestBase64DecodeShellDeny(t *testing.T) {
}
allowed := []string{
"base64 -w0 file.txt", // encode, no pipe to shell
"base64 -d file.txt", // decode without pipe to shell
"echo hello | base64", // encode
"base64 --decode file.txt", // decode without pipe to shell
"base64 -w0 file.txt", // encode, no pipe to shell
"base64 -d file.txt", // decode without pipe to shell
"echo hello | base64", // encode
"base64 --decode file.txt", // decode without pipe to shell
}
for _, cmd := range denied {
@@ -98,7 +101,7 @@ func TestDestructiveOpsGaps(t *testing.T) {
)
mustAllow(t, patterns,
"halting the process", // "halt" inside word
"halting the process", // "halt" inside word
"initialize", // "init" inside word
"initial setup", // "init" inside word
"init_db", // no space+digit after init
@@ -171,12 +174,18 @@ func TestExecute_RejectsNULByte(t *testing.T) {
}
func TestPathExemptions(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := &ExecTool{
workspace: "/workspace",
workspace: workspace,
restrict: false,
}
tool.DenyPaths("/app/data", ".goclaw/")
tool.AllowPathExemptions(".goclaw/skills-store/", "/app/data/skills-store/")
tool.DenyPaths(dataDir, ".goclaw/")
tool.AllowPathExemptions(".goclaw/skills-store/", filepath.Join(dataDir, "skills-store")+"/")
cases := []struct {
name string
@@ -334,30 +343,24 @@ func TestPathExemptions(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
normalizedCmd := normalizeCommand(tc.cmd)
normalizedCmd := strings.ReplaceAll(normalizeCommand(tc.cmd), "/app/data", dataDir)
denied := false
for _, pattern := range allPatterns {
if !pattern.MatchString(normalizedCmd) {
continue
}
// Replicate per-field exemption logic from Execute()
fields := strings.Fields(strings.TrimSpace(normalizedCmd))
fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd))
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue
}
matchingFields++
if strings.Contains(clean, "..") {
continue // traversal — never exempt
}
for _, ex := range tool.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) {
exemptFields++
}
}
exempt := matchingFields > 0 && exemptFields == matchingFields
@@ -381,11 +384,16 @@ func TestPathExemptions(t *testing.T) {
// path and an exempt path in different arguments is correctly denied.
// Per-field matching ensures the non-exempt field causes denial.
func TestPathExemptions_MixedArgs(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := &ExecTool{}
tool.DenyPaths("/app/data")
tool.AllowPathExemptions("/app/data/skills-store/")
tool.DenyPaths(dataDir)
tool.AllowPathExemptions(filepath.Join(dataDir, "skills-store") + "/")
cmd := "cat /app/data/config.json /app/data/skills-store/tool.py"
cmd := "cat " + filepath.Join(dataDir, "config.json") + " " + filepath.Join(dataDir, "skills-store", "tool.py")
normalizedCmd := normalizeCommand(cmd)
denied := false
@@ -393,23 +401,17 @@ func TestPathExemptions_MixedArgs(t *testing.T) {
if !pattern.MatchString(normalizedCmd) {
continue
}
fields := strings.Fields(strings.TrimSpace(normalizedCmd))
fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd))
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue
}
matchingFields++
if strings.Contains(clean, "..") {
continue
}
for _, ex := range tool.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) {
exemptFields++
}
}
if matchingFields == 0 || exemptFields != matchingFields {
@@ -422,6 +424,195 @@ func TestPathExemptions_MixedArgs(t *testing.T) {
}
}
func TestExecute_AllowsCurrentWorkspaceNestedUnderDeniedRoot(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
target := filepath.Join(workspace, ".uploads", "report.png")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + target,
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected nested workspace path to bypass dataDir deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, target) {
t.Fatalf("expected command output to include workspace path, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsCurrentTeamWorkspaceNestedUnderDeniedRoot(t *testing.T) {
dataDir := t.TempDir()
teamWorkspace := filepath.Join(dataDir, "tenants", "acme", "teams", "team-123")
if err := os.MkdirAll(teamWorkspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
target := filepath.Join(teamWorkspace, "report.png")
ctx := WithToolWorkspace(context.Background(), t.TempDir())
ctx = WithToolTeamWorkspace(ctx, teamWorkspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + target,
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected nested team workspace path to bypass dataDir deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, target) {
t.Fatalf("expected command output to include team workspace path, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptOtherDataDirPaths(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + filepath.Join(dataDir, "config.json"),
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected unrelated dataDir path to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptWorkspaceLocalDotGoclawPaths(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir, ".goclaw/")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"),
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected workspace-local .goclaw path to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsQuotedAndPrefixedUploadArguments(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(filepath.Join(workspace, ".uploads"), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := filepath.Join(workspace, ".uploads", "Quarterly Report.png")
if err := os.WriteFile(target, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' file=@\"" + target + "\"",
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected quoted/prefixed upload argument to bypass deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "file=@"+target) {
t.Fatalf("expected output to contain prefixed path, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptSymlinkEscapeInsideTeamWorkspace(t *testing.T) {
if err := os.MkdirAll(t.TempDir(), 0755); err != nil {
t.Fatalf("TempDir setup error = %v", err)
}
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "personal")
teamWorkspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.MkdirAll(teamWorkspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
protected := filepath.Join(dataDir, "config.json")
if err := os.WriteFile(protected, []byte("secret"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
linkPath := filepath.Join(teamWorkspace, "leak.txt")
if err := os.Symlink(protected, linkPath); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithToolTeamWorkspace(ctx, teamWorkspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + linkPath,
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected symlink escape to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsLegacyWorkspaceUploadsLayout(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system")
legacyUploads := filepath.Join(workspace, "uploads")
if err := os.MkdirAll(legacyUploads, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := filepath.Join(legacyUploads, "Quarterly Report.png")
if err := os.WriteFile(target, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir, ".goclaw/")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "cp \"" + target + "\" /tmp/partner.png",
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected legacy uploads layout to bypass deny, got: %s", result.ForLLM)
}
}
func TestPathAliasVariants_AppWorkspaceMirror(t *testing.T) {
got := pathAliasVariants("/app/workspace/glm-thuc-bo/ws/user/.uploads")
want := "/app/.goclaw/glm-thuc-bo/ws/user/.uploads"
if !slices.Contains(got, want) {
t.Fatalf("expected mirror variant %q in %v", want, got)
}
}
func TestIsNestedUnderDeniedRoot_RelativeDotGoclaw(t *testing.T) {
tool := &ExecTool{pathDenyRoots: []string{".goclaw/"}}
if !tool.isNestedUnderDeniedRoot("/app/.goclaw/glm-thuc-bo/ws/user/.uploads") {
t.Fatal("expected absolute .goclaw path to be treated as nested under relative deny root")
}
}
func TestLimitedBuffer(t *testing.T) {
t.Run("under limit", func(t *testing.T) {
lb := &limitedBuffer{max: 100}