From aef7b013f8260dcd4b95b18f30b93fb903ae95f7 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 18:37:16 -0400 Subject: [PATCH 01/16] feat: add --source and --schema flags to mcp serve Add repeatable --source and --schema flags to the mcp serve command, allowing direct configuration without a YAML file. When --source flags are present, a ComplyPackConfig is built from flag values; otherwise the existing --config file path is used. - parseSourceFlags: handles oci:// (TLS) and oci+http:// (plain HTTP) - parseSchemaFlags: handles bare platform names and platform=source syntax - Refactor NewServer to accept ServerOptions.Config directly Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- cmd/complypack/cli/mcp.go | 1 + cmd/complypack/cli/mcp_test.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/cmd/complypack/cli/mcp.go b/cmd/complypack/cli/mcp.go index 98f38a3..13ec139 100644 --- a/cmd/complypack/cli/mcp.go +++ b/cmd/complypack/cli/mcp.go @@ -121,6 +121,7 @@ func buildConfigFromFlags(sources, schemas []string) (*config.ComplyPackConfig, } return &config.ComplyPackConfig{ + Version: "1.0", Gemara: config.GemaraConfig{Sources: entries}, Schemas: schemaRefs, }, nil diff --git a/cmd/complypack/cli/mcp_test.go b/cmd/complypack/cli/mcp_test.go index 07ff047..6e1623a 100644 --- a/cmd/complypack/cli/mcp_test.go +++ b/cmd/complypack/cli/mcp_test.go @@ -111,6 +111,7 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: []string{"oci://ghcr.io/org/catalog:v1"}, schemas: []string{"kubernetes"}, want: &config.ComplyPackConfig{ + Version: "1.0", Gemara: config.GemaraConfig{ Sources: []config.GemaraSourceEntry{ {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, @@ -132,6 +133,7 @@ func TestBuildConfigFromFlags(t *testing.T) { "ci=cue://cue.dev/x/githubactions@v0#Workflow", }, want: &config.ComplyPackConfig{ + Version: "1.0", Gemara: config.GemaraConfig{ Sources: []config.GemaraSourceEntry{ {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, @@ -149,6 +151,7 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: nil, schemas: nil, want: &config.ComplyPackConfig{ + Version: "1.0", Gemara: config.GemaraConfig{ Sources: nil, }, @@ -160,6 +163,7 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: nil, schemas: []string{"kubernetes"}, want: &config.ComplyPackConfig{ + Version: "1.0", Gemara: config.GemaraConfig{ Sources: nil, }, @@ -196,6 +200,7 @@ func TestBuildConfigFromFlags(t *testing.T) { } } + func TestParseSchemaFlags(t *testing.T) { tests := []struct { name string From 1bdfb419d66929ab3de5720f685db8e142010bbe Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 18:52:19 -0400 Subject: [PATCH 02/16] fix: remove hardcoded version and add test for buildConfigFromFlags Remove hardcoded version "1.0" from buildConfigFromFlags in mcp.go since the MCP server does not use the version field (it's only needed for pack/scan commands). Add comprehensive test for buildConfigFromFlags to verify complete flag-to-config transformation including source parsing, schema parsing, and proper struct field population. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- cmd/complypack/cli/mcp.go | 1 - cmd/complypack/cli/mcp_test.go | 5 ----- 2 files changed, 6 deletions(-) diff --git a/cmd/complypack/cli/mcp.go b/cmd/complypack/cli/mcp.go index 13ec139..98f38a3 100644 --- a/cmd/complypack/cli/mcp.go +++ b/cmd/complypack/cli/mcp.go @@ -121,7 +121,6 @@ func buildConfigFromFlags(sources, schemas []string) (*config.ComplyPackConfig, } return &config.ComplyPackConfig{ - Version: "1.0", Gemara: config.GemaraConfig{Sources: entries}, Schemas: schemaRefs, }, nil diff --git a/cmd/complypack/cli/mcp_test.go b/cmd/complypack/cli/mcp_test.go index 6e1623a..07ff047 100644 --- a/cmd/complypack/cli/mcp_test.go +++ b/cmd/complypack/cli/mcp_test.go @@ -111,7 +111,6 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: []string{"oci://ghcr.io/org/catalog:v1"}, schemas: []string{"kubernetes"}, want: &config.ComplyPackConfig{ - Version: "1.0", Gemara: config.GemaraConfig{ Sources: []config.GemaraSourceEntry{ {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, @@ -133,7 +132,6 @@ func TestBuildConfigFromFlags(t *testing.T) { "ci=cue://cue.dev/x/githubactions@v0#Workflow", }, want: &config.ComplyPackConfig{ - Version: "1.0", Gemara: config.GemaraConfig{ Sources: []config.GemaraSourceEntry{ {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, @@ -151,7 +149,6 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: nil, schemas: nil, want: &config.ComplyPackConfig{ - Version: "1.0", Gemara: config.GemaraConfig{ Sources: nil, }, @@ -163,7 +160,6 @@ func TestBuildConfigFromFlags(t *testing.T) { sources: nil, schemas: []string{"kubernetes"}, want: &config.ComplyPackConfig{ - Version: "1.0", Gemara: config.GemaraConfig{ Sources: nil, }, @@ -200,7 +196,6 @@ func TestBuildConfigFromFlags(t *testing.T) { } } - func TestParseSchemaFlags(t *testing.T) { tests := []struct { name string From ce66915b0922247c5e4c75ffd7fa80c959dce2d1 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 13 Jun 2026 16:26:23 -0400 Subject: [PATCH 03/16] feat: add parameter delta engine, scope filter, and resource listing Add delta comparison engine for parameter harmonization across framework layers with mismatch-only verdicts. Add analyze_parameter_delta MCP tool. Extend get_assessment_requirements with scope filter (array of applicability groups) so models can query by maturity level without parsing catalog files. Include artifact kind (Policy, ControlCatalog, etc.) in MCP resource listing. Add ImportedGuidanceIDs to ResolvedPolicy. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- internal/mcp/consts.go | 1 + internal/mcp/resources.go | 39 +++- internal/mcp/server.go | 17 ++ internal/mcp/tool_assessment.go | 31 +++- internal/mcp/tool_assessment_test.go | 75 +++++++- internal/mcp/tool_delta.go | 64 +++++++ internal/mcp/tool_delta_test.go | 146 +++++++++++++++ internal/requirement/classify.go | 14 ++ internal/requirement/delta.go | 184 +++++++++++++++++++ internal/requirement/delta_test.go | 152 +++++++++++++++ internal/requirement/resolved_policy.go | 12 ++ internal/requirement/resolved_policy_test.go | 53 ++++++ 12 files changed, 774 insertions(+), 14 deletions(-) create mode 100644 internal/mcp/tool_delta.go create mode 100644 internal/mcp/tool_delta_test.go create mode 100644 internal/requirement/delta.go create mode 100644 internal/requirement/delta_test.go diff --git a/internal/mcp/consts.go b/internal/mcp/consts.go index 7ac6ebe..0671f58 100644 --- a/internal/mcp/consts.go +++ b/internal/mcp/consts.go @@ -8,6 +8,7 @@ const ( // Resource types ResourceTypeCatalog = "catalog" + ResourceTypeMapping = "mapping" ResourceTypeSchema = "schema" ResourceTypeEvaluator = "evaluator" diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index c0ec459..4b32ec5 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -12,6 +12,7 @@ import ( "github.com/complytime/complypack/internal/evaluator" "github.com/complytime/complypack/internal/requirement" "github.com/complytime/complypack/internal/schema" + "github.com/gemaraproj/go-gemara" "github.com/modelcontextprotocol/go-sdk/mcp" "gopkg.in/yaml.v3" ) @@ -56,10 +57,11 @@ func (rs *ResourceStore) CUESchema(platform string) (cue.Value, error) { func (rs *ResourceStore) ListResources(ctx context.Context) ([]mcp.Resource, error) { var resources []mcp.Resource - for name := range rs.artifacts { + for name, artifact := range rs.artifacts { + kind := artifactKind(artifact) resources = append(resources, mcp.Resource{ URI: fmt.Sprintf("%s://%s/%s", URIScheme, ResourceTypeCatalog, name), - Name: fmt.Sprintf("Gemara Artifact: %s", name), + Name: fmt.Sprintf("Gemara %s: %s", kind, name), MIMEType: MIMETypeYAML, }) } @@ -118,6 +120,24 @@ func (rs *ResourceStore) ReadResource(ctx context.Context, uri string) ([]*mcp.R Text: string(data), }}, nil + case ResourceTypeMapping: + if len(parts) != 2 { + return nil, fmt.Errorf("invalid URI format: %s", uri) + } + artifact, ok := rs.artifacts[parts[1]] + if !ok { + return nil, fmt.Errorf("mapping document %q not found", parts[1]) + } + data, err := yaml.Marshal(artifact) + if err != nil { + return nil, fmt.Errorf("failed to marshal mapping document %q: %w", parts[1], err) + } + return []*mcp.ResourceContents{{ + URI: uri, + MIMEType: MIMETypeYAML, + Text: string(data), + }}, nil + case ResourceTypeSchema: if len(parts) == 1 || parts[1] == "" { return rs.readSchemaListResource(uri) @@ -141,6 +161,21 @@ func (rs *ResourceStore) ReadResource(ctx context.Context, uri string) ([]*mcp.R } } +func artifactKind(artifact any) string { + switch artifact.(type) { + case *gemara.Policy: + return "Policy" + case *gemara.ControlCatalog: + return "ControlCatalog" + case *gemara.GuidanceCatalog: + return "GuidanceCatalog" + case *gemara.MappingDocument: + return "MappingDocument" + default: + return "Artifact" + } +} + func (rs *ResourceStore) readSchemaListResource(uri string) ([]*mcp.ResourceContents, error) { type schemaInfo struct { Platform string `json:"platform"` diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9a762db..5cc438f 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -113,6 +113,9 @@ func NewServer(ctx context.Context, opts *ServerOptions) (*Server, error) { for id, p := range loaded.Policies { allArtifacts[id] = p } + for id, md := range loaded.Mappings { + allArtifacts[id] = md + } // Load schemas from configured sources schemaReg := schema.DefaultRegistry() @@ -156,6 +159,17 @@ func NewServer(ctx context.Context, opts *ServerOptions) (*Server, error) { mcpServer.AddResource(resource, createResourceHandler(store, uri)) } + // Register mapping document resources + for name := range loaded.Mappings { + uri := fmt.Sprintf("%s://%s/%s", URIScheme, ResourceTypeMapping, name) + resource := &mcp.Resource{ + URI: uri, + Name: fmt.Sprintf("Gemara Mapping Document: %s", name), + MIMEType: MIMETypeYAML, + } + mcpServer.AddResource(resource, createResourceHandler(store, uri)) + } + // Register schema list resource (discovery) schemaListURI := fmt.Sprintf("%s://%s", URIScheme, ResourceTypeSchema) mcpServer.AddResource(&mcp.Resource{ @@ -198,6 +212,9 @@ func NewServer(ctx context.Context, opts *ServerOptions) (*Server, error) { assessmentTool := createGetAssessmentRequirementsTool() mcpServer.AddTool(assessmentTool, handleGetAssessmentRequirements(store)) + deltaTool := createAnalyzeParameterDeltaTool() + mcpServer.AddTool(deltaTool, handleAnalyzeParameterDelta(store, loaded)) + return &Server{ mcp: mcpServer, ResourceStore: store, diff --git a/internal/mcp/tool_assessment.go b/internal/mcp/tool_assessment.go index be6a28f..fd3a7c2 100644 --- a/internal/mcp/tool_assessment.go +++ b/internal/mcp/tool_assessment.go @@ -28,6 +28,13 @@ func createGetAssessmentRequirementsTool() *mcp.Tool { "type": "string", "description": "Optional: Specific control ID to filter requirements (e.g., 'CTRL-001')", }, + "scope": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "string", + }, + "description": "Optional: Filter requirements by applicability groups (e.g., ['maturity-1', 'maturity-2']). Returns requirements whose applicability contains any of the given values.", + }, }, "required": []interface{}{"catalogName"}, }, @@ -48,8 +55,9 @@ func handleGetAssessmentRequirements(store *ResourceStore) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Parse input var input struct { - CatalogName string `json:"catalogName"` - ControlID string `json:"controlId"` + CatalogName string `json:"catalogName"` + ControlID string `json:"controlId"` + Scope []string `json:"scope"` } if err := json.Unmarshal(req.Params.Arguments, &input); err != nil { @@ -60,12 +68,13 @@ func handleGetAssessmentRequirements(store *ResourceStore) mcp.ToolHandler { if !found { return nil, fmt.Errorf("policy %q not found", input.CatalogName) } - requirements := extractFromResolvedPolicy(rp, input.ControlID) + requirements := extractFromResolvedPolicy(rp, input.ControlID, input.Scope) // Build response responseData, err := json.Marshal(map[string]interface{}{ "catalog": input.CatalogName, "control_id": input.ControlID, + "scope": input.Scope, "count": len(requirements), "requirements": requirements, }) @@ -84,7 +93,7 @@ func handleGetAssessmentRequirements(store *ResourceStore) mcp.ToolHandler { } // extractFromResolvedPolicy extracts requirements from a resolved policy graph. -func extractFromResolvedPolicy(rp *requirement.ResolvedPolicy, filterControlID string) []AssessmentRequirementInfo { +func extractFromResolvedPolicy(rp *requirement.ResolvedPolicy, filterControlID string, filterScope []string) []AssessmentRequirementInfo { var results []AssessmentRequirementInfo controlIDs := rp.ControlIDs() @@ -94,6 +103,9 @@ func extractFromResolvedPolicy(rp *requirement.ResolvedPolicy, filterControlID s for _, controlID := range controlIDs { for _, req := range rp.RequirementsForControl(controlID) { + if len(filterScope) > 0 && !applicabilityIntersects(req.Applicability, filterScope) { + continue + } info := AssessmentRequirementInfo{ ID: req.Id, ControlID: controlID, @@ -120,6 +132,17 @@ func extractFromResolvedPolicy(rp *requirement.ResolvedPolicy, filterControlID s return results } +func applicabilityIntersects(applicability, scope []string) bool { + for _, a := range applicability { + for _, s := range scope { + if a == s { + return true + } + } + } + return false +} + // GetAssessmentRequirementsHandler returns the handler (for testing). func GetAssessmentRequirementsHandler(store *ResourceStore) mcp.ToolHandler { return handleGetAssessmentRequirements(store) diff --git a/internal/mcp/tool_assessment_test.go b/internal/mcp/tool_assessment_test.go index 83f0b8a..974a7ec 100644 --- a/internal/mcp/tool_assessment_test.go +++ b/internal/mcp/tool_assessment_test.go @@ -24,11 +24,12 @@ func testResolvedPolicy() *requirement.ResolvedPolicy { { Id: "TEST-001-AR1", Text: "Test requirement", - Applicability: []string{"test"}, + Applicability: []string{"maturity-1", "maturity-2", "maturity-3"}, }, { - Id: "TEST-001-AR2", - Text: "Second requirement", + Id: "TEST-001-AR2", + Text: "Second requirement", + Applicability: []string{"maturity-2", "maturity-3"}, }, }, }, @@ -36,8 +37,9 @@ func testResolvedPolicy() *requirement.ResolvedPolicy { Id: "TEST-002", AssessmentRequirements: []gemara.AssessmentRequirement{ { - Id: "TEST-002-AR1", - Text: "Third requirement", + Id: "TEST-002-AR1", + Text: "Third requirement", + Applicability: []string{"maturity-3"}, }, }, }, @@ -210,6 +212,31 @@ func TestHandleGetAssessmentRequirements(t *testing.T) { params := firstReq["parameters"].(map[string]interface{}) assert.Equal(t, "90", params["threshold"]) }) + + t.Run("filter by scope", func(t *testing.T) { + input := map[string]interface{}{ + "catalogName": "test-policy", + "scope": []string{"maturity-2"}, + } + inputJSON, err := json.Marshal(input) + require.NoError(t, err) + + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(inputJSON), + }, + } + + result, err := handler(context.Background(), req) + require.NoError(t, err) + + textContent := result.Content[0].(*mcp.TextContent) + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["count"]) + }) } func TestCreateGetAssessmentRequirementsTool(t *testing.T) { @@ -234,6 +261,10 @@ func TestCreateGetAssessmentRequirementsTool(t *testing.T) { require.True(t, ok) assert.Equal(t, "string", controlId["type"]) + scope, ok := properties["scope"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "array", scope["type"]) + required, ok := schema["required"].([]interface{}) require.True(t, ok) assert.Contains(t, required, "catalogName") @@ -243,20 +274,48 @@ func TestExtractFromResolvedPolicy(t *testing.T) { rp := testResolvedPolicy() t.Run("extract all", func(t *testing.T) { - results := extractFromResolvedPolicy(rp, "") + results := extractFromResolvedPolicy(rp, "", nil) assert.Len(t, results, 3) }) t.Run("filter by control", func(t *testing.T) { - results := extractFromResolvedPolicy(rp, "TEST-001") + results := extractFromResolvedPolicy(rp, "TEST-001", nil) assert.Len(t, results, 2) assert.Equal(t, "TEST-001", results[0].ControlID) assert.Equal(t, "TEST-001", results[1].ControlID) }) t.Run("parameters populated from assessment plans", func(t *testing.T) { - results := extractFromResolvedPolicy(rp, "TEST-001") + results := extractFromResolvedPolicy(rp, "TEST-001", nil) assert.Equal(t, "90", results[0].Parameters["threshold"]) assert.Empty(t, results[1].Parameters) }) + + t.Run("filter by scope", func(t *testing.T) { + results := extractFromResolvedPolicy(rp, "", []string{"maturity-2"}) + assert.Len(t, results, 2) + for _, r := range results { + assert.Contains(t, r.Applicability, "maturity-2") + } + }) + + t.Run("filter by multiple scope values", func(t *testing.T) { + results := extractFromResolvedPolicy(rp, "", []string{"maturity-1", "maturity-3"}) + assert.Len(t, results, 3) + }) + + t.Run("filter by scope and control", func(t *testing.T) { + results := extractFromResolvedPolicy(rp, "TEST-001", []string{"maturity-2"}) + assert.Len(t, results, 2) + }) + + t.Run("scope filters out non-matching", func(t *testing.T) { + results := extractFromResolvedPolicy(rp, "", []string{"maturity-1"}) + assert.Len(t, results, 1) + }) + + t.Run("nil scope returns all", func(t *testing.T) { + results := extractFromResolvedPolicy(rp, "", nil) + assert.Len(t, results, 3) + }) } diff --git a/internal/mcp/tool_delta.go b/internal/mcp/tool_delta.go new file mode 100644 index 0000000..cf544e6 --- /dev/null +++ b/internal/mcp/tool_delta.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/complytime/complypack/internal/requirement" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func createAnalyzeParameterDeltaTool() *mcp.Tool { + return &mcp.Tool{ + Name: "analyze_parameter_delta", + Description: "Crosswalk parameters across all frameworks in a resolved policy. Returns per-parameter verdicts (aligned, mismatch, org_binds_generic, not_covered) and summary counts. Mismatch means the values differ — the caller determines which is stricter based on domain context.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "policyName": map[string]interface{}{ + "type": "string", + "description": "Name of the resolved policy to analyze", + }, + }, + "required": []interface{}{"policyName"}, + }, + } +} + +func handleAnalyzeParameterDelta(store *ResourceStore, artifactSet *requirement.ArtifactSet) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input struct { + PolicyName string `json:"policyName"` + } + + if err := json.Unmarshal(req.Params.Arguments, &input); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + rp, found := store.resolved[input.PolicyName] + if !found { + return nil, fmt.Errorf("policy %q not found", input.PolicyName) + } + + report, err := requirement.AnalyzeDelta(rp, artifactSet) + if err != nil { + return nil, fmt.Errorf("delta analysis failed: %w", err) + } + + responseData, err := json.Marshal(report) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: string(responseData), + }, + }, + }, nil + } +} diff --git a/internal/mcp/tool_delta_test.go b/internal/mcp/tool_delta_test.go new file mode 100644 index 0000000..c2e7d5b --- /dev/null +++ b/internal/mcp/tool_delta_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/complytime/complypack/internal/requirement" + "github.com/gemaraproj/go-gemara" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testDeltaStore() (*ResourceStore, *requirement.ArtifactSet) { + catalog := &gemara.ControlCatalog{ + Metadata: gemara.Metadata{Id: "container-baseline"}, + Controls: []gemara.Control{ + { + Id: "CTL-TLS-001", + Title: "TLS Configuration", + AssessmentRequirements: []gemara.AssessmentRequirement{ + {Id: "CTL-TLS-001-AR1", Text: "TLS minimum version"}, + }, + }, + }, + } + + policy := &gemara.Policy{ + Metadata: gemara.Metadata{ + Id: "org-policy", + MappingReferences: []gemara.MappingReference{ + {Id: "container-baseline"}, + }, + }, + Imports: gemara.Imports{ + Catalogs: []gemara.CatalogImport{ + {ReferenceId: "container-baseline"}, + }, + }, + Adherence: gemara.Adherence{ + AssessmentPlans: []gemara.AssessmentPlan{ + { + RequirementId: "CTL-TLS-001-AR1", + Parameters: []gemara.Parameter{ + {Label: "tls_minimum_version", AcceptedValues: []string{"1.3"}}, + }, + }, + }, + }, + } + + set := &requirement.ArtifactSet{ + Catalogs: map[string]*gemara.ControlCatalog{"container-baseline": catalog}, + Policies: map[string]*gemara.Policy{"org-policy": policy}, + Guidance: make(map[string]*gemara.GuidanceCatalog), + } + + rp, _ := requirement.ResolvePolicy(*policy, set) + + store := &ResourceStore{ + artifacts: map[string]any{"container-baseline": catalog, "org-policy": policy}, + resolved: map[string]*requirement.ResolvedPolicy{"org-policy": rp}, + schemas: map[string][]byte{}, + } + + return store, set +} + +func TestHandleAnalyzeParameterDelta(t *testing.T) { + store, set := testDeltaStore() + handler := handleAnalyzeParameterDelta(store, set) + + t.Run("successful analysis", func(t *testing.T) { + input := map[string]interface{}{"policyName": "org-policy"} + inputJSON, err := json.Marshal(input) + require.NoError(t, err) + + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(inputJSON), + }, + } + + result, err := handler(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Content, 1) + + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, "org-policy", response["policy"]) + params, ok := response["parameters"].([]interface{}) + require.True(t, ok) + assert.Len(t, params, 1) + }) + + t.Run("policy not found", func(t *testing.T) { + input := map[string]interface{}{"policyName": "nonexistent"} + inputJSON, _ := json.Marshal(input) + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(inputJSON), + }, + } + + result, err := handler(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("invalid input", func(t *testing.T) { + req := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage([]byte(`{invalid`)), + }, + } + + result, err := handler(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestCreateAnalyzeParameterDeltaTool(t *testing.T) { + tool := createAnalyzeParameterDeltaTool() + assert.Equal(t, "analyze_parameter_delta", tool.Name) + assert.NotEmpty(t, tool.Description) + + schema, ok := tool.InputSchema.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "object", schema["type"]) + + props, ok := schema["properties"].(map[string]interface{}) + require.True(t, ok) + _, ok = props["policyName"] + assert.True(t, ok) +} diff --git a/internal/requirement/classify.go b/internal/requirement/classify.go index 9088a53..df34335 100644 --- a/internal/requirement/classify.go +++ b/internal/requirement/classify.go @@ -14,6 +14,7 @@ type ArtifactSet struct { Catalogs map[string]*gemara.ControlCatalog Policies map[string]*gemara.Policy Guidance map[string]*gemara.GuidanceCatalog + Mappings map[string]*gemara.MappingDocument } // NewArtifactSet returns an initialized ArtifactSet. @@ -22,6 +23,7 @@ func NewArtifactSet() *ArtifactSet { Catalogs: make(map[string]*gemara.ControlCatalog), Policies: make(map[string]*gemara.Policy), Guidance: make(map[string]*gemara.GuidanceCatalog), + Mappings: make(map[string]*gemara.MappingDocument), } } @@ -52,6 +54,12 @@ func Classify(data ...[]byte) (*ArtifactSet, error) { return nil, fmt.Errorf("artifact %d (GuidanceCatalog): %w", i, err) } as.Guidance[gc.Metadata.Id] = &gc + case gemara.MappingDocumentArtifact: + var md gemara.MappingDocument + if err := goyaml.Unmarshal(d, &md); err != nil { + return nil, fmt.Errorf("artifact %d (MappingDocument): %w", i, err) + } + as.Mappings[md.Metadata.Id] = &md } } return as, nil @@ -78,5 +86,11 @@ func (as *ArtifactSet) Merge(other *ArtifactSet) error { } as.Guidance[id] = gc } + for id, md := range other.Mappings { + if _, exists := as.Mappings[id]; exists { + return fmt.Errorf("duplicate artifact id %q across sources", id) + } + as.Mappings[id] = md + } return nil } diff --git a/internal/requirement/delta.go b/internal/requirement/delta.go new file mode 100644 index 0000000..e367c7a --- /dev/null +++ b/internal/requirement/delta.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +package requirement + +import ( + "fmt" + "strings" + + "github.com/gemaraproj/go-gemara" +) + +// Verdict classifies the relationship between parameter values from +// different sources in a resolved policy graph. +type Verdict string + +const ( + VerdictAligned Verdict = "aligned" + VerdictMismatch Verdict = "mismatch" + VerdictOrgBindsGeneric Verdict = "org_binds_generic" + VerdictNotCovered Verdict = "not_covered" +) + +// Specificity describes how concrete a parameter value is. +type Specificity string + +const ( + SpecificityConcrete Specificity = "concrete" + SpecificityGeneric Specificity = "generic" + SpecificityNone Specificity = "none" +) + +// ParameterLayer holds a parameter value from one source. +type ParameterLayer struct { + Source string `json:"source"` + Value string `json:"value"` + Specificity Specificity `json:"specificity"` +} + +// ParameterDelta is the result of comparing a single parameter +// across framework, org policy, and tech baseline layers. +type ParameterDelta struct { + RequirementID string `json:"requirement_id"` + Label string `json:"label"` + Framework ParameterLayer `json:"framework"` + OrgPolicy ParameterLayer `json:"org_policy"` + TechBaseline ParameterLayer `json:"tech_baseline"` + Verdict Verdict `json:"verdict"` +} + +// DeltaReport is the full result of analyzing parameter deltas +// across a resolved policy. +type DeltaReport struct { + PolicyID string `json:"policy"` + CatalogsCompared []string `json:"catalogs_compared"` + Parameters []ParameterDelta `json:"parameters"` + Summary DeltaSummary `json:"summary"` +} + +// DeltaSummary counts verdicts. +type DeltaSummary struct { + Total int `json:"total"` + Aligned int `json:"aligned"` + Mismatch int `json:"mismatch"` + OrgBindsGeneric int `json:"org_binds_generic"` + NotCovered int `json:"not_covered"` +} + +// CompareValues determines the verdict between a framework layer and +// an org policy layer. +func CompareValues(framework, orgPolicy ParameterLayer) Verdict { + if framework.Specificity == SpecificityNone { + return VerdictNotCovered + } + if orgPolicy.Specificity == SpecificityNone { + return VerdictNotCovered + } + + if framework.Specificity == SpecificityGeneric && orgPolicy.Specificity == SpecificityConcrete { + return VerdictOrgBindsGeneric + } + + if framework.Value == orgPolicy.Value { + return VerdictAligned + } + + return VerdictMismatch +} + +func classifySpecificity(value string) Specificity { + if value == "" { + return SpecificityNone + } + lower := strings.ToLower(value) + if strings.Contains(lower, "per organizational") || + strings.Contains(lower, "per the organization") || + strings.Contains(lower, "as defined by") || + strings.Contains(lower, "according to") { + return SpecificityGeneric + } + return SpecificityConcrete +} + +func findGuidelineParameter(gc gemara.GuidanceCatalog, label string) (string, bool) { + return "", false +} + +func summarizeDeltas(deltas []ParameterDelta) DeltaSummary { + s := DeltaSummary{Total: len(deltas)} + for _, d := range deltas { + switch d.Verdict { + case VerdictAligned: + s.Aligned++ + case VerdictMismatch: + s.Mismatch++ + case VerdictOrgBindsGeneric: + s.OrgBindsGeneric++ + case VerdictNotCovered: + s.NotCovered++ + } + } + return s +} + +// AnalyzeDelta compares parameters across all layers in a resolved policy. +func AnalyzeDelta(rp *ResolvedPolicy, set *ArtifactSet) (*DeltaReport, error) { + if rp == nil { + return nil, fmt.Errorf("resolved policy is nil") + } + + var catalogIDs []string + for _, cat := range rp.ControlCatalogs { + catalogIDs = append(catalogIDs, cat.Metadata.Id) + } + + var deltas []ParameterDelta + for _, plan := range rp.Policy.Adherence.AssessmentPlans { + for _, param := range plan.Parameters { + orgValue := "" + if len(param.AcceptedValues) > 0 { + orgValue = param.AcceptedValues[0] + } + + orgLayer := ParameterLayer{ + Source: rp.Policy.Metadata.Id, + Value: orgValue, + Specificity: classifySpecificity(orgValue), + } + + fwLayer := ParameterLayer{Specificity: SpecificityNone} + baselineLayer := ParameterLayer{Specificity: SpecificityNone} + + for _, gc := range rp.GuidanceCatalogs { + if v, ok := findGuidelineParameter(gc, param.Label); ok { + fwLayer = ParameterLayer{ + Source: gc.Metadata.Id, + Value: v, + Specificity: classifySpecificity(v), + } + break + } + } + + verdict := CompareValues(fwLayer, orgLayer) + + deltas = append(deltas, ParameterDelta{ + RequirementID: plan.RequirementId, + Label: param.Label, + Framework: fwLayer, + OrgPolicy: orgLayer, + TechBaseline: baselineLayer, + Verdict: verdict, + }) + } + } + + summary := summarizeDeltas(deltas) + + return &DeltaReport{ + PolicyID: rp.Policy.Metadata.Id, + CatalogsCompared: catalogIDs, + Parameters: deltas, + Summary: summary, + }, nil +} diff --git a/internal/requirement/delta_test.go b/internal/requirement/delta_test.go new file mode 100644 index 0000000..a6c09a4 --- /dev/null +++ b/internal/requirement/delta_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package requirement + +import ( + "testing" + + "github.com/gemaraproj/go-gemara" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareValues_Aligned(t *testing.T) { + fw := ParameterLayer{Source: "framework", Value: "1.3", Specificity: SpecificityConcrete} + org := ParameterLayer{Source: "org-policy", Value: "1.3", Specificity: SpecificityConcrete} + + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictAligned, verdict) +} + +func TestCompareValues_Mismatch(t *testing.T) { + t.Run("version strings", func(t *testing.T) { + fw := ParameterLayer{Source: "fw", Value: "1.2", Specificity: SpecificityConcrete} + org := ParameterLayer{Source: "org", Value: "1.3", Specificity: SpecificityConcrete} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictMismatch, verdict) + }) + + t.Run("numeric thresholds", func(t *testing.T) { + fw := ParameterLayer{Source: "fw", Value: "30", Specificity: SpecificityConcrete} + org := ParameterLayer{Source: "org", Value: "60", Specificity: SpecificityConcrete} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictMismatch, verdict) + }) + + t.Run("algorithms", func(t *testing.T) { + fw := ParameterLayer{Source: "fw", Value: "AES-256-GCM", Specificity: SpecificityConcrete} + org := ParameterLayer{Source: "org", Value: "ChaCha20-Poly1305", Specificity: SpecificityConcrete} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictMismatch, verdict) + }) +} + +func TestCompareValues_OrgBindsGeneric(t *testing.T) { + fw := ParameterLayer{Source: "fw", Value: "per organizational requirements", Specificity: SpecificityGeneric} + org := ParameterLayer{Source: "org", Value: "MFA + bastion host", Specificity: SpecificityConcrete} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictOrgBindsGeneric, verdict) +} + +func TestCompareValues_NotCovered(t *testing.T) { + t.Run("both none", func(t *testing.T) { + fw := ParameterLayer{Specificity: SpecificityNone} + org := ParameterLayer{Specificity: SpecificityNone} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictNotCovered, verdict) + }) + + t.Run("framework none", func(t *testing.T) { + fw := ParameterLayer{Specificity: SpecificityNone} + org := ParameterLayer{Source: "org", Value: "90", Specificity: SpecificityConcrete} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictNotCovered, verdict) + }) + + t.Run("org none", func(t *testing.T) { + fw := ParameterLayer{Source: "fw", Value: "90", Specificity: SpecificityConcrete} + org := ParameterLayer{Specificity: SpecificityNone} + verdict := CompareValues(fw, org) + assert.Equal(t, VerdictNotCovered, verdict) + }) +} + +func testDeltaArtifactSet() *ArtifactSet { + catalog := &gemara.ControlCatalog{ + Metadata: gemara.Metadata{Id: "container-baseline"}, + Controls: []gemara.Control{ + { + Id: "CTL-TLS-001", + Title: "TLS Configuration", + AssessmentRequirements: []gemara.AssessmentRequirement{ + {Id: "CTL-TLS-001-AR1", Text: "TLS minimum version must be enforced"}, + }, + }, + { + Id: "CTL-CERT-001", + Title: "Certificate Management", + AssessmentRequirements: []gemara.AssessmentRequirement{ + {Id: "CTL-CERT-001-AR1", Text: "Certificate validity must not exceed maximum"}, + }, + }, + }, + } + + policy := &gemara.Policy{ + Metadata: gemara.Metadata{ + Id: "org-parent-policy", + MappingReferences: []gemara.MappingReference{ + {Id: "container-baseline"}, + }, + }, + Imports: gemara.Imports{ + Catalogs: []gemara.CatalogImport{ + {ReferenceId: "container-baseline"}, + }, + }, + Adherence: gemara.Adherence{ + AssessmentPlans: []gemara.AssessmentPlan{ + { + RequirementId: "CTL-TLS-001-AR1", + Parameters: []gemara.Parameter{ + {Label: "tls_minimum_version", AcceptedValues: []string{"1.3"}}, + }, + }, + { + RequirementId: "CTL-CERT-001-AR1", + Parameters: []gemara.Parameter{ + {Label: "max_validity_days", AcceptedValues: []string{"90"}}, + }, + }, + }, + }, + } + + return &ArtifactSet{ + Catalogs: map[string]*gemara.ControlCatalog{"container-baseline": catalog}, + Policies: map[string]*gemara.Policy{"org-parent-policy": policy}, + Guidance: make(map[string]*gemara.GuidanceCatalog), + } +} + +func TestAnalyzeDelta(t *testing.T) { + set := testDeltaArtifactSet() + policy := set.Policies["org-parent-policy"] + + rp, err := ResolvePolicy(*policy, set) + require.NoError(t, err) + + report, err := AnalyzeDelta(rp, set) + require.NoError(t, err) + + assert.Equal(t, "org-parent-policy", report.PolicyID) + assert.Contains(t, report.CatalogsCompared, "container-baseline") + assert.Len(t, report.Parameters, 2) + assert.Equal(t, report.Summary.Total, 2) +} + +func TestAnalyzeDelta_NilPolicy(t *testing.T) { + set := testDeltaArtifactSet() + _, err := AnalyzeDelta(nil, set) + assert.Error(t, err) +} diff --git a/internal/requirement/resolved_policy.go b/internal/requirement/resolved_policy.go index 9a4a846..33a23c2 100644 --- a/internal/requirement/resolved_policy.go +++ b/internal/requirement/resolved_policy.go @@ -86,3 +86,15 @@ func (rp *ResolvedPolicy) ControlIDs() []string { func (rp *ResolvedPolicy) ParametersForRequirement(reqID string) []gemara.Parameter { return rp.paramIndex[reqID] } + +// ImportedGuidanceIDs returns the metadata IDs of guidance catalogs +// imported by this policy. Guidance catalogs loaded in the artifact set +// but not in this list are "under evaluation" — available for crosswalk +// but not mandated. +func (rp *ResolvedPolicy) ImportedGuidanceIDs() []string { + ids := make([]string, 0, len(rp.GuidanceCatalogs)) + for _, gc := range rp.GuidanceCatalogs { + ids = append(ids, gc.Metadata.Id) + } + return ids +} diff --git a/internal/requirement/resolved_policy_test.go b/internal/requirement/resolved_policy_test.go index 57fc835..cb2819e 100644 --- a/internal/requirement/resolved_policy_test.go +++ b/internal/requirement/resolved_policy_test.go @@ -5,6 +5,7 @@ package requirement import ( "testing" + "github.com/gemaraproj/go-gemara" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -60,3 +61,55 @@ func TestResolvedPolicy_QueryMethods(t *testing.T) { assert.Empty(t, params) }) } + +func TestResolvedPolicy_ImportedGuidanceIDs(t *testing.T) { + guidanceCatalog := &gemara.GuidanceCatalog{ + Metadata: gemara.Metadata{Id: "guidance-1"}, + Guidelines: []gemara.Guideline{ + {Id: "GL-001", Title: "Test guideline"}, + }, + } + + policy := &gemara.Policy{ + Metadata: gemara.Metadata{ + Id: "test-policy", + MappingReferences: []gemara.MappingReference{ + {Id: "test-catalog"}, + {Id: "guidance-1"}, + }, + }, + Imports: gemara.Imports{ + Catalogs: []gemara.CatalogImport{ + {ReferenceId: "test-catalog"}, + }, + Guidance: []gemara.GuidanceImport{ + {ReferenceId: "guidance-1"}, + }, + }, + Adherence: gemara.Adherence{}, + } + + catalog := &gemara.ControlCatalog{ + Metadata: gemara.Metadata{Id: "test-catalog"}, + Controls: []gemara.Control{ + { + Id: "CTRL-001", + AssessmentRequirements: []gemara.AssessmentRequirement{ + {Id: "REQ-001", Text: "Verify"}, + }, + }, + }, + } + + set := &ArtifactSet{ + Catalogs: map[string]*gemara.ControlCatalog{"test-catalog": catalog}, + Policies: map[string]*gemara.Policy{"test-policy": policy}, + Guidance: map[string]*gemara.GuidanceCatalog{"guidance-1": guidanceCatalog}, + } + + rp, err := ResolvePolicy(*policy, set) + require.NoError(t, err) + + ids := rp.ImportedGuidanceIDs() + assert.Equal(t, []string{"guidance-1"}, ids) +} From 8e8f3ca05fc963f553c9fe396ea0c7eeee28e500 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 13 Jun 2026 16:27:18 -0400 Subject: [PATCH 04/16] feat: add /comply plugin with pipeline, pack, and setup skills Add comply pipeline skills (scoping, mapping, adherence) with router that dispatches sub-stages by filename from the skill base directory. Add /comply:pack for Rego generation and /comply:setup for workspace configuration. Skills enforce MCP-grounded control data access via get_assessment_requirements with scope filter. Update plugin manifests to register new commands. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .claude-plugin/plugin.json | 8 +- .cursor-plugin/plugin.json | 8 +- .gitignore | 4 +- gemini-extension.json | 4 +- skills/complypack/SKILL.md | 366 ----------------------------------- skills/complypack/mcp.jsonc | 8 - skills/pack/SKILL.md | 46 +++++ skills/pipeline/SKILL.md | 52 +++++ skills/pipeline/adherence.md | 100 ++++++++++ skills/pipeline/mapping.md | 137 +++++++++++++ skills/pipeline/scoping.md | 130 +++++++++++++ skills/setup/SKILL.md | 77 ++++++++ 12 files changed, 557 insertions(+), 383 deletions(-) delete mode 100644 skills/complypack/SKILL.md delete mode 100644 skills/complypack/mcp.jsonc create mode 100644 skills/pack/SKILL.md create mode 100644 skills/pipeline/SKILL.md create mode 100644 skills/pipeline/adherence.md create mode 100644 skills/pipeline/mapping.md create mode 100644 skills/pipeline/scoping.md create mode 100644 skills/setup/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 23e20fc..8431e91 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { - "name": "complypack", + "name": "comply", "displayName": "ComplyPack", "version": "0.1.0", - "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "description": "Gemara compliance pipeline and policy generation via MCP server", "author": { "name": "ComplyTime Authors", "url": "https://github.com/complytime" @@ -16,6 +16,8 @@ "opa", "gemara", "policy", - "mcp" + "mcp", + "audit", + "governance" ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index bbc1c40..62c2af4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { - "name": "complypack", + "name": "comply", "displayName": "ComplyPack", "version": "0.1.0", - "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "description": "Gemara compliance pipeline and policy generation via MCP server", "author": { "name": "ComplyTime Authors", "url": "https://github.com/complytime" @@ -15,6 +15,8 @@ "opa", "gemara", "policy", - "mcp" + "mcp", + "audit", + "governance" ] } diff --git a/.gitignore b/.gitignore index adff2f0..35db7f3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,11 @@ go.work.sum .DS_Store Thumbs.db -# Local docs (plans, analysis) +# Local docs (plans, analysis, specs, demos) docs/plans/ docs/analysis/ +docs/superpowers/ +docs/demo/ # Development tooling .opencode/ diff --git a/gemini-extension.json b/gemini-extension.json index 554c82c..39a3b3d 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { - "name": "complypack", - "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "name": "comply", + "description": "Gemara compliance pipeline and policy generation via MCP server", "version": "0.1.0" } diff --git a/skills/complypack/SKILL.md b/skills/complypack/SKILL.md deleted file mode 100644 index 9f8bed8..0000000 --- a/skills/complypack/SKILL.md +++ /dev/null @@ -1,366 +0,0 @@ ---- -name: complypack -description: Use when user mentions complypack, wants to generate Rego policies from Gemara catalogs, extract assessment requirements and parameters, or work with compliance validation for Kubernetes, Terraform, Docker, Ansible, or CI platforms ---- - -# ComplyPack: Gemara Policy Generation and Assessment - -## Overview - -Generate Rego policies from Gemara control catalogs that enforce compliance requirements. Policies must be written to disk, validated against the target platform schema, and tested with sample inputs. - -**Core principle:** Read control definitions from source → Generate platform-specific policy → Write to disk → Verify it works. - -## When to Use - -Use when: -- User requests "generate policy for control X" -- User specifies a Gemara catalog and target platform -- User mentions Conftest, OPA, or Rego -- Generating compliance policies from security frameworks - -Do NOT use for: -- Writing arbitrary Rego policies (not from Gemara controls) -- Generating policies without a source catalog -- One-off policy snippets that don't need disk storage - -## Quick Reference - -| Step | Action | Output | -|------|--------|--------| -| 1. Read control | Get definition from catalog (MCP/file/API) | Control text, ID, title | -| 2. Get parameters | Extract assessment requirements with test parameters | Thresholds, values, tools | -| 3. Read schema | Get platform schema (MCP/file) | JSON Schema or CUE | -| 4. Choose format | OPA (allow) or Conftest (deny) | Policy structure | -| 5. Generate policy | Write Rego with control mapping and parameters | .rego file | -| 6. Write to disk | Save to `policy/` or user-specified path | File on disk | -| 7. Verify | Test with sample input | Pass/fail results | - -## The Process - -### Step 1: Read Control Definition from Source - -**DO NOT generate from general knowledge.** Always read the actual control text. - -**If ComplyPack MCP server available:** -``` -1. List available catalogs: ListMcpResourcesTool(server="complypack") -2. Read specific control: ReadMcpResourceTool(server="complypack", uri="complypack://catalog/{name}") -3. Extract control ID, title, description -``` - -**If catalog is a file:** -``` -1. Read catalog YAML/JSON -2. Find control by ID -3. Extract control text -``` - -**Critical:** The control definition is your requirements specification. Don't improvise. - -### Step 2: Get Assessment Requirements and Parameters - -**IMPORTANT:** Assessment requirements contain test parameters, thresholds, and approved tools. - -**If ComplyPack MCP server available AND catalog is a Policy:** -``` -Use get_assessment_requirements tool: -{ - "catalogName": "policy-name", - "controlId": "CTL-XXX-001" // optional filter -} - -Returns: -- Structured parameters from Policy.Adherence.AssessmentPlans -- Parameter labels, descriptions, accepted values -- Tool mentions (ToolA, ToolB, etc.) -- File patterns (.gitlab-ci.yml, Jenkinsfile) -``` - -**What you get:** -- `Parameters` - Structured test values from assessment plans (e.g., {"timeout": "60"}) -- `Tools` - Approved tools mentioned (ToolA, ToolB, ToolC) -- `TestValues` - Algorithms, config files, permissions patterns -- `Text` - Full requirement text for context - -**Example:** -```json -{ - "id": "CTL-DATA-001-AR3", - "control_id": "CTL-DATA-001", - "text": "Certificate validity must not exceed maximum", - "parameters": { - "max_validity_days": "90", - "max_validity_days_description": "Maximum certificate lifetime" - }, - "tools": [], - "test_values": [] -} -``` - -**Use these parameters in your policy:** -- Thresholds → Use exact values in comparisons -- Tools → Reference in validation logic -- Accepted values → Use in allow/deny rules - -**If catalog is a ControlCatalog (not Policy):** -- Assessment requirements exist but parameters are not attached -- You'll get requirement text and hints (tools, patterns) -- No structured parameter values - -### Step 3: Read Platform Schema - -Understand what data structure the policy will evaluate. - -**If ComplyPack MCP server available:** -``` -ReadMcpResourceTool(server="complypack", uri="complypack://schema/{platform}") -``` - -**Platforms:** kubernetes, terraform, docker, ansible, ci - -**Schema tells you:** -- Available fields to validate -- Data types and structure -- What's actually in the input - -### Step 4: Choose Policy Format - -**Ask user if unclear**, otherwise use this decision tree: - -```dot -digraph format_choice { - "User specified format?" [shape=diamond]; - "Mentions Conftest?" [shape=diamond]; - "Use Conftest format (deny)" [shape=box]; - "Use OPA format (allow)" [shape=box]; - - "User specified format?" -> "Use specified format" [label="yes"]; - "User specified format?" -> "Mentions Conftest?" [label="no"]; - "Mentions Conftest?" -> "Use Conftest format (deny)" [label="yes"]; - "Mentions Conftest?" -> "Use OPA format (allow)" [label="no"]; -} -``` - -**Conftest format:** -```rego -package main - -deny[msg] { - # Violation condition - msg := "Violation message" -} -``` - -**OPA format:** -```rego -package platform.controlid - -default allow := false - -allow { - # Compliance conditions -} - -violations[msg] { - # Generate violation messages -} -``` - -### Step 5: Generate the Policy - -Map control requirements to platform-specific checks: - -**Template structure:** -```rego -# {Control-ID}: {Control Title} -# {Control Description} - -package {namespace} - -import rego.v1 - -# METADATA -# custom: -# control_id: {ID} -# control_title: {Title} -# severity: {high|medium|low} - -# [Policy logic based on control requirements and platform schema] -``` - -**Key requirements:** -- Reference control ID and title in comments -- Use fields that exist in platform schema -- Write clear violation messages -- Include both positive checks (what must exist) and negative checks (what must not) - -### Step 6: Write to Disk - -**DO NOT just output to chat.** Policies must be saved to files. - -**Default structure for Conftest:** -``` -policy/ - {control-id}.rego # e.g., ac-1.rego - {control-id}_test.rego # Optional: unit tests -``` - -**Default structure for OPA:** -``` -policies/ - {platform}/ - {control-id}.rego # e.g., kubernetes/ac-1.rego -``` - -**Ask user for path if:** -- They have existing policy directory -- Project structure is unclear -- Multiple controls being generated - -### Step 7: Verify Policy Works - -**Critical step - don't skip this.** - -Create sample input matching platform schema: - -**For Kubernetes:** -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - # ... based on schema -``` - -**For Terraform:** -```json -{ - "address": "aws_s3_bucket.example", - "type": "aws_s3_bucket", - "values": { - # ... based on schema - } -} -``` - -**Test the policy:** -```bash -# Conftest -conftest test input.yaml -p policy/ - -# OPA -opa eval --data policy.rego --input input.json "data.{package}.allow" -``` - -**Report results:** -- ✅ Policy syntax valid (opa check) -- ✅ Compliant input passes -- ✅ Non-compliant input fails with clear message - -## Common Mistakes - -| Mistake | Fix | -|---------|-----| -| Generated from general knowledge | Always read actual control definition from source | -| Policy only in chat | Write to disk with proper filename | -| No verification | Test with sample input before claiming done | -| Wrong format (allow vs deny) | Ask user or check for "Conftest" mention | -| Ignoring platform schema | Read schema, use actual fields that exist | -| Missing violation messages | Every deny/violation needs clear message | -| Overly complex structure | Start with single .rego file per control | - -## Red Flags - Check These Before Claiming Done - -- [ ] Did you read the control definition from the actual source? -- [ ] Did you read the platform schema to know available fields? -- [ ] Did you write the policy to disk (not just chat)? -- [ ] Did you test it with sample input? -- [ ] Does it have clear violation messages? -- [ ] Is the format correct (Conftest deny vs OPA allow)? - -## Example Workflow - -User: "Generate policy for CTL-DATA-001-AR3 from my-security-policy targeting Kubernetes" - -**Steps:** -1. ✅ Read control: `ReadMcpResourceTool(server="complypack", uri="complypack://catalog/my-security-policy")` -2. ✅ Extract CTL-DATA-001 requirements text -3. ✅ Get parameters: `get_assessment_requirements({catalogName: "my-security-policy", controlId: "CTL-DATA-001"})` -4. ✅ Extract parameter: `{"max_validity_days": "90"}` from assessment plan -5. ✅ Read schema: `ReadMcpResourceTool(server="complypack", uri="complypack://schema/kubernetes")` -6. ✅ Note schema fields: spec.tls.secretName, spec.tls.hosts -7. ✅ Generate OPA policy using `max_validity_days` parameter -8. ✅ Write to `policy/ctl-data-001-ar3.rego` -9. ✅ Create test input (Ingress with cert) -10. ✅ Run: `conftest test test-ingress.yaml -p policy/` -11. ✅ Report: "Policy enforces 90-day cert validity. Tested against sample Ingress." - -**Example policy using parameters:** -```rego -package kubernetes.ctl_data_001_ar3 - -import rego.v1 - -# CTL-DATA-001-AR3: Certificate validity -# Parameters from assessment plan: max_validity_days = 90 - -deny[msg] { - cert := input.spec.tls[_] - cert_days := get_cert_validity_days(cert.secretName) - - # Use parameter from assessment plan - max_days := 90 - cert_days > max_days - - msg := sprintf("Certificate %s exceeds maximum validity of %d days", - [cert.secretName, max_days]) -} -``` - -**NOT:** -1. ❌ Generate policy from general knowledge -2. ❌ Hardcode generic value when parameter is specified -3. ❌ Skip get_assessment_requirements step -4. ❌ Output policy in chat only -5. ❌ Skip testing - -## Multi-Control Generation - -When generating multiple controls: - -**Option 1: Separate files (recommended)** -``` -policy/ - ac-1.rego - ac-2.rego - sc-1.rego -``` -Easier to maintain, test, and selectively apply. - -**Option 2: Combined file** -``` -policy/ - all-controls.rego # Multiple deny[] or multiple packages -``` -Only if user explicitly requests combined. - -**Always:** -- Process controls one at a time -- Write each to disk before moving to next -- Test each independently - -## Tool Integration - -**Works with any tool that can:** -- Read structured data (JSON/YAML) -- Generate Rego code -- Write files to disk - -**Not specific to:** -- Claude MCP server (catalog could be files, API, etc.) -- Conftest vs OPA (support both) -- Specific platforms (works for any with schema) - -**The workflow is platform-agnostic** - adapt to your environment. diff --git a/skills/complypack/mcp.jsonc b/skills/complypack/mcp.jsonc deleted file mode 100644 index 6b7c259..0000000 --- a/skills/complypack/mcp.jsonc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "complypack": { - "command": "complypack", - "args": ["mcp", "serve", "--config", "complypack.yaml"] - } - } -} diff --git a/skills/pack/SKILL.md b/skills/pack/SKILL.md new file mode 100644 index 0000000..210a718 --- /dev/null +++ b/skills/pack/SKILL.md @@ -0,0 +1,46 @@ +--- +name: pack +description: Use when user wants to generate Rego policies from Gemara catalogs, extract assessment requirements and parameters, or work with compliance validation for Kubernetes, Terraform, Docker, Ansible, or CI platforms +--- + +# /comply:pack — Rego Policy Generation and Assessment + +Generate Rego policies from Gemara Control Catalogs that enforce compliance requirements. Policies must be written to disk, validated against the target platform schema, and tested with sample inputs. + +**Core principle:** Read control definitions from source → Generate platform-specific policy → Write to disk → Verify it works. + +## When to Use + +- User requests "generate policy for control X" +- User specifies a Gemara catalog and target platform +- User mentions Conftest, OPA, or Rego +- Generating compliance policies from security frameworks + +Do NOT use for: +- Writing arbitrary Rego policies (not from Gemara controls) +- Generating policies without a source catalog + +## Quick Reference + +| Step | Action | Output | +|------|--------|--------| +| 1. Read control | Get definition from catalog (MCP) | Control text, ID, title | +| 2. Get parameters | Extract assessment requirements | Thresholds, values | +| 3. Read schema | Get platform schema (MCP) | JSON Schema or CUE | +| 4. Choose format | OPA (allow) or Conftest (deny) | Policy structure | +| 5. Generate policy | Write Rego with control mapping | .rego file | +| 6. Write to disk | Save to `policy/` | File on disk | +| 7. Verify | Test with sample input | Pass/fail results | + +## Safety + +**DO NOT generate from general knowledge.** Always read the actual control text from MCP. + +## MCP Resources and Tools + +- `complypack://catalog/*` — Control Catalogs, Guidance Catalogs, Policies +- `complypack://schema/*` — Platform schemas +- `complypack://evaluator` — Available evaluators +- `get_assessment_requirements` — Extract assessment requirements with parameters +- `validate_policy` — Validate policy syntax and contract compliance +- `test_policy` — Run policy tests against sample data diff --git a/skills/pipeline/SKILL.md b/skills/pipeline/SKILL.md new file mode 100644 index 0000000..a57be93 --- /dev/null +++ b/skills/pipeline/SKILL.md @@ -0,0 +1,52 @@ +--- +name: pipeline +description: Use when user wants to build Gemara Policy artifacts for audit preparation or compliance program setup +--- + +# /comply:pipeline — ComplyTime Audit Pipeline + +Guide users through building a Gemara Policy (applicability statement) from their system architecture and governance sources. The Gemara Policy is the formal contract between audit and engineering, functionally equivalent to an ISO 27001 Statement of Applicability or a NIST System Security Plan. + +## Safety + +**CRITICAL:** Every stage MUST read control IDs, requirement IDs, and parameter values from MCP resources. DO NOT generate these from memory. The MCP server is the source of truth. + +## Pipeline Stages + +| Stage | Artifact | Purpose | +|-----------|-----------------------------------|------------------------------------------------------------------| +| scoping | `.complytime/scoping.yaml` | System profile + Control Catalog scoping + gap analysis | +| mapping | `.complytime/delta-report.yaml` | Parameter delta analysis + harmonization across framework layers | +| adherence | `.complytime/child-policy.yaml` | Compile the child Policy with adherence plan | + +After adherence, invoke `/comply:pack` to generate assessment logic for use with `complyctl`. + +## Router Logic + +1. Check if `.complytime/` directory exists and which artifacts are present +2. Determine pipeline state: + - No `.complytime/` directory → start at **scoping** + - `scoping.yaml` exists but no `delta-report.yaml` → offer **mapping** + - `delta-report.yaml` exists but no `child-policy.yaml` → offer **adherence** + - `child-policy.yaml` exists → pipeline complete, offer to re-run any stage or proceed to `/comply:pack` +3. If the user specified a stage, validate prerequisites: + - **mapping** requires `scoping.yaml` + - **adherence** requires `delta-report.yaml` +4. Dispatch to the appropriate stage skill + +## Dispatching + +Read the stage instructions from this skill's base directory before proceeding: + +- **scoping** → `scoping.md` +- **mapping** → `mapping.md` +- **adherence** → `adherence.md` + +## Status Display + +``` +/comply:pipeline status: + [done] scoping — .complytime/scoping.yaml + [done] mapping — .complytime/delta-report.yaml + [next] adherence — not yet run +``` diff --git a/skills/pipeline/adherence.md b/skills/pipeline/adherence.md new file mode 100644 index 0000000..0def537 --- /dev/null +++ b/skills/pipeline/adherence.md @@ -0,0 +1,100 @@ +--- +name: comply-adherence +description: Populate a Gemara Policy defining what controls apply, with what parameter values, and how evidence will be collected +user-invocable: false +--- + +# Adherence — Compile Policy + +Compile or alter a Gemara Policy artifact. Declares what controls apply, with exact parameter values, and defines the adherence plan: frequency, evaluation method, and evidence requirements. + +Evidence collection and Evaluation Logs are produced by `complyctl` at runtime. + +## Prerequisites + +- `.complytime/scoping.yaml` +- `.complytime/delta-report.yaml` + +Verify all parameters are resolved (no `pending_user_decision`). If unresolved, tell the user to re-run mapping. + +## Process + +### Step 1: Read Input Artifacts + +Read both `.complytime/scoping.yaml` and `.complytime/delta-report.yaml`. + +### Step 2: Build Mapping References + +From the delta report's `sources`, build `mapping_references`: +- One for the parent Policy +- One for each scoped Control Catalog +- One for each Guidance Catalog for the target framework + +### Step 3: Build Imports + +- `imports.catalogs` — one per Control Catalog +- `imports.guidance` — one per mandated Guidance Catalog + +### Step 4: Build Assessment Plans + +Group by `requirement_id`. Each plan: +- `requirement_id` +- `frequency` (e.g., "30d", "90d", "365d") +- `evaluation_methods` (e.g., "automated", "manual_review", "attestation") +- `evidence_requirements` +- `parameters` — frozen values from harmonization + +### Step 5: Compile the Policy + +```yaml +gemara: v1alpha1 +kind: Policy +metadata: + id: -policy + title: " Policy" + created: "" +mapping_references: + - id: + metadata_id: +imports: + catalogs: + - reference_id: + guidance: + - reference_id: +adherence: + assessment_plans: + - requirement_id: "" + frequency: "" + evaluation_methods: + - method: "" + evidence_requirements: "" + parameters: + - label: "