Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Dev tooling: `@azure/msal-node` is pinned to 7.1.0 in the VS Code extension's development tree, which closes GHSA-xcx6-jx8p-x3h5.** The package reaches that tree through `@azure/identity`, and the packaged extension does not include it.
- **Dev tooling: the Studio frontend's npm tree resolves `dompurify` 3.4.17.** `monaco-editor` pins `dompurify` exactly to 3.4.15, so an override sets the npm package to 3.4.17. The editor bundles its own copy of DOMPurify, which is unchanged at 3.4.15, so the code Studio ships does not change. The advisories need options that the bundled sanitizer does not set.

### Security
- **Studio no longer returns the Anthropic API key to the window.** The key panel shows whether a key is saved and starts with an empty field. Saving an empty field no longer clears a saved key; use Remove key to delete it.
- **Studio keeps each WiFi token in the OS keychain, under its device, and never returns it to the window.** A device with a saved token shows a 🔑 saved tag, and connecting to it needs no paste. Its ✕ control forgets the token. Studio deletes the plain-text copy that an earlier version kept in local storage on first start.
- **Studio's Run File accepts only `.probe` files inside the open workspace or an automation folder.** Other paths are refused before anything runs, as the automation endpoint already refused them.
- **Studio opens only `.probe` files inside the open workspace or an automation folder.** The editor's open action uses the same check as Run File, and a path with `..` is refused.
- **Studio saves `.probe` files and lists folders only inside the open workspace or an automation folder.** Save and the file tree use the same rule as Read File and Run File, including the `..` and symlink checks. With no workspace open, the file tree is empty.

