Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -16,6 +16,8 @@
"opa",
"gemara",
"policy",
"mcp"
"mcp",
"audit",
"governance"
]
}
8 changes: 5 additions & 3 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -15,6 +15,8 @@
"opa",
"gemara",
"policy",
"mcp"
"mcp",
"audit",
"governance"
]
}
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
permissions:
contents: read
issues: read
pull-requests: read

call_reusable_security:
name: Security Scan
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
32 changes: 32 additions & 0 deletions docs/adr/012-container-mcp-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ADR 012: Container-Based MCP Server Distribution

**Status:** Proposed

**Date:** 2026-06-08

**Context:**

ComplyPack's MCP server is a Go binary that users must build locally. Issue #24 requires single-click distribution. Four options were evaluated:

1. **Container image** — `docker run --rm -i ghcr.io/complytime/complypack`
2. **Binary download via plugin SessionStart hook** — download pre-built binaries from GitHub Releases into `~/.claude/plugins/data/`
3. **`go install`** — users install via Go toolchain
4. **Homebrew tap** — formula for macOS/Linux

Option 2 was rejected on supply chain grounds: no plugin in the Claude Code ecosystem ships unsigned binaries, and downloading executables into the plugin data directory has no verification standard. The MCP security surface is already a known concern (CVE-2025-59536, CVE-2026-21852).

Option 3 requires the Go toolchain on every user's machine — a non-starter for non-developer users.

Option 4 adds a distribution channel to maintain and doesn't cover Fedora users natively.

**Decision:**

Distribute the MCP server as a multi-arch container image (`ghcr.io/complytime/complypack`). Sign images with cosign (keyless/OIDC). Users invoke via `docker run --rm -i` or `podman run --rm -i` in their `.mcp.json`.

**Consequences:**

- Users must have Docker or Podman installed — acceptable for the target audience (macOS and Fedora developers)
- Container startup adds ~1-2s latency on first invocation (image pull is one-time)
- OCI registry authentication for pulling Gemara catalogs works from inside the container (standard Docker credential chain)
- Image size will be ~30-50MB (Go static binary in distroless/alpine)
- No unsigned binary downloads, no Go toolchain requirement
36 changes: 36 additions & 0 deletions docs/adr/013-cli-flags-mcp-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 013: CLI Flags for `mcp serve` Configuration

**Status:** Proposed

**Date:** 2026-06-08

**Context:**

`mcp serve` requires a `complypack.yaml` config file. In a containerized deployment, getting a config file into the container requires a volume mount (`-v ./complypack.yaml:/config/complypack.yaml:ro`), which adds friction — the file must exist at a known path before the MCP server starts.

The MCP server only uses two config sections: `gemara.sources` (which OCI artifacts to load) and `schemas` (which platform schemas to serve). Fields like `id`, `evaluator-id`, `policies.dir`, and `output.dir` are only used by `pack` and `scan`.

**Decision:**

Add repeatable `--source` and `--schema` flags to `mcp serve`:

```shell
complypack mcp serve \
--source oci://registry.example.com/gemara/controls:v1 \
--source oci+http://localhost:5001/gemara/guidance:v1 \
--schema ci=cue://cue.dev/x/githubactions@v0#Workflow \
--schema kubernetes
```

**`--source`** accepts `oci://` (TLS) or `oci+http://` (plain HTTP) URIs. The `+http` scheme variant provides per-source plain-HTTP control without a global flag.

**`--schema`** accepts either a bare platform name (`kubernetes` — uses embedded schema) or `platform=source` syntax (`ci=cue://cue.dev/x/githubactions@v0#Workflow` — loads from the specified source).

When `--source` flags are present, they replace the config file for source resolution. `--config` remains supported and takes precedence if both are provided.

**Consequences:**

- Containerized MCP servers need no volume mount — all configuration passes through `args` in `.mcp.json`
- Users edit one file (`.mcp.json`) instead of two (`.mcp.json` + `complypack.yaml`)
- `pack` and `scan` commands are unaffected — they continue to require `complypack.yaml`
- The `oci+http://` URI scheme is non-standard but self-documenting and avoids a global `--plain-http` flag that would apply to all sources
36 changes: 36 additions & 0 deletions docs/adr/014-parameter-delta-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 014: Parameter Delta Gathering Engine

**Status:** Proposed

**Date:** 2026-06-10

**Context:**

Gemara Policies bind parameters at the org level as structured YAML (L3), while Guidance Catalogs (L1) and Control Catalogs (L2) express parameter expectations in prose — requirement text like "the system MUST require multi-factor authentication" or "builds SHOULD achieve at least SLSA Build Level 1."

