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) <noreply@anthropic.com>
This commit is contained in:
Mathias Bergqvist
2026-05-01 10:05:48 +02:00
parent 7dcb5610fe
commit e4a94df4fc
4 changed files with 121 additions and 44 deletions

View File

@@ -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/<filename>, 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.

View File

@@ -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})
}

View File

@@ -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")
}

View File

@@ -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)
}