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:
@@ -85,6 +85,57 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]any{"results": results})
|
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/.
|
// Write handles POST /write — write raw content to brain/knowledge/.
|
||||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||||
var req writeRequest
|
var req writeRequest
|
||||||
@@ -92,53 +143,13 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Content == "" {
|
relPath, err := WriteNote(h.brainDir, req.Content, req.Filename, req.Type, req.Domain)
|
||||||
writeError(w, http.StatusBadRequest, "content is required")
|
if err != nil {
|
||||||
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 {
|
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "write error")
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeJSON(w, map[string]string{"path": relPath})
|
||||||
rel, _ := filepath.Rel(h.brainDir, dest)
|
|
||||||
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingest handles POST /ingest — run the pipeline on provided content.
|
// Ingest handles POST /ingest — run the pipeline on provided content.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"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})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,47 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
text := content[0].(map[string]any)["text"].(string)
|
text := content[0].(map[string]any)["text"].(string)
|
||||||
assert.Contains(t, text, "tdd.md")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
switch name {
|
switch name {
|
||||||
case "brain_query":
|
case "brain_query":
|
||||||
return s.brainQuery(ctx, args)
|
return s.brainQuery(ctx, args)
|
||||||
|
case "brain_write":
|
||||||
|
return s.brainWrite(ctx, args)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user