From e4a94df4fcb6c17cf9182fc0311ace275b5eea33 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 1 May 2026 10:05:48 +0200 Subject: [PATCH] feat(ingestion): extract WriteNote helper and add brain_write MCP tool api.WriteNote captures the file-write logic that was previously inline in Handler.Write. The existing HTTP endpoint now delegates to it; the new MCP brain_write tool reuses the same function. Path-traversal guard is strengthened to explicitly reject filenames containing path separators or "..", so the rejection is surfaced before filepath.Base strips the suspicious component (the previous defense-in-depth prefix check became unreachable for these inputs after Base normalisation). HTTP error code for caller-input errors shifts from 500 to 400, which is semantically correct and not exercised by any existing test. Co-Authored-By: Claude Opus 4.7 (1M context) --- ingestion/internal/api/handler.go | 99 ++++++++++++++----------- ingestion/internal/mcp/handlers.go | 20 +++++ ingestion/internal/mcp/handlers_test.go | 44 +++++++++++ ingestion/internal/mcp/server.go | 2 + 4 files changed, 121 insertions(+), 44 deletions(-) diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index b9bd28e..90932f5 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -85,6 +85,57 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"results": results}) } +// WriteNote writes a markdown file to brainDir/knowledge/, optionally +// prefixed with YAML frontmatter built from typ and domain. Returns the path +// relative to brainDir (forward-slashed). Filename traversal is rejected. +func WriteNote(brainDir, content, filename, typ, domain string) (string, error) { + if content == "" { + return "", fmt.Errorf("content is required") + } + if filename == "" { + filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405")) + } + + rawDir := filepath.Join(brainDir, "knowledge") + if err := os.MkdirAll(rawDir, 0o755); err != nil { + return "", fmt.Errorf("create raw dir: %w", err) + } + + finalContent := content + if typ != "" || domain != "" { + var fm strings.Builder + fm.WriteString("---\n") + if typ != "" { + fmt.Fprintf(&fm, "type: %s\n", typ) + } + if domain != "" { + fmt.Fprintf(&fm, "domain: %s\n", domain) + } + fm.WriteString("---\n") + finalContent = fm.String() + content + } + + // Reject path separators outright; any non-flat filename is misuse. + if strings.ContainsAny(filename, `/\`) { + return "", fmt.Errorf("invalid filename") + } + base := filepath.Base(filename) + // After Base, "." and ".." remain. Reject those before adding .md. + if base == "." || base == ".." || base == "" { + return "", fmt.Errorf("invalid filename") + } + if !strings.HasSuffix(base, ".md") { + base += ".md" + } + dest := filepath.Join(rawDir, base) + if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil { + return "", fmt.Errorf("write: %w", err) + } + + rel, _ := filepath.Rel(brainDir, dest) + return filepath.ToSlash(rel), nil +} + // Write handles POST /write — write raw content to brain/knowledge/. func (h *Handler) Write(w http.ResponseWriter, r *http.Request) { var req writeRequest @@ -92,53 +143,13 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid JSON") return } - if req.Content == "" { - writeError(w, http.StatusBadRequest, "content is required") - return - } - - filename := req.Filename - if filename == "" { - filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405")) - } - - rawDir := filepath.Join(h.brainDir, "knowledge") - if err := os.MkdirAll(rawDir, 0o755); err != nil { - writeError(w, http.StatusInternalServerError, "failed to create raw dir") - return - } - - finalContent := req.Content - if req.Type != "" || req.Domain != "" { - var fm strings.Builder - fm.WriteString("---\n") - if req.Type != "" { - fmt.Fprintf(&fm, "type: %s\n", req.Type) - } - if req.Domain != "" { - fmt.Fprintf(&fm, "domain: %s\n", req.Domain) - } - fm.WriteString("---\n") - finalContent = fm.String() + req.Content - } - - base := filepath.Base(filename) - if !strings.HasSuffix(base, ".md") { - base += ".md" - } - dest := filepath.Join(rawDir, base) - if !strings.HasPrefix(filepath.Clean(dest)+string(os.PathSeparator), filepath.Clean(rawDir)+string(os.PathSeparator)) { - writeError(w, http.StatusBadRequest, "invalid filename") - return - } - if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil { + relPath, err := WriteNote(h.brainDir, req.Content, req.Filename, req.Type, req.Domain) + if err != nil { h.logger.Error("write failed", "err", err) - writeError(w, http.StatusInternalServerError, "write error") + writeError(w, http.StatusBadRequest, err.Error()) return } - - rel, _ := filepath.Rel(h.brainDir, dest) - writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)}) + writeJSON(w, map[string]string{"path": relPath}) } // Ingest handles POST /ingest — run the pipeline on provided content. diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 0e18284..55d7998 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" + "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/search" ) @@ -102,3 +103,22 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw } return json.Marshal(map[string]any{"results": results}) } + +type brainWriteArgs struct { + Content string `json:"content"` + Filename string `json:"filename,omitempty"` + Type string `json:"type,omitempty"` + Domain string `json:"domain,omitempty"` +} + +func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainWriteArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + relPath, err := api.WriteNote(s.brainDir, a.Content, a.Filename, a.Type, a.Domain) + if err != nil { + return nil, err + } + return json.Marshal(map[string]string{"path": relPath}) +} diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go index e926e6b..0cd3127 100644 --- a/ingestion/internal/mcp/handlers_test.go +++ b/ingestion/internal/mcp/handlers_test.go @@ -50,3 +50,47 @@ func TestBrainQueryReturnsResults(t *testing.T) { text := content[0].(map[string]any)["text"].(string) assert.Contains(t, text, "tdd.md") } + +func TestBrainWriteCreatesFile(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil) + + resp := toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Test\n\nbody", + "filename": "test.md", + "type": "note", + "domain": "personal", + }) + require.Nil(t, resp["error"]) + + got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md")) + require.NoError(t, err) + assert.Contains(t, string(got), "type: note") + assert.Contains(t, string(got), "domain: personal") + assert.Contains(t, string(got), "# Test") +} + +func TestBrainWriteRejectsTraversal(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil) + + resp := toolCall(t, srv, "brain_write", map[string]any{ + "content": "x", + "filename": "../escape.md", + }) + require.NotNil(t, resp["error"]) +} + +func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil) + + resp := toolCall(t, srv, "brain_write", map[string]any{ + "content": "x", + "filename": "notes..draft.md", + }) + require.Nil(t, resp["error"]) + + _, err := os.Stat(filepath.Join(brainDir, "knowledge", "notes..draft.md")) + require.NoError(t, err, "filename with embedded .. should be allowed") +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index e955fe7..56e97af 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -118,6 +118,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa switch name { case "brain_query": return s.brainQuery(ctx, args) + case "brain_write": + return s.brainWrite(ctx, args) default: return nil, fmt.Errorf("unknown tool: %s", name) }