From 4ffd14ea2ea26d7585cf8824ee22c0852e2c3d58 Mon Sep 17 00:00:00 2001 From: Satarupa22-SD Date: Sun, 1 Mar 2026 23:06:44 +0530 Subject: [PATCH 1/4] add evidence parser to gemara-mcp --- Dockerfile | 1 + internal/evidence/evidence.go | 32 +++ internal/evidence/evidence_test.go | 308 ++++++++++++++++++++++++ internal/evidence/mapper.go | 83 +++++++ internal/evidence/parsers/kubernetes.go | 125 ++++++++++ internal/evidence/parsers/markdown.go | 73 ++++++ internal/evidence/parsers/yaml.go | 68 ++++++ internal/evidence/pipeline.go | 75 ++++++ internal/tool/mode.go | 3 + internal/tool/parse.go | 103 ++++++++ internal/tool/parse_test.go | 157 ++++++++++++ 11 files changed, 1028 insertions(+) create mode 100644 internal/evidence/evidence.go create mode 100644 internal/evidence/evidence_test.go create mode 100644 internal/evidence/mapper.go create mode 100644 internal/evidence/parsers/kubernetes.go create mode 100644 internal/evidence/parsers/markdown.go create mode 100644 internal/evidence/parsers/yaml.go create mode 100644 internal/evidence/pipeline.go create mode 100644 internal/tool/parse.go create mode 100644 internal/tool/parse_test.go diff --git a/Dockerfile b/Dockerfile index 9ef6db5..1848404 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM golang:1.25.4-alpine AS builder # Install build dependencies diff --git a/internal/evidence/evidence.go b/internal/evidence/evidence.go new file mode 100644 index 0000000..e961242 --- /dev/null +++ b/internal/evidence/evidence.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package evidence + +import "context" + +type EvidenceChunk struct { + Text string + SourceID string + SectionPath string + Confidence float64 +} + +type SchemaCandidate struct { + TargetField string `json:"field"` + Value string `json:"value"` + SourceRef string `json:"source"` + Confidence float64 `json:"confidence"` +} + +// EvidenceSource describes the raw input to the evidence pipeline. +type EvidenceSource struct { + // Content is the raw document content. + Content []byte + Format string + ID string +} +type EvidenceParser interface { + CanHandle(source EvidenceSource) bool + Parse(ctx context.Context, source EvidenceSource) ([]EvidenceChunk, error) + Name() string +} diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go new file mode 100644 index 0000000..3280912 --- /dev/null +++ b/internal/evidence/evidence_test.go @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: Apache-2.0 + +package evidence_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gemaraproj/gemara-mcp/internal/evidence" + "github.com/gemaraproj/gemara-mcp/internal/evidence/parsers" +) + +// --------------------------------------------------------------------------- +// SchemaMapper +// --------------------------------------------------------------------------- + +func TestSchemaMapper_Map(t *testing.T) { + mapper := evidence.NewSchemaMapper() + + tests := []struct { + name string + chunks []evidence.EvidenceChunk + wantMinCount int + wantTargetField string // at least one candidate should map to this field + }{ + { + name: "objective keyword maps to controls objective", + chunks: []evidence.EvidenceChunk{ + {Text: "The objective of this control is to ensure TLS 1.2+", SourceID: "doc.md", SectionPath: "Section 1", Confidence: 1.0}, + }, + wantMinCount: 1, + wantTargetField: "controls[].objective", + }, + { + name: "title keyword maps to metadata title", + chunks: []evidence.EvidenceChunk{ + {Text: "title: Network Security Policy", SourceID: "policy.yaml", SectionPath: "root", Confidence: 1.0}, + }, + wantMinCount: 1, + wantTargetField: "metadata.title", + }, + { + name: "assessment keyword maps to controls assessment", + chunks: []evidence.EvidenceChunk{ + {Text: "Verify that TLS certificates are valid and unexpired", SourceID: "doc.md", SectionPath: "Audit", Confidence: 0.9}, + }, + wantMinCount: 1, + wantTargetField: "controls[].assessment", + }, + { + name: "unrecognised text produces no candidates", + chunks: []evidence.EvidenceChunk{{Text: "Lorem ipsum dolor sit amet", SourceID: "doc.md", SectionPath: "random", Confidence: 1.0}}, + wantMinCount: 0, + }, + { + name: "empty chunks list returns empty candidates", + chunks: []evidence.EvidenceChunk{}, + wantMinCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + candidates := mapper.Map(tt.chunks) + assert.GreaterOrEqual(t, len(candidates), tt.wantMinCount) + + if tt.wantTargetField != "" { + found := false + for _, c := range candidates { + if c.TargetField == tt.wantTargetField { + found = true + break + } + } + assert.True(t, found, "expected at least one candidate with TargetField=%q, got: %+v", tt.wantTargetField, candidates) + } + }) + } +} + +func TestSchemaMapper_ConfidencePropagation(t *testing.T) { + mapper := evidence.NewSchemaMapper() + chunks := []evidence.EvidenceChunk{ + {Text: "objective: ensure encryption", SourceID: "doc.md", SectionPath: "s1", Confidence: 1.0}, + {Text: "objective: ensure encryption", SourceID: "doc.md", SectionPath: "s2", Confidence: 0.5}, + } + candidates := mapper.Map(chunks) + require.Len(t, candidates, 2) + assert.Greater(t, candidates[0].Confidence, candidates[1].Confidence, "higher chunk confidence should yield higher candidate confidence") +} + +// --------------------------------------------------------------------------- +// Pipeline +// --------------------------------------------------------------------------- + +func TestPipeline_UnsupportedFormat(t *testing.T) { + p := evidence.NewPipeline() // no parsers registered + _, err := p.Run(context.Background(), evidence.EvidenceSource{ + Content: []byte("anything"), + Format: "pdf", + ID: "test.pdf", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported evidence format") +} + +func TestPipeline_RegisteredParsers(t *testing.T) { + p := evidence.NewPipeline(parsers.NewMarkdownParser(), parsers.NewYAMLParser()) + names := p.RegisteredParsers() + assert.Equal(t, []string{"markdown", "yaml"}, names) +} + +func TestPipeline_RunWithMeta_MarkdownDoc(t *testing.T) { + p := evidence.NewPipeline(parsers.NewMarkdownParser()) + src := evidence.EvidenceSource{ + Content: []byte("# Network Security\nThe objective of this control is to encrypt all traffic.\n\n## Assessment\nVerify TLS settings."), + ID: "policy.md", + } + result, err := p.RunWithMeta(context.Background(), src) + require.NoError(t, err) + assert.Equal(t, "markdown", result.ParserUsed) + assert.Greater(t, result.ChunkCount, 0) +} + +// --------------------------------------------------------------------------- +// MarkdownParser +// --------------------------------------------------------------------------- + +func TestMarkdownParser_CanHandle(t *testing.T) { + p := parsers.NewMarkdownParser() + + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "markdown"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "md"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("# Heading\ntext")})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("preamble\n# Heading")})) + assert.False(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("apiVersion: v1")})) +} + +func TestMarkdownParser_Parse(t *testing.T) { + p := parsers.NewMarkdownParser() + src := evidence.EvidenceSource{ + Content: []byte("# Section One\nContent of section one.\n\n## Subsection\nMore content here.\n\n# Section Two\nAnother section."), + ID: "test.md", + } + chunks, err := p.Parse(context.Background(), src) + require.NoError(t, err) + assert.Len(t, chunks, 3) // Section One, Subsection, Section Two + + assert.Equal(t, "Section One", chunks[0].SectionPath) + assert.Contains(t, chunks[0].Text, "Content of section one") + assert.Equal(t, "test.md", chunks[0].SourceID) + assert.Equal(t, 0.85, chunks[0].Confidence) +} + +func TestMarkdownParser_Parse_Preamble(t *testing.T) { + p := parsers.NewMarkdownParser() + src := evidence.EvidenceSource{ + Content: []byte("This is a preamble.\n\n# First Section\nSection content."), + ID: "doc.md", + } + chunks, err := p.Parse(context.Background(), src) + require.NoError(t, err) + assert.Len(t, chunks, 2) + assert.Equal(t, "preamble", chunks[0].SectionPath) +} + +func TestMarkdownParser_Parse_EmptyContent(t *testing.T) { + p := parsers.NewMarkdownParser() + chunks, err := p.Parse(context.Background(), evidence.EvidenceSource{Content: []byte(""), ID: "empty.md"}) + require.NoError(t, err) + assert.Empty(t, chunks) +} + +// --------------------------------------------------------------------------- +// YAMLParser +// --------------------------------------------------------------------------- + +func TestYAMLParser_CanHandle(t *testing.T) { + p := parsers.NewYAMLParser() + + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "yaml"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "yml"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "json"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Content: []byte(`{"key": "value"}`)})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("key: value\nother: thing")})) + // Should NOT steal Markdown content + assert.False(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("# Heading\ntext")})) +} + +func TestYAMLParser_Parse(t *testing.T) { + p := parsers.NewYAMLParser() + src := evidence.EvidenceSource{ + Content: []byte("title: My Policy\nversion: \"1.0\"\nobjective: Ensure security"), + ID: "policy.yaml", + } + chunks, err := p.Parse(context.Background(), src) + require.NoError(t, err) + assert.NotEmpty(t, chunks) + + // All chunks should have the correct SourceID + for _, c := range chunks { + assert.Equal(t, "policy.yaml", c.SourceID) + assert.Equal(t, 0.80, c.Confidence) + } +} + +func TestYAMLParser_Parse_InvalidYAML(t *testing.T) { + p := parsers.NewYAMLParser() + _, err := p.Parse(context.Background(), evidence.EvidenceSource{ + Content: []byte("invalid: [unclosed"), + ID: "bad.yaml", + }) + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// KubernetesParser +// --------------------------------------------------------------------------- + +const sampleDeployment = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + securityContext: + runAsNonRoot: true + containers: + - name: app + image: my-app:1.0 + env: + - name: SECRET + value: "abc" +` + +func TestKubernetesParser_CanHandle(t *testing.T) { + p := parsers.NewKubernetesParser() + + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "kubernetes"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Format: "k8s"})) + assert.True(t, p.CanHandle(evidence.EvidenceSource{Content: []byte(sampleDeployment)})) + assert.False(t, p.CanHandle(evidence.EvidenceSource{Content: []byte("# Markdown doc")})) +} + +func TestKubernetesParser_Parse(t *testing.T) { + p := parsers.NewKubernetesParser() + src := evidence.EvidenceSource{Content: []byte(sampleDeployment), ID: "deploy.yaml"} + chunks, err := p.Parse(context.Background(), src) + require.NoError(t, err) + assert.NotEmpty(t, chunks) + + // Should have extracted at least the identity chunk and some spec chunks + var sectionPaths []string + for _, c := range chunks { + sectionPaths = append(sectionPaths, c.SectionPath) + } + + hasIdentity := false + hasSecurityCtx := false + for _, sp := range sectionPaths { + if contains(sp, "identity") { + hasIdentity = true + } + if contains(sp, "securityContext") { + hasSecurityCtx = true + } + } + assert.True(t, hasIdentity, "should have an identity chunk") + assert.True(t, hasSecurityCtx, "should have a securityContext chunk") +} + +func TestKubernetesParser_Parse_MultiDoc(t *testing.T) { + p := parsers.NewKubernetesParser() + content := sampleDeployment + "\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\nspec:\n type: ClusterIP\n" + src := evidence.EvidenceSource{Content: []byte(content), ID: "multi.yaml"} + chunks, err := p.Parse(context.Background(), src) + require.NoError(t, err) + + kinds := map[string]bool{} + for _, c := range chunks { + if contains(c.Text, "kind: Deployment") { + kinds["Deployment"] = true + } + if contains(c.Text, "kind: Service") { + kinds["Service"] = true + } + } + assert.True(t, kinds["Deployment"], "should parse Deployment document") + assert.True(t, kinds["Service"], "should parse Service document") +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} diff --git a/internal/evidence/mapper.go b/internal/evidence/mapper.go new file mode 100644 index 0000000..a7e4413 --- /dev/null +++ b/internal/evidence/mapper.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package evidence + +import ( + "strings" +) + +// fieldRule maps a set of trigger keywords to a target Gemara schema field. +type fieldRule struct { + keywords []string + targetField string +} + +// schemaFieldRules defines the keyword-to-field mapping table used by the SchemaMapper. +// Rules are evaluated in order; the first match wins. +var schemaFieldRules = []fieldRule{ + {keywords: []string{"identifier", "id:", "control id", "policy id"}, targetField: "metadata.id"}, + {keywords: []string{"title:", "name:", "policy name", "control name"}, targetField: "metadata.title"}, + {keywords: []string{"version:", "revision:"}, targetField: "metadata.version"}, + {keywords: []string{"objective", "goal", "purpose", "intent"}, targetField: "controls[].objective"}, + {keywords: []string{"control statement", "requirement", "must ", "shall ", "required to"}, targetField: "controls[].statement"}, + {keywords: []string{"assessment", "verify", "verification", "audit", "check"}, targetField: "controls[].assessment"}, + {keywords: []string{"implementation", "procedure", "how to", "steps to"}, targetField: "controls[].implementation"}, + {keywords: []string{"parameter", "setting", "configuration", "config value"}, targetField: "controls[].parameters[]"}, + {keywords: []string{"reference", "see also", "related", "maps to"}, targetField: "metadata.references[]"}, + {keywords: []string{"scope", "applies to", "applicability"}, targetField: "metadata.scope"}, + {keywords: []string{"description", "overview", "summary", "background"}, targetField: "metadata.description"}, +} + +// SchemaMapper maps a list of EvidenceChunks to SchemaCandidate proposals. +type SchemaMapper struct{} + +// NewSchemaMapper creates a new SchemaMapper. +func NewSchemaMapper() *SchemaMapper { + return &SchemaMapper{} +} + +func (m *SchemaMapper) Map(chunks []EvidenceChunk) []SchemaCandidate { + candidates := make([]SchemaCandidate, 0, len(chunks)) + for _, chunk := range chunks { + candidate := m.mapChunk(chunk) + if candidate != nil { + candidates = append(candidates, *candidate) + } + } + return candidates +} + +func (m *SchemaMapper) mapChunk(chunk EvidenceChunk) *SchemaCandidate { + lower := strings.ToLower(chunk.Text) + + for _, rule := range schemaFieldRules { + for _, kw := range rule.keywords { + if strings.Contains(lower, kw) { + + mappingConfidence := 0.75 + combined := mappingConfidence * chunk.Confidence + + return &SchemaCandidate{ + TargetField: rule.targetField, + Value: normalizeValue(chunk.Text), + SourceRef: chunk.SourceID + " / " + chunk.SectionPath, + Confidence: combined, + } + } + } + } + + return nil +} + +func normalizeValue(text string) string { + lines := strings.Split(text, "\n") + parts := make([]string, 0, len(lines)) + for _, l := range lines { + trimmed := strings.TrimSpace(l) + if trimmed != "" { + parts = append(parts, trimmed) + } + } + return strings.Join(parts, " ") +} diff --git a/internal/evidence/parsers/kubernetes.go b/internal/evidence/parsers/kubernetes.go new file mode 100644 index 0000000..7ee20fc --- /dev/null +++ b/internal/evidence/parsers/kubernetes.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 + +package parsers + +import ( + "context" + "fmt" + "strings" + + "github.com/gemaraproj/gemara-mcp/internal/evidence" + "github.com/goccy/go-yaml" +) + +// kubeManifest is a minimal struct for reading the top-level fields of a +// Kubernetes manifest without pulling in a full k8s client dependency. +type kubeManifest struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]interface{} `yaml:"metadata"` + Spec map[string]interface{} `yaml:"spec"` +} + +// KubernetesParser parses Kubernetes manifests into EvidenceChunks. +// It extracts security-relevant fields (image, securityContext, env, resources) +// from workload specs, making them available for control mapping. +type KubernetesParser struct{} + +// NewKubernetesParser creates a new KubernetesParser. +func NewKubernetesParser() *KubernetesParser { + return &KubernetesParser{} +} + +func (p *KubernetesParser) Name() string { + return "kubernetes" +} + +// CanHandle returns true for sources with a "kubernetes" or "k8s" format hint, +// or whose content contains the characteristic apiVersion/kind YAML fields. +func (p *KubernetesParser) CanHandle(source evidence.EvidenceSource) bool { + switch strings.ToLower(source.Format) { + case "kubernetes", "k8s": + return true + } + content := string(source.Content) + return strings.Contains(content, "apiVersion:") && strings.Contains(content, "kind:") +} + +// Parse extracts security-relevant fields from a Kubernetes manifest. +// Multi-document YAML (separated by '---') is split and each document parsed independently. +func (p *KubernetesParser) Parse(_ context.Context, source evidence.EvidenceSource) ([]evidence.EvidenceChunk, error) { + docs := strings.Split(string(source.Content), "\n---") + var chunks []evidence.EvidenceChunk + + for i, doc := range docs { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + docChunks, err := p.parseDocument([]byte(doc), source.ID, i) + if err != nil { + + continue + } + chunks = append(chunks, docChunks...) + } + return chunks, nil +} + +func (p *KubernetesParser) parseDocument(content []byte, sourceID string, docIndex int) ([]evidence.EvidenceChunk, error) { + var manifest kubeManifest + if err := yaml.Unmarshal(content, &manifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + resourceRef := fmt.Sprintf("%s/%s", manifest.Kind, manifest.APIVersion) + if name, ok := manifest.Metadata["name"]; ok { + resourceRef = fmt.Sprintf("%s/%v (doc %d)", manifest.Kind, name, docIndex) + } + + var chunks []evidence.EvidenceChunk + + // Emit a chunk for the resource identity itself + if manifest.Kind != "" { + chunks = append(chunks, evidence.EvidenceChunk{ + Text: fmt.Sprintf("kind: %s\napiVersion: %s", manifest.Kind, manifest.APIVersion), + SourceID: sourceID, + SectionPath: resourceRef + " / identity", + Confidence: 0.90, + }) + } + + if manifest.Spec != nil { + chunks = append(chunks, p.extractSpecChunks(manifest.Spec, sourceID, resourceRef)...) + } + + return chunks, nil +} + +// extractSpecChunks walks the spec looking for security-relevant keys. +func (p *KubernetesParser) extractSpecChunks(spec map[string]interface{}, sourceID, resourceRef string) []evidence.EvidenceChunk { + securityKeys := []string{ + "securityContext", "containers", "initContainers", + "volumes", "serviceAccountName", "hostNetwork", + "hostPID", "hostIPC", "resources", "env", "image", + } + + var chunks []evidence.EvidenceChunk + for _, key := range securityKeys { + val, ok := spec[key] + if !ok { + continue + } + rendered, err := yaml.Marshal(val) + if err != nil { + rendered = []byte(fmt.Sprintf("%v", val)) + } + chunks = append(chunks, evidence.EvidenceChunk{ + Text: fmt.Sprintf("%s:\n%s", key, strings.TrimSpace(string(rendered))), + SourceID: sourceID, + SectionPath: resourceRef + " / spec." + key, + Confidence: 0.88, + }) + } + return chunks +} diff --git a/internal/evidence/parsers/markdown.go b/internal/evidence/parsers/markdown.go new file mode 100644 index 0000000..2c5e386 --- /dev/null +++ b/internal/evidence/parsers/markdown.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package parsers + +import ( + "context" + "strings" + + "github.com/gemaraproj/gemara-mcp/internal/evidence" +) + +// MarkdownParser parses Markdown governance documents into EvidenceChunks. +// It splits the document on headings (lines starting with '#') and treats +// each section as a separate chunk, using the heading text as the SectionPath. +type MarkdownParser struct{} + +// NewMarkdownParser creates a new MarkdownParser. +func NewMarkdownParser() *MarkdownParser { + return &MarkdownParser{} +} + +func (p *MarkdownParser) Name() string { + return "markdown" +} + +// CanHandle returns true for sources that use the "markdown" format hint, +// or whose content begins with a Markdown heading or common Markdown patterns. +func (p *MarkdownParser) CanHandle(source evidence.EvidenceSource) bool { + if strings.EqualFold(source.Format, "markdown") || strings.EqualFold(source.Format, "md") { + return true + } + content := strings.TrimSpace(string(source.Content)) + return strings.HasPrefix(content, "#") || strings.Contains(content, "\n#") +} + +func (p *MarkdownParser) Parse(_ context.Context, source evidence.EvidenceSource) ([]evidence.EvidenceChunk, error) { + lines := strings.Split(string(source.Content), "\n") + + var chunks []evidence.EvidenceChunk + var currentHeading string + var currentLines []string + + flush := func() { + text := strings.TrimSpace(strings.Join(currentLines, "\n")) + if text == "" { + return + } + sectionPath := currentHeading + if sectionPath == "" { + sectionPath = "preamble" + } + chunks = append(chunks, evidence.EvidenceChunk{ + Text: text, + SourceID: source.ID, + SectionPath: sectionPath, + Confidence: 0.85, + }) + } + + for _, line := range lines { + if strings.HasPrefix(line, "#") { + // Flush previous section + flush() + currentHeading = strings.TrimSpace(strings.TrimLeft(line, "#")) + currentLines = nil + } else { + currentLines = append(currentLines, line) + } + } + flush() + + return chunks, nil +} diff --git a/internal/evidence/parsers/yaml.go b/internal/evidence/parsers/yaml.go new file mode 100644 index 0000000..32b3382 --- /dev/null +++ b/internal/evidence/parsers/yaml.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package parsers + +import ( + "context" + "fmt" + "strings" + + "github.com/gemaraproj/gemara-mcp/internal/evidence" + "github.com/goccy/go-yaml" +) + +// YAMLParser parses YAML and JSON configuration files into EvidenceChunks. +// It flattens the top-level keys of the document, treating each key-value +// pair as a separate chunk with the key as the SectionPath. +type YAMLParser struct{} + +func NewYAMLParser() *YAMLParser { + return &YAMLParser{} +} + +func (p *YAMLParser) Name() string { + return "yaml" +} + +func (p *YAMLParser) CanHandle(source evidence.EvidenceSource) bool { + switch strings.ToLower(source.Format) { + case "yaml", "yml", "json": + return true + } + content := strings.TrimSpace(string(source.Content)) + // JSON object + if strings.HasPrefix(content, "{") { + return true + } + // Plain YAML: key: value at the start + if len(content) > 0 && strings.Contains(strings.SplitN(content, "\n", 2)[0], ":") { + // Avoid stealing from Dockerfile or Markdown parsers + if !strings.HasPrefix(content, "#") && !strings.HasPrefix(content, "FROM") { + return true + } + } + return false +} + +func (p *YAMLParser) Parse(_ context.Context, source evidence.EvidenceSource) ([]evidence.EvidenceChunk, error) { + var doc map[string]interface{} + if err := yaml.Unmarshal(source.Content, &doc); err != nil { + return nil, fmt.Errorf("failed to unmarshal YAML/JSON: %w", err) + } + + var chunks []evidence.EvidenceChunk + for key, value := range doc { + rendered, err := yaml.Marshal(value) + if err != nil { + + rendered = []byte(fmt.Sprintf("%v", value)) + } + chunks = append(chunks, evidence.EvidenceChunk{ + Text: fmt.Sprintf("%s: %s", key, strings.TrimSpace(string(rendered))), + SourceID: source.ID, + SectionPath: key, + Confidence: 0.80, + }) + } + return chunks, nil +} diff --git a/internal/evidence/pipeline.go b/internal/evidence/pipeline.go new file mode 100644 index 0000000..b077e8d --- /dev/null +++ b/internal/evidence/pipeline.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package evidence + +import ( + "context" + "fmt" +) + +type Pipeline struct { + parsers []EvidenceParser + mapper *SchemaMapper +} + +// NewPipeline creates a new Pipeline with the provided parsers. +// The SchemaMapper is created internally. +func NewPipeline(parsers ...EvidenceParser) *Pipeline { + return &Pipeline{ + parsers: parsers, + mapper: NewSchemaMapper(), + } +} + +// RunResult is the output of a successful pipeline run. +type RunResult struct { + Candidates []SchemaCandidate + ParserUsed string + ChunkCount int +} + +func (p *Pipeline) Run(ctx context.Context, source EvidenceSource) ([]SchemaCandidate, error) { + result, err := p.RunWithMeta(ctx, source) + if err != nil { + return nil, err + } + return result.Candidates, nil +} + +func (p *Pipeline) RunWithMeta(ctx context.Context, source EvidenceSource) (RunResult, error) { + parser, err := p.selectParser(source) + if err != nil { + return RunResult{}, err + } + + chunks, err := parser.Parse(ctx, source) + if err != nil { + return RunResult{}, fmt.Errorf("parser %q failed: %w", parser.Name(), err) + } + + candidates := p.mapper.Map(chunks) + return RunResult{ + Candidates: candidates, + ParserUsed: parser.Name(), + ChunkCount: len(chunks), + }, nil +} + +// selectParser returns the first registered parser that can handle the given source. +func (p *Pipeline) selectParser(source EvidenceSource) (EvidenceParser, error) { + for _, parser := range p.parsers { + if parser.CanHandle(source) { + return parser, nil + } + } + return nil, fmt.Errorf("unsupported evidence format: no parser found for source %q (format hint: %q)", source.ID, source.Format) +} + +// RegisteredParsers returns the names of all currently registered parsers. +func (p *Pipeline) RegisteredParsers() []string { + names := make([]string, len(p.parsers)) + for i, parser := range p.parsers { + names[i] = parser.Name() + } + return names +} diff --git a/internal/tool/mode.go b/internal/tool/mode.go index 5be65c5..82be2de 100644 --- a/internal/tool/mode.go +++ b/internal/tool/mode.go @@ -59,6 +59,9 @@ func (a AdvisoryMode) Register(server *mcp.Server) { // Schema documentation tool - retrieves schema documentation from CUE registry mcp.AddTool(server, MetadataGetSchemaDocs, a.getSchemaDocs) + + // Evidence pipeline tool - parses governance and config documents into schema candidates + mcp.AddTool(server, MetadataParseGovernanceDocument, ParseGovernanceDocument) } // getLexicon wraps GetLexicon with cache access and configuration. diff --git a/internal/tool/parse.go b/internal/tool/parse.go new file mode 100644 index 0000000..ee5ba5d --- /dev/null +++ b/internal/tool/parse.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tool + +import ( + "context" + "fmt" + + "github.com/gemaraproj/gemara-mcp/internal/evidence" + "github.com/gemaraproj/gemara-mcp/internal/evidence/parsers" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MetadataParseGovernanceDocument describes the parse_governance_document tool. +var MetadataParseGovernanceDocument = &mcp.Tool{ + Name: "parse_governance_document", + Description: "Parse a governance or technical configuration document and return schema-aligned " + + "candidates for Gemara artifact generation. " + + "Supported formats: markdown, yaml, json, kubernetes, dockerfile. " + + "Each candidate includes a target schema field, a proposed value, its source reference, " + + "and a confidence score. High-confidence candidates (≥0.7) are suitable for Tier 1 " + + "(automated) artifact generation. Lower-confidence candidates should be reviewed by a human " + + "(Tier 2) before inclusion.", + InputSchema: map[string]interface{}{ + "type": "object", + "required": []string{"content"}, + "properties": map[string]interface{}{ + "content": map[string]interface{}{ + "type": "string", + "description": "Raw content of the document to parse", + }, + "format": map[string]interface{}{ + "type": "string", + "description": "Format hint for the document. One of: markdown, yaml, json, kubernetes, dockerfile. If omitted, auto-detection is used.", + "enum": []string{"markdown", "yaml", "json", "kubernetes", "dockerfile"}, + }, + "source_id": map[string]interface{}{ + "type": "string", + "description": "Optional identifier for the document (file path, URL, etc.) used in candidate source references.", + }, + }, + }, +} + +// InputParseGovernanceDocument is the input for the ParseGovernanceDocument tool. +type InputParseGovernanceDocument struct { + Content string `json:"content"` + Format string `json:"format"` + SourceID string `json:"source_id"` +} + +// OutputParseGovernanceDocument is the output for the ParseGovernanceDocument tool. +type OutputParseGovernanceDocument struct { + // Candidates is the list of schema-aligned field proposals. + Candidates []evidence.SchemaCandidate `json:"candidates"` + // ParserUsed is the name of the parser that was selected. + ParserUsed string `json:"parser_used"` + // TotalChunks is the number of evidence chunks extracted before mapping. + TotalChunks int `json:"total_chunks"` +} + +// defaultPipeline builds a Pipeline with all default parsers registered. +// Parser order matters: more specific parsers (kubernetes, dockerfile) are +// registered before generic ones (yaml, markdown) to avoid mis-detection. +func defaultPipeline() *evidence.Pipeline { + return evidence.NewPipeline( + parsers.NewKubernetesParser(), + parsers.NewDockerfileParser(), + parsers.NewMarkdownParser(), + parsers.NewYAMLParser(), + ) +} + +// ParseGovernanceDocument runs the evidence pipeline over the provided document +// and returns schema-aligned candidates for artifact generation. +func ParseGovernanceDocument(ctx context.Context, _ *mcp.CallToolRequest, input InputParseGovernanceDocument) (*mcp.CallToolResult, OutputParseGovernanceDocument, error) { + if input.Content == "" { + return nil, OutputParseGovernanceDocument{}, fmt.Errorf("content is required") + } + + sourceID := input.SourceID + if sourceID == "" { + sourceID = "unknown" + } + + src := evidence.EvidenceSource{ + Content: []byte(input.Content), + Format: input.Format, + ID: sourceID, + } + + pipeline := defaultPipeline() + result, err := pipeline.RunWithMeta(ctx, src) + if err != nil { + return nil, OutputParseGovernanceDocument{}, err + } + + return nil, OutputParseGovernanceDocument{ + Candidates: result.Candidates, + ParserUsed: result.ParserUsed, + TotalChunks: result.ChunkCount, + }, nil +} diff --git a/internal/tool/parse_test.go b/internal/tool/parse_test.go new file mode 100644 index 0000000..4b09890 --- /dev/null +++ b/internal/tool/parse_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tool + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGovernanceDocument(t *testing.T) { + ctx := context.Background() + req := &mcp.CallToolRequest{} + + tests := []struct { + name string + input InputParseGovernanceDocument + wantErr bool + errContains string + validateOutput func(t *testing.T, output OutputParseGovernanceDocument) + }{ + { + name: "empty content returns error", + input: InputParseGovernanceDocument{Content: ""}, + wantErr: true, + errContains: "content is required", + }, + { + name: "markdown governance document produces candidates", + input: InputParseGovernanceDocument{ + Content: "# Network Security\nThe objective of this control is to encrypt all traffic.\n\n## Assessment\nVerify that TLS 1.2 or higher is enforced on all endpoints.", + Format: "markdown", + SourceID: "network-policy.md", + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.Equal(t, "markdown", output.ParserUsed) + assert.Greater(t, output.TotalChunks, 0, "should extract at least one chunk") + assert.NotEmpty(t, output.Candidates, "should produce at least one candidate") + for _, c := range output.Candidates { + assert.NotEmpty(t, c.TargetField, "candidate must have a target field") + assert.NotEmpty(t, c.Value, "candidate must have a value") + assert.Greater(t, c.Confidence, 0.0, "candidate confidence must be positive") + assert.LessOrEqual(t, c.Confidence, 1.0, "candidate confidence must be <= 1.0") + } + }, + }, + { + name: "kubernetes manifest produces candidates", + input: InputParseGovernanceDocument{ + Content: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: secure-app +spec: + securityContext: + runAsNonRoot: true + containers: + - name: app + image: myapp:1.0 +`, + Format: "kubernetes", + SourceID: "deployment.yaml", + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.Equal(t, "kubernetes", output.ParserUsed) + assert.Greater(t, output.TotalChunks, 0) + }, + }, + { + name: "yaml config produces candidates", + input: InputParseGovernanceDocument{ + Content: "title: Data Encryption Policy\nversion: \"2.0\"\nobjective: Ensure all data at rest is encrypted", + Format: "yaml", + SourceID: "config.yaml", + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.Equal(t, "yaml", output.ParserUsed) + assert.NotEmpty(t, output.Candidates) + }, + }, + { + name: "dockerfile produces candidates", + input: InputParseGovernanceDocument{ + Content: "FROM ubuntu:22.04\nRUN apt-get install -y ca-certificates\nUSER nonroot\nEXPOSE 8080\n", + Format: "dockerfile", + SourceID: "Dockerfile", + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.Equal(t, "dockerfile", output.ParserUsed) + assert.Greater(t, output.TotalChunks, 0) + }, + }, + { + name: "auto-detection without format hint", + input: InputParseGovernanceDocument{ + Content: "# Auto-detected Markdown\nThis is a control. The objective is to ensure compliance.", + // No Format field — auto-detection should kick in + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.NotEmpty(t, output.ParserUsed) + assert.Greater(t, output.TotalChunks, 0) + }, + }, + { + name: "source_id is optional and defaults gracefully", + input: InputParseGovernanceDocument{ + Content: "# Policy\nThe objective of this policy is to enforce access controls.", + Format: "markdown", + // No SourceID + }, + wantErr: false, + validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { + assert.NotEmpty(t, output.Candidates) + // SourceRef should still be populated + for _, c := range output.Candidates { + assert.NotEmpty(t, c.SourceRef) + } + }, + }, + { + name: "unsupported format returns error", + input: InputParseGovernanceDocument{ + Content: "some binary or unsupported content", + Format: "pdf", + }, + wantErr: true, + errContains: "unsupported evidence format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, output, err := ParseGovernanceDocument(ctx, req, tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + if tt.validateOutput != nil { + tt.validateOutput(t, output) + } + }) + } +} From adfed65aac9d2a5cc9fb43e4b530f7913a0ac7af Mon Sep 17 00:00:00 2001 From: Satarupa22-SD Date: Sun, 1 Mar 2026 23:09:06 +0530 Subject: [PATCH 2/4] update parse.go --- internal/tool/parse.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/internal/tool/parse.go b/internal/tool/parse.go index ee5ba5d..2ca0c43 100644 --- a/internal/tool/parse.go +++ b/internal/tool/parse.go @@ -51,21 +51,15 @@ type InputParseGovernanceDocument struct { // OutputParseGovernanceDocument is the output for the ParseGovernanceDocument tool. type OutputParseGovernanceDocument struct { - // Candidates is the list of schema-aligned field proposals. - Candidates []evidence.SchemaCandidate `json:"candidates"` - // ParserUsed is the name of the parser that was selected. - ParserUsed string `json:"parser_used"` - // TotalChunks is the number of evidence chunks extracted before mapping. - TotalChunks int `json:"total_chunks"` + Candidates []evidence.SchemaCandidate `json:"candidates"` + ParserUsed string `json:"parser_used"` + TotalChunks int `json:"total_chunks"` } -// defaultPipeline builds a Pipeline with all default parsers registered. -// Parser order matters: more specific parsers (kubernetes, dockerfile) are -// registered before generic ones (yaml, markdown) to avoid mis-detection. +// defaultPipeline builds a Pipeline with all default parsers registered.. func defaultPipeline() *evidence.Pipeline { return evidence.NewPipeline( parsers.NewKubernetesParser(), - parsers.NewDockerfileParser(), parsers.NewMarkdownParser(), parsers.NewYAMLParser(), ) From 78fbba506024de9e8219494e3c82dd3d96589b85 Mon Sep 17 00:00:00 2001 From: Satarupa22-SD Date: Tue, 3 Mar 2026 23:48:58 +0530 Subject: [PATCH 3/4] Update supported formats --- internal/tool/parse.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tool/parse.go b/internal/tool/parse.go index 2ca0c43..12d36a0 100644 --- a/internal/tool/parse.go +++ b/internal/tool/parse.go @@ -31,8 +31,8 @@ var MetadataParseGovernanceDocument = &mcp.Tool{ }, "format": map[string]interface{}{ "type": "string", - "description": "Format hint for the document. One of: markdown, yaml, json, kubernetes, dockerfile. If omitted, auto-detection is used.", - "enum": []string{"markdown", "yaml", "json", "kubernetes", "dockerfile"}, + "description": "Format hint for the document. One of: markdown, yaml, json, kubernetes. If omitted, auto-detection is used.", + "enum": []string{"markdown", "yaml", "json", "kubernetes"}, }, "source_id": map[string]interface{}{ "type": "string", From b68ca2525e3b8fa28ec9ac40b46dc5da25f1f233 Mon Sep 17 00:00:00 2001 From: Satarupa22-SD Date: Wed, 4 Mar 2026 00:17:15 +0530 Subject: [PATCH 4/4] Update tests --- internal/tool/parse_test.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/tool/parse_test.go b/internal/tool/parse_test.go index 4b09890..c38a736 100644 --- a/internal/tool/parse_test.go +++ b/internal/tool/parse_test.go @@ -84,19 +84,6 @@ spec: assert.NotEmpty(t, output.Candidates) }, }, - { - name: "dockerfile produces candidates", - input: InputParseGovernanceDocument{ - Content: "FROM ubuntu:22.04\nRUN apt-get install -y ca-certificates\nUSER nonroot\nEXPOSE 8080\n", - Format: "dockerfile", - SourceID: "Dockerfile", - }, - wantErr: false, - validateOutput: func(t *testing.T, output OutputParseGovernanceDocument) { - assert.Equal(t, "dockerfile", output.ParserUsed) - assert.Greater(t, output.TotalChunks, 0) - }, - }, { name: "auto-detection without format hint", input: InputParseGovernanceDocument{