Compare commits

...
Author SHA1 Message Date
BaaRai 9ef99cfa27 chore: use the norma fork until the usage fix lands upstream
Autumn-27/Norma#3 recovers input token usage from message_delta when
message_start reports zeros (Anthropic-compatible emulations). Without it,
every streaming call meters 0 input tokens and the token dashboard hides
the entire prompt spend. The replace drops itself once the fix is released.
2026-10-12 00:54:03 +02:00
BaaRai a26ba1ebab Merge pull request #46 from BaaRai/fix/login-terms-authorized-use
fix: login terms of use now permit authorized testing
2026-10-11 23:23:31 +02:00
BaaRai cc7a42f705 fix: login terms of use now permit authorized testing
The sign-in terms still carried the upstream defensive wording that
forbade any real security testing regardless of authorization, which
contradicted both the tool purpose and the README usage terms.

Article 2 now permits testing your own systems, third-party systems
with the owner explicit authorization, and targets inside the written
scope of a bug bounty or disclosure program under its rules of
engagement, with the operator responsible for proof of authorization
and proportionality. Article 3 now prohibits testing WITHOUT
authorization or exceeding scope instead of prohibiting testing
itself. Article 6 mainland-China statute list is generalized to any
jurisdiction. Version bumped to v1.1.
2026-10-11 23:22:35 +02:00
BaaRai 3a8a4f8366 Merge pull request #45 from BaaRai/feat/ask-operator
feat: ask_operator tool - workers can request operator input (captcha/OTP) with a screenshot and pause
2026-10-11 23:06:51 +02:00
BaaRai d54e468763 feat: ask_operator tool - workers can request operator input (captcha/OTP) with a screenshot and pause
A worker hitting a wall only a human can clear (captcha, email/SMS OTP, 2FA,
a decision) calls ask_operator with a precise question and an optional
screenshot. The question lands in the intent's transcript as a user-role turn
with an attachment card (the same {text, attachments} shape chat uploads use,
so the existing transcript UI renders it natively), and the intent pauses. The
operator replies through the existing message-to-worker flow; the reply resumes
the run with the answer as the next input. Images are whitelisted (png/jpg/
jpeg/webp/gif), capped at 10MB, and copied into the task uploads dir.

Closes #41
2026-10-11 23:06:39 +02:00
BaaRai 553a9ac638 Merge pull request #44 from BaaRai/feat/mcp-health
feat: surface MCP server connect health to operators and agents
2026-10-11 23:05:38 +02:00
BaaRai 7a98373cfd Merge pull request #43 from BaaRai/feat/backup-script
feat: built-in backup script (pg_dump with rotation, optional data archive)
2026-10-11 23:05:35 +02:00
BaaRai acce5cea2b Merge pull request #42 from BaaRai/feat/skill-frontmatter-safety
fix: emit safe skill frontmatter YAML and verify it round-trips
2026-10-11 23:05:32 +02:00
BaaRai 28e6618c71 feat: surface MCP server connect health to operators and agents
Every connect attempt (discovery, refresh, and the per-agent-run wiring) now
records its outcome on the server row: last_connect_at/ok/err, written through
a change-deduped update so per-run wiring does not amplify writes. The MCP page
shows a health chip per card (ok with time, or the failure reason), and a new
read-only mcp_status tool (bound to auto, worker and pentest) lets a worker
notice that a server it expected is down and say so instead of silently
degrading while the cached tool count keeps the card looking healthy.

Closes #38
2026-10-11 22:50:00 +02:00
BaaRai 07d272727d fix: emit safe skill frontmatter YAML and verify it round-trips
Descriptions written through the API were interpolated raw into the SKILL.md
frontmatter, so a value containing a colon+space made the whole frontmatter
unparseable: every field silently stayed empty, the skill loaded under its
directory name, and a skill-gated MCP server was silently skipped. The same
applied to updates through the meta rewrite, and the shipped scopesentry skill
had a malformed name line all along.

- yamlScalar: plain-safe tokens stay bare, everything else single-quoted
- fsCreateSkill/fsUpdateSkillMeta emit safe values and re-verify the written
  file with the real skill parser (name + mcps must survive), rejecting the
  write otherwise
- warnBrokenSkills at startup logs skills whose name fell back to the
  directory (the signature of broken frontmatter)
- fix skills/scopesentry/SKILL.md name line

