From 739516b10a203960ac1f948171196599f2e72e8b Mon Sep 17 00:00:00 2001 From: Vortex Dispatch Date: Thu, 16 Jul 2026 03:51:33 +0200 Subject: [PATCH] security(figma): owner-only perms for .vxd-design context artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Figma design context (node IDs + token metadata that map back to internal design files) was written 0o755/0o644 — readable by any local user on a shared dispatch host. - figma.BuildDesignContext: dir 0o700 (chmod repairs a pre-existing looser dir), DESIGN_CONTEXT.md + PNG renders 0o600. - engine.copyDesignDir (worktree copies): same 0o700/0o600. - Test: TestFigmaBuildContext_FilePermsOwnerOnly (incl. loose-dir repair). - Docs: CLAUDE.md Figma hygiene bullet. Closes WEAKNESSES.md P0-07. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- internal/engine/design_context.go | 6 ++-- internal/figma/client_test.go | 50 +++++++++++++++++++++++++++++++ internal/figma/context.go | 13 ++++++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7d19ea..da413bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -474,7 +474,7 @@ Requirements can reference figma.com design URLs; vxd builds the UI to match the - **Interactive-once auth, communicated up front:** Figma needs an operator credential, so the FIRST Figma-referencing run breaks fire-and-forget. `vxd req` detects design URLs (`figma.ParseURLs` — design/file/proto/board forms, node-id canonicalised to `12:345`) and fails fast BEFORE any LLM spend when no credential exists, naming the interactive step: `vxd figma auth` (prints the settings URL — never auto-opens a browser — reads the token, validates via `/v1/me`, stores at `/figma.token` 0600) or `FIGMA_TOKEN` env. After that single session, runs are autonomous again. - **Design pull (`figma.BuildDesignContext`):** fetches referenced nodes (`/v1/files/:key/nodes`, depth 3) + 2x PNG renders (`/v1/images/:key`) into `/.vxd-design/` — `DESIGN_CONTEXT.md` (frame inventory with dimensions, text styles, solid fills as hex) + the rendered PNGs. Per-ref failures degrade to a note; a deleted frame never strands the requirement. - **Pipeline flow:** the planner appends the context inside `` data tags (instructing token derivation FROM the design); frontend stories get it in their goal prompt after `FrontendDesignBrief` (the reference overrides generic design guidance) and `copyDesignDir` puts the PNGs in each frontend worktree so agents can open them. `loadDesignContext` injection-scans the markdown (Figma layer names are third-party data — `sanitize.MatchInjectionPattern` hit drops the context loudly) and caps it at 16 KB. -- **Hygiene:** `.vxd-design/` is gitignored (adapter + repo patterns) and stripped from story branches (`stripVXDArtifactsFromBranch`) — design pulls are working material, not deliverables. +- **Hygiene:** `.vxd-design/` is gitignored (adapter + repo patterns) and stripped from story branches (`stripVXDArtifactsFromBranch`) — design pulls are working material, not deliverables. The directory is owner-only (`0o700` dir, `0o600` files, repaired on reuse) in both the repo root and worktree copies, because design refs map back to internal design files and must not be readable by other users on a shared dispatch host (`TestFigmaBuildContext_FilePermsOwnerOnly`). - **Tests:** `internal/figma/*_test.go` (URL parse/dedupe, token resolution chain incl. the interactive-step error text, owner-only perms, httptest client + context extraction with hex/typography assertions), `engine/design_context_test.go` (load/injection-drop/size-cap/copy + planner prompt injection), `cli/figma_test.go` (auth validates-then-stores, invalid token not stored, status paths). **NXD: not ported — Figma is a cloud API; NXD stays offline-first.** ### External-audit response 2026-07-05 (docs/audit-findings-2026-07-05.md) diff --git a/internal/engine/design_context.go b/internal/engine/design_context.go index 2557671..662b72b 100644 --- a/internal/engine/design_context.go +++ b/internal/engine/design_context.go @@ -49,8 +49,10 @@ func copyDesignDir(repoDir, worktreePath string) { if err != nil { return } + // Owner-only, matching figma.BuildDesignContext — design context must not + // be readable by other local users on a shared dispatch host. dst := filepath.Join(worktreePath, figma.DirName) - if err := os.MkdirAll(dst, 0o755); err != nil { + if err := os.MkdirAll(dst, 0o700); err != nil { log.Printf("[figma] create %s in worktree: %v", figma.DirName, err) return } @@ -63,7 +65,7 @@ func copyDesignDir(repoDir, worktreePath string) { log.Printf("[figma] copy %s: %v", e.Name(), err) continue } - if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o600); err != nil { log.Printf("[figma] write %s to worktree: %v", e.Name(), err) } } diff --git a/internal/figma/client_test.go b/internal/figma/client_test.go index 2bb1e59..3ec864e 100644 --- a/internal/figma/client_test.go +++ b/internal/figma/client_test.go @@ -181,3 +181,53 @@ func TestResolveToken_UnreadableFileIsDistinguished(t *testing.T) { t.Errorf("permission failure must be named, got %v", rerr) } } + +// TestFigmaBuildContext_FilePermsOwnerOnly pins owner-only permissions on the +// design-context artifacts: the .vxd-design dir is 0o700 and every file in it +// (markdown + PNG renders) is 0o600. Design refs map back to internal design +// files and must not be readable by other users on a shared dispatch host. +// A pre-existing dir with looser perms is repaired. +func TestFigmaBuildContext_FilePermsOwnerOnly(t *testing.T) { + srv := newFixtureServer(t) + c := NewClient("good-token") + c.BaseURL = srv.URL + + outDir := filepath.Join(t.TempDir(), DirName) + // Pre-create with loose perms — BuildDesignContext must tighten, not trust. + if err := os.MkdirAll(outDir, 0o755); err != nil { + t.Fatal(err) + } + + dc, err := BuildDesignContext(t.Context(), c, []Ref{{FileKey: "KEY1", NodeID: "12:345", RawURL: "https://figma.com/design/KEY1?node-id=12-345"}}, outDir) + if err != nil { + t.Fatalf("BuildDesignContext: %v", err) + } + if dc == nil || len(dc.Images) == 0 { + t.Fatal("fixture should produce a design context with a render") + } + + info, err := os.Stat(outDir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("design dir perms = %o, want 700", got) + } + + entries, err := os.ReadDir(outDir) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("expected files under design dir") + } + for _, e := range entries { + fi, err := e.Info() + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != 0o600 { + t.Errorf("%s perms = %o, want 600", e.Name(), got) + } + } +} diff --git a/internal/figma/context.go b/internal/figma/context.go index 5f4a41e..93b4fd8 100644 --- a/internal/figma/context.go +++ b/internal/figma/context.go @@ -33,9 +33,16 @@ func BuildDesignContext(ctx context.Context, c *Client, refs []Ref, outDir strin if len(refs) == 0 { return nil, nil } - if err := os.MkdirAll(outDir, 0o755); err != nil { + // Owner-only: the design context carries node IDs + token metadata that + // map back to internal design files — on a shared dispatch host it must + // not be readable by other local users. Chmod repairs a pre-existing dir + // left looser by an older VXD. + if err := os.MkdirAll(outDir, 0o700); err != nil { return nil, fmt.Errorf("create design dir: %w", err) } + if err := os.Chmod(outDir, 0o700); err != nil { + return nil, fmt.Errorf("tighten design dir perms: %w", err) + } var md strings.Builder md.WriteString("## DESIGN REFERENCE (pulled from Figma)\n\n") @@ -78,7 +85,7 @@ func BuildDesignContext(ctx context.Context, c *Client, refs []Ref, outDir strin continue } name := fmt.Sprintf("%s-%s.png", ref.FileKey, strings.ReplaceAll(nodeID, ":", "-")) - if err := os.WriteFile(filepath.Join(outDir, name), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(outDir, name), data, 0o600); err != nil { log.Printf("[figma] write render %s: %v", name, err) continue } @@ -95,7 +102,7 @@ func BuildDesignContext(ctx context.Context, c *Client, refs []Ref, outDir strin // Persist the markdown next to the renders so the executor can copy the // whole directory into worktrees. - if err := os.WriteFile(filepath.Join(outDir, ContextFileName), []byte(dc.Markdown), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(outDir, ContextFileName), []byte(dc.Markdown), 0o600); err != nil { return nil, fmt.Errorf("write design context: %w", err) } return dc, nil