Compare commits
2
Commits
v0.21.1
...
21a53c4159
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a53c4159 | ||
|
|
99fedeaa98 |
@@ -20,6 +20,7 @@ const defaultBrainURL = "http://koala:30330"
|
|||||||
// shell-friendly client; MCP framing is unnecessary.
|
// shell-friendly client; MCP framing is unnecessary.
|
||||||
type brainClient struct {
|
type brainClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ func newBrainClient() *brainClient {
|
|||||||
}
|
}
|
||||||
return &brainClient{
|
return &brainClient{
|
||||||
baseURL: u,
|
baseURL: u,
|
||||||
|
token: os.Getenv("BRAIN_MCP_TOKEN"),
|
||||||
http: &http.Client{Timeout: 5 * time.Second},
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,7 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.authorize(req)
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,6 +108,7 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.authorize(req)
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,3 +162,10 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
|||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authorize adds the bearer token when one is configured.
|
||||||
|
func (c *brainClient) authorize(req *http.Request) {
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI
|
||||||
|
// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call.
|
||||||
|
func TestBrainClientSendsBrainMCPToken(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = append(got, r.URL.Path+" "+r.Header.Get("Authorization"))
|
||||||
|
if r.URL.Path == "/write" {
|
||||||
|
_, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token")
|
||||||
|
|
||||||
|
c := newBrainClient()
|
||||||
|
_, err := c.Query(context.Background(), "x", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) {
|
||||||
|
var auth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth = r.Header.Get("Authorization")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
t.Setenv("BRAIN_MCP_TOKEN", "")
|
||||||
|
_, err := newBrainClient().Query(context.Background(), "x", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, auth)
|
||||||
|
}
|
||||||
@@ -504,17 +504,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /query", h.Query)
|
|
||||||
mux.HandleFunc("POST /write", h.Write)
|
|
||||||
mux.HandleFunc("POST /index", h.Index)
|
|
||||||
mux.HandleFunc("POST /ingest", h.Ingest)
|
|
||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
|
||||||
mux.HandleFunc("GET /pending", h.Pending)
|
|
||||||
mux.HandleFunc("POST /promote", h.Promote)
|
|
||||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
|
||||||
if webhookHandler != nil {
|
if webhookHandler != nil {
|
||||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
}
|
}
|
||||||
@@ -532,6 +521,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||||
|
// The REST API, authenticated as a whole (hyperguild#149). Same credential
|
||||||
|
// as /mcp: callers (brain-ui, brain-ingest-ntfy, the hyperguild CLI) send
|
||||||
|
// BRAIN_MCP_TOKEN. Registered after the public routes on purpose only for
|
||||||
|
// reading order; ServeMux precedence is by pattern specificity.
|
||||||
|
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||||
|
return chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, next)
|
||||||
|
})
|
||||||
|
|
||||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||||
// tracker to file action items, so it only mounts when Gitea is
|
// tracker to file action items, so it only mounts when Gitea is
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountREST registers the REST API on its own mux and mounts it at "/" behind
|
||||||
|
// auth (hyperguild#149). Authentication is a property of the mux, not of each
|
||||||
|
// route: anything not registered on the outer mux as explicitly public (/mcp,
|
||||||
|
// /capture, the HMAC webhook, OAuth metadata, /metrics) lands here and is
|
||||||
|
// authenticated, including routes added later. Before this, only /mcp was
|
||||||
|
// wrapped, and the REST routes beside it were open; infra#517 found them on
|
||||||
|
// the internet.
|
||||||
|
func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) {
|
||||||
|
rest := http.NewServeMux()
|
||||||
|
rest.HandleFunc("POST /query", h.Query)
|
||||||
|
rest.HandleFunc("POST /write", h.Write)
|
||||||
|
rest.HandleFunc("POST /index", h.Index)
|
||||||
|
rest.HandleFunc("POST /ingest", h.Ingest)
|
||||||
|
rest.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
|
rest.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
|
rest.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
rest.HandleFunc("GET /pending", h.Pending)
|
||||||
|
rest.HandleFunc("POST /promote", h.Promote)
|
||||||
|
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
|
rest.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
|
mux.Handle("/", auth(rest))
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
const routesTok = "routes-test-token"
|
||||||
|
|
||||||
|
// hyperguild#149: only /mcp was authenticated; every REST route on the same
|
||||||
|
// mux was open, and infra#517 found them on the internet. The REST API is now
|
||||||
|
// mounted at "/" behind the bearer middleware, so any route not registered
|
||||||
|
// as explicitly public is authenticated, including routes added later.
|
||||||
|
func testRouter(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||||
|
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||||
|
return chassisauth.BearerMiddleware(routesTok, nil, "brain", "", next)
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, h http.Handler, method, path, authz string) int {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) {
|
||||||
|
r := testRouter(t)
|
||||||
|
for _, rt := range []struct{ method, path string }{
|
||||||
|
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
||||||
|
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
||||||
|
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
||||||
|
} {
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, ""), "%s %s without a token", rt.method, rt.path)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, "Bearer wrong"), "%s %s with a wrong token", rt.method, rt.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRESTRoutesServeAValidToken(t *testing.T) {
|
||||||
|
r := testRouter(t)
|
||||||
|
assert.NotEqual(t, http.StatusUnauthorized, do(t, r, "POST", "/query", "Bearer "+routesTok))
|
||||||
|
assert.Equal(t, http.StatusOK, do(t, r, "GET", "/pending", "Bearer "+routesTok))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-deny: a path nobody registered is still behind auth, so a route
|
||||||
|
// added to the REST mux later cannot ship open by omission.
|
||||||
|
func TestUnregisteredPathIsAuthenticatedByDefault(t *testing.T) {
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, do(t, testRouter(t), "POST", "/some-future-route", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly public routes registered on the outer mux keep working.
|
||||||
|
func TestMetricsStaysOpenForPrometheus(t *testing.T) {
|
||||||
|
assert.Equal(t, http.StatusOK, do(t, testRouter(t), "GET", "/metrics", ""))
|
||||||
|
}
|
||||||
@@ -447,6 +447,12 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "path is required")
|
writeError(w, http.StatusBadRequest, "path is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resolved, err := ResolveIngestPath(h.brainDir, req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Path = resolved
|
||||||
|
|
||||||
info, err := os.Stat(req.Path)
|
info, err := os.Stat(req.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -469,6 +475,12 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !isSupportedExtension(ext) {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Each walked file is re-resolved: a symlinked file inside raw/
|
||||||
|
// would otherwise lead extract.Text out of the inbox (#149).
|
||||||
|
if _, rerr := ResolveIngestPath(h.brainDir, path); rerr != nil {
|
||||||
|
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
content, readErr := extract.Text(path)
|
content, readErr := extract.Text(path)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||||
|
|||||||
@@ -361,10 +361,11 @@ func TestIngestPath_MissingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_File(t *testing.T) {
|
func TestIngestPath_File(t *testing.T) {
|
||||||
_, h := setup(t)
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
// Create a temp file with content
|
// Create a temp file with content
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
f := filepath.Join(dir, "doc.md")
|
f := filepath.Join(dir, "doc.md")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
||||||
|
|
||||||
@@ -468,10 +469,11 @@ func TestIngestRaw_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_Directory(t *testing.T) {
|
func TestIngestPath_Directory(t *testing.T) {
|
||||||
_, h := setup(t)
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
// Create a temp dir with one .md file
|
// Create a temp dir with one .md file
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
|||||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
_, h := setup(t)
|
brainDir, h := setup(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
f := filepath.Join(dir, "doc.docx")
|
f := filepath.Join(dir, "doc.docx")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrOutsideIngestRoot means an ingest path does not resolve inside brain/raw/.
|
||||||
|
var ErrOutsideIngestRoot = errors.New("ingest path must be inside brain/raw/")
|
||||||
|
|
||||||
|
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
|
||||||
|
// inbox the watcher already owns (hyperguild#149). Relative paths resolve
|
||||||
|
// against that root; symlinks are evaluated before the containment check, so
|
||||||
|
// a link inside raw/ that points elsewhere is refused. The path must exist.
|
||||||
|
func ResolveIngestPath(brainDir, p string) (string, error) {
|
||||||
|
if strings.TrimSpace(p) == "" {
|
||||||
|
return "", fmt.Errorf("%w: path is empty", ErrOutsideIngestRoot)
|
||||||
|
}
|
||||||
|
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: inbox unavailable: %v", ErrOutsideIngestRoot, err)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(root, p)
|
||||||
|
}
|
||||||
|
real, err := filepath.EvalSymlinks(filepath.Clean(p))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrOutsideIngestRoot, err)
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, real)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||||
|
return "", ErrOutsideIngestRoot
|
||||||
|
}
|
||||||
|
return real, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: /ingest-path and MCP brain_ingest read a caller-chosen
|
||||||
|
// server path, so any file the pod could read (the ServiceAccount token,
|
||||||
|
// /etc/passwd) could be pulled into the brain and read back out. Ingest paths
|
||||||
|
// now resolve inside brain/raw/ — the inbox the watcher already owns — with
|
||||||
|
// symlinks evaluated, or are refused.
|
||||||
|
func TestResolveIngestPathConfinesToRawInbox(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
raw := filepath.Join(brain, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(raw, "sub"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "sub", "ok.md"), []byte("x"), 0o644))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("s"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "link.md")))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(raw, "dirlink")))
|
||||||
|
|
||||||
|
for _, ok := range []string{filepath.Join(raw, "sub", "ok.md"), "sub/ok.md", raw, "sub"} {
|
||||||
|
_, err := api.ResolveIngestPath(brain, ok)
|
||||||
|
assert.NoError(t, err, ok)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
"/etc/hostname", outside, "../secret.txt", filepath.Join(raw, "..", "wiki"),
|
||||||
|
filepath.Join(raw, "link.md"), filepath.Join(raw, "dirlink", "secret.txt"),
|
||||||
|
raw + "-evil/x.md", "",
|
||||||
|
} {
|
||||||
|
_, err := api.ResolveIngestPath(brain, bad)
|
||||||
|
assert.Error(t, err, "must refuse %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPathRefusesAPathOutsideTheInbox(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": "/etc/hostname"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "raw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory ingest must not follow a symlinked FILE inside raw/ out of it.
|
||||||
|
func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
raw := filepath.Join(dir, "raw", "batch")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\nSENTINEL-149-OUTSIDE"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "leak.md")))
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": "batch", "source": "walk"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
||||||
|
assert.Contains(t, rec.Body.String(), "skipped")
|
||||||
|
assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE")
|
||||||
|
}
|
||||||
@@ -543,6 +543,12 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
|
// Confined to brain/raw/ with symlinks evaluated (hyperguild#149).
|
||||||
|
resolved, err := api.ResolveIngestPath(s.brainDir, a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Path = resolved
|
||||||
text, err := extract.Text(a.Path)
|
text, err := extract.Text(a.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: %w", err)
|
return nil, fmt.Errorf("extract: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#149: brain_ingest's `path` read any file the pod could read and
|
||||||
|
// ingested it into the brain, where brain_query reads it back out.
|
||||||
|
func TestBrainIngestRefusesAPathOutsideTheRawInbox(t *testing.T) {
|
||||||
|
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
return `[{"title":"Leak","type":"concept","content":"x"}]`, nil
|
||||||
|
}}
|
||||||
|
srv := mcp.NewServer(newBrainRepo(t), &stub, nil, nil)
|
||||||
|
resp := authCall(t, srv, "", "brain_ingest", map[string]any{"path": "/etc/hostname"})
|
||||||
|
require.NotNil(t, resp["error"], "a path outside brain/raw/ must be refused")
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"], "brain/raw/")
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,10 @@ func Query(ctx context.Context, baseURL, query string, limit int) (string, error
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
// The REST API requires a bearer token since hyperguild#149.
|
||||||
|
if tok := os.Getenv("BRAIN_MCP_TOKEN"); tok != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,3 +65,18 @@ func TestQueryUnavailableServerReturnsEmpty(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, result)
|
assert.Empty(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hyperguild#149: the REST API requires a bearer token.
|
||||||
|
func TestQuerySendsBrainMCPToken(t *testing.T) {
|
||||||
|
var auth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth = r.Header.Get("Authorization")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("BRAIN_MCP_TOKEN", "lib-test-token")
|
||||||
|
_, _ = brain.Query(context.Background(), srv.URL, "x", 1)
|
||||||
|
if auth != "Bearer lib-test-token" {
|
||||||
|
t.Fatalf("Authorization = %q, want the bearer token", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user