Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions sdks/go/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ func (t *WorkspaceToolkit) handlePresentFiles(ctx context.Context, args json.Raw

for _, fp := range a.Filepaths {
cleaned := filepath.Clean(fp)
if !strings.HasPrefix(cleaned, "/sandbox/session/outputs/") || strings.Contains(cleaned, "..") {
return fmt.Sprintf("file %s is not in /sandbox/session/outputs/", fp), nil, nil
if strings.Contains(cleaned, "..") || !isPresentablePath(cleaned) {
return fmt.Sprintf("file %s is not in /sandbox/session/outputs/ or /sandbox/session/uploads/", fp), nil, nil
}
}

Expand Down Expand Up @@ -289,6 +289,21 @@ func (t *WorkspaceToolkit) handlePresentFiles(ctx context.Context, args json.Raw
return output, refs, nil
}

// presentableDirs lists the absolute prefixes that present_files accepts. outputs/ holds agent-generated deliverables; uploads/ holds files the user uploaded earlier in the session — both are first-class targets for re-display.
var presentableDirs = []string{
"/sandbox/session/outputs/",
"/sandbox/session/uploads/",
}

func isPresentablePath(cleaned string) bool {
for _, dir := range presentableDirs {
if strings.HasPrefix(cleaned, dir) {
return true
}
}
return false
}

// --------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------
Expand Down Expand Up @@ -420,13 +435,13 @@ var workspaceToolDefs = []ToolDefinition{
},
{
Name: "present_files",
Description: "Present output files to the user as downloadable artifacts. Files must be in /sandbox/session/outputs/.",
Description: "Present files to the user as downloadable artifacts. Accepts files under /sandbox/session/outputs/ (agent deliverables) and /sandbox/session/uploads/ (files the user uploaded earlier in this session).",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the question is here. what is better, having one tool for both paths or 2 seperate tools. Can you ask AI what it recommends? I don't know where the LLM will make a better decision how or which tool to call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Refactored to a single tool with a source enum instead of two tools or string-prefix paths:

present_files({
  source: "outputs" | "uploads",
  filenames: ["report.pdf"]
})

Tradeoff analysis: two tools (present_outputs, present_uploads) make intent obvious in the name but add a permanent +1 tool entry per request and bake the dir count into the schema. A single tool with a source enum captures the same intent (the LLM picks "uploads" to re-display a user upload, "outputs" for generated deliverables) while keeping the surface flat. Future presentable surfaces (drive, scratch, etc.) become a one-line allowlist entry on both sides instead of a new tool + schema migration.

Other wins:

  • enum is visible to the LLM at tool-call schema level — model can't pick an unsupported source.
  • filenames are basenames only; .. and slashes rejected at param level. Path-traversal class collapses to "unreachable by construction".
  • aigent + skillbox now share the same presentableSources map shape; drift between sides becomes a one-line review instead of a string-prefix-correctness review.

Done in latest commits on both sides — skillbox PR #15 + aigent PR #308 updated in lockstep.

Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"filepaths": map[string]any{
"type": "array",
"description": "Array of absolute paths to files in /sandbox/session/outputs/ to present.",
"description": "Array of absolute paths to present. Must be under /sandbox/session/outputs/ or /sandbox/session/uploads/.",
"items": map[string]any{"type": "string"},
},
},
Expand Down
46 changes: 45 additions & 1 deletion sdks/go/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,55 @@ func TestHandle_PresentFiles_BadPath(t *testing.T) {
toolkit := NewWorkspaceToolkit(New("http://localhost", "sk-test"), "sess-1")
output, _, _ := toolkit.Handle(context.Background(), "present_files",
json.RawMessage(`{"filepaths": ["/sandbox/session/not-outputs/file.txt"]}`))
if !strings.Contains(output, "not in /sandbox/session/outputs/") {
if !strings.Contains(output, "/sandbox/session/outputs/") || !strings.Contains(output, "/sandbox/session/uploads/") {
t.Errorf("rejection should list both allowed prefixes; output = %q", output)
}
}

// User-uploaded files mirrored to /sandbox/session/uploads/ must be presentable in a single tool call. Without this the agent has to cp the upload into outputs/ before calling present_files — a wasteful 3-call dance per re-display.
func TestHandle_PresentFiles_UploadsPathAccepted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sandbox/read-file":
_ = json.NewEncoder(w).Encode(struct {
Content string `json:"content"`
}{Content: "user uploaded image bytes"})
case "/v1/files":
_ = json.NewEncoder(w).Encode(FileInfo{
ID: "file-upload-1",
Name: "librarian.png",
})
}
}))
defer srv.Close() //nolint:errcheck

toolkit := NewWorkspaceToolkit(New(srv.URL, "sk-test"), "sess-1")
output, files, err := toolkit.Handle(context.Background(), "present_files",
json.RawMessage(`{"filepaths": ["/sandbox/session/uploads/librarian.png"]}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 1 {
t.Fatalf("got %d files, want 1; output=%q", len(files), output)
}
if files[0].Filename != "librarian.png" {
t.Errorf("filename = %q, want %q", files[0].Filename, "librarian.png")
}
if !strings.Contains(output, "Presented 1 file") {
t.Errorf("output = %q", output)
}
}

// Path traversal still blocked even when the prefix is allowed.
func TestHandle_PresentFiles_TraversalRejected(t *testing.T) {
toolkit := NewWorkspaceToolkit(New("http://localhost", "sk-test"), "sess-1")
output, _, _ := toolkit.Handle(context.Background(), "present_files",
json.RawMessage(`{"filepaths": ["/sandbox/session/uploads/../etc/passwd"]}`))
if strings.Contains(output, "Presented") {
t.Errorf("traversal must be rejected; output = %q", output)
}
}

func TestHandle_PresentFiles_TooMany(t *testing.T) {
toolkit := NewWorkspaceToolkit(New("http://localhost", "sk-test"), "sess-1")
paths := make([]string, 21)
Expand Down
Loading