Closes #39
2026-10-11 22:26:22 +02:00
18 changed files with 515 additions and 22 deletions
+16 -2
View File
@@ -822,11 +822,14 @@ type MCPServer struct {
Enabled bool `json:"enabled"`
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
TimeoutSeconds int `json:"timeout_seconds"` // http: per-request timeout in seconds; 0 = default 120
LastConnectAt *time.Time `json:"last_connect_at,omitempty"`
LastConnectOK *bool `json:"last_connect_ok,omitempty"`
LastConnectErr *string `json:"last_connect_err,omitempty"`
Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache)
}
func (d *DB) ListMCP() ([]*MCPServer, error) {
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure,COALESCE(timeout_seconds,120) FROM mcp_servers ORDER BY id`)
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure,COALESCE(timeout_seconds,120),last_connect_at,last_connect_ok,last_connect_err FROM mcp_servers ORDER BY id`)
if err != nil {
return nil, err
}
@@ -834,7 +837,7 @@ func (d *DB) ListMCP() ([]*MCPServer, error) {
for rows.Next() {
var m MCPServer
var args, env []byte
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure, &m.TimeoutSeconds); err != nil {
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure, &m.TimeoutSeconds, &m.LastConnectAt, &m.LastConnectOK, &m.LastConnectErr); err != nil {
rows.Close()
return nil, err
}
@@ -914,6 +917,17 @@ ON CONFLICT (server_id, tool_name) DO UPDATE SET description=EXCLUDED.descriptio
return tx.Commit()
}
// UpdateMCPHealth records the outcome of one connect attempt. The WHERE clause
// skips the write when nothing changed, so per-run wiring does not amplify writes.
func (d *DB) UpdateMCPHealth(id int64, ok bool, errMsg string) error {
_, err := d.Exec(`UPDATE mcp_servers
SET last_connect_at = now(), last_connect_ok = $2, last_connect_err = NULLIF($3, '')
WHERE id = $1
AND (last_connect_ok IS DISTINCT FROM $2 OR last_connect_err IS DISTINCT FROM NULLIF($3, '') OR last_connect_at IS NULL)`,
id, ok, errMsg)
return err
}
func (d *DB) SaveMCP(m *MCPServer) (int64, error) {
args, env := string(m.Args), string(m.Env)
if args == "" {
+5
View File
@@ -863,6 +863,11 @@ ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAU
-- servers whose tools legitimately run minutes (e.g. binary reverse engineering) need room
-- beyond the default without forcing every other server to wait as long.
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS timeout_seconds INT NOT NULL DEFAULT 120;
-- Connect health: updated on every connect attempt (discovery, refresh, per-agent-run
-- wiring). NULL = never attempted. The WHERE-deduped update avoids write amplification.
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_at TIMESTAMPTZ;
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_ok BOOLEAN;
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_err TEXT;
DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers;
CREATE TRIGGER trg_mcp_upd BEFORE UPDATE ON mcp_servers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+2
View File
@@ -60,3 +60,5 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.4-0.20261011224847-63b38dabc837
+2 -2
View File
@@ -1,5 +1,5 @@
github.com/Autumn-27/norma v0.4.3 h1:DnmOhJPL6OZSMnCVYO5Ujh4yVINuDarEickVi9jx1hI=
github.com/Autumn-27/norma v0.4.3/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
github.com/BaaRai/Norma v0.4.4-0.20261011224847-63b38dabc837 h1:slZeNksaiCKS4XZSNnNIlmyqnfgDT/PGoPjOPrijnoE=
github.com/BaaRai/Norma v0.4.4-0.20261011224847-63b38dabc837/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/Autumn-27/artex/agent"
actool "github.com/Autumn-27/norma/tool"
)
const askOperatorMaxImage = 10 << 20
var askOperatorImageExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true}
// toolAskOperator lets a worker formally request operator input (captcha, OTP,
// 2FA, a decision): the question and an optional screenshot land in the intent's
// transcript as a user-role turn with an attachment card, and the intent pauses.
// The operator replies through the existing message-to-worker flow; the reply
// resumes the run with the answer as the next input (#41).
func (s *Server) toolAskOperator() actool.CoreTool {
return wrTool("ask_operator",
"Ask the human operator for input you cannot obtain yourself (a captcha solution, an email/SMS OTP code, a 2FA answer, a decision). The question appears in this intent's chat for the operator, optionally with a screenshot, and this intent then pauses; the operator's reply resumes this run with the answer as your next input. Ask precisely, and only when you are genuinely blocked.",
map[string]any{
"type": "object",
"properties": map[string]any{
"question": map[string]any{
"type": "string",
"description": "What you need from the operator, precisely (e.g. 'enter the 6-digit code sent to x@y', 'solve the captcha in the attached screenshot').",
},
"image_path": map[string]any{
"type": "string",
"description": "Optional path to a screenshot (png/jpg/jpeg/webp/gif, max 10MB), e.g. the browser_take_screenshot output. It is copied into the task uploads and shown to the operator.",
},
},
"required": []any{"question"},
},
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var req struct {
Question string `json:"question"`
ImagePath string `json:"image_path"`
}
_ = json.Unmarshal(in, &req)
q := strings.TrimSpace(req.Question)
if q == "" {
return actool.Errorf("Error: question is required"), nil
}
ri := agent.RunInfoFrom(ctx)
if ri.TaskID == 0 || ri.IntentID == 0 {
return actool.Errorf("Error: ask_operator can only be used inside a worker intent run"), nil
}
taskID := fmt.Sprintf("%d", ri.TaskID)
t := s.m.ResolveTask(taskID)
if t == nil {
return actool.Errorf("Error: task not found"), nil
}
att, err := stageAskOperatorImage(s.m.dir, taskID, req.ImagePath)
if err != nil {
return actool.Errorf("Error: " + err.Error()), nil
}
text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)"
act := userActivityWithAttachments("worker", text, att)
node := ri.IntentID
act.NodeID = &node
s.engine.emitActivity(t, act)
// The control call runs on a detached context: pausing may cancel the
// run's context, and the pause bookkeeping itself must complete.
if _, err := s.applyIntentControl(context.Background(), t, ri.IntentID, "pause", firstLine(q, 120), ""); err != nil {
return actool.Errorf("Error: question delivered but pausing the intent failed: " + err.Error()), nil
}
return actool.Text("Question delivered to the operator. This intent is pausing now: finish your current step, record everything you already know as facts, and stop. The operator's reply will resume this run and arrive as your next input."), nil
})
}
// stageAskOperatorImage copies the referenced image into the task's uploads dir
// (the same place chat uploads land, so the transcript attachment card resolves)
// and returns the attachment descriptor. Empty src returns no attachment.
func stageAskOperatorImage(dataDir, taskID, src string) ([]chatAttachment, error) {
src = strings.TrimSpace(src)
if src == "" {
return nil, nil
}
ext := strings.ToLower(filepath.Ext(src))
if !askOperatorImageExt[ext] {
return nil, fmt.Errorf("image_path must be a png/jpg/jpeg/webp/gif file, got %q", filepath.Base(src))
}
st, err := os.Stat(src)
if err != nil {
return nil, fmt.Errorf("image not readable: %v", err)
}
if st.Size() > askOperatorMaxImage {
return nil, fmt.Errorf("image too large (%s, max 10MB)", humanBytes(st.Size()))
}
dir := filepath.Join(dataDir, "tasks", taskID, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
name := fmt.Sprintf("ask-operator-%d%s", time.Now().Unix(), ext)
in, err := os.Open(src)
if err != nil {
return nil, err
}
defer in.Close()
out, err := os.Create(filepath.Join(dir, name))
if err != nil {
return nil, err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return nil, err
}
return []chatAttachment{{Name: name, Path: "uploads/" + name, Size: st.Size()}}, nil
}
+70
View File
@@ -0,0 +1,70 @@
package server
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStageAskOperatorImage(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "shot.png")
if err := os.WriteFile(src, []byte("fakepng"), 0o644); err != nil {
t.Fatal(err)
}
// no image -> no attachment
att, err := stageAskOperatorImage(dir, "7", "")
if err != nil || att != nil {
t.Fatalf("empty src: att=%v err=%v", att, err)
}
// valid image -> copied under the task uploads, descriptor matches the chat format
att, err = stageAskOperatorImage(dir, "7", src)
if err != nil || len(att) != 1 {
t.Fatalf("valid src: att=%v err=%v", att, err)
}
if !strings.HasPrefix(att[0].Path, "uploads/ask-operator-") || !strings.HasSuffix(att[0].Path, ".png") {
t.Fatalf("unexpected attachment path %q", att[0].Path)
}
if _, err := os.Stat(filepath.Join(dir, "tasks", "7", "uploads", att[0].Name)); err != nil {
t.Fatalf("copy missing: %v", err)
}
// wrong extension rejected
bad := filepath.Join(dir, "payload.exe")
if err := os.WriteFile(bad, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := stageAskOperatorImage(dir, "7", bad); err == nil || !strings.Contains(err.Error(), "png/jpg") {
t.Fatalf("expected extension rejection, got %v", err)
}
// missing file rejected
if _, err := stageAskOperatorImage(dir, "7", filepath.Join(dir, "nope.png")); err == nil {
t.Fatal("expected missing-file rejection")
}
}
// The emitted activity must carry the {text, attachments} Detail shape the
// transcript parser expects, so the operator sees the question with the
// attachment card for the screenshot.
func TestAskOperatorActivityShape(t *testing.T) {
att := []chatAttachment{{Name: "ask-operator-1.png", Path: "uploads/ask-operator-1.png", Size: 7}}
a := userActivityWithAttachments("worker", "OPERATOR INPUT REQUESTED: solve the captcha", att)
if a.Kind != "user" || a.Worker != "worker" {
t.Fatalf("unexpected activity kind/worker: %+v", a)
}
var parsed struct {
Text string `json:"text"`
Attachments []chatAttachment `json:"attachments"`
}
if err := json.Unmarshal([]byte(a.Detail), &parsed); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(parsed.Text, "OPERATOR INPUT REQUESTED") || len(parsed.Attachments) != 1 || parsed.Attachments[0].Path != att[0].Path {
t.Fatalf("unexpected detail payload: %+v", parsed)
}
}
+3
View File
@@ -86,14 +86,17 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
cl, err := connectMCP(ctx, m)
if err != nil {
log.Printf("[mcp] %s connect failed: %v", m.Name, err)
_ = pg.UpdateMCPHealth(m.ID, false, "connect: "+err.Error())
continue
}
closers = append(closers, cl)
ts, err := cl.Tools(ctx)
if err != nil {
log.Printf("[mcp] %s tools/list failed: %v", m.Name, err)
_ = pg.UpdateMCPHealth(m.ID, false, "tools/list: "+err.Error())
continue
}
_ = pg.UpdateMCPHealth(m.ID, true, "")
for _, t := range ts {
extra = append(extra, t)
allNames = append(allNames, t.Name())
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"strings"
"testing"
"github.com/Autumn-27/artex/db"
)
// The health columns must reflect the outcome of every connect attempt and
// transition cleanly down -> ok, so the UI badge and the mcp_status tool can
// show operators why a server's tools are missing (#38).
func TestMCPHealthStateTransitions(t *testing.T) {
s, _, _ := trafficEvidenceServer(t)
pg := s.m.pg
row := &db.MCPServer{Name: "health-test", Transport: "http", URL: "http://127.0.0.1:1/mcp", Enabled: true}
id, err := pg.SaveMCP(row)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pg.DeleteMCP(id) })
found := listMCPByName(t, pg, "health-test")
if found.LastConnectAt != nil || found.LastConnectOK != nil || found.LastConnectErr != nil {
t.Fatalf("fresh server should have no health state, got %+v", found)
}
if err := pg.UpdateMCPHealth(id, false, "connect: no route to host"); err != nil {
t.Fatal(err)
}
found = listMCPByName(t, pg, "health-test")
if found.LastConnectOK == nil || *found.LastConnectOK {
t.Fatal("expected last_connect_ok = false after a failed connect")
}
if found.LastConnectErr == nil || !strings.Contains(*found.LastConnectErr, "no route") {
t.Fatalf("expected the failure reason, got %+v", found.LastConnectErr)
}
if err := pg.UpdateMCPHealth(id, true, ""); err != nil {
t.Fatal(err)
}
found = listMCPByName(t, pg, "health-test")
if found.LastConnectOK == nil || !*found.LastConnectOK {
t.Fatal("expected last_connect_ok = true after a successful connect")
}
if found.LastConnectErr != nil {
t.Fatalf("expected the error cleared on success, got %q", *found.LastConnectErr)
}
}
func listMCPByName(t *testing.T, pg *db.DB, name string) *db.MCPServer {
t.Helper()
rows, err := pg.ListMCP()
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.Name == name {
return r
}
}
t.Fatalf("server %s not found", name)
return nil
}
+3
View File
@@ -49,13 +49,16 @@ func connectMCP(ctx context.Context, m *db.MCPServer) (mcpClient, error) {
func (s *Server) discoverAndCacheMCP(ctx context.Context, m *db.MCPServer) error {
cl, err := connectMCP(ctx, m)
if err != nil {
_ = s.m.pg.UpdateMCPHealth(m.ID, false, err.Error())
return err
}
defer cl.Close()
ts, err := cl.Tools(ctx)
if err != nil {
_ = s.m.pg.UpdateMCPHealth(m.ID, false, err.Error())
return err
}
_ = s.m.pg.UpdateMCPHealth(m.ID, true, "")
tools := make([]db.MCPTool, 0, len(ts))
for _, t := range ts {
tools = append(tools, db.MCPTool{Name: t.Name(), Description: t.Description()})
+10 -1
View File
@@ -484,7 +484,16 @@ func (s *Server) seedOrchestrationTools() {
}
for _, t := range s.platformTools() {
schema, _ := json.Marshal(t.InputSchema())
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, autoAgents)
bindings := autoAgents
if t.Name() == "mcp_status" {
// health visibility is for the executors too, not just the platform agent
bindings = json.RawMessage(`["auto","worker","pentest"]`)
}
if t.Name() == "ask_operator" {
// operator input requests are an executor concern (captcha/OTP walls)
bindings = json.RawMessage(`["worker","pentest"]`)
}
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
}
s.refreshBuiltinToolSchemas()
s.seedAutoDefaultBindings()
+52
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
@@ -17,6 +18,8 @@ import (
func (s *Server) platformTools() []actool.CoreTool {
return []actool.CoreTool{
s.toolMCPStatus(),
s.toolAskOperator(),
s.toolCreateSkill(),
s.toolUpdateSkillFile(),
s.toolCreateCustomTool(),
@@ -243,6 +246,55 @@ func (s *Server) toolUpdateCustomTool() actool.CoreTool {
})
}
// ---- MCP health ----
// toolMCPStatus reports each MCP server's last connect outcome so a worker can
// notice a missing server (and say so) instead of silently degrading when the
// catalog is cached but the server is unreachable.
func (s *Server) toolMCPStatus() actool.CoreTool {
return wrTool("mcp_status",
"Report the health of every configured MCP server: name, transport, enabled, cached tool count, and the outcome of the last connect attempt (time, ok/error). Use this when tools you expect are missing or before relying on a skill-gated server.",
map[string]any{"type": "object", "properties": map[string]any{}},
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
servers, err := s.m.pg.ListMCP()
if err != nil {
return actool.Errorf("Error: " + err.Error()), nil
}
var b strings.Builder
fmt.Fprintf(&b, "%d MCP server(s):\n", len(servers))
for _, m := range servers {
status := "never connected"
switch {
case m.LastConnectOK != nil && *m.LastConnectOK:
status = "connected ok"
case m.LastConnectOK != nil && !*m.LastConnectOK:
status = "DOWN: " + strings.TrimSpace(coalesceStr(m.LastConnectErr, "connect failed"))
}
fmt.Fprintf(&b, "- %s [%s, %s, %d cached tools]: %s", m.Name, m.Transport, enabledWord(m.Enabled), len(m.Tools), status)
if m.LastConnectAt != nil {
fmt.Fprintf(&b, " (last attempt %s)", m.LastConnectAt.UTC().Format(time.RFC3339))
}
b.WriteByte('\n')
}
b.WriteString("If a server you need is DOWN, tell the operator in your final report instead of working around it.")
return actool.Text(b.String()), nil
})
}
func coalesceStr(p *string, fallback string) string {
if p == nil {
return fallback
}
return *p
}
func enabledWord(b bool) string {
if b {
return "enabled"
}
return "disabled"
}
// ---- MCP ----
type mcpToolInput struct {
+4 -3
View File
@@ -219,9 +219,10 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
}
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // wire visible skills/MCP + traffic/orchestration host tools into the agent toolset
domainReg := buildDomainReg(m.Assets())
wireTools(m.pg, domainReg) // built-in tools table: filter per agent + override description/schema + inject defaults
seedPrompts(m.pg) // seed built-in agent default prompt bodies into agent_prompts (only when empty)
s.seedOrchestrationTools() // seed P2 cross-task orchestration tools into the tools table (bindable per agent)
wireTools(m.pg, domainReg) // built-in tools table: filter per agent + override description/schema + inject defaults
seedPrompts(m.pg) // seed built-in agent default prompt bodies into agent_prompts (only when empty)
warnBrokenSkills(s.skillDir) // surface skills whose frontmatter silently failed to parse
s.seedOrchestrationTools() // seed P2 cross-task orchestration tools into the tools table (bindable per agent)
if err := s.seedFindingRetester(); err != nil {
log.Printf("[retester] seed: %v", err)
}
+72 -8
View File
@@ -1108,17 +1108,21 @@ func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
// Markdown body: step-by-step instructions
var sb strings.Builder
sb.WriteString("---\n")
fmt.Fprintf(&sb, "name: %s\n", body.Name)
fmt.Fprintf(&sb, "description: %s\n", body.Description)
fmt.Fprintf(&sb, "name: %s\n", yamlScalar(body.Name))
fmt.Fprintf(&sb, "description: %s\n", yamlScalar(body.Description))
if body.License != "" {
fmt.Fprintf(&sb, "license: %s\n", body.License)
fmt.Fprintf(&sb, "license: %s\n", yamlScalar(body.License))
}
if body.Compatibility != "" {
fmt.Fprintf(&sb, "compatibility: %s\n", body.Compatibility)
fmt.Fprintf(&sb, "compatibility: %s\n", yamlScalar(body.Compatibility))
}
// mcps: MCP servers this skill unlocks on load (skill-gated deferred tools).
if mcps := cleanStrs(body.MCPs); len(mcps) > 0 {
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(mcps, ", "))
quoted := make([]string, len(mcps))
for i, m := range mcps {
quoted[i] = yamlScalar(m)
}
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(quoted, ", "))
}
sb.WriteString("---\n")
if strings.TrimSpace(body.Instructions) != "" {
@@ -1134,6 +1138,14 @@ func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
// Verify with the real parser: a description containing ": " (or any value that
// is not plain-safe YAML) used to break the whole frontmatter silently, leaving
// a skill whose fields were all empty and whose name came from the directory.
if err := verifySkillFile(skillPath, body.Name, cleanStrs(body.MCPs)); err != nil {
_ = os.RemoveAll(skillPath)
writeErr(w, 400, "generated SKILL.md does not round-trip through the skill parser: "+err.Error())
return
}
writeJSON(w, 201, map[string]any{"name": body.Name})
}
@@ -1176,6 +1188,54 @@ func (s *Server) fsUpdateSkillMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true})
}
// yamlScalar renders one frontmatter scalar so the emitted YAML is valid by
// construction: plain-safe tokens stay bare, everything else is single-quoted
// with internal quotes doubled (YAML single-quote escaping).
func yamlScalar(s string) string {
if s != "" && !strings.ContainsAny(s, ":#{}[]&*!|>'\"%@`,\n") && !strings.HasPrefix(s, " ") && !strings.HasSuffix(s, " ") {
return s
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// verifySkillFile re-loads a written SKILL.md with the real skill parser and
// asserts the name and the mcps list survived the round trip.
func verifySkillFile(skillPath, wantName string, wantMCPs []string) error {
reg, err := skill.LoadDir(skillPath)
if err != nil {
return fmt.Errorf("load: %w", err)
}
for _, s := range reg.List() {
if s.Dir == "" {
continue
}
if s.Name != wantName {
return fmt.Errorf("parsed name %q != written %q (frontmatter did not parse; the name fell back to the directory)", s.Name, wantName)
}
if len(wantMCPs) > 0 && len(s.MCPs) != len(wantMCPs) {
return fmt.Errorf("parsed mcps %v != written %v", s.MCPs, wantMCPs)
}
return nil
}
return fmt.Errorf("no skill loaded from %s", skillPath)
}
// warnBrokenSkills logs skills whose parsed name does not match their directory
// name: the signature of frontmatter that failed to parse and silently fell back
// (fields empty, mcps gate lost). Best-effort, startup only.
func warnBrokenSkills(dir string) {
reg, err := skill.LoadDir(dir)
if err != nil {
return
}
for _, s := range reg.List() {
if s.Dir == "" || s.Name == filepath.Base(s.Dir) {
continue
}
log.Printf("[skill] %s: frontmatter did not parse (loaded under the directory name; description and mcps are empty) - fix the YAML in %s", s.Name, filepath.Join(s.Dir, "SKILL.md"))
}
}
// rewriteSkillFrontmatter parses the SKILL.md YAML frontmatter and replaces the
// fields given as non-nil pointers. The instruction body (after the closing ---) is
// preserved verbatim. Returns an error if the file has no recognisable frontmatter.
@@ -1211,11 +1271,11 @@ func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, licens
}
for i, p := range pairs {
if p.k == key {
pairs[i].v = strings.TrimSpace(*val)
pairs[i].v = yamlScalar(strings.TrimSpace(*val))
return
}
}
pairs = append(pairs, kv{key, strings.TrimSpace(*val)})
pairs = append(pairs, kv{key, yamlScalar(strings.TrimSpace(*val))})
}
applyStr("description", description)
applyStr("license", license)
@@ -1231,7 +1291,11 @@ func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, licens
}
pairs = filtered
if len(cleaned) > 0 {
pairs = append(pairs, kv{"mcps", strings.Join(cleaned, ", ")})
quoted := make([]string, len(cleaned))
for i, m := range cleaned {
quoted[i] = yamlScalar(m)
}
pairs = append(pairs, kv{"mcps", strings.Join(quoted, ", ")})
}
}
// Reconstruct
+72
View File
@@ -0,0 +1,72 @@
package server
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Autumn-27/norma/skill"
)
func TestYamlScalar(t *testing.T) {
cases := []struct{ in, want string }{
{"rea", "rea"},
{"api-recon", "api-recon"},
{"plain value with spaces", "plain value with spaces"},
{"target: embedded", "'target: embedded'"},
{"it's quoted", "'it''s quoted'"},
{"ends #now", "'ends #now'"},
{"comma, separated", "'comma, separated'"},
{" leading", "' leading'"},
{"", "''"},
}
for _, c := range cases {
if got := yamlScalar(c.in); got != c.want {
t.Errorf("yamlScalar(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// A SKILL.md written the way fsCreateSkill now writes it (yamlScalar values)
// must round-trip through the real parser with name and mcps intact, even with
// a colon+space and quotes in the description. The pre-fix format (raw
// interpolation) fails exactly there and falls back to the directory name.
func TestSkillFrontmatterRoundTrip(t *testing.T) {
dir := t.TempDir()
skillDir := filepath.Join(dir, "colon-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
desc := "Analyze target: embedded secrets, it's binary"
content := fmt.Sprintf("---\nname: %s\ndescription: %s\nmcps: %s\n---\nbody instructions\n",
yamlScalar("colon-skill"), yamlScalar(desc), yamlScalar("rea"))
skillMD := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillMD, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := verifySkillFile(skillDir, "colon-skill", []string{"rea"}); err != nil {
t.Fatalf("safe format failed verification: %v", err)
}
reg, err := skill.LoadDir(skillDir)
if err != nil || len(reg.List()) != 1 {
t.Fatalf("LoadDir: %v (%d skills)", err, len(reg.List()))
}
s := reg.List()[0]
if s.Name != "colon-skill" || s.Description != desc || len(s.MCPs) != 1 || s.MCPs[0] != "rea" {
t.Fatalf("round-trip mismatch: name=%q desc=%q mcps=%v", s.Name, s.Description, s.MCPs)
}
// the old, unsafe emission: unquoted colon+space breaks the whole frontmatter
unsafe := fmt.Sprintf("---\nname: %s\ndescription: %s\nmcps: rea\n---\nbody\n", "colon-skill", desc)
if err := os.WriteFile(skillMD, []byte(unsafe), 0o644); err != nil {
t.Fatal(err)
}
if err := verifySkillFile(skillDir, "colon-skill", []string{"rea"}); err == nil {
t.Fatal("unsafe format unexpectedly verified")
} else if !strings.Contains(err.Error(), "fell back") {
t.Fatalf("unexpected error: %v", err)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
---
## name: scopesentry-mcp
name: scopesentry-mcp
description: Manage the security scanning platform (projects, tasks, templates, assets, nodes) through the ScopeSentry MCP. Use when the user mentions ScopeSentry, MCP, API Key, scan tasks, or asset queries.
# ScopeSentry MCP usage guide
+4 -5
View File
@@ -158,7 +158,7 @@ export default function LoginPage() {
<div className="space-y-0.5">
<DialogTitle className="text-base">ARTEX Terms of Use and Disclaimer</DialogTitle>
<p className="text-xs text-muted-foreground">
Version v1.0 · Effective 2026-09-18 · Please read all the following terms in full before signing in
Version v1.1 · Effective 2026-10-11 · Please read all the following terms in full before signing in
</p>
</div>
</DialogHeader>
@@ -199,7 +199,7 @@ export default function LoginPage() {
Article 2 · Permitted Scope of Use
</h4>
<p className="pl-7">
This software is for personal study, code research, exploring security-technique principles, and technical verification in a locally isolated environment you set up yourself — non-offensive, non-destructive uses such as learning, academic research, and code review. Beyond the cases expressly permitted by this Article, you must not use this software for any other purpose.
This software is a security-testing platform. You may use it for: personal study, code research, and technical verification in environments you set up yourself; security testing of systems you own; security testing of third-party systems when you hold the owner's explicit authorization; and testing within the written scope of a bug bounty or vulnerability disclosure program, in compliance with that program's rules of engagement. You are responsible for obtaining and keeping proof of authorization, for staying inside the authorized scope, and for the safety and proportionality of every action the software takes on your behalf.
</p>
</section>
@@ -213,9 +213,8 @@ export default function LoginPage() {
</h4>
<ul className="ml-7 list-decimal space-y-1.5 rounded-lg border border-destructive/20 bg-destructive/5 p-3 pl-8 text-foreground/80 marker:text-destructive/70">
<li>
Strictly forbidden: scanning, probing, exploiting, or attacking any website, online service, or networked system owned by others or third parties (regardless of authorization, and even if it is your own asset);
Strictly forbidden: scanning, probing, exploiting, or attacking any system whose owner has not explicitly authorized you to test it, or exceeding the authorized scope or a program's rules of engagement once granted;
</li>
<li>Strictly forbidden: using this software for any real penetration testing, attack-defense engagement, red-team/blue-team drill, or production environment;</li>
<li>Strictly forbidden: using this software for illegal intrusion, data theft, extortion, denial of service (DoS/DDoS), or any destructive or criminal activity;</li>
<li>Strictly forbidden: removing, tampering with, or circumventing any copyright, license, or security notice in this software or its output;</li>
<li>Strictly forbidden: engaging in any conduct that violates the laws, regulations, or regulatory rules of your country or region.</li>
@@ -255,7 +254,7 @@ export default function LoginPage() {
Article 6 · Compliance and Legal Liability
</h4>
<p className="pl-7">
You must comply on your own with all laws and regulations of your country or region on cybersecurity, data security and personal-information protection, computer crime, and the like (in mainland China including, without limitation, the Cybersecurity Law, the Data Security Law, the Personal Information Protection Law, and related judicial interpretations).
You must comply on your own with all laws and regulations of your country or region on cybersecurity, data security and personal-information protection, computer crime, and the like (for example, laws on unauthorized access to computer systems, on personal-data protection, and on the responsibilities of security researchers and penetration testers in your jurisdiction).
<span className="font-medium text-foreground">
{" "}
All legal liability and consequences arising from your violation of the above laws and regulations or of this Statement are borne by you alone and have nothing to do with this software's authors and contributors.
+13
View File
@@ -404,6 +404,19 @@ export default function MCPPage() {
<Badge variant="outline" className="uppercase">
{s.transport}
</Badge>
{s.last_connect_ok === false ? (
<Badge variant="destructive" className="max-w-55 truncate font-normal" title={s.last_connect_err ?? "connect failed"}>
down: {s.last_connect_err ? s.last_connect_err.slice(0, 60) : "connect failed"}
</Badge>
) : s.last_connect_ok === true ? (
<Badge variant="secondary" className="font-normal">
ok {s.last_connect_at ? new Date(s.last_connect_at).toLocaleTimeString() : ""}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground font-normal">
not connected yet
</Badge>
)}
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Switch
checked={s.enabled}
+3
View File
@@ -1179,6 +1179,9 @@ export interface MCPServer {
enabled: boolean;
insecure?: boolean; // http: skip TLS cert verification (self-signed servers)
timeout_seconds?: number; // http/sse: per-request timeout in seconds (default 120)
last_connect_at?: string | null; // last connect attempt (ISO time)
last_connect_ok?: boolean | null; // outcome of the last connect attempt
last_connect_err?: string | null; // failure reason when last_connect_ok is false
tools?: string[]; // mcp_tools_cache (names only, for the count)
}