## [0.26.0] - 2026-10-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/studio.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Studio automation is off by default: if no Studio answers, start it with PROBE_S
},
{
Name: "studio_run_file",
Description: "Start running a .probe file in Studio. Returns at once; poll studio_run_state for step-by-step progress and studio_results for the verdicts. Fails if Studio is not connected to a device or a run is already in progress.",
Description: "Start running a .probe file in Studio. Returns at once; poll studio_run_state for step-by-step progress and studio_results for the verdicts. The file must be a .probe file inside the open workspace or an automation folder; any other path is refused. Fails if Studio is not connected to a device or a run is already in progress.",
InputSchema: mcpSchema{Type: "object", Required: []string{"path"}, Properties: map[string]mcpProp{
"path": {Type: "string", Description: "Absolute path of the .probe file"},
}},
Expand Down
12 changes: 4 additions & 8 deletions studio/ai_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,10 @@ type claudeMessage struct {

var chatHTTPClient = &http.Client{Timeout: 120 * time.Second}

// GetAPIKey retrieves the stored Anthropic API key from the platform keychain.
// Returns an empty string (not an error) when no key is stored.
func (a *App) GetAPIKey() (string, error) {
key, err := keychainGet()
if err != nil {
return "", nil
}
return key, nil
// GetAPIKey reports whether an Anthropic API key is saved in the platform keychain. It never returns the key: the
// settings panel only needs to show that one is saved.
func (a *App) GetAPIKey() (bool, error) {
return keychainHas(keychainService, keychainUser)
}

// SetAPIKey stores the Anthropic API key in the platform keychain.
Expand Down
85 changes: 57 additions & 28 deletions studio/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,30 @@ func (a *App) workspaceConfig() *config.Config {
return cfg
}

// ListDir returns immediate children of dir. Hidden entries (dot-prefixed)
// are filtered. Returns empty slice + nil on missing directories so the UI
// can render an empty state without special-casing errors.
// ListDir returns immediate children of a folder inside the workspace a person opened or an automation folder. Hidden
// entries (dot-prefixed) are filtered. A folder that does not exist yet lists as empty, so the UI can render an empty
// state without special-casing errors; with no workspace open there is nothing to list.
func (a *App) ListDir(dir string) ([]FileEntry, error) {
if dir == "" {
dir = "."
roots := a.automationRoots()
if len(roots) == 0 {
return []FileEntry{}, nil
}
entries, err := os.ReadDir(dir)
real, err := resolveInRoots(roots, dir, true)
if err != nil {
return nil, err
}
info, err := os.Stat(real)
if os.IsNotExist(err) {
return []FileEntry{}, nil
}
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a folder", dir)
}
entries, err := os.ReadDir(real)
if err != nil {
if os.IsNotExist(err) {
return []FileEntry{}, nil
}
return nil, err
}
out := make([]FileEntry, 0, len(entries))
Expand All @@ -234,43 +246,53 @@ func (a *App) ListDir(dir string) ([]FileEntry, error) {
}
out = append(out, FileEntry{
Name: name,
Path: filepath.Join(dir, name),
Path: filepath.Join(filepath.Clean(dir), name),
IsDir: e.IsDir(),
})
}
return out, nil
}

// ReadFile returns the contents of a .probe file. Path traversal is rejected
// to keep the UI from accidentally opening anything outside the workspace.
// ReadFile returns the contents of a .probe file inside the workspace a person opened or an automation folder. The
// editor opens only the .probe entries of the open workspace tree, so those are the only files it reads. The check is
// the one RunFile makes: an absolute path, resolved through every symlink, inside an allowed root, and a regular
// .probe file. A ".." segment is refused before anything else, so a path cannot climb out of a root.
func (a *App) ReadFile(path string) (string, error) {
if !strings.HasSuffix(path, ".probe") {
return "", fmt.Errorf("only .probe files can be opened")
}
clean := filepath.Clean(path)
if strings.Contains(clean, "..") {
if hasDotDotSegment(path) {
return "", fmt.Errorf("path traversal rejected: %s", path)
}
b, err := os.ReadFile(clean)
real, err := probeFileWithinRoots(a.automationRoots(), path)
if err != nil {
return "", err
}
b, err := os.ReadFile(real)
if err != nil {
return "", err
}
return string(b), nil
}

// WriteFile saves content to a .probe path. Creates parent directories.
func (a *App) WriteFile(path, content string) error {
if !strings.HasSuffix(path, ".probe") {
return fmt.Errorf("only .probe files can be written")
// hasDotDotSegment reports whether any segment of p is "..", on either path separator.
func hasDotDotSegment(p string) bool {
for _, part := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) {
if part == ".." {
return true
}
}
clean := filepath.Clean(path)
if strings.Contains(clean, "..") {
return fmt.Errorf("path traversal rejected: %s", path)
return false
}

// WriteFile saves content to a .probe file inside the workspace a person opened or an automation folder. Missing
// folders are created inside the same check, so a folder that is a link out of the workspace refuses the write.
func (a *App) WriteFile(path, content string) error {
real, err := probeFileForWriteWithinRoots(a.automationRoots(), path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(clean), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
return err
}
return os.WriteFile(clean, []byte(content), 0o644)
return os.WriteFile(real, []byte(content), 0o644)
}

// ---- Lint ----
Expand Down Expand Up @@ -905,7 +927,14 @@ type RunResult struct {
//
// The connection must be established (Connect first). Lint warnings are not
// surfaced here — use Lint() for that; this method runs whatever parses.
func (a *App) RunFile(path string) ([]RunResult, error) {
//
// The path must be a .probe file inside the workspace a person opened or an automation folder: the check the
// automation endpoint makes, applied to every caller. Other paths are refused before anything runs.
func (a *App) RunFile(requested string) ([]RunResult, error) {
path, err := probeFileWithinRoots(a.automationRoots(), requested)
if err != nil {
return nil, err
}
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
Expand Down
2 changes: 2 additions & 0 deletions studio/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ func TestApp_WriteFile_ReadFile_RoundTrip(t *testing.T) {
a := NewApp()
dir := t.TempDir()
path := filepath.Join(dir, "sample.probe")
a.SetWorkspace(dir) // ReadFile reads inside the workspace a person opened
content := "test \"round trip\"\n wait 1 seconds\n"

if err := a.WriteFile(path, content); err != nil {
Expand Down Expand Up @@ -246,6 +247,7 @@ func TestApp_ListDir_MissingDirReturnsEmpty(t *testing.T) {
func TestApp_ListDir_FiltersHiddenEntries(t *testing.T) {
a := NewApp()
dir := t.TempDir()
a.SetWorkspace(dir) // the page opens the workspace before it lists it
for _, name := range []string{"visible.probe", ".hidden"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
Expand Down
59 changes: 58 additions & 1 deletion studio/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,25 @@ func (s *automationServer) handleRPC(w http.ResponseWriter, r *http.Request) {
// decided with filepath.Rel, so a sibling such as /ws-evil is not inside /ws and a symlink inside
// a root that points out of it does not get out.
func withinRoots(roots []string, p string) (string, error) {
return resolveInRoots(roots, p, false)
}

// resolveInRoots is the one rule for every file and folder Studio touches, for the person in the window and for the
// automation endpoint alike: the path must be absolute, must have no ".." segment on either separator, and after its
// symlinks are resolved it must be one of the roots or inside one. With create set, the final part may not exist yet
// (a new file, or the folders above it): the nearest existing parent is resolved and checked, and the missing part is
// taken as written, which cannot hold a symlink. The resolved path is returned, and callers use that, not p.
func resolveInRoots(roots []string, p string, create bool) (string, error) {
if len(roots) == 0 {
return "", fmt.Errorf("no folder is open to automation: open a workspace in Studio, or set PROBE_STUDIO_WORKSPACE or PROBE_STUDIO_AUTOMATION_ROOTS")
}
if !filepath.IsAbs(p) {
return "", fmt.Errorf("%s: use an absolute path", p)
}
real, err := filepath.EvalSymlinks(p)
if hasDotDotSegment(p) {
return "", fmt.Errorf("path traversal rejected: %s", p)
}
real, err := resolveExisting(filepath.Clean(p), create)
if err != nil {
return "", fmt.Errorf("%s: %v", p, err)
}
Expand All @@ -214,6 +226,51 @@ func withinRoots(roots []string, p string) (string, error) {
return "", fmt.Errorf("%s is outside the folders automation may use", p)
}

// resolveExisting returns clean with every symlink that exists resolved. With create set, the parts that do not exist
// yet are appended to the resolved nearest existing parent; a missing part cannot be a symlink, so nothing is hidden.
func resolveExisting(clean string, create bool) (string, error) {
if !create {
return filepath.EvalSymlinks(clean)
}
existing, missing := clean, []string{}
for {
_, err := os.Lstat(existing)
if err == nil {
break
}
if !os.IsNotExist(err) {
return "", err
}
parent := filepath.Dir(existing)
if parent == existing {
return "", fmt.Errorf("no existing folder above it")
}
missing = append([]string{filepath.Base(existing)}, missing...)
existing = parent
}
real, err := filepath.EvalSymlinks(existing)
if err != nil {
return "", err
}
return filepath.Join(append([]string{real}, missing...)...), nil
}

// probeFileForWriteWithinRoots is the write side of probeFileWithinRoots: the file may be new, its folders may be new,
// and its name must end in .probe. A path that is an existing folder is refused.
func probeFileForWriteWithinRoots(roots []string, p string) (string, error) {
real, err := resolveInRoots(roots, p, true)
if err != nil {
return "", err
}
if !strings.HasSuffix(real, ".probe") {
return "", fmt.Errorf("%s: only .probe files can be written", p)
}
if info, err := os.Lstat(real); err == nil && info.IsDir() {
return "", fmt.Errorf("%s is a folder", p)
}
return real, nil
}

// probeFileWithinRoots is withinRoots for a .probe file; the suffix is checked on the
// resolved path, so a link named x.probe that points at another file does not pass.
func probeFileWithinRoots(roots []string, p string) (string, error) {
Expand Down
Loading
Loading