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})
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user