From 5823ec0f6c571ce4dc2fae116a514ff4db0e368f Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 18:37:16 -0400 Subject: [PATCH 01/24] 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 | 107 ++++++++++++++++++++++- cmd/complypack/cli/mcp_test.go | 155 ++++++++++++++++++++++++++++++++- internal/mcp/server.go | 19 +++- 3 files changed, 271 insertions(+), 10 deletions(-) diff --git a/cmd/complypack/cli/mcp.go b/cmd/complypack/cli/mcp.go index e0656e1..42c75a1 100644 --- a/cmd/complypack/cli/mcp.go +++ b/cmd/complypack/cli/mcp.go @@ -7,7 +7,9 @@ import ( "log" "os" "path/filepath" + "strings" + "github.com/complytime/complypack/internal/config" "github.com/complytime/complypack/internal/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" @@ -31,6 +33,8 @@ func mcpServeCmd() *cobra.Command { var ( configPath string cacheDir string + sources []string + schemas []string ) cmd := &cobra.Command{ @@ -45,6 +49,12 @@ specified in complypack.yaml. Example: complypack mcp serve --config complypack.yaml + # Or use flags directly (no config file needed): + complypack mcp serve \ + --source oci://ghcr.io/org/catalog:v1 \ + --schema kubernetes \ + --schema ci=cue://cue.dev/x/githubactions@v0#Workflow + The server runs until interrupted (Ctrl+C) or the client disconnects.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -59,10 +69,20 @@ The server runs until interrupted (Ctrl+C) or the client disconnects.`, resolvedCacheDir = filepath.Join(homeDir, ".complypack", "cache") } - // Create MCP server + // Create MCP server options opts := &mcp.ServerOptions{ - ConfigPath: configPath, - CacheDir: resolvedCacheDir, + CacheDir: resolvedCacheDir, + } + + // If --source flags are present, build config from flags + if len(sources) > 0 { + cfg, err := buildConfigFromFlags(sources, schemas) + if err != nil { + return fmt.Errorf("failed to build config from flags: %w", err) + } + opts.Config = cfg + } else { + opts.ConfigPath = configPath } server, err := mcp.NewServer(ctx, opts) @@ -82,6 +102,87 @@ The server runs until interrupted (Ctrl+C) or the client disconnects.`, cmd.Flags().StringVarP(&configPath, "config", "c", "complypack.yaml", "Path to complypack.yaml config file") cmd.Flags().StringVar(&cacheDir, "cache-dir", "", "Cache directory (default: $HOME/.complypack/cache)") + cmd.Flags().StringArrayVar(&sources, "source", nil, "Gemara OCI source (repeatable, e.g. oci://ghcr.io/org/catalog:v1)") + cmd.Flags().StringArrayVar(&schemas, "schema", nil, "Platform schema (repeatable, e.g. kubernetes or ci=cue://...)") return cmd } + +// buildConfigFromFlags creates a ComplyPackConfig from --source and --schema flag values. +func buildConfigFromFlags(sources, schemas []string) (*config.ComplyPackConfig, error) { + entries, err := parseSourceFlags(sources) + if err != nil { + return nil, err + } + + schemaRefs, err := parseSchemaFlags(schemas) + if err != nil { + return nil, err + } + + return &config.ComplyPackConfig{ + Version: "1.0", + Gemara: config.GemaraConfig{Sources: entries}, + Schemas: schemaRefs, + }, nil +} + +// parseSourceFlags converts --source flag values into GemaraSourceEntry values. +// +// - oci://... -> GemaraSourceEntry{Source: "oci://...", PlainHTTP: false} +// - oci+http://... -> GemaraSourceEntry{Source: "oci://...", PlainHTTP: true} +func parseSourceFlags(sources []string) ([]config.GemaraSourceEntry, error) { + if len(sources) == 0 { + return nil, nil + } + + entries := make([]config.GemaraSourceEntry, 0, len(sources)) + for _, s := range sources { + if s == "" { + return nil, fmt.Errorf("empty source flag value") + } + + entry := config.GemaraSourceEntry{} + if strings.HasPrefix(s, "oci+http://") { + entry.Source = "oci://" + strings.TrimPrefix(s, "oci+http://") + entry.PlainHTTP = true + } else { + entry.Source = s + } + entries = append(entries, entry) + } + return entries, nil +} + +// parseSchemaFlags converts --schema flag values into SchemaRef values. +// +// - "kubernetes" -> SchemaRef{Platform: "kubernetes"} (embedded) +// - "ci=cue://cue.dev/x/actions@v0" -> SchemaRef{Platform: "ci", Source: "cue://..."} +func parseSchemaFlags(schemas []string) ([]config.SchemaRef, error) { + if len(schemas) == 0 { + return nil, nil + } + + refs := make([]config.SchemaRef, 0, len(schemas)) + for _, s := range schemas { + if s == "" { + return nil, fmt.Errorf("empty schema flag value") + } + + ref := config.SchemaRef{} + if idx := strings.IndexByte(s, '='); idx >= 0 { + ref.Platform = s[:idx] + ref.Source = s[idx+1:] + if ref.Platform == "" { + return nil, fmt.Errorf("empty platform name in schema flag %q", s) + } + if ref.Source == "" { + return nil, fmt.Errorf("empty source for platform %q in schema flag %q", ref.Platform, s) + } + } else { + ref.Platform = s + } + refs = append(refs, ref) + } + return refs, nil +} diff --git a/cmd/complypack/cli/mcp_test.go b/cmd/complypack/cli/mcp_test.go index 761765e..067f901 100644 --- a/cmd/complypack/cli/mcp_test.go +++ b/cmd/complypack/cli/mcp_test.go @@ -1,17 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -package cli_test +package cli import ( "testing" - "github.com/complytime/complypack/cmd/complypack/cli" + "github.com/complytime/complypack/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMcpCommand(t *testing.T) { - root := cli.New() + root := New() // Find the mcp command mcpCmd, _, err := root.Find([]string{"mcp"}) @@ -29,4 +29,153 @@ func TestMcpCommand(t *testing.T) { flags := serveCmd.Flags() assert.NotNil(t, flags.Lookup("config"), "should have --config flag") assert.NotNil(t, flags.Lookup("cache-dir"), "should have --cache-dir flag") + assert.NotNil(t, flags.Lookup("source"), "should have --source flag") + assert.NotNil(t, flags.Lookup("schema"), "should have --schema flag") +} + +func TestParseSourceFlags(t *testing.T) { + tests := []struct { + name string + sources []string + want []config.GemaraSourceEntry + wantErr string + }{ + { + name: "single OCI source with TLS", + sources: []string{"oci://ghcr.io/org/catalog:v1"}, + want: []config.GemaraSourceEntry{ + {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, + }, + }, + { + name: "single OCI source with plain HTTP", + sources: []string{"oci+http://localhost:5000/catalog:v1"}, + want: []config.GemaraSourceEntry{ + {Source: "oci://localhost:5000/catalog:v1", PlainHTTP: true}, + }, + }, + { + name: "multiple mixed sources", + sources: []string{ + "oci://ghcr.io/org/catalog:v1", + "oci+http://localhost:5000/guidance:latest", + "oci://ghcr.io/org/policy:v2", + }, + want: []config.GemaraSourceEntry{ + {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, + {Source: "oci://localhost:5000/guidance:latest", PlainHTTP: true}, + {Source: "oci://ghcr.io/org/policy:v2", PlainHTTP: false}, + }, + }, + { + name: "empty source", + sources: []string{""}, + wantErr: "empty source flag value", + }, + { + name: "nil sources returns nil", + sources: nil, + want: nil, + }, + { + name: "empty slice returns nil", + sources: []string{}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSourceFlags(tt.sources) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseSchemaFlags(t *testing.T) { + tests := []struct { + name string + schemas []string + want []config.SchemaRef + wantErr string + }{ + { + name: "bare platform name uses embedded schema", + schemas: []string{"kubernetes"}, + want: []config.SchemaRef{ + {Platform: "kubernetes"}, + }, + }, + { + name: "platform with external CUE source", + schemas: []string{"ci=cue://cue.dev/x/githubactions@v0#Workflow"}, + want: []config.SchemaRef{ + {Platform: "ci", Source: "cue://cue.dev/x/githubactions@v0#Workflow"}, + }, + }, + { + name: "platform with HTTPS source", + schemas: []string{"terraform=https://example.com/schema.json"}, + want: []config.SchemaRef{ + {Platform: "terraform", Source: "https://example.com/schema.json"}, + }, + }, + { + name: "mixed embedded and external schemas", + schemas: []string{ + "kubernetes", + "ci=cue://cue.dev/x/githubactions@v0#Workflow", + "docker", + }, + want: []config.SchemaRef{ + {Platform: "kubernetes"}, + {Platform: "ci", Source: "cue://cue.dev/x/githubactions@v0#Workflow"}, + {Platform: "docker"}, + }, + }, + { + name: "empty schema", + schemas: []string{""}, + wantErr: "empty schema flag value", + }, + { + name: "empty platform in key=value", + schemas: []string{"=cue://something"}, + wantErr: "empty platform name", + }, + { + name: "empty source in key=value", + schemas: []string{"ci="}, + wantErr: "empty source for platform", + }, + { + name: "nil schemas returns nil", + schemas: nil, + want: nil, + }, + { + name: "empty slice returns nil", + schemas: []string{}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSchemaFlags(tt.schemas) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f930b9e..9a762db 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -31,8 +31,13 @@ type Server struct { // ServerOptions configures ComplyPack MCP server initialization. type ServerOptions struct { // ConfigPath is the path to complypack.yaml. + // Ignored when Config is set. ConfigPath string + // Config provides configuration directly, bypassing file loading. + // When set, ConfigPath is ignored. + Config *config.ComplyPackConfig + // OCIStore is the directory for OCI artifact caching. OCIStore string @@ -58,10 +63,16 @@ func NewServer(ctx context.Context, opts *ServerOptions) (*Server, error) { return nil, fmt.Errorf("ServerOptions cannot be nil") } - // Load config - cfg, err := config.LoadConfig(opts.ConfigPath) - if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) + // Load config: use provided config or load from file + var cfg *config.ComplyPackConfig + if opts.Config != nil { + cfg = opts.Config + } else { + var err error + cfg, err = config.LoadConfig(opts.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } } if err := cfg.ValidateForMCP(); err != nil { return nil, fmt.Errorf("failed to load config: %w", err) From 0b646476c80463f2fab6fad09d6e64a9e2a3ec0a Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 18:52:19 -0400 Subject: [PATCH 02/24] 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 | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/cmd/complypack/cli/mcp.go b/cmd/complypack/cli/mcp.go index 42c75a1..0770815 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 067f901..f9dfb7d 100644 --- a/cmd/complypack/cli/mcp_test.go +++ b/cmd/complypack/cli/mcp_test.go @@ -98,6 +98,91 @@ func TestParseSourceFlags(t *testing.T) { } } +func TestBuildConfigFromFlags(t *testing.T) { + tests := []struct { + name string + sources []string + schemas []string + want *config.ComplyPackConfig + wantErr string + }{ + { + name: "single source and schema", + sources: []string{"oci://ghcr.io/org/catalog:v1"}, + schemas: []string{"kubernetes"}, + want: &config.ComplyPackConfig{ + Gemara: config.GemaraConfig{ + Sources: []config.GemaraSourceEntry{ + {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, + }, + }, + Schemas: []config.SchemaRef{ + {Platform: "kubernetes"}, + }, + }, + }, + { + name: "multiple sources and schemas", + sources: []string{ + "oci://ghcr.io/org/catalog:v1", + "oci+http://localhost:5000/guidance:latest", + }, + schemas: []string{ + "kubernetes", + "ci=cue://cue.dev/x/githubactions@v0#Workflow", + }, + want: &config.ComplyPackConfig{ + Gemara: config.GemaraConfig{ + Sources: []config.GemaraSourceEntry{ + {Source: "oci://ghcr.io/org/catalog:v1", PlainHTTP: false}, + {Source: "oci://localhost:5000/guidance:latest", PlainHTTP: true}, + }, + }, + Schemas: []config.SchemaRef{ + {Platform: "kubernetes"}, + {Platform: "ci", Source: "cue://cue.dev/x/githubactions@v0#Workflow"}, + }, + }, + }, + { + name: "empty sources and schemas", + sources: nil, + schemas: nil, + want: &config.ComplyPackConfig{ + Gemara: config.GemaraConfig{ + Sources: nil, + }, + Schemas: nil, + }, + }, + { + name: "invalid source", + sources: []string{""}, + schemas: []string{"kubernetes"}, + wantErr: "empty source flag value", + }, + { + name: "invalid schema", + sources: []string{"oci://ghcr.io/org/catalog:v1"}, + schemas: []string{""}, + wantErr: "empty schema flag value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildConfigFromFlags(tt.sources, tt.schemas) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestParseSchemaFlags(t *testing.T) { tests := []struct { name string From ae6c62bfe9275fd681d6b786ec409801c404630e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 18:58:04 -0400 Subject: [PATCH 03/24] build: add Containerfile for container distribution Multi-stage build with UBI 9 micro base image. Produces a minimal container for MCP server distribution via GHCR. Refs: #24 ADR: docs/adr/012-container-mcp-distribution.md Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .dockerignore | 12 ++++++++++++ Containerfile | 13 +++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 .dockerignore create mode 100644 Containerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ffa7566 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.complytime +.cursor +.opencode +docs +kb +skills +tests +acceptance +*.md +!LICENSE diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..45289c5 --- /dev/null +++ b/Containerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o complypack ./cmd/complypack + +FROM registry.access.redhat.com/ubi9-micro:latest + +COPY --from=builder /build/complypack /usr/local/bin/complypack + +ENTRYPOINT ["complypack"] From 62927c17d62c91f029cdfb0fb2c4f535efa110d0 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 19:13:41 -0400 Subject: [PATCH 04/24] ci: add container image build and push workflow Uses org-infra reusable workflows for GHCR publish with SLSA provenance, SBOM attestations, and Sigstore signing. Multi-arch (amd64/arm64) build on version tags and main pushes. Refs: #24 Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .github/workflows/container.yml | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/container.yml diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..862fe80 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,41 @@ +name: Container Image + +on: + push: + tags: + - 'v*' + branches: + - main + +permissions: + contents: read + +jobs: + push: + uses: complytime/org-infra/.github/workflows/reusable_publish_ghcr.yml@main + permissions: + contents: read + packages: write + actions: read + id-token: write + attestations: write + with: + component_name: complypack + containerfile_path: Containerfile + context_path: . + image_name: complytime/complypack + image_description: "ComplyPack MCP server for compliance policy generation" + platforms: linux/amd64,linux/arm64 + + sign: + needs: push + uses: complytime/org-infra/.github/workflows/reusable_sign_and_verify.yml@main + permissions: + contents: read + packages: write + id-token: write + with: + image_name: ${{ needs.push.outputs.image }} + digest: ${{ needs.push.outputs.digest }} + allowed_identity_regex: "https://github.com/complytime/.*" + verify_vuln: false From 87b163446ca86228dc2642ffa1971bcb99020af4 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 19:21:13 -0400 Subject: [PATCH 05/24] feat: add multi-platform plugin manifests Claude Code, Cursor, and Gemini CLI manifests following the superpowers multi-manifest pattern. Updates .mcp.json to reference the container image. Restructures skill directory layout. Removes openpackage.yml and legacy install docs. Refs: #24 Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .claude-plugin/plugin.json | 21 +++ .cursor-plugin/plugin.json | 20 +++ .mcp.json | 8 +- gemini-extension.json | 5 + skills/complypack/INSTALL.md | 162 ------------------------ skills/complypack/{skills => }/SKILL.md | 0 skills/complypack/openpackage.yml | 27 ---- 7 files changed, 52 insertions(+), 191 deletions(-) create mode 100644 .claude-plugin/plugin.json create mode 100644 .cursor-plugin/plugin.json create mode 100644 gemini-extension.json delete mode 100644 skills/complypack/INSTALL.md rename skills/complypack/{skills => }/SKILL.md (100%) delete mode 100644 skills/complypack/openpackage.yml diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..e491e87 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "complypack", + "displayName": "ComplyPack", + "version": "2.0.0", + "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "author": { + "name": "Jennifer Power", + "url": "https://github.com/complytime" + }, + "homepage": "https://github.com/complytime/complypack", + "repository": "https://github.com/complytime/complypack", + "license": "Apache-2.0", + "keywords": [ + "compliance", + "rego", + "opa", + "gemara", + "policy", + "mcp" + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..4ebc985 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "complypack", + "displayName": "ComplyPack", + "version": "2.0.0", + "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "author": { + "name": "Jennifer Power", + "url": "https://github.com/complytime" + }, + "repository": "https://github.com/complytime/complypack", + "license": "Apache-2.0", + "keywords": [ + "compliance", + "rego", + "opa", + "gemara", + "policy", + "mcp" + ] +} diff --git a/.mcp.json b/.mcp.json index 4c1fac4..0f4cc16 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,12 @@ { "mcpServers": { "complypack": { - "command": "/tmp/complypack", - "args": ["mcp", "serve", "--config", "/tmp/mcp-test/complypack.yaml"] + "command": "docker", + "args": ["run", "--rm", "-i", + "ghcr.io/complytime/complypack:latest", + "mcp", "serve", + "--source", "oci://YOUR_REGISTRY/gemara/YOUR_CATALOG:TAG", + "--schema", "YOUR_PLATFORM"] } } } diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..f70eb41 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "complypack", + "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", + "version": "2.0.0" +} diff --git a/skills/complypack/INSTALL.md b/skills/complypack/INSTALL.md deleted file mode 100644 index 30ed835..0000000 --- a/skills/complypack/INSTALL.md +++ /dev/null @@ -1,162 +0,0 @@ -# Installing the Gemara Policy Generation Skill - -This skill works with any AI agent that can: -- Read markdown documentation -- Access MCP servers or file-based catalogs -- Generate Rego code -- Write files to disk - -## Quick Install (Recommended) - -### Using OpenPackage (OPKG) - -**Prerequisites:** Package must be published to local registry first: - -```bash -cd /path/to/complypack/skills/generating-gemara-policies -opkg publish --local -``` - -**Install for Claude Code:** -```bash -cd /path/to/your/project -opkg install generating-gemara-policies --platforms claude -``` - -**Install for Cursor:** -```bash -opkg install generating-gemara-policies --platforms cursor -``` - -**Install for Windsurf:** -```bash -opkg install generating-gemara-policies --platforms windsurf -``` - -**Learn more:** [OpenPackage Documentation](https://github.com/enulus/OpenPackage) - -## Manual Installation by Platform - -### Claude Code (Anthropic) - -**Location:** `~/.claude/skills/generating-gemara-policies/` - -```bash -mkdir -p ~/.claude/skills/generating-gemara-policies -cp skills/generating-gemara-policies/SKILL.md ~/.claude/skills/generating-gemara-policies/ -``` - -Claude Code automatically discovers skills in `~/.claude/skills/`. - -**Verify:** -```bash -ls ~/.claude/skills/generating-gemara-policies/SKILL.md -``` - -### Cursor - -**Location:** `.cursor/skills/generating-gemara-policies/` (project-level) - -```bash -mkdir -p .cursor/skills/generating-gemara-policies -cp skills/generating-gemara-policies/SKILL.md .cursor/skills/generating-gemara-policies/ -``` - -May require explicit `@` mention: `@generating-gemara-policies` - -### Windsurf - -**Location:** `.windsurf/skills/generating-gemara-policies/` (project-level) - -```bash -mkdir -p .windsurf/skills/generating-gemara-policies -cp skills/generating-gemara-policies/SKILL.md .windsurf/skills/generating-gemara-policies/ -``` - -### Other AI Agents - -**For agents that support skill directories:** -Check your agent's documentation for the skills directory location, then copy `SKILL.md` there. - -**For web-based AI without skill auto-loading:** - -1. Copy skill content to your prompt -2. Prepend to request: - -``` -I have a skill for generating Rego policies from Gemara controls. - -[Paste SKILL.md content here] - -Now, using this skill: Generate a policy for AC-1 targeting Kubernetes using Conftest. -``` - -## Project-Level Installation - -To include this skill in your project for team sharing: - -```bash -cd /path/to/your/project - -# For Claude Code projects -mkdir -p .claude/skills -cp /path/to/complypack/skills/generating-gemara-policies/SKILL.md \ - .claude/skills/generating-gemara-policies/ - -# For Cursor projects -mkdir -p .cursor/skills -cp /path/to/complypack/skills/generating-gemara-policies/SKILL.md \ - .cursor/skills/generating-gemara-policies/ - -git add .claude/skills/ .cursor/skills/ -git commit -m "Add Gemara policy generation skill" -``` - -## Verification - -After installation, test the skill is accessible: - -**For agents with CLI:** -```bash - --help # Should show skills if supported -``` - -**For all agents:** -Ask the AI: -``` -Do you have access to a skill called "generating-gemara-policies"? -``` - -If yes, it should describe the skill's purpose. - -## Troubleshooting - -**Skill not found:** -- Check skill is in correct directory for your platform -- Verify SKILL.md has proper frontmatter (name, description) -- Restart the AI agent/IDE - -**Skill doesn't execute correctly:** -- Ensure MCP server is configured (for ComplyPack integration) -- Check agent has file write permissions -- Verify platform schemas are accessible - -## Updating the Skill - -When the skill is updated in the ComplyPack repo: - -```bash -cd /path/to/complypack -git pull - -# Re-copy to your platform's skills directory -cp skills/generating-gemara-policies/SKILL.md ~/.claude/skills/generating-gemara-policies/ -# or -cp skills/generating-gemara-policies/SKILL.md .cursor/skills/generating-gemara-policies/ -# etc. -``` - -Or use OpenPackage to update: -```bash -opkg update generating-gemara-policies -``` diff --git a/skills/complypack/skills/SKILL.md b/skills/complypack/SKILL.md similarity index 100% rename from skills/complypack/skills/SKILL.md rename to skills/complypack/SKILL.md diff --git a/skills/complypack/openpackage.yml b/skills/complypack/openpackage.yml deleted file mode 100644 index 911af55..0000000 --- a/skills/complypack/openpackage.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: complypack -version: 2.0.0 -type: skill -description: Generate policies from Gemara catalogs and extract assessment requirements with ComplyPack MCP server -author: Jennifer Power -license: Apache-2.0 - -repository: - type: git - url: https://github.com/complytime/complypack - -keywords: - - rego - - opa - - conftest - - gemara - - compliance - - policy - - kubernetes - - terraform - - docker - - complypack - -files: - - "skills/SKILL.md" - - "INSTALL.md" - - "mcp.jsonc" From 1ee43bd0e8289e61607c78be37c307b8f87079f8 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 19:23:38 -0400 Subject: [PATCH 06/24] docs: add multi-platform install instructions Covers Claude Code, OpenCode, flag syntax, config file fallback, and image verification. Refs: #24 Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- INSTALL.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 INSTALL.md diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..b2326e9 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,107 @@ +# Installing ComplyPack + +ComplyPack is a plugin that provides a compliance policy generation skill and +an MCP server for working with Gemara catalogs. + +## Prerequisites + +- Docker or Podman (Fedora users: `sudo dnf install podman-docker`) + +## Claude Code + +Install from the marketplace: + +``` +/plugin install complypack@claude-plugins-official +``` + +The skill is auto-discovered. To configure the MCP server, create a +`.mcp.json` in your project: + +```json +{ + "mcpServers": { + "complypack": { + "command": "docker", + "args": ["run", "--rm", "-i", + "ghcr.io/complytime/complypack:latest", + "mcp", "serve", + "--source", "oci://your-registry/gemara/your-catalog:v1", + "--schema", "ci"] + } + } +} +``` + +Replace the `--source` and `--schema` values with your Gemara catalog +references and target platforms. + +### Multiple sources and schemas + +```json +"args": ["run", "--rm", "-i", + "ghcr.io/complytime/complypack:latest", + "mcp", "serve", + "--source", "oci://registry.example.com/gemara/controls:v1", + "--source", "oci://registry.example.com/gemara/guidance:v1", + "--schema", "ci=cue://cue.dev/x/githubactions@v0#Workflow", + "--schema", "kubernetes"] +``` + +### Plain HTTP registries (development) + +Use `oci+http://` for registries without TLS: + +```json +"--source", "oci+http://localhost:5001/gemara/controls:v1" +``` + +## OpenCode + +Add to your `opencode.json`: + +```json +{ + "mcpServers": { + "complypack": { + "command": "docker", + "args": ["run", "--rm", "-i", + "ghcr.io/complytime/complypack:latest", + "mcp", "serve", + "--source", "oci://your-registry/gemara/your-catalog:v1", + "--schema", "ci"] + } + } +} +``` + +## Using a config file (advanced) + +If you prefer YAML configuration, mount a `complypack.yaml`: + +```json +"args": ["run", "--rm", "-i", + "-v", "./complypack.yaml:/config/complypack.yaml:ro", + "ghcr.io/complytime/complypack:latest", + "mcp", "serve", + "--config", "/config/complypack.yaml"] +``` + +## Verifying the image + +Images include SLSA provenance and SBOM attestations. To verify: + +``` +gh attestation verify oci://ghcr.io/complytime/complypack:latest \ + --owner complytime +``` + +## Embedded schemas + +These platforms have built-in schemas (no `--schema source` needed): + +- `kubernetes` +- `terraform` +- `docker` +- `ansible` +- `ci` From 2378fea150a0277563077ca0b6260c2d3d6541e7 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 8 Jun 2026 19:43:24 -0400 Subject: [PATCH 07/24] fix: add CA certificates to container image Copy CA certificates into the UBI micro container so the CUE registry (registry.cue.works) is reachable over TLS. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- Containerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Containerfile b/Containerfile index 45289c5..e7a3eb3 100644 --- a/Containerfile +++ b/Containerfile @@ -8,6 +8,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o complypack ./cmd/comply FROM registry.access.redhat.com/ubi9-micro:latest +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt COPY --from=builder /build/complypack /usr/local/bin/complypack ENTRYPOINT ["complypack"] From 7a822ce10fada5f02d1b3192d8e80600f95b11cc Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 9 Jun 2026 19:47:07 -0400 Subject: [PATCH 08/24] fix: pin workflow refs and container base image Pin reusable workflow references to SHA and pin ubi9-micro to a versioned digest to satisfy zizmor and hadolint. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .github/workflows/container.yml | 4 ++-- Containerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 862fe80..5bee7e6 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -12,7 +12,7 @@ permissions: jobs: push: - uses: complytime/org-infra/.github/workflows/reusable_publish_ghcr.yml@main + uses: complytime/org-infra/.github/workflows/reusable_publish_ghcr.yml@e266be092e71ac9343fcd6d5cafc50402161981e # main permissions: contents: read packages: write @@ -29,7 +29,7 @@ jobs: sign: needs: push - uses: complytime/org-infra/.github/workflows/reusable_sign_and_verify.yml@main + uses: complytime/org-infra/.github/workflows/reusable_sign_and_verify.yml@e266be092e71ac9343fcd6d5cafc50402161981e # main permissions: contents: read packages: write diff --git a/Containerfile b/Containerfile index e7a3eb3..5078596 100644 --- a/Containerfile +++ b/Containerfile @@ -6,7 +6,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o complypack ./cmd/complypack -FROM registry.access.redhat.com/ubi9-micro:latest +FROM registry.access.redhat.com/ubi9-micro:9.6-4@sha256:b498b3ea26111ab4b81d65139f2ebd2ef9a2abb7a4588b7fdcc54889f95e9caa COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt COPY --from=builder /build/complypack /usr/local/bin/complypack From 888408612adbf411c7a3a8b411771f50050d4c74 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 10 Jun 2026 13:53:39 -0400 Subject: [PATCH 09/24] chore: apply suggestions from code review Signed-off-by: Jennifer Power Co-authored-by: Jennifer Power --- .claude-plugin/plugin.json | 4 ++-- .cursor-plugin/plugin.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e491e87..23e20fc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,10 +1,10 @@ { "name": "complypack", "displayName": "ComplyPack", - "version": "2.0.0", + "version": "0.1.0", "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", "author": { - "name": "Jennifer Power", + "name": "ComplyTime Authors", "url": "https://github.com/complytime" }, "homepage": "https://github.com/complytime/complypack", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 4ebc985..bbc1c40 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,10 +1,10 @@ { "name": "complypack", "displayName": "ComplyPack", - "version": "2.0.0", + "version": "0.1.0", "description": "Generate Rego policies from Gemara catalogs and extract assessment requirements via MCP server", "author": { - "name": "Jennifer Power", + "name": "ComplyTime Authors", "url": "https://github.com/complytime" }, "repository": "https://github.com/complytime/complypack", From 38622a4f28be48f5d9c99d88740978d5ab1b6b6e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 10 Jun 2026 13:54:03 -0400 Subject: [PATCH 10/24] chore: update gemini-extension.json Signed-off-by: Jennifer Power --- gemini-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gemini-extension.json b/gemini-extension.json index f70eb41..554c82c 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", - "version": "2.0.0" + "version": "0.1.0" } From 2627c971bd9e7010df3a03c6da12ae6e30c1fc35 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 12 Jun 2026 13:08:19 -0400 Subject: [PATCH 11/24] fix: allow --schema flag without --source in mcp serve Previously, passing only --schema without --source fell through to config file loading and failed. Now either flag triggers the flags-based config path. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- cmd/complypack/cli/mcp.go | 4 ++-- cmd/complypack/cli/mcp_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/complypack/cli/mcp.go b/cmd/complypack/cli/mcp.go index 0770815..98f38a3 100644 --- a/cmd/complypack/cli/mcp.go +++ b/cmd/complypack/cli/mcp.go @@ -74,8 +74,8 @@ The server runs until interrupted (Ctrl+C) or the client disconnects.`, CacheDir: resolvedCacheDir, } - // If --source flags are present, build config from flags - if len(sources) > 0 { + // If any CLI flags are present, build config from flags + if len(sources) > 0 || len(schemas) > 0 { cfg, err := buildConfigFromFlags(sources, schemas) if err != nil { return fmt.Errorf("failed to build config from flags: %w", err) diff --git a/cmd/complypack/cli/mcp_test.go b/cmd/complypack/cli/mcp_test.go index f9dfb7d..07ff047 100644 --- a/cmd/complypack/cli/mcp_test.go +++ b/cmd/complypack/cli/mcp_test.go @@ -155,6 +155,19 @@ func TestBuildConfigFromFlags(t *testing.T) { Schemas: nil, }, }, + { + name: "schema only without sources", + sources: nil, + schemas: []string{"kubernetes"}, + want: &config.ComplyPackConfig{ + Gemara: config.GemaraConfig{ + Sources: nil, + }, + Schemas: []config.SchemaRef{ + {Platform: "kubernetes"}, + }, + }, + }, { name: "invalid source", sources: []string{""}, From 3a493693554400343c372ffa427b2174e9ae40c6 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 12 Jun 2026 13:39:46 -0400 Subject: [PATCH 12/24] fix: rename .mcp.json to .mcp.json.example Prevents auto-loading a broken config with placeholder values and :latest tag. Users copy and fill in their own registry, source, and pinned version. Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .mcp.json => .mcp.json.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .mcp.json => .mcp.json.example (82%) diff --git a/.mcp.json b/.mcp.json.example similarity index 82% rename from .mcp.json rename to .mcp.json.example index 0f4cc16..e8694a4 100644 --- a/.mcp.json +++ b/.mcp.json.example @@ -3,7 +3,7 @@ "complypack": { "command": "docker", "args": ["run", "--rm", "-i", - "ghcr.io/complytime/complypack:latest", + "ghcr.io/complytime/complypack:VERSION", "mcp", "serve", "--source", "oci://YOUR_REGISTRY/gemara/YOUR_CATALOG:TAG", "--schema", "YOUR_PLATFORM"] From d4818091c6b33be5d7227f573334174e89852dc3 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 12 Jun 2026 18:38:37 -0400 Subject: [PATCH 13/24] fix: address container workflow review feedback - Fix allowed_identity_regex to match org-infra reusable workflow origin - Add Trivy image scan stage between build and sign - Gate signing on scan success via verify_vuln - Run container as non-root user (ARG USER_UID=10001) Assisted-by: Claude (Anthropic, Claude Opus 4.6) Signed-off-by: Jennifer Power --- .github/workflows/container.yml | 26 +++++++++++++++++++++++--- Containerfile | 3 +++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 5bee7e6..e16cb9b 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -27,8 +27,28 @@ jobs: image_description: "ComplyPack MCP server for compliance policy generation" platforms: linux/amd64,linux/arm64 - sign: + scan: needs: push + if: >- + always() + && needs.push.result == 'success' + && needs.push.outputs.image != '' + uses: complytime/org-infra/.github/workflows/reusable_trivy_image_scan.yml@e266be092e71ac9343fcd6d5cafc50402161981e # main + permissions: + contents: read + packages: write + security-events: write + id-token: write + with: + image_ref: ${{ needs.push.outputs.image }}:${{ needs.push.outputs.tag }} + image_digest: ${{ needs.push.outputs.digest }} + trivy_severity: HIGH,CRITICAL + + sign: + needs: [push, scan] + if: >- + always() + && needs.push.result == 'success' uses: complytime/org-infra/.github/workflows/reusable_sign_and_verify.yml@e266be092e71ac9343fcd6d5cafc50402161981e # main permissions: contents: read @@ -37,5 +57,5 @@ jobs: with: image_name: ${{ needs.push.outputs.image }} digest: ${{ needs.push.outputs.digest }} - allowed_identity_regex: "https://github.com/complytime/.*" - verify_vuln: false + allowed_identity_regex: "https://github.com/complytime/org-infra(/.*)?$" + verify_vuln: ${{ needs.scan.result == 'success' }} diff --git a/Containerfile b/Containerfile index 5078596..3f2f055 100644 --- a/Containerfile +++ b/Containerfile @@ -11,4 +11,7 @@ FROM registry.access.redhat.com/ubi9-micro:9.6-4@sha256:b498b3ea26111ab4b81d6513 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt COPY --from=builder /build/complypack /usr/local/bin/complypack +ARG USER_UID=10001 +USER ${USER_UID} + ENTRYPOINT ["complypack"] From 96772867fc9238dedfe596bb14c7461288ac2d34 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 13 Jun 2026 16:26:23 -0400 Subject: [PATCH 14/24] 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 0e3aec5b873e654d40eca01604a605471a6d2673 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 13 Jun 2026 16:27:18 -0400 Subject: [PATCH 15/24] 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: "