When preparing the mapping stage of the comply pipeline, the model needs to see L3 parameter values alongside the L1/L2 requirement text they map to. Without tooling, the model must make many MCP calls and manually cross-reference requirement IDs to build this picture.

Three approaches were considered:

1. **No tool** — the model reads policy and catalog resources via MCP and cross-references manually. Works but requires many calls and is error-prone for large catalogs.
2. **Heuristic comparison engine** — classify parameter specificity (concrete vs generic) via string matching, compute verdicts (aligned, mismatch, org_binds_generic). Rejected: heuristics for detecting generic language are brittle, and interpreting whether values differ meaningfully is what the model does well.
3. **Gathering engine** — walk the resolved policy graph, pair each structured L3 parameter with the L1/L2 requirement text it maps to, return them side by side. Let the model interpret the relationship.

Option 3 was chosen. The engine handles what it's good at — traversing the resolved policy graph and collecting structured pairs. The model handles what it's good at — interpreting prose and judging parameter relationships.

**Decision:**

Implement a parameter gathering engine (`requirement.AnalyzeDelta`) that pairs L3 parameter values with L1/L2 requirement text across the resolved policy graph. Each pair contains:

- `requirement_id` — which requirement the parameter maps to
- `label` — the parameter name
- `policy_value` — the structured value from the L3 Policy
- `requirement_text` — the prose from the L1/L2 catalog

Expose this as the `analyze_parameter_delta` MCP tool so the `/comply` pipeline's mapping stage can read comparisons directly from the server. The mapping stage model interprets each pair in domain context and presents its assessment to the user.

**Consequences:**

- The mapping stage consumes structured pairs from MCP rather than manually cross-referencing artifacts
- Interpretation of parameter relationships (which is stricter, whether values conflict) is the model's responsibility — no heuristics
- The engine is intentionally simple: traverse the graph, collect pairs, return them
39 changes: 39 additions & 0 deletions docs/adr/015-comply-pipeline-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ADR 015: Comply Pipeline as Plugin Skills

**Status:** Proposed

**Date:** 2026-06-11

**Context:**

ComplyPack's MCP server provides compliance data (controls, parameters, resolved policies) but has no opinion on workflow. Users need guided, multi-stage audit preparation: scoping which controls apply, mapping parameter deltas across frameworks, and producing an adherence plan (the applicability statement).

Three approaches were considered:

1. **Hardcoded CLI workflow** — `complypack comply --stage scoping`. Rigid; can't adapt to user context or partial completion.
2. **Autonomous agent loop** — a single agent prompt that runs all stages. Risk of generating control IDs or parameter values from model memory rather than source data when context windows grow large.
3. **Plugin skills with MCP grounding** — decompose the pipeline into discrete skills (`/comply:pipeline`, `/comply:pack`, `/comply:setup`), each reading from MCP resources at every step.

Option 2 was rejected because the core safety property is that every stage must read control IDs, requirement IDs, and parameter values from MCP resources — never from model memory. A single long-running agent loop makes this harder to enforce.

Option 1 was rejected because audit workflows are inherently conversational: auditors need to review scoping decisions, adjust parameter bindings, and approve the applicability statement before it's finalized.

Option 3 was chosen. Skills provide structured guidance while keeping the human in the loop. MCP grounding ensures data integrity. The pipeline router checks `.complytime/` artifact state to resume from the correct stage.

**Decision:**

Implement the comply pipeline as plugin skills:

- **`/comply:pipeline`** — router that inspects `.complytime/` directory state and dispatches to the correct stage (scoping → mapping → adherence)
- **`/comply:pack`** — generates assessment logic after pipeline completion
- **`/comply:setup`** — configures `.mcp.json` for the user's environment

Each stage reads exclusively from MCP resources. The pipeline produces Gemara Policy artifacts.

**Consequences:**

- Users interact conversationally with each stage rather than running a batch process — audit decisions are reviewed before being recorded
- The pipeline is stateless across sessions: `.complytime/` artifacts are the checkpoint, not conversation history
- Adding new stages (e.g., evidence collection, continuous monitoring) means adding new skill files and updating the router
- The plugin registers with Claude Code, Cursor, and Gemini via their respective plugin manifests — same skills, three runtimes
- MCP grounding is a hard constraint: if the MCP server is unreachable, the pipeline fails rather than proceeding with stale or generated data
4 changes: 2 additions & 2 deletions gemini-extension.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions internal/mcp/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (

// Resource types
ResourceTypeCatalog = "catalog"
ResourceTypeMapping = "mapping"
ResourceTypeSchema = "schema"
ResourceTypeEvaluator = "evaluator"

Expand Down
39 changes: 37 additions & 2 deletions internal/mcp/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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)
Expand All @@ -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"`
Expand Down
17 changes: 17 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down
Loading